From 7b77c5b53975f11c1eff6b754083fa3e20237f0e Mon Sep 17 00:00:00 2001 From: PiEgg Date: Thu, 25 Jun 2026 17:38:47 +0800 Subject: [PATCH] feat(client): support configurable app entry mode (#9891) --- CONTEXT.md | 33 ++++++++++ packages/core/app/client-v2/rsbuild.config.ts | 53 ++++++++++++++++ .../core/app/client/public/browser-checker.js | 58 ++++++++++++++++++ packages/core/app/client/rsbuild.config.ts | 9 +++ .../src/__tests__/build-index-html.test.js | 34 +++++++++++ .../cli-v1/src/__tests__/dev-command.test.js | 29 ++++++++- packages/core/cli-v1/src/commands/dev.js | 33 ++++++++-- packages/core/cli-v1/src/util.js | 44 +++++++++++++ .../src/__tests__/browserChecker.test.ts | 61 +++++++++++++++++++ 9 files changed, 349 insertions(+), 5 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 9e4d1b476e1..1a35fd950cf 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. --- diff --git a/packages/core/app/client-v2/rsbuild.config.ts b/packages/core/app/client-v2/rsbuild.config.ts index a1d0a4b0677..993830ee90b 100644 --- a/packages/core/app/client-v2/rsbuild.config.ts +++ b/packages/core/app/client-v2/rsbuild.config.ts @@ -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', diff --git a/packages/core/app/client/public/browser-checker.js b/packages/core/app/client/public/browser-checker.js index baf44310dee..48b25f3194a 100644 --- a/packages/core/app/client/public/browser-checker.js +++ b/packages/core/app/client/public/browser-checker.js @@ -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) { diff --git a/packages/core/app/client/rsbuild.config.ts b/packages/core/app/client/rsbuild.config.ts index bae0897c9ba..6caacdf2bdf 100644 --- a/packages/core/app/client/rsbuild.config.ts +++ b/packages/core/app/client/rsbuild.config.ts @@ -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}}';`, diff --git a/packages/core/cli-v1/src/__tests__/build-index-html.test.js b/packages/core/cli-v1/src/__tests__/build-index-html.test.js index e8a4b0c9b1b..cd2f3829394 100644 --- a/packages/core/cli-v1/src/__tests__/build-index-html.test.js +++ b/packages/core/cli-v1/src/__tests__/build-index-html.test.js @@ -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); }); }); diff --git a/packages/core/cli-v1/src/__tests__/dev-command.test.js b/packages/core/cli-v1/src/__tests__/dev-command.test.js index 17de62d013e..2f2baa06811 100644 --- a/packages/core/cli-v1/src/__tests__/dev-command.test.js +++ b/packages/core/cli-v1/src/__tests__/dev-command.test.js @@ -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, + }); + }); }); diff --git a/packages/core/cli-v1/src/commands/dev.js b/packages/core/cli-v1/src/commands/dev.js index 0ac85122c3f..2cbf375506d 100644 --- a/packages/core/cli-v1/src/commands/dev.js +++ b/packages/core/cli-v1/src/commands/dev.js @@ -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, }; diff --git a/packages/core/cli-v1/src/util.js b/packages/core/cli-v1/src/util.js index 68b4bb0c808..58f58b39c49 100644 --- a/packages/core/cli-v1/src/util.js +++ b/packages/core/cli-v1/src/util.js @@ -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']; diff --git a/packages/core/client-v2/src/__tests__/browserChecker.test.ts b/packages/core/client-v2/src/__tests__/browserChecker.test.ts index d791cfb1b48..152c8c22a3c 100644 --- a/packages/core/client-v2/src/__tests__/browserChecker.test.ts +++ b/packages/core/client-v2/src/__tests__/browserChecker.test.ts @@ -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'); + }); + } });