Skip to content
Open
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
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ Set it to wherever the `dist/public/build` output is actually served: `'/'` if a

If dynamic imports 404 at `/build/js/...` while other assets load from `/js/...`, or HMR breaks in dev, set this global — changing `publicPath` in config will not help.

If the runtime should retry another base URL when a chunk fails to load from this one, see [`publicPathFallback`](#production-build).

#### Options

All paths must be specified relative `rootDir` of the project.
Expand Down Expand Up @@ -358,6 +360,54 @@ With this `{rootDir}/src/ui/tsconfig.json`:
- `publicPath` (`string`) - public path to access files from the browser
- `compress` (`boolean`) - upload also gzip and brotli compressed versions of files
- `additionalPattern` (`string[]`) — patterns for uploading additional files. By default, only files generated by webpack are loaded.
- `publicPathFallback` (`Array<string | {publicPath: string, hosts?: string | RegExp | Array<string | RegExp>}>`) — ordered public paths to retry a failed async chunk from. Disabled by default.

On failure, the runtime switches to the next path for all later loads, so only the first failing chunk pays for a degraded CDN. The primary path `window.__PUBLIC_PATH__` is retried automatically after a backoff delay.

`window.__PUBLIC_PATH__` holds the current public path and is always tried first automatically — moved to the front of the list (or de-duplicated there) whether or not it's also configured. So `publicPathFallback` should only ever list backups; a single entry is already useful.

Each entry is a plain string, or an object:

- `publicPath` (`string`) — the backup base URL.
- `hosts` (`string | RegExp | Array<string | RegExp>`, optional) — restricts the entry to hosts it applies to, matched against `location.hostname`. A string must match exactly, a `RegExp` is tested against it; matching is always case-insensitive. Omitted means every host.

```ts
import {defineConfig} from '@gravity-ui/app-builder';

export default defineConfig({
client: {
publicPathFallback: [
{publicPath: 'https://cdn.example.ru/build/', hosts: /\.ru$/},
{publicPath: 'https://cdn.example.kz/build/', hosts: 'app.example.kz'},
'/build/',
],
},
});
// on app.example.ru: window.__PUBLIC_PATH__ -> https://cdn.example.ru/build/ -> /build/
// on app.example.kz: window.__PUBLIC_PATH__ -> https://cdn.example.kz/build/ -> /build/
// anywhere else: window.__PUBLIC_PATH__ -> /build/
```

Independent of `cdn`, which only handles uploading static — use this whether the upload is done by app-builder or by something else entirely.

If you add the local public path as a candidate, use whatever your build actually serves from — `/build/` by default, but `publicPathPrefix` and module federation asset isolation both change it.

Covers only lazy JS chunks and their async CSS. **Not covered**: initial `<script>`/`<link>` tags (they load before any bundle code runs — a primary outage there has to be handled in the page template) and worker scripts.

The option has no effect in dev mode, in SSR builds, or with `moduleFederation`.

Every switch (or exhaustion) dispatches a `window` `CustomEvent` named `app-builder:public-path-fallback` with `detail` typed as the exported `PublicPathFallbackEventDetail` (`nextPath` is `null` when no candidate is left) — listen for it to report failures to your own monitoring.

```ts
import type {PublicPathFallbackEventDetail} from '@gravity-ui/app-builder';

window.addEventListener('app-builder:public-path-fallback', (event) => {
const {chunkId, deadPath, nextPath, error} = (
event as CustomEvent<PublicPathFallbackEventDetail>
).detail;
});
```

- `sentryConfig` (`Options`) — `@sentry/webpack-plugin` [configuration options](https://www.npmjs.com/package/@sentry/webpack-plugin/v/2.7.1).

##### Optimization
Expand Down
81 changes: 81 additions & 0 deletions src/common/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import type {CosmiconfigResult} from 'cosmiconfig';

import type {
CdnHostPattern,
ClientConfig,
LibraryConfig,
NormalizedClientConfig,
Expand All @@ -19,6 +20,8 @@
NormalizedServerConfig,
NormalizedServiceConfig,
ProjectConfig,
PublicPathFallback,
PublicPathFallbackEntry,
ServerConfig,
ServiceConfig,
} from './models/index.js';
Expand All @@ -28,16 +31,93 @@

const require = createRequire(import.meta.url);

function splitPaths(paths: string | string[]) {

Check warning on line 34 in src/common/config.ts

View workflow job for this annotation

GitHub Actions / Verify Files

'paths' is already declared in the upper scope on line 9 column 8
return (Array.isArray(paths) ? paths : [paths]).flatMap((p) => p.split(','));
}

function remapPaths(paths: string | string[]) {

Check warning on line 38 in src/common/config.ts

View workflow job for this annotation

GitHub Actions / Verify Files

'paths' is already declared in the upper scope on line 9 column 8
return splitPaths(paths).map((p) => path.resolve(process.cwd(), p));
}

function withTrailingSlash(publicPath: string) {
return /[\\/]$/.test(publicPath) ? publicPath : `${publicPath}/`;
}

function normalizeHostPatterns(hosts: PublicPathFallbackEntry['hosts']) {
if (!hosts) {
return undefined;
}

const patterns = (Array.isArray(hosts) ? hosts : [hosts]).map((host) => ({
source: toPatternSource(host),
flags: toPatternFlags(host),
}));

return patterns.length > 0 ? patterns : undefined;
}

// 'app.example.ru' -> '^app\.example\.ru$';
function toPatternSource(host: CdnHostPattern) {
return typeof host === 'string'
? `^${host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`
: host.source;
}

// Forces case-insensitivity (`location.hostname` is always lower case) and drops
// `g`/`y`, which would make a shared RegExp instance stateful across calls.
function toPatternFlags(host: CdnHostPattern) {
return `i${typeof host === 'string' ? '' : host.flags.replace(/[giy]/g, '')}`;
}

function normalizePublicPathFallbacks(client: ClientConfig, mode?: string): PublicPathFallback[] {
if (!client.publicPathFallback?.length || mode === 'dev') {
return [];
}

if (client.moduleFederation) {
logger.warning(
stripIndent`
publicPathFallback option is disabled because moduleFederation is configured.
Module federation remotes load their entries and chunks through their own runtime,
which the fallback cannot reach.
`,
);
return [];
}

const candidates: PublicPathFallback[] = [];
for (const entry of client.publicPathFallback) {
const candidate = normalizeFallbackEntry(entry);

if (candidate && !isDuplicatePublicPath(candidates, candidate.publicPath)) {
candidates.push(candidate);
}
}

return candidates;
}

function normalizeFallbackEntry(
entry: string | PublicPathFallbackEntry,
): PublicPathFallback | undefined {
const {publicPath, hosts} = typeof entry === 'string' ? {publicPath: entry} : entry;

if (!publicPath) {
return undefined;
}

return omitUndefined({
publicPath: withTrailingSlash(publicPath),
hosts: normalizeHostPatterns(hosts),
}) as PublicPathFallback;
}

function isDuplicatePublicPath(candidates: PublicPathFallback[], publicPath: string) {
return candidates.some((candidate) => candidate.publicPath === publicPath);
}

function omitUndefined<T extends object>(obj: T) {
const newObj: Record<string, any> = {};

Check warning on line 120 in src/common/config.ts

View workflow job for this annotation

GitHub Actions / Verify Files

Unexpected any. Specify a different type
for (const [key, value] of Object.entries(obj)) {
if (value !== undefined) {
newObj[key] = value;
Expand Down Expand Up @@ -264,6 +344,7 @@
publicPath,
cdnPublicPath: cdnConfig?.publicPath,
browserPublicPath,
publicPathFallbacks: normalizePublicPathFallbacks(client, mode),
assetsManifestFile:
client.assetsManifestFile ||
(client.moduleFederation?.version
Expand Down
60 changes: 60 additions & 0 deletions src/common/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,25 @@ export interface ClientCommonConfig {
};
// TODO(DakEnviy): Allow only one cdn config
cdn?: CdnUploadConfig | CdnUploadConfig[];
/**
* Ordered public paths to retry a failed async chunk from.
*
* On failure, the runtime switches to the next path for all later loads. The primary
* path (window.__PUBLIC_PATH__) retries automatically after a backoff delay.
*
* `window.__PUBLIC_PATH__` is always tried first - moved to the front whether or not
* it's also listed here, so this array usually holds only backups. Entries with
* `hosts` apply only when `location.hostname` matches.
*
* Covers only lazy JS chunks and their async CSS. Not covered: initial `<script>`/
* `<link>` tags (they load before any bundle code runs - a primary outage there
* has to be handled in the page template) and worker scripts.
*
* Has no effect in dev mode, in SSR builds, or with `moduleFederation`.
*
* @example [{publicPath: 'https://cdn.example.ru/build/', hosts: /\.ru$/}, '/build/']
*/
publicPathFallback?: (string | PublicPathFallbackEntry)[];
/**
* use webpack 5 Web Workers [syntax](https://webpack.js.org/guides/web-workers/#syntax)
*
Expand Down Expand Up @@ -492,6 +511,42 @@ export interface CdnUploadConfig {
additionalPattern?: string | string[];
}

export type CdnHostPattern = string | RegExp;

export interface PublicPathFallbackEntry {
/** The backup base URL to retry a failed chunk from. */
publicPath: string;
/**
* Hosts this public path may be used on.
*
* A string must match the whole `location.hostname` exactly; a RegExp is tested
* as-is. Matching is always case-insensitive (`location.hostname` is lower case).
* When omitted, the public path is used on every host.
*
* @example ['app.example.ru', /\.example\.kz$/]
*/
hosts?: CdnHostPattern | CdnHostPattern[];
}

/** Serialized form of `PublicPathFallbackEntry`, injected into the bundle via `DefinePlugin`. */
export interface PublicPathFallback {
publicPath: string;
hosts?: {source: string; flags: string}[];
}

/**
* `detail` of the `app-builder:public-path-fallback` window `CustomEvent`,
* dispatched on every switch or exhaustion. Cast to `CustomEvent<PublicPathFallbackEventDetail>`
* in the event listener, e.g. `window.addEventListener('app-builder:public-path-fallback', (event) => ...)`.
*/
export interface PublicPathFallbackEventDetail {
chunkId: string | number;
deadPath: string;
/** Next public path now active, or `null` when every candidate is exhausted. */
nextPath: string | null;
error: Error;
}

export interface ServerConfig {
port?: number | true;
watch?: string[];
Expand Down Expand Up @@ -587,6 +642,11 @@ export type NormalizedClientBaseConfig = Omit<
* (concatenated with micro-frontend name if module federation is configured).
*/
browserPublicPath: string;
/**
* Ordered list of public paths the runtime may load async chunks from,
* derived from `publicPathFallback`. Empty if the fallback is disabled.
*/
publicPathFallbacks: PublicPathFallback[];
assetsManifestFile: string;
hiddenSourceMap: boolean;
svgr: NonNullable<ClientConfig['svgr']>;
Expand Down
15 changes: 15 additions & 0 deletions src/common/webpack/browser-events.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/* eslint-env browser */

const NAMESPACE = 'app-builder';

export const BrowserEvents = {
PublicPathFallback: `${NAMESPACE}:public-path-fallback`,
};

export function dispatchBrowserEvent(name, detail) {
if (typeof window === 'undefined' || typeof window.CustomEvent !== 'function') {
return;
}

window.dispatchEvent(new CustomEvent(name, {detail}));
}
32 changes: 23 additions & 9 deletions src/common/webpack/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,31 +465,44 @@ export function configureResolve({isEnvProduction, config}: HelperOptions) {
} satisfies webpack.ResolveOptions;
}

function createEntryArray(entry: string | string[]) {
function getRuntimeEntries({config, isSsr}: HelperOptions) {
const runtimeEntries = [require.resolve('./public-path.js')];

if (!isSsr && config.publicPathFallbacks.length > 0) {
runtimeEntries.push(require.resolve('./public-path-fallback.js'));
}

return runtimeEntries;
}

function createEntryArray(entry: string | string[], runtimeEntries: string[]) {
if (typeof entry === 'string') {
return [require.resolve('./public-path.js'), entry];
return [...runtimeEntries, entry];
}

return [require.resolve('./public-path.js'), ...entry];
return [...runtimeEntries, ...entry];
}

function addEntry(entry: Record<string, string[]>, file: string) {
function addEntry(entry: Record<string, string[]>, file: string, runtimeEntries: string[]) {
return {
...entry,
[path.parse(file).name]: createEntryArray(file),
[path.parse(file).name]: createEntryArray(file, runtimeEntries),
};
}

function configureEntry({config, entriesDirectory}: HelperOptions) {
function configureEntry(options: HelperOptions) {
const {config, entriesDirectory} = options;
const runtimeEntries = getRuntimeEntries(options);

if (typeof config.entry === 'string' || Array.isArray(config.entry)) {
return createEntryArray(config.entry);
return createEntryArray(config.entry, runtimeEntries);
}

if (typeof config.entry === 'object') {
return Object.entries(config.entry).reduce<Record<string, string[]>>(
(acc, [key, value]) => ({
...acc,
[key]: createEntryArray(value),
[key]: createEntryArray(value, runtimeEntries),
}),
{},
);
Expand Down Expand Up @@ -537,7 +550,7 @@ function configureEntry({config, entriesDirectory}: HelperOptions) {
}

return entryFiles.reduce<Record<string, string[]>>(
(acc, file) => addEntry(acc, path.resolve(entriesDirectory, file)),
(acc, file) => addEntry(acc, path.resolve(entriesDirectory, file), runtimeEntries),
{},
);
}
Expand Down Expand Up @@ -1127,6 +1140,7 @@ function getDefinitions({config, isSsr}: HelperOptions) {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
'process.env.IS_SSR': JSON.stringify(isSsr),
'process.env.PUBLIC_PATH': JSON.stringify(config.browserPublicPath),
__PUBLIC_PATH_FALLBACKS__: JSON.stringify(isSsr ? [] : config.publicPathFallbacks),
...config.definitions,
};
}
Expand Down
Loading
Loading