From 21da3c06258b91f61ddc452f73f4e6886f7b2dfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=91=D0=BE=D1=80=D0=BE=D0=B2=D1=81=D0=BA=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 13 Aug 2026 01:09:20 +0300 Subject: [PATCH 1/3] feat(modules): add runtime support for dev module overrides --- .changeset/wmf-dev-overrides-runtime.md | 5 + .../__tests__/create-module-fetcher.tests.ts | 61 ++++++++ ...reate-server-state-module-fetcher.tests.ts | 78 +++++++++++ .../module-loader/create-module-fetcher.ts | 13 +- .../create-server-state-module-fetcher.ts | 20 ++- .../utils/__tests__/module-overrides.tests.ts | 131 ++++++++++++++++++ .../module-loader/utils/module-overrides.ts | 113 +++++++++++++++ 7 files changed, 413 insertions(+), 8 deletions(-) create mode 100644 .changeset/wmf-dev-overrides-runtime.md create mode 100644 packages/arui-scripts-modules/src/module-loader/utils/__tests__/module-overrides.tests.ts create mode 100644 packages/arui-scripts-modules/src/module-loader/utils/module-overrides.ts diff --git a/.changeset/wmf-dev-overrides-runtime.md b/.changeset/wmf-dev-overrides-runtime.md new file mode 100644 index 00000000..476f2af5 --- /dev/null +++ b/.changeset/wmf-dev-overrides-runtime.md @@ -0,0 +1,5 @@ +--- +'@alfalab/scripts-modules': minor +--- + +Добавлена возможность подменить адрес приложения-провайдера модуля в dev-режиме. `createModuleFetcher` и `createServerStateModuleFetcher` теперь читают подмены из `localStorage['arui:module-overrides']` и переменной сборки `ARUI_MODULE_OVERRIDES` — это позволяет разрабатывать модуль локально вместе с приложением-потребителем, без деплоя на стенд. В production-сборке чтение подмен полностью вырезается. diff --git a/packages/arui-scripts-modules/src/module-loader/__tests__/create-module-fetcher.tests.ts b/packages/arui-scripts-modules/src/module-loader/__tests__/create-module-fetcher.tests.ts index 83b6b984..583cbcc1 100644 --- a/packages/arui-scripts-modules/src/module-loader/__tests__/create-module-fetcher.tests.ts +++ b/packages/arui-scripts-modules/src/module-loader/__tests__/create-module-fetcher.tests.ts @@ -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 () => { @@ -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'); + }); + }); }); diff --git a/packages/arui-scripts-modules/src/module-loader/__tests__/create-server-state-module-fetcher.tests.ts b/packages/arui-scripts-modules/src/module-loader/__tests__/create-server-state-module-fetcher.tests.ts index 0b8daf71..2ac1873e 100644 --- a/packages/arui-scripts-modules/src/module-loader/__tests__/create-server-state-module-fetcher.tests.ts +++ b/packages/arui-scripts-modules/src/module-loader/__tests__/create-server-state-module-fetcher.tests.ts @@ -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'); @@ -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, + ); + }); + }); }); diff --git a/packages/arui-scripts-modules/src/module-loader/create-module-fetcher.ts b/packages/arui-scripts-modules/src/module-loader/create-module-fetcher.ts index 971a64ca..915c888b 100644 --- a/packages/arui-scripts-modules/src/module-loader/create-module-fetcher.ts +++ b/packages/arui-scripts-modules/src/module-loader/create-module-fetcher.ts @@ -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'; @@ -18,9 +19,7 @@ export function createModuleFetcher({ baseUrl, assetsUrl = '/assets/webpack-assets.json', }: CreateClientResourcesFetcherParams): ModuleResourcesGetter { - 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}`); } @@ -39,8 +38,12 @@ export function createModuleFetcher({ moduleId, hostAppId, }): Promise { + // при локальной разработке адрес 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, @@ -50,7 +53,7 @@ export function createModuleFetcher({ /* eslint-enable no-underscore-dangle */ mountMode: mode, moduleState: { - baseUrl, + baseUrl: effectiveBaseUrl, hostAppId, }, }; diff --git a/packages/arui-scripts-modules/src/module-loader/create-server-state-module-fetcher.ts b/packages/arui-scripts-modules/src/module-loader/create-server-state-module-fetcher.ts index 1d3cabc4..569b5972 100644 --- a/packages/arui-scripts-modules/src/module-loader/create-server-state-module-fetcher.ts +++ b/packages/arui-scripts-modules/src/module-loader/create-server-state-module-fetcher.ts @@ -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; @@ -20,7 +21,10 @@ export function createServerStateModuleFetcher({ }: CreateServerResourcesFetcherParams): ModuleResourcesGetter { 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) => { @@ -39,7 +43,17 @@ export function createServerStateModuleFetcher({ } 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)); } diff --git a/packages/arui-scripts-modules/src/module-loader/utils/__tests__/module-overrides.tests.ts b/packages/arui-scripts-modules/src/module-loader/utils/__tests__/module-overrides.tests.ts new file mode 100644 index 00000000..2839accf --- /dev/null +++ b/packages/arui-scripts-modules/src/module-loader/utils/__tests__/module-overrides.tests.ts @@ -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(); + }); +}); diff --git a/packages/arui-scripts-modules/src/module-loader/utils/module-overrides.ts b/packages/arui-scripts-modules/src/module-loader/utils/module-overrides.ts new file mode 100644 index 00000000..518809ac --- /dev/null +++ b/packages/arui-scripts-modules/src/module-loader/utils/module-overrides.ts @@ -0,0 +1,113 @@ +/** + * Ключ localStorage, в котором хранятся dev-подмены адресов модулей. + * Значение - json вида `{ "moduleId": "http://localhost:8081" }`. + */ +export const MODULE_OVERRIDES_STORAGE_KEY = 'arui:module-overrides'; + +/** + * Имя переменной окружения с dev-подменами. В клиентскую сборку она попадает через DefinePlugin, + * который подставляет ее значение только в dev-режиме. + */ +export const MODULE_OVERRIDES_ENV_KEY = 'ARUI_MODULE_OVERRIDES'; + +type Overrides = Record; + +/** + * Парсит json подмен moduleId -> baseUrl. + * Формат и правила фильтрации должны совпадать с `parseOverridesFromEnv` в + * `arui-scripts` (`configs/modules.ts`): общий пакет ради этой утилиты не + * заводим, build-time и runtime живут в разных бандлах. + */ +function parseOverrides(rawValue: string | undefined | null, source: string): Overrides { + if (!rawValue) { + return {}; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(rawValue); + } catch { + // eslint-disable-next-line no-console -- битая конфигурация не должна ронять приложение, но и молчать о ней нельзя + console.warn( + `[arui-modules] Не удалось разобрать подмены модулей из ${source}, они будут проигнорированы.`, + rawValue, + ); + + return {}; + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + // eslint-disable-next-line no-console -- см. комментарий выше + console.warn( + `[arui-modules] Подмены модулей из ${source} должны быть json-объектом вида {"moduleId":"http://localhost:8081"}, они будут проигнорированы.`, + rawValue, + ); + + return {}; + } + + const result: Overrides = {}; + + Object.entries(parsed as Record).forEach(([moduleId, baseUrl]) => { + if (typeof baseUrl === 'string' && baseUrl) { + result[moduleId] = baseUrl; + } + }); + + return result; +} + +function readFromStorage(): Overrides { + try { + return parseOverrides( + window.localStorage.getItem(MODULE_OVERRIDES_STORAGE_KEY), + `localStorage["${MODULE_OVERRIDES_STORAGE_KEY}"]`, + ); + } catch { + // localStorage может быть недоступен (приватный режим, отключенные куки) - это не повод падать + return {}; + } +} + +function readFromEnv(): Overrides { + // Обращение к process.env.ARUI_MODULE_OVERRIDES должно быть записано литералом: + // именно эту строку DefinePlugin заменяет на значение при сборке. + return parseOverrides( + process.env.ARUI_MODULE_OVERRIDES, + `process.env.${MODULE_OVERRIDES_ENV_KEY}`, + ); +} + +/** + * Возвращает dev-подмену базового адреса приложения, предоставляющего модуль. + * + * Подмены нужны для локальной разработки модуля вместе с приложением-потребителем: модуль поднимается + * локально, а хост грузит его с localhost вместо стенда. + * + * В production-сборке функция всегда возвращает undefined, а все чтение конфигурации вырезается + * минификатором. Это принципиально: возможность подменить источник исполняемого js в проде - + * это возможность выполнить произвольный код на странице приложения. + * + * @param moduleId id модуля, для которого ищется подмена + */ +export function getModuleOverride(moduleId: string): string | undefined { + if (process.env.NODE_ENV === 'production') { + return undefined; + } + + const overrides = { ...readFromEnv(), ...readFromStorage() }; + const baseUrl = overrides[moduleId]; + + if (!baseUrl) { + return undefined; + } + + // eslint-disable-next-line no-console -- без этого предупреждения разработчики часами ищут, почему модуль "не обновляется" + console.warn( + `[arui-modules] Для модуля "${moduleId}" включена dev-подмена адреса. Модуль будет загружен с`, + baseUrl, + ); + + return baseUrl; +} From dc05efc0a7509e0f9055c998a4f29d9e987e6f79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=91=D0=BE=D1=80=D0=BE=D0=B2=D1=81=D0=BA=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 13 Aug 2026 01:09:30 +0300 Subject: [PATCH 2/3] feat(modules): add modules.devOverrides and ARUI_MODULE_OVERRIDES inject --- .changeset/wmf-dev-overrides-config.md | 5 + packages/arui-scripts/docs/modules.md | 80 +++++++++++ .../client-modules-dev-overrides.tests.ts | 126 ++++++++++++++++++ .../src/configs/app-configs/types.ts | 3 +- packages/arui-scripts/src/configs/modules.ts | 85 ++++++++++++ .../arui-scripts/src/configs/rspack.client.ts | 14 +- 6 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 .changeset/wmf-dev-overrides-config.md create mode 100644 packages/arui-scripts/src/configs/__tests__/client-modules-dev-overrides.tests.ts diff --git a/.changeset/wmf-dev-overrides-config.md b/.changeset/wmf-dev-overrides-config.md new file mode 100644 index 00000000..4d07124e --- /dev/null +++ b/.changeset/wmf-dev-overrides-config.md @@ -0,0 +1,5 @@ +--- +'arui-scripts': minor +--- + +Добавлена настройка `modules.devOverrides` и переменная окружения `ARUI_MODULE_OVERRIDES` — они позволяют в dev-режиме загружать модуль с локально поднятого приложения-провайдера вместо стенда. Подмены попадают в клиентскую сборку только в dev-режиме, в production-сборке они не определяются вовсе. Требуется `@alfalab/scripts-modules` с поддержкой подмен. Подробнее — в разделе «Локальная разработка модуля» документации по модулям. diff --git a/packages/arui-scripts/docs/modules.md b/packages/arui-scripts/docs/modules.md index 1767b4f8..54187e89 100644 --- a/packages/arui-scripts/docs/modules.md +++ b/packages/arui-scripts/docs/modules.md @@ -585,6 +585,83 @@ const loader = createModuleLoader({ **Внимание!** Использование `resourcesCache: 'single-item'` не будет работать вместе с `useShadowDom` из-за особенностей работы со стилями. +# Локальная разработка модуля + +По умолчанию приложение-потребитель грузит модуль с того адреса, который указан в `createModuleFetcher`, +то есть со стенда. Из-за этого цикл разработки модуля выглядит так: поправил модуль → собрал → +задеплоил на стенд → перезагрузил хост. Это дорого. + +Подмена адреса позволяет грузить модуль с локально поднятого приложения-провайдера. + +## Как это работает + +Поднимаем приложение-провайдер локально: + +```sh +cd foo-app && yarn start +``` + +и запускаем приложение-потребитель, указав, откуда брать модуль: + +```sh +cd bar-app && ARUI_MODULE_OVERRIDES='{"SomeModule":"http://localhost:8081"}' yarn start +``` + +Ключ - `moduleId`, значение - базовый адрес приложения, которое предоставляет модуль (тот же, +что вы передали бы в `baseUrl`). Подмена действует и на манифест, и на сами ресурсы модуля, +и на запрос серверного состояния - то есть модуль целиком приезжает с локального адреса. + +Постоянные подмены можно держать в конфиге: + +```ts +// ./arui-scripts.config.ts +const aruiScriptsConfig: PackageSettings = { + modules: { + shared: {}, + devOverrides: { + SomeModule: 'http://localhost:8081', + }, + }, +}; +``` + +Переменная окружения приоритетнее конфига - она задается на один запуск. + +## Подмена без пересборки + +Оба способа выше требуют перезапуска dev-сервера. Если нужно переключаться на лету - положите +подмены в `localStorage` и перезагрузите страницу: + +```js +localStorage.setItem('arui:module-overrides', '{"SomeModule":"http://localhost:8081"}'); +``` + +`localStorage` приоритетнее и конфига, и переменной окружения. Чтобы вернуться к стенду - +`localStorage.removeItem('arui:module-overrides')`. + +## Что нужно от приложения-провайдера + +Хост грузит ресурсы с другого origin, поэтому локальный dev-сервер провайдера должен отдавать +CORS-заголовки. По умолчанию `devServerCors: 'auto'` — для приложений с +`modules.exposes` или `compatModules.exposes` заголовки включаются сами. Явно задавать +`devServerCors: true` нужно только если автоопределение не срабатывает (например, модули +настраиваются вне arui-scripts через `disableModulesSupport`). + +Если модуль использует серверное состояние, CORS-заголовки должен отдавать и эндпоинт +`/api/getModuleResources` вашего nodejs-сервера - это уже код приложения, а не arui-scripts. + +## Ограничения + +- **HMR не пробрасывается между приложениями.** После правки кода модуля приложение-провайдер + пересоберется само, но приложение-потребитель об этом не узнает - страницу хоста нужно перезагрузить. + Цикл разработки все равно занимает секунды вместо деплоя на стенд. +- **Подмены работают только в dev-сборке.** В production-сборке весь код чтения подмен вырезается. + Это сделано намеренно: возможность подменить источник исполняемого js в проде - это возможность + выполнить произвольный код на странице приложения. +- Когда подмена активна, в консоль пишется предупреждение с адресом, откуда реально грузится модуль. + Если модуль ведет себя не так, как ожидается, - в первую очередь проверьте консоль. +- Версии shared-библиотек локального провайдера и хоста должны совпадать так же, как и на стенде. + # Другие типы модулей Помимо создания монтируемых модулей, есть возможность создавать и другие типы модулей, более подходящие для некоторых вариантов использования. @@ -768,6 +845,9 @@ type Modules = { }; shared?: (string | SharedObject)[] | SharedObject; // конфигурация shared параметра для ModuleFederationPlugin shareScope?: string // скоуп который будет присваиваться модулям в shared если иное имя не будет задано в sharedConfig. Значение по умолчанию - 'default' + devOverrides?: { // подмена адресов модулей для локальной разработки, см. "Локальная разработка модуля". Применяется только в dev-сборке + [moduleId: string]: string; // moduleId - id модуля, value - базовый адрес приложения-провайдера + }; options?: { // дополнительные настройки модулей cssPrefix?: false | string; // префикс, который будет добавляться ко всем css стилям useSeparateBuild?: boolean; // использовать ли отдельную сборку для wmf. Влияет на то, к чему будет применяться cssPrefix. Если false - cssPrefix применится ко всей сборке приложения diff --git a/packages/arui-scripts/src/configs/__tests__/client-modules-dev-overrides.tests.ts b/packages/arui-scripts/src/configs/__tests__/client-modules-dev-overrides.tests.ts new file mode 100644 index 00000000..1b5fac9a --- /dev/null +++ b/packages/arui-scripts/src/configs/__tests__/client-modules-dev-overrides.tests.ts @@ -0,0 +1,126 @@ +import { type Configuration } from '@rspack/core'; + +import { configs } from '../app-configs'; +import { getDevModuleOverrides, MODULE_OVERRIDES_DEFINE_KEY } from '../modules'; +import { createClientWebpackConfig } from '../rspack.client'; + +/** + * rspack хранит аргументы конструктора плагина в `_args`, а не в `definitions`, + * как это было в webpack. Публичного способа прочитать определения нет. + */ +function getDefineDefinitions(config: Configuration): Record { + const definePlugin = config.plugins?.find( + (plugin) => plugin?.constructor.name === 'DefinePlugin', + ) as unknown as { _args: [Record] } | undefined; + + // eslint-disable-next-line no-underscore-dangle -- это внутреннее поле rspack, других вариантов нет + return definePlugin?._args[0] ?? {}; +} + +function getMainClientConfig(mode: 'dev' | 'prod') { + // createClientWebpackConfig возвращает массив только когда сборок несколько + // (отдельный wmf-билд или compat-модули), иначе - один конфиг + const clientConfig = createClientWebpackConfig(mode); + + return ( + Array.isArray(clientConfig) ? clientConfig.find((config) => !config.name) : clientConfig + ) as Configuration; +} + +describe('dev module overrides in the client config', () => { + const originalModules = configs.modules; + const originalEnvValue = process.env.ARUI_MODULE_OVERRIDES; + + beforeEach(() => { + delete process.env.ARUI_MODULE_OVERRIDES; + configs.modules = null; + }); + + afterEach(() => { + configs.modules = originalModules; + if (originalEnvValue === undefined) { + delete process.env.ARUI_MODULE_OVERRIDES; + } else { + process.env.ARUI_MODULE_OVERRIDES = originalEnvValue; + } + }); + + describe('getDevModuleOverrides', () => { + it('should return an empty object when nothing is configured', () => { + expect(getDevModuleOverrides()).toEqual({}); + }); + + it('should read overrides from the config file', () => { + configs.modules = { shared: {}, devOverrides: { someModule: 'http://localhost:8081' } }; + + expect(getDevModuleOverrides()).toEqual({ someModule: 'http://localhost:8081' }); + }); + + it('should let the env variable win over the config file', () => { + configs.modules = { + shared: {}, + devOverrides: { + someModule: 'http://localhost:8081', + otherModule: 'http://localhost:9091', + }, + }; + process.env.ARUI_MODULE_OVERRIDES = JSON.stringify({ + someModule: 'http://localhost:7071', + }); + + expect(getDevModuleOverrides()).toEqual({ + someModule: 'http://localhost:7071', + otherModule: 'http://localhost:9091', + }); + }); + + it('should ignore a malformed env variable and warn about it', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + + process.env.ARUI_MODULE_OVERRIDES = 'not a json'; + + expect(getDevModuleOverrides()).toEqual({}); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it('should ignore non-string values', () => { + process.env.ARUI_MODULE_OVERRIDES = JSON.stringify({ someModule: 42 }); + + expect(getDevModuleOverrides()).toEqual({}); + }); + }); + + describe('DefinePlugin wiring', () => { + it('should define the overrides in dev mode', () => { + process.env.ARUI_MODULE_OVERRIDES = JSON.stringify({ + someModule: 'http://localhost:8081', + }); + + const definitions = getDefineDefinitions(getMainClientConfig('dev')); + + // DefinePlugin подставляет значение как код, поэтому в бандле должен оказаться + // строковый литерал с json внутри + expect(definitions[MODULE_OVERRIDES_DEFINE_KEY]).toBe( + JSON.stringify(JSON.stringify({ someModule: 'http://localhost:8081' })), + ); + }); + + it('should define an empty object in dev mode when nothing is configured', () => { + const definitions = getDefineDefinitions(getMainClientConfig('dev')); + + expect(definitions[MODULE_OVERRIDES_DEFINE_KEY]).toBe(JSON.stringify('{}')); + }); + + it('should not define anything in prod mode', () => { + process.env.ARUI_MODULE_OVERRIDES = JSON.stringify({ + someModule: 'http://localhost:8081', + }); + + const definitions = getDefineDefinitions(getMainClientConfig('prod')); + + expect(definitions).not.toHaveProperty(MODULE_OVERRIDES_DEFINE_KEY); + }); + }); +}); diff --git a/packages/arui-scripts/src/configs/app-configs/types.ts b/packages/arui-scripts/src/configs/app-configs/types.ts index 6a6929cd..ffab61cf 100644 --- a/packages/arui-scripts/src/configs/app-configs/types.ts +++ b/packages/arui-scripts/src/configs/app-configs/types.ts @@ -12,7 +12,7 @@ export type AppConfigs = { serverPort: number; debug: boolean; devSourceMaps: DevTool; - devServerCors: boolean; + devServerCors: boolean | 'auto'; useServerHMR: boolean; presets: string | null; proxy: DevServerConfiguration['proxy']; @@ -106,6 +106,7 @@ export type AppConfigs = { exposes?: Record; options?: ModuleConfigBase; shareScope?: string; + devOverrides?: Record; } | null; nodeExternals?: Omit; }; diff --git a/packages/arui-scripts/src/configs/modules.ts b/packages/arui-scripts/src/configs/modules.ts index f582a741..80547463 100644 --- a/packages/arui-scripts/src/configs/modules.ts +++ b/packages/arui-scripts/src/configs/modules.ts @@ -11,9 +11,94 @@ export function haveExposedDefaultModules() { return configs.modules?.exposes; } +function hasEntries(exposes: Record | undefined | null) { + return Boolean(exposes && Object.keys(exposes).length > 0); +} + +/** + * Предоставляет ли приложение модули наружу - неважно, default или compat. + * Приложения, которые сами разбираются с WMF (`disableModulesSupport`), провайдерами не считаются: + * arui-scripts про их модули ничего не знает. + */ +export function isModulesProvider() { + if (configs.disableModulesSupport) { + return false; + } + + return hasEntries(configs.modules?.exposes) || hasEntries(configs.compatModules?.exposes); +} + export const MODULES_ENTRY_NAME = 'remoteEntry.js'; export const MODULES_SEPARATE_BUILD_NAME = 'wmf'; +/** + * Переменная окружения, через которую можно задать подмены адресов модулей для одного запуска: + * `ARUI_MODULE_OVERRIDES='{"someModule":"http://localhost:8081"}' yarn start` + */ +export const MODULE_OVERRIDES_ENV_KEY = 'ARUI_MODULE_OVERRIDES'; + +/** + * Ключ, под которым подмены попадают в клиентскую сборку. Значение читает `@alfalab/scripts-modules`. + * Ключ определяется только в dev-режиме: возможность подменить источник исполняемого js в проде - + * это возможность выполнить произвольный код на странице приложения. + */ +export const MODULE_OVERRIDES_DEFINE_KEY = `process.env.${MODULE_OVERRIDES_ENV_KEY}`; + +/** + * Парсит json подмен moduleId -> baseUrl. + * Формат и правила фильтрации должны совпадать с `parseOverrides` в + * `@alfalab/scripts-modules` (`module-overrides.ts`): общий пакет ради этой + * утилиты не заводим, build-time и runtime живут в разных бандлах. + */ +function parseOverridesFromEnv(): Record { + const rawValue = process.env[MODULE_OVERRIDES_ENV_KEY]; + + if (!rawValue) { + return {}; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(rawValue); + } catch { + console.warn( + `Не удалось разобрать ${MODULE_OVERRIDES_ENV_KEY}, подмены модулей будут проигнорированы. Ожидается json вида {"moduleId":"http://localhost:8081"}`, + ); + + return {}; + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + console.warn( + `${MODULE_OVERRIDES_ENV_KEY} должен быть json-объектом вида {"moduleId":"http://localhost:8081"}, подмены будут проигнорированы.`, + ); + + return {}; + } + + const result: Record = {}; + + Object.entries(parsed as Record).forEach(([moduleId, baseUrl]) => { + if (typeof baseUrl === 'string' && baseUrl) { + result[moduleId] = baseUrl; + } + }); + + return result; +} + +/** + * Собирает подмены адресов модулей для dev-сборки: из `modules.devOverrides` в конфиге приложения + * и из переменной окружения. Переменная окружения приоритетнее - она задается на один запуск. + */ +export function getDevModuleOverrides(): Record { + return { + ...configs.modules?.devOverrides, + ...parseOverridesFromEnv(), + }; +} + function getModuleFederationContainerName() { return configs.modules?.name || configs.normalizedName; } diff --git a/packages/arui-scripts/src/configs/rspack.client.ts b/packages/arui-scripts/src/configs/rspack.client.ts index 980e0177..121494f7 100644 --- a/packages/arui-scripts/src/configs/rspack.client.ts +++ b/packages/arui-scripts/src/configs/rspack.client.ts @@ -37,6 +37,8 @@ import { babelClientConfig as babelConf } from './babel-client'; import { babelDependencies } from './babel-dependencies'; import { addEnvToHtmlTemplate, ClientConfigPlugin } from './client-env-config'; import { + getDevModuleOverrides, + MODULE_OVERRIDES_DEFINE_KEY, MODULES_SEPARATE_BUILD_NAME, patchMainRspackConfigForModules, patchWebpackConfigForCompat, @@ -298,7 +300,17 @@ export const createSingleClientWebpackConfig = ( // В прод режиме webpack автоматически подставляет NODE_ENV=production, но нам нужно чтобы эта переменная // была доступна всегда. При этом мы не хотим давать возможность переопределять ее в production режиме. ...(mode === 'dev' - ? { 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) } + ? { + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), + // Подмены адресов модулей для локальной разработки. В prod-режиме ключ не + // определяется вовсе, и обращение к нему схлопывается в undefined благодаря + // 'process.env': '{}' выше - код чтения подмен вырезается из бандла целиком. + // DefinePlugin подставляет значение как код, поэтому нужен двойной stringify: + // в бандле должен оказаться строковый литерал с json внутри. + [MODULE_OVERRIDES_DEFINE_KEY]: JSON.stringify( + JSON.stringify(getDevModuleOverrides()), + ), + } : {}), }), new CssExtractRspackPlugin( From 5f4a20c0f5a48b08281408169ff867a269df70db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=91=D0=BE=D1=80=D0=BE=D0=B2=D1=81=D0=BA=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 13 Aug 2026 01:09:34 +0300 Subject: [PATCH 3/3] feat(modules): enable auto CORS for module providers in dev --- .changeset/wmf-provider-dev-cors.md | 5 + .../module-loader/utils/module-overrides.ts | 16 +- packages/arui-scripts/docs/settings.md | 9 +- .../__tests__/dev-server-cors.tests.ts | 151 ++++++++++++++++++ .../src/configs/app-configs/get-defaults.ts | 2 +- .../arui-scripts/src/configs/dev-server.ts | 22 ++- 6 files changed, 186 insertions(+), 19 deletions(-) create mode 100644 .changeset/wmf-provider-dev-cors.md create mode 100644 packages/arui-scripts/src/configs/__tests__/dev-server-cors.tests.ts diff --git a/.changeset/wmf-provider-dev-cors.md b/.changeset/wmf-provider-dev-cors.md new file mode 100644 index 00000000..36177b30 --- /dev/null +++ b/.changeset/wmf-provider-dev-cors.md @@ -0,0 +1,5 @@ +--- +'arui-scripts': minor +--- + +Настройка `devServerCors` теперь принимает значение `'auto'` и использует его по умолчанию: CORS-заголовки в dev-режиме включаются автоматически для приложений, предоставляющих модули (`modules.exposes` или `compatModules.exposes`). Без них приложение-потребитель не может загрузить модуль с локально поднятого провайдера. Явные `true` и `false` работают как раньше и всегда выигрывают у автоопределения. diff --git a/packages/arui-scripts-modules/src/module-loader/utils/module-overrides.ts b/packages/arui-scripts-modules/src/module-loader/utils/module-overrides.ts index 518809ac..69379ce5 100644 --- a/packages/arui-scripts-modules/src/module-loader/utils/module-overrides.ts +++ b/packages/arui-scripts-modules/src/module-loader/utils/module-overrides.ts @@ -1,23 +1,11 @@ -/** - * Ключ localStorage, в котором хранятся dev-подмены адресов модулей. - * Значение - json вида `{ "moduleId": "http://localhost:8081" }`. - */ + +// Ключ localStorage, в котором хранятся значение такого формата { "moduleId": "http://localhost:8080" } export const MODULE_OVERRIDES_STORAGE_KEY = 'arui:module-overrides'; -/** - * Имя переменной окружения с dev-подменами. В клиентскую сборку она попадает через DefinePlugin, - * который подставляет ее значение только в dev-режиме. - */ export const MODULE_OVERRIDES_ENV_KEY = 'ARUI_MODULE_OVERRIDES'; type Overrides = Record; -/** - * Парсит json подмен moduleId -> baseUrl. - * Формат и правила фильтрации должны совпадать с `parseOverridesFromEnv` в - * `arui-scripts` (`configs/modules.ts`): общий пакет ради этой утилиты не - * заводим, build-time и runtime живут в разных бандлах. - */ function parseOverrides(rawValue: string | undefined | null, source: string): Overrides { if (!rawValue) { return {}; diff --git a/packages/arui-scripts/docs/settings.md b/packages/arui-scripts/docs/settings.md index 0e39781d..05557243 100644 --- a/packages/arui-scripts/docs/settings.md +++ b/packages/arui-scripts/docs/settings.md @@ -78,7 +78,14 @@ ARUI_SCRIPTS_CONFIG="{\"clientEntry\":{\"mobile\":\"./src/mobile\",\"desktop\":\ тип source-map на любой другой, не использующий eval. #### devServerCors -Включает добавление CORS заголовков в ответы dev-сервера. По умолчанию `false`. +Включает добавление CORS заголовков в ответы dev-сервера. По умолчанию `'auto'`. + +В режиме `'auto'` заголовки добавляются, если приложение предоставляет модули (задан +`modules.exposes` или `compatModules.exposes`). Это нужно, чтобы приложение-потребитель могло +загрузить модуль с локально поднятого провайдера: браузер грузит его с другого origin и без этих +заголовков отклонит запрос. + +Значения `true` и `false` задают поведение явно и всегда выигрывают у автоопределения. #### useServerHMR Использовать ли HotModuleReplacement для сервера. По умолчанию `false`. diff --git a/packages/arui-scripts/src/configs/__tests__/dev-server-cors.tests.ts b/packages/arui-scripts/src/configs/__tests__/dev-server-cors.tests.ts new file mode 100644 index 00000000..0ea3bd5b --- /dev/null +++ b/packages/arui-scripts/src/configs/__tests__/dev-server-cors.tests.ts @@ -0,0 +1,151 @@ +import { configs } from '../app-configs'; +import { isDevServerCorsEnabled } from '../dev-server'; +import { isModulesProvider } from '../modules'; + +describe('dev server cors', () => { + const originalModules = configs.modules; + const originalCompatModules = configs.compatModules; + const originalDevServerCors = configs.devServerCors; + const originalDisableModulesSupport = configs.disableModulesSupport; + + beforeEach(() => { + configs.modules = null; + configs.compatModules = null; + configs.disableModulesSupport = false; + }); + + afterEach(() => { + configs.modules = originalModules; + configs.compatModules = originalCompatModules; + configs.devServerCors = originalDevServerCors; + configs.disableModulesSupport = originalDisableModulesSupport; + }); + + describe('isModulesProvider', () => { + it('should be false for an app without exposed modules', () => { + expect(isModulesProvider()).toBe(false); + }); + + it('should be false when modules section has no exposes', () => { + configs.modules = { shared: { react: '^18.0.0' } }; + + expect(isModulesProvider()).toBe(false); + }); + + it('should be false for an empty exposes', () => { + configs.modules = { shared: {}, exposes: {} }; + + expect(isModulesProvider()).toBe(false); + }); + + it('should be true when default modules are exposed', () => { + configs.modules = { shared: {}, exposes: { SomeModule: './src/modules/some-module' } }; + + expect(isModulesProvider()).toBe(true); + }); + + it('should be true when compat modules are exposed', () => { + configs.compatModules = { + exposes: { SomeModule: { entry: './src/modules/some-module' } }, + }; + + expect(isModulesProvider()).toBe(true); + }); + + it('should be false when the app opted out of modules support', () => { + configs.modules = { shared: {}, exposes: { SomeModule: './src/modules/some-module' } }; + configs.disableModulesSupport = true; + + expect(isModulesProvider()).toBe(false); + }); + }); + + describe('isDevServerCorsEnabled', () => { + it('should respect an explicit true', () => { + configs.devServerCors = true; + + expect(isDevServerCorsEnabled()).toBe(true); + }); + + it('should respect an explicit false even for a provider', () => { + configs.devServerCors = false; + configs.modules = { shared: {}, exposes: { SomeModule: './src/modules/some-module' } }; + + expect(isDevServerCorsEnabled()).toBe(false); + }); + + it('should enable cors for a provider in auto mode', () => { + configs.devServerCors = 'auto'; + configs.modules = { shared: {}, exposes: { SomeModule: './src/modules/some-module' } }; + + expect(isDevServerCorsEnabled()).toBe(true); + }); + + it('should not enable cors for a non-provider in auto mode', () => { + configs.devServerCors = 'auto'; + + expect(isDevServerCorsEnabled()).toBe(false); + }); + + it('should not enable cors in auto mode when modules support is disabled', () => { + configs.devServerCors = 'auto'; + configs.modules = { shared: {}, exposes: { SomeModule: './src/modules/some-module' } }; + configs.disableModulesSupport = true; + + expect(isDevServerCorsEnabled()).toBe(false); + }); + }); + + describe('dev server config', () => { + /** + * devServerConfig вычисляется на импорте модуля, поэтому его нужно перечитать в изоляции. + * isolateModules перечитывает и app-configs, так что мутации `configs` до него не доедут - + * конфигурацию приходится подменять моком. + */ + function buildDevServerConfig(configOverrides: Partial) { + let result: unknown; + + jest.isolateModules(() => { + jest.doMock('../app-configs', () => ({ + configs: { ...configs, ...configOverrides }, + })); + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + result = require('../dev-server').devServerConfig; + }); + jest.dontMock('../app-configs'); + + return result as { headers: Record }; + } + + it('should send cors headers for a provider in auto mode', () => { + const devServerConfig = buildDevServerConfig({ + devServerCors: 'auto', + modules: { shared: {}, exposes: { SomeModule: './src/modules/some-module' } }, + }); + + expect(devServerConfig.headers).toMatchObject({ + 'Access-Control-Allow-Origin': '*', + }); + }); + + it('should not send cors headers for a non-provider in auto mode', () => { + const devServerConfig = buildDevServerConfig({ + devServerCors: 'auto', + modules: null, + compatModules: null, + }); + + expect(devServerConfig.headers).toEqual({}); + }); + + it('should not send cors headers in auto mode when modules support is disabled', () => { + const devServerConfig = buildDevServerConfig({ + devServerCors: 'auto', + disableModulesSupport: true, + modules: { shared: {}, exposes: { SomeModule: './src/modules/some-module' } }, + }); + + expect(devServerConfig.headers).toEqual({}); + }); + }); +}); diff --git a/packages/arui-scripts/src/configs/app-configs/get-defaults.ts b/packages/arui-scripts/src/configs/app-configs/get-defaults.ts index a50ba405..fc4aa82b 100644 --- a/packages/arui-scripts/src/configs/app-configs/get-defaults.ts +++ b/packages/arui-scripts/src/configs/app-configs/get-defaults.ts @@ -22,7 +22,7 @@ export function getDefaultAppConfig(): AppConfigs { serverPort: 3000, debug: false, devSourceMaps: 'cheap-module-source-map', // так получаются нормальные сорсмапы, с eval - они не работают - devServerCors: false, + devServerCors: 'auto', useServerHMR: false, presets: configFile?.presets || appPackage?.aruiScripts?.presets || null, proxy: appPackage.proxy || [], diff --git a/packages/arui-scripts/src/configs/dev-server.ts b/packages/arui-scripts/src/configs/dev-server.ts index eefe86b4..53277592 100644 --- a/packages/arui-scripts/src/configs/dev-server.ts +++ b/packages/arui-scripts/src/configs/dev-server.ts @@ -6,6 +6,22 @@ import type http from 'http'; import { applyOverrides } from './util/apply-overrides'; import { configs } from './app-configs'; import { ENV_CONFIG_FILENAME } from './client-env-config'; +import { isModulesProvider } from './modules'; + +/** + * Нужно ли добавлять CORS-заголовки в ответы dev-сервера. + * + * В режиме `'auto'` они включаются для приложений, предоставляющих модули: хост грузит модуль + * с другого origin, и без этих заголовков браузер запрос отклонит. Явно заданное значение + * всегда выигрывает. + */ +export function isDevServerCorsEnabled() { + if (configs.devServerCors !== 'auto') { + return configs.devServerCors; + } + + return isModulesProvider(); +} function getServerToClientProxyConfig(): NonNullable[number] { const assetsRoot = path.normalize(`/${configs.publicPath}`).replace(/\\/g, '/'); @@ -16,7 +32,7 @@ function getServerToClientProxyConfig(): NonNullable[num // на бэкенд уходят только запросы, чей path не относится к клиентской статике (префикс publicPath). pathFilter: (pathname: string) => !pathname.startsWith(assetsRoot), ...((configs.devSourceMaps && configs.devSourceMaps.includes('eval')) || - configs.devServerCors + isDevServerCorsEnabled() ? { on: { proxyRes: (proxyRes: http.IncomingMessage, req: http.IncomingMessage) => { @@ -38,7 +54,7 @@ function getServerToClientProxyConfig(): NonNullable[num } // если включен devServerCors, то нужно принудительно менять статус ответа на 200, чтобы // браузер не отклонял ответы с CORS - if (configs.devServerCors && req.method === 'OPTIONS') { + if (isDevServerCorsEnabled() && req.method === 'OPTIONS') { // eslint-disable-next-line no-param-reassign proxyRes.statusCode = 200; } @@ -68,7 +84,7 @@ export const devServerConfig = applyOverrides('devServer', { }, static: [configs.serverOutputPath, configs.clientOutputPath], proxy: getProxyConfig(), - headers: configs.devServerCors + headers: isDevServerCorsEnabled() ? { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*',