Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wmf-dev-overrides-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'arui-scripts': minor
---

Добавлена настройка `modules.devOverrides` и переменная окружения `ARUI_MODULE_OVERRIDES` — они позволяют в dev-режиме загружать модуль с локально поднятого приложения-провайдера вместо стенда. Подмены попадают в клиентскую сборку только в dev-режиме, в production-сборке они не определяются вовсе. Требуется `@alfalab/scripts-modules` с поддержкой подмен. Подробнее — в разделе «Локальная разработка модуля» документации по модулям.
5 changes: 5 additions & 0 deletions .changeset/wmf-dev-overrides-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@alfalab/scripts-modules': minor
---

Добавлена возможность подменить адрес приложения-провайдера модуля в dev-режиме. `createModuleFetcher` и `createServerStateModuleFetcher` теперь читают подмены из `localStorage['arui:module-overrides']` и переменной сборки `ARUI_MODULE_OVERRIDES` — это позволяет разрабатывать модуль локально вместе с приложением-потребителем, без деплоя на стенд. В production-сборке чтение подмен полностью вырезается.
5 changes: 5 additions & 0 deletions .changeset/wmf-provider-dev-cors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'arui-scripts': minor
---

Настройка `devServerCors` теперь принимает значение `'auto'` и использует его по умолчанию: CORS-заголовки в dev-режиме включаются автоматически для приложений, предоставляющих модули (`modules.exposes` или `compatModules.exposes`). Без них приложение-потребитель не может загрузить модуль с локально поднятого провайдера. Явные `true` и `false` работают как раньше и всегда выигрывают у автоопределения.
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { createModuleFetcher } from '../create-module-fetcher';
import { fetchAppManifest } from '../utils/fetch-app-manifest';
import { MODULE_OVERRIDES_STORAGE_KEY } from '../utils/module-overrides';

jest.mock('../utils/fetch-app-manifest');

describe('createModuleFetcher', () => {
beforeEach(() => {
jest.clearAllMocks();
window.localStorage.clear();
});

afterEach(() => {
window.localStorage.clear();
});

it('should fetch the app manifest and return module resources', async () => {
Expand Down Expand Up @@ -75,4 +81,59 @@ describe('createModuleFetcher', () => {
'http://example.com/assets/webpack-assets.json',
);
});

describe('dev overrides', () => {
beforeEach(() => {
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
window.localStorage.setItem(
MODULE_OVERRIDES_STORAGE_KEY,
JSON.stringify({ module1: 'http://localhost:8081' }),
);
});

afterEach(() => {
jest.restoreAllMocks();
});

it('should take the manifest and the base url from the override', async () => {
(fetchAppManifest as jest.Mock).mockResolvedValue({
__metadata__: { version: '1.0', name: 'Test App' },
module1: { js: 'module1.js', css: 'module1.css', mode: 'default' },
});

const moduleFetcher = createModuleFetcher({ baseUrl: 'http://example.com' });

const moduleResources = await moduleFetcher({
moduleId: 'module1',
hostAppId: 'app1',
params: undefined,
});

expect(fetchAppManifest).toHaveBeenCalledWith(
'http://localhost:8081/assets/webpack-assets.json',
);
// baseUrl обязан быть переопределен: от него резолвятся относительные пути скриптов и стилей
expect(moduleResources.moduleState.baseUrl).toBe('http://localhost:8081');
});

it('should not affect modules without an override', async () => {
(fetchAppManifest as jest.Mock).mockResolvedValue({
__metadata__: { version: '1.0', name: 'Test App' },
module2: { js: 'module2.js', mode: 'default' },
});

const moduleFetcher = createModuleFetcher({ baseUrl: 'http://example.com' });

const moduleResources = await moduleFetcher({
moduleId: 'module2',
hostAppId: 'app1',
params: undefined,
});

expect(fetchAppManifest).toHaveBeenCalledWith(
'http://example.com/assets/webpack-assets.json',
);
expect(moduleResources.moduleState.baseUrl).toBe('http://example.com');
});
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createServerStateModuleFetcher } from '../create-server-state-module-fetcher';
import { MODULE_OVERRIDES_STORAGE_KEY } from '../utils/module-overrides';
import { urlSegmentWithoutEndSlash } from '../utils/normalize-url-segment';

jest.mock('../utils/normalize-url-segment');
Expand Down Expand Up @@ -124,4 +125,81 @@ describe('createServerStateModuleFetcher', () => {
'Module resources request for test failed: https://test.com/api/getModuleResources responded with 400 status',
);
});

describe('dev overrides', () => {
beforeEach(() => {
mockXHR.status = 200;
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
(urlSegmentWithoutEndSlash as jest.Mock).mockImplementation((url: string) =>
url.replace(/\/$/, ''),
);
window.localStorage.setItem(
MODULE_OVERRIDES_STORAGE_KEY,
JSON.stringify({ test: 'http://localhost:8081' }),
);
});

afterEach(() => {
window.localStorage.clear();
jest.restoreAllMocks();
(urlSegmentWithoutEndSlash as jest.Mock).mockReturnValue('https://test.com');
});

it('should send the request to the overridden host', () => {
const fetchServerResources = createServerStateModuleFetcher({
baseUrl: 'https://test.com/',
});

fetchServerResources({ moduleId: 'test', hostAppId: 'host', params: undefined });

expect(mockXHR.open).toHaveBeenCalledWith(
'POST',
'http://localhost:8081/api/getModuleResources',
true,
);
});

it('should force the overridden base url into the module state', async () => {
// локальный сервер модуля вполне может вернуть адрес стенда, захардкоженный в его конфиге,
// поэтому baseUrl из ответа доверять нельзя - подмена должна выигрывать
mockXHR.responseText = JSON.stringify({
scripts: ['remoteEntry.js'],
styles: [],
moduleVersion: '1.0',
appName: 'test_app',
mountMode: 'default',
moduleState: { baseUrl: 'https://test.com', hostAppId: 'host' },
});

const fetchServerResources = createServerStateModuleFetcher({
baseUrl: 'https://test.com/',
});

const promise = fetchServerResources({
moduleId: 'test',
hostAppId: 'host',
params: undefined,
});

mockXHR.onload?.();

await expect(promise).resolves.toMatchObject({
moduleState: { baseUrl: 'http://localhost:8081', hostAppId: 'host' },
});
});

it('should not affect modules without an override', () => {
const fetchServerResources = createServerStateModuleFetcher({
baseUrl: 'https://test.com/',
});

fetchServerResources({ moduleId: 'other', hostAppId: 'host', params: undefined });

expect(mockXHR.open).toHaveBeenCalledWith(
'POST',
'https://test.com/api/getModuleResources',
true,
);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fetchAppManifest } from './utils/fetch-app-manifest';
import { getModuleOverride } from './utils/module-overrides';
import { urlSegmentWithoutEndSlash } from './utils/normalize-url-segment';
import { type ModuleResourcesGetter } from './create-module-loader';
import { type AruiAppManifest, type BaseModuleState, type ModuleResources } from './types';
Expand All @@ -18,9 +19,7 @@ export function createModuleFetcher({
baseUrl,
assetsUrl = '/assets/webpack-assets.json',
}: CreateClientResourcesFetcherParams): ModuleResourcesGetter<void, BaseModuleState> {
const manifestUrl = `${urlSegmentWithoutEndSlash(baseUrl)}${assetsUrl}`;

function getModuleFiles(manifest: AruiAppManifest, moduleId: string) {
function getModuleFiles(manifest: AruiAppManifest, moduleId: string, manifestUrl: string) {
if (!manifest[moduleId]) {
throw new Error(`Module ${moduleId} not found in manifest from ${manifestUrl}`);
}
Expand All @@ -39,8 +38,12 @@ export function createModuleFetcher({
moduleId,
hostAppId,
}): Promise<ModuleResources> {
// при локальной разработке адрес remote приложения может быть заменен на локальный
const effectiveBaseUrl = getModuleOverride(moduleId) ?? baseUrl;
const manifestUrl = `${urlSegmentWithoutEndSlash(effectiveBaseUrl)}${assetsUrl}`;

const manifest = await fetchAppManifest(manifestUrl);
const { mode, ...moduleFiles } = getModuleFiles(manifest, moduleId);
const { mode, ...moduleFiles } = getModuleFiles(manifest, moduleId, manifestUrl);

return {
...moduleFiles,
Expand All @@ -50,7 +53,7 @@ export function createModuleFetcher({
/* eslint-enable no-underscore-dangle */
mountMode: mode,
moduleState: {
baseUrl,
baseUrl: effectiveBaseUrl,
hostAppId,
},
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { getModuleOverride } from './utils/module-overrides';
import { urlSegmentWithoutEndSlash } from './utils/normalize-url-segment';
import { createNetworkError, createParseError, createResponseError } from './utils/request-error';
import { type ModuleResourcesGetter } from './create-module-loader';
import { getServerStateModuleFetcherParams } from './get-server-state-module-fetcher-params';
import { type BaseModuleState } from './types';
import { type BaseModuleState, type ModuleResources } from './types';

type CreateServerResourcesFetcherParams = {
baseUrl: string;
Expand All @@ -20,7 +21,10 @@ export function createServerStateModuleFetcher<GetResourcesParams = undefined>({
}: CreateServerResourcesFetcherParams): ModuleResourcesGetter<GetResourcesParams, BaseModuleState> {
return async function fetchServerResources(params) {
const { relativePath, method } = getServerStateModuleFetcherParams();
const url = `${urlSegmentWithoutEndSlash(baseUrl)}${relativePath}`;
// при локальной разработке адрес remote приложения может быть заменен на локальный
const overriddenBaseUrl = getModuleOverride(params.moduleId);
const effectiveBaseUrl = overriddenBaseUrl ?? baseUrl;
const url = `${urlSegmentWithoutEndSlash(effectiveBaseUrl)}${relativePath}`;
const errorDescription = `Module resources request for ${params.moduleId}`;

return new Promise((resolve, reject) => {
Expand All @@ -39,7 +43,17 @@ export function createServerStateModuleFetcher<GetResourcesParams = undefined>({
}

try {
resolve(JSON.parse(xhr.responseText));
const resources: ModuleResources = JSON.parse(xhr.responseText);

// при локальной разработке адрес remote приложения может быть заменен на локальный
if (overriddenBaseUrl) {
resources.moduleState = {
...resources.moduleState,
baseUrl: overriddenBaseUrl,
};
}

resolve(resources);
} catch (error) {
reject(createParseError(errorDescription, url, error));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import {
getModuleOverride,
MODULE_OVERRIDES_ENV_KEY,
MODULE_OVERRIDES_STORAGE_KEY,
} from '../module-overrides';

describe('getModuleOverride', () => {
const initialNodeEnv = process.env.NODE_ENV;
let warnSpy: jest.SpyInstance;

beforeEach(() => {
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
delete process.env[MODULE_OVERRIDES_ENV_KEY];
window.localStorage.clear();
});

afterEach(() => {
warnSpy.mockRestore();
delete process.env[MODULE_OVERRIDES_ENV_KEY];
window.localStorage.clear();
process.env.NODE_ENV = initialNodeEnv;
});

it('should return undefined when no overrides are configured', () => {
expect(getModuleOverride('someModule')).toBeUndefined();
expect(warnSpy).not.toHaveBeenCalled();
});

it('should read an override from the env variable', () => {
process.env[MODULE_OVERRIDES_ENV_KEY] = JSON.stringify({
someModule: 'http://localhost:8081',
});

expect(getModuleOverride('someModule')).toBe('http://localhost:8081');
});

it('should read an override from the local storage', () => {
window.localStorage.setItem(
MODULE_OVERRIDES_STORAGE_KEY,
JSON.stringify({ someModule: 'http://localhost:8082' }),
);

expect(getModuleOverride('someModule')).toBe('http://localhost:8082');
});

it('should prefer the local storage over the env variable', () => {
process.env[MODULE_OVERRIDES_ENV_KEY] = JSON.stringify({
someModule: 'http://localhost:8081',
otherModule: 'http://localhost:9091',
});
window.localStorage.setItem(
MODULE_OVERRIDES_STORAGE_KEY,
JSON.stringify({ someModule: 'http://localhost:8082' }),
);

expect(getModuleOverride('someModule')).toBe('http://localhost:8082');
// ключи, которых нет в localStorage, продолжают браться из env
expect(getModuleOverride('otherModule')).toBe('http://localhost:9091');
});

it('should return undefined for a module without an override', () => {
process.env[MODULE_OVERRIDES_ENV_KEY] = JSON.stringify({
someModule: 'http://localhost:8081',
});

expect(getModuleOverride('anotherModule')).toBeUndefined();
});

it('should warn about an active override', () => {
process.env[MODULE_OVERRIDES_ENV_KEY] = JSON.stringify({
someModule: 'http://localhost:8081',
});

getModuleOverride('someModule');

expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('someModule'),
expect.stringContaining('http://localhost:8081'),
);
});

it('should ignore malformed json and warn about it', () => {
process.env[MODULE_OVERRIDES_ENV_KEY] = 'not a json';
window.localStorage.setItem(MODULE_OVERRIDES_STORAGE_KEY, '{ also not a json');

expect(getModuleOverride('someModule')).toBeUndefined();
expect(warnSpy).toHaveBeenCalledTimes(2);
});

it('should ignore non-string override values', () => {
process.env[MODULE_OVERRIDES_ENV_KEY] = JSON.stringify({ someModule: 42 });

expect(getModuleOverride('someModule')).toBeUndefined();
});

it('should ignore a non-object overrides payload', () => {
process.env[MODULE_OVERRIDES_ENV_KEY] = JSON.stringify(['http://localhost:8081']);

expect(getModuleOverride('someModule')).toBeUndefined();
});

it('should not read anything in production', () => {
process.env.NODE_ENV = 'production';
process.env[MODULE_OVERRIDES_ENV_KEY] = JSON.stringify({
someModule: 'http://localhost:8081',
});
window.localStorage.setItem(
MODULE_OVERRIDES_STORAGE_KEY,
JSON.stringify({ someModule: 'http://localhost:8082' }),
);

expect(getModuleOverride('someModule')).toBeUndefined();
expect(warnSpy).not.toHaveBeenCalled();
});

it('should survive an unavailable local storage', () => {
const getItemSpy = jest
.spyOn(window.Storage.prototype, 'getItem')
.mockImplementation(() => {
throw new Error('localStorage is disabled');
});

process.env[MODULE_OVERRIDES_ENV_KEY] = JSON.stringify({
someModule: 'http://localhost:8081',
});

expect(getModuleOverride('someModule')).toBe('http://localhost:8081');

getItemSpy.mockRestore();
});
});
Loading
Loading