feat(client): support configurable app entry mode (#9891)

This commit is contained in:
PiEgg
2026-06-25 17:38:47 +08:00
committed by GitHub
parent 456654a418
commit 7b77c5b539
9 changed files with 349 additions and 5 deletions
+33
View File
@@ -16,6 +16,10 @@ _Avoid_: v2 (in user-facing terms), new client
The base URL path the whole NocoBase app is mounted under, set by `APP_PUBLIC_PATH` (default `/`).
_Avoid_: base path, root path
**Site root**:
The domain root path `/`, which may redirect into the **App public path** when the app is mounted under a sub-path.
_Avoid_: app root, public path
**Modern client prefix**:
The single URL path segment, directly under the app public path, where the modern client is served — set by `APP_MODERN_CLIENT_PREFIX` (default `v`, historically the hardcoded `v2`). A segment, not a full path; accepted in any of `v` / `/v` / `/v/` and normalized to a bare segment.
_Avoid_: v2 prefix, route prefix, base url
@@ -28,11 +32,36 @@ _Avoid_: v2 public path
The fixed on-disk location of the modern client's built assets (`dist/client/v/`), named from `DEFAULT_MODERN_CLIENT_PREFIX`. Internal and never user-facing; intentionally does NOT track the runtime **Modern client prefix**, so the prefix can change at runtime without a rebuild.
_Avoid_: v2 dist, asset directory (without "build")
**App client entry mode**:
The runtime policy that decides whether the legacy client shell keeps an entry on a given URL, or hands that entry off to the modern client; in production this is primarily enforced by the legacy browser shell, while dev may add targeted shortcuts.
_Avoid_: route mode, bootstrap mode
**Legacy-default**:
An **App client entry mode** where the app root opens the **Legacy client**, while the **Modern client** remains available at its own public path.
_Avoid_: legacy mode, old default
**Modern-default**:
An **App client entry mode** where the app root itself hands off to the **Modern client public path**, while legacy deep links such as `/admin` or `/signin` remain valid.
_Avoid_: hybrid modern-only, redirect-all
**Modern-only**:
An **App client entry mode** where legacy client document entries hand off to the **Modern client public path**; in production this is mainly a browser-side handoff, while dev additionally avoids running the legacy client dev server and redirects non-`/v/` entries into `/v/`.
_Avoid_: compatible modern-only, route-mapped modern-only
**Client document entry request**:
An HTTP request whose job is to load a client HTML entry, not an API, websocket, upload, dist, or plugin-static resource.
_Avoid_: all frontend request, browser request
## Relationships
- The **Modern client public path** = **App public path** + **Modern client prefix** + `/`
- The **Legacy client** is served at the **App public path**; the **Modern client** is served at the **Modern client public path** nested inside it
- **App public path** and **Modern client prefix** vary independently; both default such that the modern client lands at `/v/`
- The **Site root** is not always the **App public path**
- The **App client entry mode** chooses the default entry behavior independently from the concrete route trees owned by the **Legacy client** and the **Modern client**
- A **Legacy client** deep link is not assumed to have a one-to-one **Modern client** deep link
- In production, the **Legacy client** shell is the main place where **App client entry mode** hands off document entries to the **Modern client**
- In dev, only **Modern-only** adds extra runtime handling so the legacy dev server is not started and non-`/v/` entries are redirected into the modern dev entry
## Example dialogue
@@ -44,6 +73,10 @@ _Avoid_: v2 dist, asset directory (without "build")
## Flagged ambiguities
- **"v2"** was overloaded to mean three different things: (a) the **Modern client** runtime, (b) its URL **Modern client prefix**, and (c) the physical build-output directory name. Resolved: the runtime is the *modern client*; the URL segment is the *modern client prefix* (runtime-configurable, default `v`); the *modern client build directory* is a fixed internal constant (`v`), decoupled from the prefix so the prefix can change at runtime without rebuilding (see ADR-0001).
- **"modern default" vs "modern only"** are not route-equivalent terms. Resolved so far: **Modern-default** only changes the app root entry; because legacy and modern deep links are not one-to-one, it does not imply automatic translation of legacy deep links into modern ones.
- **"/* -> /v/*"** was overloaded between a browser-visible outcome and a service-layer implementation. Resolved: in the current implementation, production primarily relies on the legacy browser shell to hand off document entries into the modern prefix, while dev keeps an additional `modern-only` shortcut.
- **"all / requests"** was ambiguous between every HTTP request and every client entry navigation. Resolved: **App client entry mode** only applies to **Client document entry requests**; API, websocket, upload, dist, and plugin-static requests keep their existing handling.
- **"root"** was ambiguous between the **Site root** `/` and the **App public path**. Resolved: entry-mode decisions apply to the **App public path**; the **Site root** may first redirect into it when the app is mounted under a sub-path.
---
@@ -52,6 +52,27 @@ function toNumber(value: string | undefined, fallback: number) {
return Number.isFinite(parsed) ? parsed : fallback;
}
function normalizePathname(value: string | undefined) {
let normalized = value || '/';
if (!normalized.startsWith('/')) {
normalized = `/${normalized}`;
}
normalized = normalized.replace(/\/{2,}/g, '/');
if (normalized !== '/' && normalized.endsWith('/')) {
return normalized.replace(/\/+$/g, '');
}
return normalized || '/';
}
function isClientDocumentEntryPath(pathname: string) {
return pathname === '/' || pathname === '/index.html' || !/\.[^/]+$/.test(pathname);
}
function isWithinBasePath(pathname: string, basePath: string) {
const normalizedBasePath = normalizePathname(basePath);
return pathname === normalizedBasePath || pathname.startsWith(`${normalizedBasePath}/`);
}
function createRuntimeHeadScript(v2PublicPath: string, isBuild: boolean, modernClientPrefix: string) {
if (!isBuild) {
return [
@@ -132,6 +153,7 @@ export default defineConfig(({ command }) => {
const modernClientDistPath = ensurePublicPath(`${appPublicPath.replace(/\/$/, '')}/${MODERN_CLIENT_DIST_DIR}/`);
const modernClientPublicPath = ensurePublicPath(`${appPublicPath.replace(/\/$/, '')}/${modernClientPrefix}/`);
const v2PublicPath = isBuild ? modernClientDistPath : modernClientPublicPath;
const appClientEntryMode = process.env.APP_CLIENT_ENTRY_MODE;
const wsBasePath = ensurePublicPath(process.env.WS_PATH || '/ws/');
const hmrPath = `${v2PublicPath.replace(/\/$/, '')}/__rspack_hmr`;
const v2Port = toNumber(process.env.APP_V2_PORT, 13002);
@@ -275,6 +297,37 @@ export default defineConfig(({ command }) => {
dev: {
assetPrefix: v2PublicPath,
lazyCompilation: false,
setupMiddlewares: [
(middlewares) => {
if (appClientEntryMode !== 'modern-only') {
return;
}
middlewares.unshift((req, res, next) => {
const [rawPathname = '/', query = ''] = String(req.url || '/').split('?');
const pathname = normalizePathname(rawPathname);
if (
!isClientDocumentEntryPath(pathname) ||
pathname.startsWith('/.well-known/') ||
pathname.startsWith(apiBasePath) ||
pathname.startsWith(wsBasePath) ||
pathname.startsWith(localStorageBasePath) ||
pathname.startsWith(staticBasePath)
) {
next();
return;
}
if (!isWithinBasePath(pathname, v2PublicPath)) {
const target = pathname === '/' ? v2PublicPath : `${v2PublicPath.replace(/\/$/, '')}${pathname}`;
res.statusCode = 302;
res.setHeader('Location', `${target}${query ? `?${query}` : ''}`);
res.end();
return;
}
next();
});
},
],
client: {
overlay: false,
protocol: 'ws',
@@ -17,9 +17,31 @@ function normalizePublicPath(value) {
return ensureTrailingSlash(normalized);
}
function trimTrailingSlash(value) {
return value === '/' ? value : value.replace(/\/+$/g, '');
}
function normalizePathname(value) {
const normalized = ensureLeadingSlash(String(value || '/').trim() || '/').replace(/\/{2,}/g, '/');
if (normalized !== '/' && normalized.endsWith('/')) {
return normalized.replace(/\/+$/g, '');
}
return normalized;
}
function isClientDocumentEntryPath(pathname) {
const normalized = normalizePathname(pathname);
return normalized === '/' || normalized === '/index.html' || !/\.[^/]+$/.test(normalized);
}
const basename = normalizePublicPath(window['__nocobase_public_path__'] || '/');
const currentPath = ensureLeadingSlash(String(window.location.pathname || '/').trim() || '/').replace(/\/{2,}/g, '/');
const basenameWithoutTrailingSlash = basename === '/' ? '/' : basename.replace(/\/+$/, '');
const modernClientPrefix =
String(window['__nocobase_modern_client_prefix__'] || 'v')
.trim()
.replace(/^\/+|\/+$/g, '') || 'v';
const appClientEntryMode = window['__nocobase_app_client_entry_mode__'];
if (basename !== '/' && currentPath === basenameWithoutTrailingSlash) {
const newUrl = `${window.location.origin}${basename}${window.location.search}${window.location.hash}`;
@@ -28,6 +50,42 @@ if (basename !== '/' && currentPath === basenameWithoutTrailingSlash) {
const newPath = currentPath === '/' ? basename : `${basenameWithoutTrailingSlash}${currentPath}`;
let newUrl = window.location.origin + newPath + window.location.search + window.location.hash;
window.location.replace(newUrl);
} else {
// This client-side redirect is still needed because legacy `index.html` is
// not always served through the node gateway. In nginx/static delivery paths
// the browser may already be running the legacy shell by the time entry-mode
// logic is evaluated, so the last hop into the modern entry has to be
// recoverable in the browser as well.
const normalizedPath = normalizePathname(currentPath);
const relativePath =
basename === '/'
? normalizedPath
: normalizedPath === basenameWithoutTrailingSlash
? '/'
: normalizedPath.startsWith(basename)
? normalizePathname(normalizedPath.slice(basename.length - 1))
: null;
const modernBase = `${trimTrailingSlash(basename)}/${modernClientPrefix}/`.replace(/\/{2,}/g, '/');
const isModernDefault = appClientEntryMode === 'modern-default';
const isModernOnly = appClientEntryMode === 'modern-only';
if (
relativePath &&
isClientDocumentEntryPath(relativePath) &&
(isModernDefault || isModernOnly) &&
!normalizedPath.startsWith(modernBase)
) {
const targetPath = isModernDefault
? relativePath === '/' || relativePath === '/index.html'
? relativePath === '/index.html'
? `${modernBase}index.html`
: modernBase
: null
: `${trimTrailingSlash(modernBase)}${relativePath}`;
if (targetPath && targetPath !== currentPath) {
const newUrl = window.location.origin + targetPath + window.location.search + window.location.hash;
window.location.replace(newUrl);
}
}
}
let showLog = true;
function log(m) {
@@ -47,9 +47,16 @@ function toDefineLiteral(value: string | undefined) {
}
function createRuntimeHeadScript(appPublicPath: string, isBuild: boolean) {
const modernClientPrefix =
String(process.env.APP_MODERN_CLIENT_PREFIX || 'v')
.trim()
.replace(/^\/+|\/+$/g, '') || 'v';
const appClientEntryMode = process.env.APP_CLIENT_ENTRY_MODE;
if (!isBuild) {
return [
`window['__nocobase_public_path__'] = ${JSON.stringify(appPublicPath)};`,
`window['__nocobase_modern_client_prefix__'] = ${JSON.stringify(modernClientPrefix)};`,
`window['__nocobase_app_client_entry_mode__'] = ${JSON.stringify(appClientEntryMode)};`,
`window['__nocobase_dev_public_path__'] = "/";`,
`window['__nocobase_app_dev__'] = ${JSON.stringify(process.env.NOCOBASE_APP_DEV === 'true')};`,
`window['__esm_cdn_base_url__'] = ${JSON.stringify(process.env.ESM_CDN_BASE_URL || '')};`,
@@ -60,6 +67,8 @@ function createRuntimeHeadScript(appPublicPath: string, isBuild: boolean) {
return [
`window['__webpack_public_path__'] = '{{env.CDN_BASE_URL}}';`,
`window['__nocobase_public_path__'] = '${appPublicPath}';`,
`window['__nocobase_modern_client_prefix__'] = '{{env.APP_MODERN_CLIENT_PREFIX}}';`,
`window['__nocobase_app_client_entry_mode__'] = '{{env.APP_CLIENT_ENTRY_MODE}}';`,
`window['__nocobase_api_base_url__'] = '{{env.API_BASE_URL}}';`,
`window['__nocobase_api_client_storage_prefix__'] = '{{env.API_CLIENT_STORAGE_PREFIX}}';`,
`window['__nocobase_api_client_storage_type__'] = '{{env.API_CLIENT_STORAGE_TYPE}}';`,
@@ -24,6 +24,8 @@ function createAppPackageRoot() {
[
"window['__nocobase_app_dev__'] = {{env.NOCOBASE_APP_DEV}};",
"window['__nocobase_public_path__'] = '{{env.APP_PUBLIC_PATH}}';",
"window['__nocobase_modern_client_prefix__'] = '{{env.APP_MODERN_CLIENT_PREFIX}}';",
"window['__nocobase_app_client_entry_mode__'] = '{{env.APP_CLIENT_ENTRY_MODE}}';",
].join('\n'),
'utf-8',
);
@@ -73,11 +75,43 @@ describe('cli-v1 buildIndexHtml', () => {
process.env.APP_PACKAGE_ROOT = appRoot;
process.env.APP_PUBLIC_PATH = '/';
process.env.NOCOBASE_APP_DEV = '';
process.env.APP_MODERN_CLIENT_PREFIX = 'console';
process.env.APP_CLIENT_ENTRY_MODE = 'modern-default';
buildIndexHtml();
const html = fs.readFileSync(path.join(appRoot, 'dist/client/index.html'), 'utf-8');
expect(html).toContain("window['__nocobase_app_dev__'] = false;");
expect(html).toContain("window['__nocobase_modern_client_prefix__'] = 'console';");
expect(html).toContain("window['__nocobase_app_client_entry_mode__'] = 'modern-default';");
fs.removeSync(appRoot);
});
test('refreshes cached tpl when new runtime placeholders are missing', () => {
const appRoot = createAppPackageRoot();
const tplPath = path.join(appRoot, 'dist/client/index.html.tpl');
const indexPath = path.join(appRoot, 'dist/client/index.html');
fs.writeFileSync(tplPath, "window['__nocobase_public_path__'] = '{{env.APP_PUBLIC_PATH}}';", 'utf-8');
fs.writeFileSync(
indexPath,
[
"window['__nocobase_public_path__'] = '{{env.APP_PUBLIC_PATH}}';",
"window['__nocobase_modern_client_prefix__'] = '{{env.APP_MODERN_CLIENT_PREFIX}}';",
"window['__nocobase_app_client_entry_mode__'] = '{{env.APP_CLIENT_ENTRY_MODE}}';",
].join('\n'),
'utf-8',
);
process.argv = ['node', 'nocobase-v1', 'start'];
process.env.APP_PACKAGE_ROOT = appRoot;
process.env.APP_PUBLIC_PATH = '/';
process.env.APP_MODERN_CLIENT_PREFIX = 'v';
process.env.APP_CLIENT_ENTRY_MODE = 'modern-only';
buildIndexHtml();
const tpl = fs.readFileSync(tplPath, 'utf-8');
expect(tpl).toContain('__nocobase_modern_client_prefix__');
expect(tpl).toContain('__nocobase_app_client_entry_mode__');
fs.removeSync(appRoot);
});
});
@@ -9,7 +9,7 @@
/* eslint-env jest */
const { buildAppDevForwardArgs, forwardDevToAppDev } = require('../commands/dev')._test;
const { buildAppDevForwardArgs, forwardDevToAppDev, resolveDevRuntimeMode } = require('../commands/dev')._test;
describe('cli-v1 dev command', () => {
test('buildAppDevForwardArgs rewrites dev argv to app-dev while preserving extra args', () => {
@@ -31,4 +31,31 @@ describe('cli-v1 dev command', () => {
expect(calls).toEqual([['nocobase-v1', ['app-dev', '--port', '13000', '--db-sync']]]);
});
test('resolveDevRuntimeMode keeps both clients in legacy-default', () => {
expect(resolveDevRuntimeMode({ appClientEntryMode: 'legacy-default' })).toMatchObject({
useModernOnlyEntryMode: false,
shouldRunClient: true,
shouldRunClientV2: true,
shouldRunServer: true,
});
});
test('resolveDevRuntimeMode only runs v2 client and server in modern-only', () => {
expect(resolveDevRuntimeMode({ appClientEntryMode: 'modern-only' })).toMatchObject({
useModernOnlyEntryMode: true,
shouldRunClient: false,
shouldRunClientV2: true,
shouldRunServer: true,
});
});
test('resolveDevRuntimeMode preserves explicit client-v2-only flag behavior', () => {
expect(resolveDevRuntimeMode({ clientV2Only: true, appClientEntryMode: 'legacy-default' })).toMatchObject({
useModernOnlyEntryMode: false,
shouldRunClient: false,
shouldRunClientV2: true,
shouldRunServer: false,
});
});
});
+29 -4
View File
@@ -20,6 +20,7 @@ const {
isPortReachable,
buildWSURL,
checkDBDialect,
resolveAppClientEntryMode,
} = require('../util');
const { getPortPromise } = require('portfinder');
const chokidar = require('chokidar');
@@ -47,6 +48,25 @@ function buildAppDevForwardArgs(argv = process.argv) {
return ['app-dev', ...argv.slice(3)];
}
function resolveDevRuntimeMode(opts = {}) {
const appClientEntryMode = opts.appClientEntryMode || resolveAppClientEntryMode();
const useModernOnlyEntryMode = appClientEntryMode === 'modern-only';
const clientV2Only = !!opts.clientV2Only;
const forceClient = !!opts.client;
const forceServer = !!opts.server;
const shouldRunClientV2 = clientV2Only || useModernOnlyEntryMode || forceClient || !forceServer;
const shouldRunClient = !clientV2Only && !useModernOnlyEntryMode && (forceClient || !forceServer);
const shouldRunServer = !clientV2Only && (forceServer || !forceClient || useModernOnlyEntryMode);
return {
appClientEntryMode,
useModernOnlyEntryMode,
shouldRunClientV2,
shouldRunClient,
shouldRunServer,
};
}
async function forwardDevToAppDev({ argv = process.argv, runCommand = run } = {}) {
await runCommand('nocobase-v1', buildAppDevForwardArgs(argv));
}
@@ -107,9 +127,9 @@ module.exports = (cli) => {
nodeCheck();
await postCheck(opts);
const shouldRunClientV2 = clientV2Only || client || !server;
const shouldRunClient = !clientV2Only && (client || !server);
const shouldRunServer = !clientV2Only && (server || !client);
const { useModernOnlyEntryMode, shouldRunClientV2, shouldRunClient, shouldRunServer } = resolveDevRuntimeMode(
opts,
);
const shouldRunClientWithRsbuild = shouldRunClient && !!rsbuild;
if (shouldRunServer && server) {
@@ -120,12 +140,16 @@ module.exports = (cli) => {
});
}
if (shouldRunClientV2 && !clientV2Only) {
if (shouldRunClientV2 && !clientV2Only && !useModernOnlyEntryMode) {
clientV2Port = await getPortPromise({
port: 1 * clientPort + 2,
});
}
if (useModernOnlyEntryMode) {
clientV2Port = APP_PORT;
}
let subprocessClient;
let subprocessClientV2;
@@ -306,4 +330,5 @@ module.exports = (cli) => {
module.exports._test = {
buildAppDevForwardArgs,
forwardDevToAppDev,
resolveDevRuntimeMode,
};
+44
View File
@@ -432,6 +432,30 @@ const DEFAULT_MODERN_CLIENT_PREFIX = 'v';
exports.DEFAULT_MODERN_CLIENT_PREFIX = DEFAULT_MODERN_CLIENT_PREFIX;
const DEFAULT_APP_CLIENT_ENTRY_MODE = 'legacy-default';
const APP_CLIENT_ENTRY_MODES = new Set(['legacy-default', 'modern-default', 'modern-only']);
exports.DEFAULT_APP_CLIENT_ENTRY_MODE = DEFAULT_APP_CLIENT_ENTRY_MODE;
function isAppClientEntryMode(value) {
return APP_CLIENT_ENTRY_MODES.has(String(value || '').trim());
}
exports.isAppClientEntryMode = isAppClientEntryMode;
function normalizeAppClientEntryMode(value) {
const normalized = String(value || '').trim();
return isAppClientEntryMode(normalized) ? normalized : DEFAULT_APP_CLIENT_ENTRY_MODE;
}
exports.normalizeAppClientEntryMode = normalizeAppClientEntryMode;
function resolveAppClientEntryMode() {
return normalizeAppClientEntryMode(process.env.APP_CLIENT_ENTRY_MODE);
}
exports.resolveAppClientEntryMode = resolveAppClientEntryMode;
// Normalize APP_MODERN_CLIENT_PREFIX (accepts `v`, `/v`, `/v/`)
// down to a bare segment like `v`.
function normalizeModernClientPrefix(value) {
@@ -455,6 +479,13 @@ function isAppDevHtml() {
return process.argv[2] === 'app-dev' || process.env.NOCOBASE_APP_DEV === 'true';
}
function shouldRefreshLegacyIndexTemplate(data = '') {
return (
!data.includes("window['__nocobase_modern_client_prefix__']") ||
!data.includes("window['__nocobase_app_client_entry_mode__']")
);
}
function buildIndexHtml(force = false) {
const file = `${process.env.APP_PACKAGE_ROOT}/dist/client/index.html`;
if (!fs.existsSync(file)) {
@@ -466,11 +497,15 @@ function buildIndexHtml(force = false) {
}
if (!fs.existsSync(tpl)) {
fs.copyFileSync(file, tpl);
} else if (shouldRefreshLegacyIndexTemplate(fs.readFileSync(tpl, 'utf-8'))) {
fs.copyFileSync(file, tpl);
}
const data = fs.readFileSync(tpl, 'utf-8');
let replacedData = data
.replace(/\{\{env.CDN_BASE_URL\}\}/g, process.env.CDN_BASE_URL)
.replace(/\{\{env.APP_PUBLIC_PATH\}\}/g, process.env.APP_PUBLIC_PATH)
.replace(/\{\{env.APP_MODERN_CLIENT_PREFIX\}\}/g, normalizeModernClientPrefix(process.env.APP_MODERN_CLIENT_PREFIX))
.replace(/\{\{env.APP_CLIENT_ENTRY_MODE\}\}/g, resolveAppClientEntryMode())
.replace(/\{\{env.API_CLIENT_SHARE_TOKEN\}\}/g, process.env.API_CLIENT_SHARE_TOKEN || 'false')
.replace(/\{\{env.API_CLIENT_STORAGE_TYPE\}\}/g, process.env.API_CLIENT_STORAGE_TYPE)
.replace(/\{\{env.API_CLIENT_STORAGE_PREFIX\}\}/g, process.env.API_CLIENT_STORAGE_PREFIX)
@@ -611,6 +646,7 @@ exports.initEnv = function initEnv() {
CDN_BASE_URL: '',
APP_PUBLIC_PATH: '/',
APP_MODERN_CLIENT_PREFIX: DEFAULT_MODERN_CLIENT_PREFIX,
APP_CLIENT_ENTRY_MODE: DEFAULT_APP_CLIENT_ENTRY_MODE,
ESM_CDN_BASE_URL: 'https://esm.sh',
ESM_CDN_SUFFIX: '',
};
@@ -662,6 +698,14 @@ exports.initEnv = function initEnv() {
}
}
const rawAppClientEntryMode = String(process.env.APP_CLIENT_ENTRY_MODE || '').trim();
if (rawAppClientEntryMode && !isAppClientEntryMode(rawAppClientEntryMode)) {
console.warn(
`Unknown APP_CLIENT_ENTRY_MODE "${rawAppClientEntryMode}", falling back to ${DEFAULT_APP_CLIENT_ENTRY_MODE}.`,
);
}
process.env.APP_CLIENT_ENTRY_MODE = normalizeAppClientEntryMode(rawAppClientEntryMode);
if (!process.env.__env_modified__ && process.env.APP_PUBLIC_PATH) {
const publicPath = process.env.APP_PUBLIC_PATH.replace(/\/$/g, '');
const keys = ['API_BASE_PATH', 'WS_PATH', 'PLUGIN_STATICS_PATH'];
@@ -15,6 +15,8 @@ import { fileURLToPath } from 'node:url';
type BrowserCheckerCase = {
pathname: string;
publicPath: string;
modernClientPrefix?: string;
appClientEntryMode?: string;
expectedRedirect?: string;
};
@@ -40,6 +42,8 @@ function executeBrowserChecker(scriptPath: string, input: BrowserCheckerCase) {
showLog: false,
window: {
__nocobase_public_path__: input.publicPath,
__nocobase_modern_client_prefix__: input.modernClientPrefix,
__nocobase_app_client_entry_mode__: input.appClientEntryMode,
location: {
origin: 'http://c.local.nocobase.com',
pathname: input.pathname,
@@ -109,4 +113,61 @@ describe.each(browserCheckerCases)('$label', ({ scriptPath }) => {
expect(replace).not.toHaveBeenCalled();
});
if (scriptPath.includes('/app/client/public/')) {
it('redirects app root to modern entry for modern-default', () => {
const replace = executeBrowserChecker(scriptPath, {
pathname: '/',
publicPath: '/',
modernClientPrefix: 'v',
appClientEntryMode: 'modern-default',
});
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/v/');
});
it('does not redirect legacy deep links for modern-default', () => {
const replace = executeBrowserChecker(scriptPath, {
pathname: '/admin',
publicPath: '/',
modernClientPrefix: 'v',
appClientEntryMode: 'modern-default',
});
expect(replace).not.toHaveBeenCalled();
});
it('rewrites legacy document paths for modern-only', () => {
const replace = executeBrowserChecker(scriptPath, {
pathname: '/admin/settings/workflow',
publicPath: '/',
modernClientPrefix: 'v',
appClientEntryMode: 'modern-only',
});
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/v/admin/settings/workflow');
});
it('redirects sub-path site root directly to final modern target', () => {
const replace = executeBrowserChecker(scriptPath, {
pathname: '/nocobase/',
publicPath: '/nocobase/',
modernClientPrefix: 'v',
appClientEntryMode: 'modern-default',
});
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/v/');
});
it('rewrites sub-app legacy deep links for modern-only without collapsing the sub-app segment', () => {
const replace = executeBrowserChecker(scriptPath, {
pathname: '/nocobase/apps/a_31itq60q4kg/admin/',
publicPath: '/nocobase/',
modernClientPrefix: 'v',
appClientEntryMode: 'modern-only',
});
expect(replace).toHaveBeenCalledWith('http://c.local.nocobase.com/nocobase/v/apps/a_31itq60q4kg/admin');
});
}
});