mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-28 17:43:07 +08:00
feat(client): support configurable app entry mode (#9891)
This commit is contained in:
+33
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user