@nocobase/cli-v1 2.3.0-beta.9 → 2.4.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/nocobase.conf.tpl CHANGED
@@ -154,6 +154,34 @@ server {
154
154
  send_timeout 600;
155
155
  }
156
156
 
157
+ location ^~ {{settingsAssetsPath}} {
158
+ alias {{cwd}}/node_modules/@nocobase/app/dist/client/settings/assets/;
159
+ expires 365d;
160
+ add_header Cache-Control "public";
161
+ access_log off;
162
+ autoindex off;
163
+ }
164
+
165
+ # The standalone Settings SPA is a Client V2 surface. Keep this matcher
166
+ # narrow so legacy /admin/settings routes continue to use the v1 HTML.
167
+ location ~ {{settingsDocumentPattern}} {
168
+ proxy_pass http://127.0.0.1:{{apiPort}};
169
+ proxy_http_version 1.1;
170
+ proxy_set_header Upgrade $http_upgrade;
171
+ proxy_set_header Connection 'upgrade';
172
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
173
+ proxy_set_header X-Forwarded-Proto $upstream_x_forwarded_proto;
174
+ proxy_set_header Host $final_host;
175
+ proxy_set_header Referer $http_referer;
176
+ proxy_set_header User-Agent $http_user_agent;
177
+ add_header Cache-Control 'no-cache, no-store';
178
+ proxy_cache_bypass $http_upgrade;
179
+ proxy_connect_timeout 600;
180
+ proxy_send_timeout 600;
181
+ proxy_read_timeout 600;
182
+ send_timeout 600;
183
+ }
184
+
157
185
  # RFC 8414 root-mounted discovery compatibility for path-based issuers/resources.
158
186
  location ~ ^/\.well-known/oauth-authorization-server/(.+)$ {
159
187
  rewrite ^/\.well-known/oauth-authorization-server/(.+)$ /$1/.well-known/oauth-authorization-server break;
@@ -212,6 +240,68 @@ server {
212
240
 
213
241
 
214
242
 
243
+ location = {{publicPath}}{{portalClientPrefix}} {
244
+ absolute_redirect off;
245
+ return 302 {{v2PublicPath}}$is_args$args;
246
+ }
247
+
248
+ location = {{publicPath}}{{portalClientPrefix}}/ {
249
+ absolute_redirect off;
250
+ return 302 {{v2PublicPath}}$is_args$args;
251
+ }
252
+
253
+ location ^~ {{publicPath}}{{portalClientPrefix}}/apps/ {
254
+ absolute_redirect off;
255
+
256
+ if ($uri ~ ^{{publicPath}}{{portalClientPrefix}}/apps/(?<subapp>[A-Za-z0-9_-]+)/?$) {
257
+ return 302 {{v2PublicPath}}apps/$subapp/$is_args$args;
258
+ }
259
+
260
+ if ($uri ~ ^{{publicPath}}{{portalClientPrefix}}/apps/(?<subapp>[A-Za-z0-9_-]+)/(?<portal>[A-Za-z0-9_-]+)$) {
261
+ return 308 {{publicPath}}{{portalClientPrefix}}/apps/$subapp/$portal/$is_args$args;
262
+ }
263
+
264
+ if ($uri !~ ^{{publicPath}}{{portalClientPrefix}}/apps/(?<subapp>[A-Za-z0-9_-]+)/(?<portal>[A-Za-z0-9_-]+)/(?<portal_path>.*)$) {
265
+ return 404;
266
+ }
267
+
268
+ root {{cwd}}/storage;
269
+
270
+ if ($portal_path = "") {
271
+ rewrite ^ /portals/$subapp/$portal/dist/index.html break;
272
+ }
273
+
274
+ try_files
275
+ /portals/$subapp/$portal/dist/$portal_path
276
+ /portals/$subapp/$portal/dist/$portal_path/
277
+ /portals/$subapp/$portal/dist/index.html
278
+ =404;
279
+ }
280
+
281
+ location ^~ {{publicPath}}{{portalClientPrefix}}/ {
282
+ absolute_redirect off;
283
+
284
+ if ($uri ~ ^{{publicPath}}{{portalClientPrefix}}/(?<portal>[A-Za-z0-9_-]+)$) {
285
+ return 308 {{publicPath}}{{portalClientPrefix}}/$portal/$is_args$args;
286
+ }
287
+
288
+ if ($uri !~ ^{{publicPath}}{{portalClientPrefix}}/(?<portal>[A-Za-z0-9_-]+)/(?<portal_path>.*)$) {
289
+ return 404;
290
+ }
291
+
292
+ root {{cwd}}/storage;
293
+
294
+ if ($portal_path = "") {
295
+ rewrite ^ /portals/main/$portal/dist/index.html break;
296
+ }
297
+
298
+ try_files
299
+ /portals/main/$portal/dist/$portal_path
300
+ /portals/main/$portal/dist/$portal_path/
301
+ /portals/main/$portal/dist/index.html
302
+ =404;
303
+ }
304
+
215
305
  location {{publicPath}} {
216
306
  alias {{cwd}}/node_modules/@nocobase/app/dist/client/;
217
307
  try_files $uri $uri/ /index.html;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/cli-v1",
3
- "version": "2.3.0-beta.9",
3
+ "version": "2.4.0-alpha.10",
4
4
  "description": "",
5
5
  "license": "Apache-2.0",
6
6
  "main": "./src/index.js",
@@ -9,9 +9,9 @@
9
9
  "nocobase-v1": "./bin/index.js"
10
10
  },
11
11
  "dependencies": {
12
- "@nocobase/cli": "2.3.0-beta.9",
12
+ "@nocobase/cli": "2.4.0-alpha.10",
13
13
  "@nocobase/license-kit": "^0.3.8",
14
- "@nocobase/utils": "2.3.0-beta.9",
14
+ "@nocobase/utils": "2.4.0-alpha.10",
15
15
  "chalk": "^4.1.1",
16
16
  "commander": "^9.2.0",
17
17
  "deepmerge": "^4.3.1",
@@ -20,7 +20,7 @@
20
20
  "fast-glob": "^3.3.1",
21
21
  "fs-extra": "^11.1.1",
22
22
  "p-all": "3.0.0",
23
- "pm2": "^6.0.5",
23
+ "pm2": "^7.0.0",
24
24
  "portfinder": "^1.0.28",
25
25
  "tar": "^7.4.3",
26
26
  "tree-kill": "^1.2.2"
@@ -33,5 +33,5 @@
33
33
  "url": "git+https://github.com/nocobase/nocobase.git",
34
34
  "directory": "packages/core/cli"
35
35
  },
36
- "gitHead": "c215e60ed704afe6c086bd96304f4b0d3b46d02e"
36
+ "gitHead": "c869865c0413fbe4ef85b26be1f2b8d7078c38df"
37
37
  }
@@ -87,6 +87,20 @@ describe('cli-v1 buildIndexHtml', () => {
87
87
  fs.removeSync(appRoot);
88
88
  });
89
89
 
90
+ test('injects settings-default as a valid client entry mode', () => {
91
+ const appRoot = createAppPackageRoot();
92
+ process.argv = ['node', 'nocobase-v1', 'start'];
93
+ process.env.APP_PACKAGE_ROOT = appRoot;
94
+ process.env.APP_PUBLIC_PATH = '/';
95
+ process.env.APP_CLIENT_ENTRY_MODE = 'settings-default';
96
+
97
+ buildIndexHtml();
98
+
99
+ const html = fs.readFileSync(path.join(appRoot, 'dist/client/index.html'), 'utf-8');
100
+ expect(html).toContain("window['__nocobase_app_client_entry_mode__'] = 'settings-default';");
101
+ fs.removeSync(appRoot);
102
+ });
103
+
90
104
  test('refreshes cached tpl when new runtime placeholders are missing', () => {
91
105
  const appRoot = createAppPackageRoot();
92
106
  const tplPath = path.join(appRoot, 'dist/client/index.html.tpl');
@@ -0,0 +1,55 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ /* eslint-env jest */
11
+
12
+ const fs = require('fs-extra');
13
+ const os = require('os');
14
+ const path = require('path');
15
+ const { renderExtractedClientIndexHtml } = require('../commands/client');
16
+
17
+ describe('cli-v1 client:extract', () => {
18
+ const originalEnv = { ...process.env };
19
+
20
+ afterEach(() => {
21
+ process.env = { ...originalEnv };
22
+ });
23
+
24
+ test('renders extracted root index with the current client entry mode', () => {
25
+ const target = fs.mkdtempSync(path.join(os.tmpdir(), 'nocobase-client-extract-'));
26
+ fs.writeFileSync(
27
+ path.join(target, 'index.html.tpl'),
28
+ [
29
+ '<html><head>',
30
+ "<script>window['__webpack_public_path__'] = '{{env.CDN_BASE_URL}}';",
31
+ "window['__nocobase_public_path__'] = '{{env.APP_PUBLIC_PATH}}';",
32
+ "window['__nocobase_modern_client_prefix__'] = '{{env.APP_MODERN_CLIENT_PREFIX}}';",
33
+ "window['__nocobase_app_client_entry_mode__'] = '{{env.APP_CLIENT_ENTRY_MODE}}';</script>",
34
+ '<script src="{{env.APP_PUBLIC_PATH}}browser-checker.js?v=1"></script>',
35
+ '<script type="module" src="assets/runtime.js"></script>',
36
+ '</head><body></body></html>',
37
+ ].join(''),
38
+ 'utf-8',
39
+ );
40
+
41
+ process.env.APP_PUBLIC_PATH = '/';
42
+ process.env.APP_MODERN_CLIENT_PREFIX = 'v';
43
+ process.env.APP_CLIENT_ENTRY_MODE = 'modern-only';
44
+ delete process.env.CDN_BASE_URL;
45
+
46
+ expect(renderExtractedClientIndexHtml(target, '2.2.0-beta.15')).toBe(true);
47
+
48
+ const html = fs.readFileSync(path.join(target, 'index.html'), 'utf-8');
49
+ expect(html).toContain("window['__nocobase_app_client_entry_mode__'] = 'modern-only';");
50
+ expect(html).toContain("window['__webpack_public_path__'] = '/dist/2.2.0-beta.15/';");
51
+ expect(html).toContain('src="/dist/2.2.0-beta.15/browser-checker.js?v=1"');
52
+ expect(html).toContain('src="/dist/2.2.0-beta.15/assets/runtime.js"');
53
+ fs.removeSync(target);
54
+ });
55
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ /* eslint-env jest */
11
+ /* global vi */
12
+
13
+ const { putWithRetry, OSS_TIMEOUT, OSS_MAX_ATTEMPTS } = require('../commands/client')._test;
14
+
15
+ describe('cli-v1 client:upload', () => {
16
+ let warn;
17
+
18
+ beforeEach(() => {
19
+ warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
20
+ });
21
+
22
+ afterEach(() => {
23
+ warn.mockRestore();
24
+ });
25
+
26
+ it('should raise the ali-oss response timeout above its 60s default', () => {
27
+ expect(OSS_TIMEOUT).toBeGreaterThan(60000);
28
+ });
29
+
30
+ it('should return the result without retrying when the upload succeeds', async () => {
31
+ const put = vi.fn().mockResolvedValue({ name: 'a.js' });
32
+
33
+ await expect(putWithRetry({ put }, 'a.js', '/tmp/a.js')).resolves.toEqual({ name: 'a.js' });
34
+
35
+ expect(put).toHaveBeenCalledTimes(1);
36
+ expect(put).toHaveBeenCalledWith('a.js', '/tmp/a.js');
37
+ expect(warn).not.toHaveBeenCalled();
38
+ });
39
+
40
+ it('should retry a transient timeout and resolve', async () => {
41
+ const put = vi
42
+ .fn()
43
+ .mockRejectedValueOnce(new Error('Response timeout for 60000ms'))
44
+ .mockResolvedValue({ name: 'a.js' });
45
+
46
+ await expect(putWithRetry({ put }, 'a.js', '/tmp/a.js')).resolves.toEqual({ name: 'a.js' });
47
+
48
+ expect(put).toHaveBeenCalledTimes(2);
49
+ expect(warn).toHaveBeenCalledTimes(1);
50
+ });
51
+
52
+ it('should give up and rethrow after the attempt limit', async () => {
53
+ const error = new Error('Response timeout for 60000ms');
54
+ const put = vi.fn().mockRejectedValue(error);
55
+
56
+ await expect(putWithRetry({ put }, 'a.js', '/tmp/a.js')).rejects.toThrow(error);
57
+
58
+ expect(put).toHaveBeenCalledTimes(OSS_MAX_ATTEMPTS);
59
+ });
60
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ /* eslint-env jest */
11
+
12
+ const fs = require('fs-extra');
13
+ const os = require('os');
14
+ const path = require('path');
15
+
16
+ const registerCreateNginxConf = require('../commands/create-nginx-conf');
17
+
18
+ describe('create-nginx-conf routing', () => {
19
+ const originalEnv = { ...process.env };
20
+ let storagePath;
21
+
22
+ afterEach(async () => {
23
+ process.env = { ...originalEnv };
24
+ if (storagePath) {
25
+ await fs.remove(storagePath);
26
+ storagePath = undefined;
27
+ }
28
+ });
29
+
30
+ async function renderConfig(appPublicPath) {
31
+ storagePath = await fs.mkdtemp(path.join(os.tmpdir(), 'nocobase-nginx-settings-'));
32
+ process.env.APP_PUBLIC_PATH = appPublicPath;
33
+ process.env.APP_MODERN_CLIENT_PREFIX = 'v';
34
+ process.env.APP_PORT = '13000';
35
+ process.env.STORAGE_PATH = storagePath;
36
+
37
+ let action;
38
+ registerCreateNginxConf({
39
+ command() {
40
+ return {
41
+ action(callback) {
42
+ action = callback;
43
+ },
44
+ };
45
+ },
46
+ });
47
+ await action();
48
+ return await fs.readFile(path.join(storagePath, 'nocobase.conf'), 'utf8');
49
+ }
50
+
51
+ test.each([
52
+ ['root mount', '/', '/settings/assets/', '^/settings(?:/|$)'],
53
+ ['custom public path', '/nocobase/', '/nocobase/settings/assets/', '^/nocobase/settings(?:/|$)'],
54
+ ])(
55
+ 'proxies Settings documents and caches Settings assets for %s',
56
+ async (_label, publicPath, assetsPath, routePattern) => {
57
+ const config = await renderConfig(publicPath);
58
+
59
+ expect(config).toContain(`location ^~ ${assetsPath} {`);
60
+ expect(config).toContain('dist/client/settings/assets/;');
61
+ expect(config).toContain('expires 365d;');
62
+ expect(config).toContain(`location ~ ${routePattern} {`);
63
+ expect(config).toContain('proxy_pass http://127.0.0.1:13000;');
64
+ expect(config).not.toContain('location ~ ^/admin/settings');
65
+ expect(config).toContain('try_files $uri $uri/ /index.html;');
66
+ },
67
+ );
68
+
69
+ test.each([
70
+ ['root mount', '/', '/x', '/v/'],
71
+ ['custom public path', '/nocobase/', '/nocobase/x', '/nocobase/v/'],
72
+ ])('redirects Portal roots to the Modern Client for %s', async (_label, publicPath, portalRoot, modernRoot) => {
73
+ const config = await renderConfig(publicPath);
74
+
75
+ expect(config).toContain(`location = ${portalRoot} {
76
+ absolute_redirect off;
77
+ return 302 ${modernRoot}$is_args$args;
78
+ }`);
79
+ expect(config).toContain(`location = ${portalRoot}/ {
80
+ absolute_redirect off;
81
+ return 302 ${modernRoot}$is_args$args;
82
+ }`);
83
+ expect(config).toContain(`return 302 ${modernRoot}$is_args$args;`);
84
+ expect(config).toContain(`if ($uri ~ ^${portalRoot}/apps/(?<subapp>[A-Za-z0-9_-]+)/?$) {`);
85
+ expect(config).toContain(`return 302 ${modernRoot}apps/$subapp/$is_args$args;`);
86
+ });
87
+ });
@@ -9,13 +9,28 @@
9
9
 
10
10
  /* eslint-env jest */
11
11
 
12
- const { buildAppDevForwardArgs, forwardDevToAppDev, resolveDevRuntimeMode } = require('../commands/dev')._test;
12
+ const {
13
+ buildAppDevForwardArgs,
14
+ createSettingsDevProcessOptions,
15
+ forwardDevToAppDev,
16
+ resolveDevRuntimeMode,
17
+ resolveSettingsDevPort,
18
+ } = require('../commands/dev')._test;
13
19
 
14
20
  describe('cli-v1 dev command', () => {
15
21
  test('buildAppDevForwardArgs rewrites dev argv to app-dev while preserving extra args', () => {
16
22
  expect(
17
- buildAppDevForwardArgs(['node', 'nocobase-v1', 'dev', '--rsbuild', '--port', '13000', '--inspect=9229']),
18
- ).toEqual(['app-dev', '--rsbuild', '--port', '13000', '--inspect=9229']);
23
+ buildAppDevForwardArgs([
24
+ 'node',
25
+ 'nocobase-v1',
26
+ 'dev',
27
+ '--rsbuild',
28
+ '--quickstart',
29
+ '--port',
30
+ '13000',
31
+ '--inspect=9229',
32
+ ]),
33
+ ).toEqual(['app-dev', '--rsbuild', '--quickstart', '--port', '13000', '--inspect=9229']);
19
34
  });
20
35
 
21
36
  test('forwardDevToAppDev delegates to nocobase-v1 app-dev for create-app projects', async () => {
@@ -37,6 +52,7 @@ describe('cli-v1 dev command', () => {
37
52
  useModernOnlyEntryMode: false,
38
53
  shouldRunClient: true,
39
54
  shouldRunClientV2: true,
55
+ shouldRunSettings: true,
40
56
  shouldRunServer: true,
41
57
  });
42
58
  });
@@ -46,6 +62,17 @@ describe('cli-v1 dev command', () => {
46
62
  useModernOnlyEntryMode: true,
47
63
  shouldRunClient: false,
48
64
  shouldRunClientV2: true,
65
+ shouldRunSettings: true,
66
+ shouldRunServer: true,
67
+ });
68
+ });
69
+
70
+ test('resolveDevRuntimeMode keeps all clients in settings-default', () => {
71
+ expect(resolveDevRuntimeMode({ appClientEntryMode: 'settings-default' })).toMatchObject({
72
+ useModernOnlyEntryMode: false,
73
+ shouldRunClient: true,
74
+ shouldRunClientV2: true,
75
+ shouldRunSettings: true,
49
76
  shouldRunServer: true,
50
77
  });
51
78
  });
@@ -55,7 +82,37 @@ describe('cli-v1 dev command', () => {
55
82
  useModernOnlyEntryMode: false,
56
83
  shouldRunClient: false,
57
84
  shouldRunClientV2: true,
85
+ shouldRunSettings: true,
58
86
  shouldRunServer: false,
59
87
  });
60
88
  });
89
+
90
+ test('resolveSettingsDevPort reserves APP_PORT + 3 by default', () => {
91
+ expect(resolveSettingsDevPort(13001)).toBe(13004);
92
+ });
93
+
94
+ test('createSettingsDevProcessOptions uses the standalone config and settings HMR path', () => {
95
+ expect(
96
+ createSettingsDevProcessOptions({
97
+ appPackageRoot: '/repo/packages/core/app',
98
+ appPort: 13001,
99
+ settingsPort: 13004,
100
+ browserPort: 13001,
101
+ appPublicPath: '/nocobase/',
102
+ processEnv: { API_BASE_URL: '/api/' },
103
+ }),
104
+ ).toMatchObject({
105
+ command: 'rsbuild',
106
+ args: ['dev', '--config', '/repo/packages/core/app/client-settings/rsbuild.config.ts'],
107
+ runOptions: {
108
+ prefix: 'client-settings',
109
+ env: {
110
+ APP_PORT: '13001',
111
+ APP_SETTINGS_PORT: '13004',
112
+ RSPACK_HMR_CLIENT_PORT: '13001',
113
+ RSPACK_HMR_PATH: '/nocobase/settings/__rspack_hmr',
114
+ },
115
+ },
116
+ });
117
+ });
61
118
  });
@@ -9,9 +9,14 @@
9
9
 
10
10
  /* eslint-env jest */
11
11
 
12
+ const { normalizeModernClientPrefix } = require('../util');
12
13
  const { colorizedDevLogEnv, createRunWithPrefixLabel } = require('../util')._test;
13
14
 
14
15
  describe('cli-v1 util helpers', () => {
16
+ test('normalizeModernClientPrefix reserves the Settings SPA path', () => {
17
+ expect(() => normalizeModernClientPrefix('/settings/')).toThrow('APP_MODERN_CLIENT_PREFIX "settings" is reserved');
18
+ });
19
+
15
20
  test('colorizedDevLogEnv enables color for dev child output by default', () => {
16
21
  expect(colorizedDevLogEnv({})).toEqual({ FORCE_COLOR: '1' });
17
22
  });
@@ -11,7 +11,100 @@ const chalk = require('chalk');
11
11
  const fs = require('fs-extra');
12
12
  const { resolve } = require('path');
13
13
  const { discoverPluginPackages } = require('@nocobase/utils/plugin-package');
14
- const { storagePathJoin } = require('../util');
14
+ const {
15
+ normalizeModernClientPrefix,
16
+ resolveAppClientEntryMode,
17
+ resolvePublicPath,
18
+ storagePathJoin,
19
+ } = require('../util');
20
+
21
+ function replaceRuntimeEnvPlaceholders(data, values) {
22
+ return data
23
+ .replace(/\{\{env.CDN_BASE_URL\}\}/g, values.CDN_BASE_URL)
24
+ .replace(/\{\{env.APP_PUBLIC_PATH\}\}/g, values.APP_PUBLIC_PATH)
25
+ .replace(/\{\{env.APP_MODERN_CLIENT_PREFIX\}\}/g, values.APP_MODERN_CLIENT_PREFIX)
26
+ .replace(/\{\{env.APP_CLIENT_ENTRY_MODE\}\}/g, values.APP_CLIENT_ENTRY_MODE)
27
+ .replace(/\{\{env.API_CLIENT_SHARE_TOKEN\}\}/g, values.API_CLIENT_SHARE_TOKEN)
28
+ .replace(/\{\{env.API_CLIENT_STORAGE_TYPE\}\}/g, values.API_CLIENT_STORAGE_TYPE)
29
+ .replace(/\{\{env.API_CLIENT_STORAGE_PREFIX\}\}/g, values.API_CLIENT_STORAGE_PREFIX)
30
+ .replace(/\{\{env.API_BASE_URL\}\}/g, values.API_BASE_URL)
31
+ .replace(/\{\{env.WS_URL\}\}/g, values.WS_URL)
32
+ .replace(/\{\{env.WS_PATH\}\}/g, values.WS_PATH)
33
+ .replace(/\{\{env.NOCOBASE_APP_DEV\}\}/g, values.NOCOBASE_APP_DEV)
34
+ .replace(/\{\{env.ESM_CDN_BASE_URL\}\}/g, values.ESM_CDN_BASE_URL)
35
+ .replace(/\{\{env.ESM_CDN_SUFFIX\}\}/g, values.ESM_CDN_SUFFIX);
36
+ }
37
+
38
+ function replaceRuntimeAssignment(data, key, value) {
39
+ const literal =
40
+ typeof value === 'boolean' ? String(value) : `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
41
+ const assignment = `window['${key}'] = ${literal};`;
42
+ const pattern = new RegExp(`window\\['${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}'\\]\\s*=\\s*[^;]*;`);
43
+
44
+ return pattern.test(data) ? data.replace(pattern, assignment) : data;
45
+ }
46
+
47
+ function renderExtractedClientIndexHtml(target, version) {
48
+ const indexPath = resolve(target, 'index.html');
49
+ const tplPath = resolve(target, 'index.html.tpl');
50
+ if (!fs.existsSync(indexPath) && !fs.existsSync(tplPath)) {
51
+ return false;
52
+ }
53
+
54
+ const appPublicPath = resolvePublicPath(process.env.APP_PUBLIC_PATH || '/');
55
+ const cdnBaseUrl =
56
+ process.env.CDN_BASE_URL || `${appPublicPath.replace(/\/$/, '')}/dist/${version}/`.replace(/^\/\//, '/');
57
+ const values = {
58
+ CDN_BASE_URL: cdnBaseUrl,
59
+ APP_PUBLIC_PATH: appPublicPath,
60
+ APP_MODERN_CLIENT_PREFIX: normalizeModernClientPrefix(process.env.APP_MODERN_CLIENT_PREFIX),
61
+ APP_CLIENT_ENTRY_MODE: resolveAppClientEntryMode(),
62
+ API_CLIENT_SHARE_TOKEN: process.env.API_CLIENT_SHARE_TOKEN || 'false',
63
+ API_CLIENT_STORAGE_TYPE: process.env.API_CLIENT_STORAGE_TYPE || 'localStorage',
64
+ API_CLIENT_STORAGE_PREFIX: process.env.API_CLIENT_STORAGE_PREFIX || 'NOCOBASE_',
65
+ API_BASE_URL: process.env.API_BASE_URL || process.env.API_BASE_PATH || '/api/',
66
+ WS_URL: process.env.WEBSOCKET_URL || '',
67
+ WS_PATH: process.env.WS_PATH || '/ws',
68
+ NOCOBASE_APP_DEV: 'false',
69
+ ESM_CDN_BASE_URL: process.env.ESM_CDN_BASE_URL || 'https://esm.sh',
70
+ ESM_CDN_SUFFIX: process.env.ESM_CDN_SUFFIX || '',
71
+ };
72
+ const sourcePath = fs.existsSync(tplPath) ? tplPath : indexPath;
73
+ let data = replaceRuntimeEnvPlaceholders(fs.readFileSync(sourcePath, 'utf-8'), values)
74
+ .replace(/((?:src|href)=")(?:\.\/)?assets\//g, `$1${values.APP_PUBLIC_PATH}assets/`)
75
+ .replace(/((?:src|href)=")\/assets\//g, `$1${values.APP_PUBLIC_PATH}assets/`)
76
+ .replace('src="/umi.', `src="${values.APP_PUBLIC_PATH}umi.`)
77
+ .replace(/((?:src|href)="[^"]*?)\/{2,}(assets\/)/g, '$1/$2');
78
+
79
+ if (values.CDN_BASE_URL) {
80
+ const appBaseUrl = values.CDN_BASE_URL.replace(/\/+$/, '');
81
+ const publicPath = values.APP_PUBLIC_PATH.replace(/\/+$/, '');
82
+ data = data
83
+ .replace(new RegExp(`src="${publicPath}/`, 'g'), `src="${appBaseUrl}/`)
84
+ .replace(new RegExp(`href="${publicPath}/`, 'g'), `href="${appBaseUrl}/`);
85
+ }
86
+
87
+ data = replaceRuntimeAssignment(data, '__webpack_public_path__', values.CDN_BASE_URL);
88
+ data = replaceRuntimeAssignment(data, '__nocobase_public_path__', values.APP_PUBLIC_PATH);
89
+ data = replaceRuntimeAssignment(data, '__nocobase_modern_client_prefix__', values.APP_MODERN_CLIENT_PREFIX);
90
+ data = replaceRuntimeAssignment(data, '__nocobase_app_client_entry_mode__', values.APP_CLIENT_ENTRY_MODE);
91
+ data = replaceRuntimeAssignment(data, '__nocobase_api_base_url__', values.API_BASE_URL);
92
+ data = replaceRuntimeAssignment(data, '__nocobase_api_client_storage_prefix__', values.API_CLIENT_STORAGE_PREFIX);
93
+ data = replaceRuntimeAssignment(data, '__nocobase_api_client_storage_type__', values.API_CLIENT_STORAGE_TYPE);
94
+ data = replaceRuntimeAssignment(
95
+ data,
96
+ '__nocobase_api_client_share_token__',
97
+ /^true$/i.test(values.API_CLIENT_SHARE_TOKEN),
98
+ );
99
+ data = replaceRuntimeAssignment(data, '__nocobase_ws_url__', values.WS_URL);
100
+ data = replaceRuntimeAssignment(data, '__nocobase_ws_path__', values.WS_PATH);
101
+ data = replaceRuntimeAssignment(data, '__nocobase_app_dev__', false);
102
+ data = replaceRuntimeAssignment(data, '__esm_cdn_base_url__', values.ESM_CDN_BASE_URL);
103
+ data = replaceRuntimeAssignment(data, '__esm_cdn_suffix__', values.ESM_CDN_SUFFIX);
104
+
105
+ fs.writeFileSync(indexPath, data, 'utf-8');
106
+ return true;
107
+ }
15
108
 
16
109
  /**
17
110
  * 复制主应用客户端文件
@@ -63,6 +156,31 @@ async function writeActiveVersion(version) {
63
156
  return activeVersionFile;
64
157
  }
65
158
 
159
+ // 客户端资源里有体积较大的 bundle,ali-oss 默认的 60s 响应超时在 CI 上不够用。
160
+ const OSS_TIMEOUT = 300000;
161
+ // 单个文件偶发超时不应该让整次上传前功尽弃,做有限重试。
162
+ const OSS_MAX_ATTEMPTS = 3;
163
+
164
+ /**
165
+ * 上传单个文件,对偶发失败重试
166
+ * @param {Client} client - OSS 客户端实例
167
+ * @param {string} ossKey - OSS 对象名
168
+ * @param {string} filePath - 本地文件路径
169
+ */
170
+ async function putWithRetry(client, ossKey, filePath) {
171
+ for (let attempt = 1; ; attempt++) {
172
+ try {
173
+ return await client.put(ossKey, filePath);
174
+ } catch (error) {
175
+ if (attempt >= OSS_MAX_ATTEMPTS) {
176
+ throw error;
177
+ }
178
+ console.warn(chalk.yellow(`Retrying ${ossKey} (${attempt}/${OSS_MAX_ATTEMPTS - 1}): ${error.message}`));
179
+ await new Promise((_resolve) => setTimeout(_resolve, attempt * 2000));
180
+ }
181
+ }
182
+ }
183
+
66
184
  /**
67
185
  * 递归上传目录到 OSS
68
186
  * @param {Client} client - OSS 客户端实例
@@ -96,7 +214,7 @@ async function uploadDirectoryToOSS(client, localDir, ossPrefix = '') {
96
214
  // 上传文件
97
215
  const ossKey = ossPrefix ? `${ossPrefix}/${file}` : file;
98
216
  try {
99
- await client.put(ossKey, filePath);
217
+ await putWithRetry(client, ossKey, filePath);
100
218
  // console.log(chalk.green(`Uploaded: ${ossKey}`));
101
219
  uploadedCount++;
102
220
  } catch (error) {
@@ -127,6 +245,7 @@ module.exports = (cli) => {
127
245
  nodeModulesPath: resolve(process.cwd(), 'node_modules'),
128
246
  });
129
247
  const copiedMainClient = await copyMainClient(mainClientSource, target);
248
+ const renderedMainIndex = copiedMainClient ? renderExtractedClientIndexHtml(target, version) : false;
130
249
  const copiedPluginBundles = await copyPluginClients(plugins, target);
131
250
  const activeVersionFile = await writeActiveVersion(version);
132
251
 
@@ -145,7 +264,7 @@ module.exports = (cli) => {
145
264
  chalk.green(
146
265
  `Extracted client assets ${version} to ${target} (main client: ${
147
266
  copiedMainClient ? 'yes' : 'no'
148
- }, plugin bundles: ${copiedPluginBundles}).`,
267
+ }, rendered index: ${renderedMainIndex ? 'yes' : 'no'}, plugin bundles: ${copiedPluginBundles}).`,
149
268
  ),
150
269
  );
151
270
  });
@@ -179,6 +298,7 @@ module.exports = (cli) => {
179
298
  accessKeySecret: process.env.CDN_ALI_OSS_ACCESS_KEY_SECRET,
180
299
  bucket: process.env.CDN_ALI_OSS_BUCKET,
181
300
  region: process.env.CDN_ALI_OSS_REGION,
301
+ timeout: OSS_TIMEOUT,
182
302
  });
183
303
 
184
304
  if (!(await fs.exists(target))) {
@@ -198,3 +318,11 @@ module.exports = (cli) => {
198
318
  }
199
319
  });
200
320
  };
321
+
322
+ module.exports.renderExtractedClientIndexHtml = renderExtractedClientIndexHtml;
323
+
324
+ module.exports._test = {
325
+ putWithRetry,
326
+ OSS_TIMEOUT,
327
+ OSS_MAX_ATTEMPTS,
328
+ };
@@ -11,6 +11,12 @@ const { resolve, posix } = require('path');
11
11
  const { storagePathJoin, resolvePublicPath, resolveV2PublicPath, normalizeModernClientPrefix } = require('../util');
12
12
  const { readFileSync, writeFileSync } = require('fs');
13
13
 
14
+ const PORTAL_CLIENT_PREFIX = 'x';
15
+
16
+ function escapeRegExp(value) {
17
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
18
+ }
19
+
14
20
  /**
15
21
  *
16
22
  * @param {Command} cli
@@ -24,6 +30,8 @@ module.exports = (cli) => {
24
30
  const modernClientPrefix = normalizeModernClientPrefix(process.env.APP_MODERN_CLIENT_PREFIX);
25
31
  const appPublicPathWithoutTrailingSlash = appPublicPath.replace(/\/$/, '');
26
32
  const v2PublicPathWithoutTrailingSlash = v2PublicPath.replace(/\/$/, '');
33
+ const settingsAssetsPath = `${appPublicPath}settings/assets/`;
34
+ const settingsDocumentPattern = `^${escapeRegExp(appPublicPath)}settings(?:/|$)`;
27
35
  const file = resolve(__dirname, '../../nocobase.conf.tpl');
28
36
  const data = readFileSync(file, 'utf-8');
29
37
  let otherLocation = '';
@@ -64,6 +72,9 @@ module.exports = (cli) => {
64
72
  .replace(/\{\{distPath\}\}/g, distPath)
65
73
  .replace(/\{\{v2PublicPath\}\}/g, v2PublicPath)
66
74
  .replace(/\{\{v2PublicPathNoTrailingSlash\}\}/g, v2PublicPathWithoutTrailingSlash)
75
+ .replace(/\{\{portalClientPrefix\}\}/g, PORTAL_CLIENT_PREFIX)
76
+ .replace(/\{\{settingsAssetsPath\}\}/g, settingsAssetsPath)
77
+ .replace(/\{\{settingsDocumentPattern\}\}/g, settingsDocumentPattern)
67
78
  .replace(/\{\{apiPort\}\}/g, process.env.APP_PORT)
68
79
  .replace(/\{\{otherLocation\}\}/g, otherLocation);
69
80
  const targetFile = storagePathJoin('nocobase.conf');
@@ -48,6 +48,48 @@ function buildAppDevForwardArgs(argv = process.argv) {
48
48
  return ['app-dev', ...argv.slice(3)];
49
49
  }
50
50
 
51
+ function resolveSettingsDevPort(appPort) {
52
+ return Number(appPort) + 3;
53
+ }
54
+
55
+ function normalizePublicPath(value) {
56
+ let normalized = value || '/';
57
+ if (!normalized.startsWith('/')) {
58
+ normalized = `/${normalized}`;
59
+ }
60
+ if (!normalized.endsWith('/')) {
61
+ normalized = `${normalized}/`;
62
+ }
63
+ return normalized.replace(/\/{2,}/g, '/');
64
+ }
65
+
66
+ function createSettingsDevProcessOptions({
67
+ appPackageRoot,
68
+ appPort,
69
+ settingsPort,
70
+ browserPort,
71
+ appPublicPath,
72
+ processEnv = process.env,
73
+ }) {
74
+ const settingsHmrPath = `${normalizePublicPath(appPublicPath)}settings/__rspack_hmr`;
75
+ return {
76
+ command: 'rsbuild',
77
+ args: ['dev', '--config', `${appPackageRoot}/client-settings/rsbuild.config.ts`],
78
+ runOptions: {
79
+ prefix: 'client-settings',
80
+ color: 'yellow',
81
+ env: {
82
+ ...processEnv,
83
+ APP_PORT: `${appPort}`,
84
+ APP_SETTINGS_PORT: `${settingsPort}`,
85
+ NODE_ENV: 'development',
86
+ RSPACK_HMR_CLIENT_PORT: `${browserPort}`,
87
+ RSPACK_HMR_PATH: settingsHmrPath,
88
+ },
89
+ },
90
+ };
91
+ }
92
+
51
93
  function resolveDevRuntimeMode(opts = {}) {
52
94
  const appClientEntryMode = opts.appClientEntryMode || resolveAppClientEntryMode();
53
95
  const useModernOnlyEntryMode = appClientEntryMode === 'modern-only';
@@ -57,6 +99,7 @@ function resolveDevRuntimeMode(opts = {}) {
57
99
  const shouldRunClientV2 = clientV2Only || useModernOnlyEntryMode || forceClient || !forceServer;
58
100
  const shouldRunClient = !clientV2Only && !useModernOnlyEntryMode && (forceClient || !forceServer);
59
101
  const shouldRunServer = !clientV2Only && (forceServer || !forceClient || useModernOnlyEntryMode);
102
+ const shouldRunSettings = shouldRunClientV2;
60
103
 
61
104
  return {
62
105
  appClientEntryMode,
@@ -64,6 +107,7 @@ function resolveDevRuntimeMode(opts = {}) {
64
107
  shouldRunClientV2,
65
108
  shouldRunClient,
66
109
  shouldRunServer,
110
+ shouldRunSettings,
67
111
  };
68
112
  }
69
113
 
@@ -83,6 +127,7 @@ module.exports = (cli) => {
83
127
  .option('-c, --client')
84
128
  .option('-s, --server')
85
129
  .option('--db-sync')
130
+ .option('--quickstart')
86
131
  .option('--rsbuild')
87
132
  .option('--client-v2-only')
88
133
  .option('-i, --inspect [port]')
@@ -123,13 +168,18 @@ module.exports = (cli) => {
123
168
  let clientPort = APP_PORT;
124
169
  let serverPort;
125
170
  let clientV2Port = APP_PORT;
171
+ let settingsPort = resolveSettingsDevPort(APP_PORT);
126
172
 
127
173
  nodeCheck();
128
174
  await postCheck(opts);
129
175
 
130
- const { useModernOnlyEntryMode, shouldRunClientV2, shouldRunClient, shouldRunServer } = resolveDevRuntimeMode(
131
- opts,
132
- );
176
+ const {
177
+ useModernOnlyEntryMode,
178
+ shouldRunClientV2,
179
+ shouldRunClient,
180
+ shouldRunServer,
181
+ shouldRunSettings,
182
+ } = resolveDevRuntimeMode(opts);
133
183
  const shouldRunClientWithRsbuild = shouldRunClient && !!rsbuild;
134
184
 
135
185
  if (shouldRunServer && server) {
@@ -150,8 +200,15 @@ module.exports = (cli) => {
150
200
  clientV2Port = APP_PORT;
151
201
  }
152
202
 
203
+ if (shouldRunSettings) {
204
+ settingsPort = await getPortPromise({
205
+ port: resolveSettingsDevPort(APP_PORT),
206
+ });
207
+ }
208
+
153
209
  let subprocessClient;
154
210
  let subprocessClientV2;
211
+ let subprocessSettings;
155
212
 
156
213
  const runDevClientV2 = () => {
157
214
  console.log('starting client-v2', 1 * clientV2Port);
@@ -164,6 +221,7 @@ module.exports = (cli) => {
164
221
  env: {
165
222
  ...process.env,
166
223
  APP_V2_PORT: `${clientV2Port}`,
224
+ APP_SETTINGS_PORT: `${settingsPort}`,
167
225
  NODE_ENV: 'development',
168
226
  RSPACK_HMR_CLIENT_PORT: `${clientV2Only ? clientV2Port : clientPort}`,
169
227
  API_BASE_URL: process.env.API_BASE_URL || process.env.API_BASE_PATH,
@@ -181,8 +239,34 @@ module.exports = (cli) => {
181
239
  );
182
240
  };
183
241
 
242
+ const runDevSettings = () => {
243
+ console.log('starting client-settings', 1 * settingsPort);
244
+ const { command, args, runOptions } = createSettingsDevProcessOptions({
245
+ appPackageRoot: APP_PACKAGE_ROOT,
246
+ appPort: APP_PORT,
247
+ settingsPort,
248
+ browserPort: clientV2Only ? clientV2Port : clientPort,
249
+ appPublicPath: process.env.APP_PUBLIC_PATH,
250
+ processEnv: {
251
+ ...process.env,
252
+ API_BASE_URL: process.env.API_BASE_URL || process.env.API_BASE_PATH,
253
+ API_CLIENT_STORAGE_PREFIX: process.env.API_CLIENT_STORAGE_PREFIX,
254
+ API_CLIENT_STORAGE_TYPE: process.env.API_CLIENT_STORAGE_TYPE,
255
+ API_CLIENT_SHARE_TOKEN: process.env.API_CLIENT_SHARE_TOKEN || 'false',
256
+ WEBSOCKET_URL: process.env.WEBSOCKET_URL || buildWSURL(process.env.API_BASE_URL, serverPort),
257
+ WS_PATH: process.env.WS_PATH,
258
+ ESM_CDN_BASE_URL: process.env.ESM_CDN_BASE_URL || 'https://esm.sh',
259
+ ESM_CDN_SUFFIX: process.env.ESM_CDN_SUFFIX || '',
260
+ PROXY_TARGET_URL:
261
+ process.env.PROXY_TARGET_URL || (serverPort ? `http://127.0.0.1:${serverPort}` : undefined),
262
+ },
263
+ });
264
+ subprocessSettings = runWithPrefix(command, args, runOptions);
265
+ };
266
+
184
267
  if (clientV2Only) {
185
268
  runDevClientV2();
269
+ runDevSettings();
186
270
  return;
187
271
  }
188
272
 
@@ -203,6 +287,7 @@ module.exports = (cli) => {
203
287
  APP_PORT: `${clientPort}`,
204
288
  APP_ROOT: `${APP_PACKAGE_ROOT}/client`,
205
289
  APP_V2_PORT: `${clientV2Port}`,
290
+ APP_SETTINGS_PORT: `${settingsPort}`,
206
291
  NODE_ENV: 'development',
207
292
  RSPACK_HMR_CLIENT_PORT: `${clientPort}`,
208
293
  API_BASE_URL: process.env.API_BASE_URL || process.env.API_BASE_PATH,
@@ -255,6 +340,9 @@ module.exports = (cli) => {
255
340
  if (shouldRunClientV2) {
256
341
  await restartSubprocess(subprocessClientV2, clientV2Port, runDevClientV2);
257
342
  }
343
+ if (shouldRunSettings) {
344
+ await restartSubprocess(subprocessSettings, settingsPort, runDevSettings);
345
+ }
258
346
  await fs.promises.writeFile(process.env.WATCH_FILE, `export const watchId = '${uid()}';`, 'utf-8');
259
347
  }, 500);
260
348
 
@@ -324,11 +412,17 @@ module.exports = (cli) => {
324
412
  if (shouldRunClientV2) {
325
413
  runDevClientV2();
326
414
  }
415
+
416
+ if (shouldRunSettings) {
417
+ runDevSettings();
418
+ }
327
419
  });
328
420
  };
329
421
 
330
422
  module.exports._test = {
331
423
  buildAppDevForwardArgs,
424
+ createSettingsDevProcessOptions,
332
425
  forwardDevToAppDev,
333
426
  resolveDevRuntimeMode,
427
+ resolveSettingsDevPort,
334
428
  };
@@ -57,6 +57,8 @@ function createCdnClient() {
57
57
  * @param {string} timestampDir
58
58
  */
59
59
  const REWRITE_RULES = [
60
+ // AI API 请求保持原路径,避免被后续规则补上文档版本的时间戳前缀
61
+ { sourceUrl: '^/api/ai/(.*)$', targetTemplate: () => '/api/ai/$1', flag: 'break' },
60
62
  // /en/ 下无后缀的页面路由,去掉 /en/ 前缀并改写到目录式 index.html
61
63
  { sourceUrl: '^/en/([^.]*[^/.])$', targetTemplate: (ts) => `/${ts}/$1/index.html`, flag: 'break' },
62
64
  // 其他语言和默认语言的无后缀页面路由,统一改写到目录式 index.html
@@ -147,7 +149,11 @@ async function waitForRewriteRule(cdnClient, domain, timestampDir) {
147
149
  if (!config) return false;
148
150
  const args = config.functionArgs?.functionArg || [];
149
151
  const targetArg = args.find((a) => a.argName === 'target_url');
150
- return config.status === 'success' && (targetArg?.argValue || '').startsWith(expectedPrefix);
152
+ const expectedTarget = rule.targetTemplate(timestampDir);
153
+ const targetMatches = expectedTarget.startsWith(expectedPrefix)
154
+ ? (targetArg?.argValue || '').startsWith(expectedPrefix)
155
+ : targetArg?.argValue === expectedTarget;
156
+ return config.status === 'success' && targetMatches;
151
157
  });
152
158
 
153
159
  if (allEffective) {
package/src/util.js CHANGED
@@ -433,7 +433,7 @@ const DEFAULT_MODERN_CLIENT_PREFIX = 'v';
433
433
  exports.DEFAULT_MODERN_CLIENT_PREFIX = DEFAULT_MODERN_CLIENT_PREFIX;
434
434
 
435
435
  const DEFAULT_APP_CLIENT_ENTRY_MODE = 'legacy-default';
436
- const APP_CLIENT_ENTRY_MODES = new Set(['legacy-default', 'modern-default', 'modern-only']);
436
+ const APP_CLIENT_ENTRY_MODES = new Set(['legacy-default', 'modern-default', 'modern-only', 'settings-default']);
437
437
 
438
438
  exports.DEFAULT_APP_CLIENT_ENTRY_MODE = DEFAULT_APP_CLIENT_ENTRY_MODE;
439
439
 
@@ -462,7 +462,11 @@ function normalizeModernClientPrefix(value) {
462
462
  const segment = String(value || '')
463
463
  .trim()
464
464
  .replace(/^\/+|\/+$/g, '');
465
- return segment || DEFAULT_MODERN_CLIENT_PREFIX;
465
+ const normalized = segment || DEFAULT_MODERN_CLIENT_PREFIX;
466
+ if (normalized === 'settings') {
467
+ throw new Error('APP_MODERN_CLIENT_PREFIX "settings" is reserved for the standalone Settings application.');
468
+ }
469
+ return normalized;
466
470
  }
467
471
 
468
472
  exports.normalizeModernClientPrefix = normalizeModernClientPrefix;