proteum 2.5.24 → 2.6.0

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.
@@ -0,0 +1,197 @@
1
+ const assert = require('node:assert/strict');
2
+
3
+ const { ReactClientContext, createRouter, restore } = require('./clientRouterHarness.cjs');
4
+ const { h, options } = require('preact');
5
+ const { renderToString } = require('preact-render-to-string');
6
+ const { Link } = require('../client/services/router/components/Link.tsx');
7
+
8
+ /*----------------------------------
9
+ - HARNESS
10
+ ----------------------------------*/
11
+
12
+ const flush = () => new Promise((resolve) => setImmediate(resolve));
13
+
14
+ // An unresolved route as registerRoutes() builds it from the SSR route list
15
+ const createUnresolvedRoute = (index, routePath, load) => ({
16
+ index,
17
+ chunk: `chunk${routePath.replace(/\W/g, '_')}`,
18
+ regex: new RegExp(`^${routePath}$`),
19
+ keys: [],
20
+ load,
21
+ });
22
+
23
+ const createModule = (routePath) => ({
24
+ __register: () => ({ method: 'GET', path: routePath, options: {}, controller: () => null }),
25
+ });
26
+
27
+ const createRouterWithRoutes = (paths, { config, failing = [] } = {}) => {
28
+ const loads = [];
29
+ const router = createRouter(config);
30
+ router.routes = paths.map((routePath, index) =>
31
+ createUnresolvedRoute(index, routePath, () => {
32
+ loads.push(routePath);
33
+ return failing.includes(routePath)
34
+ ? Promise.reject(new Error('ChunkLoadError'))
35
+ : Promise.resolve(createModule(routePath));
36
+ }),
37
+ );
38
+
39
+ return { router, loads };
40
+ };
41
+
42
+ /*----------------------------------
43
+ - TESTS
44
+ ----------------------------------*/
45
+
46
+ test('prefetch(path) loads the matching route chunk once and keeps it resolved', async () => {
47
+ const { router, loads } = createRouterWithRoutes(['/radars', '/sales']);
48
+
49
+ await Promise.all([router.prefetch('/sales?tab=near#top'), router.prefetch('/sales')]);
50
+ await router.prefetch('/sales');
51
+
52
+ assert.deepEqual(loads, ['/sales']);
53
+ assert.equal('load' in router.routes[1], false);
54
+ assert.equal(router.routes[1].path, '/sales');
55
+ assert.deepEqual(router.routes[1].regex, /^\/sales$/);
56
+ assert.equal('load' in router.routes[0], true);
57
+ });
58
+
59
+ test('a navigation joins a prefetch already in flight', async () => {
60
+ const { router, loads } = createRouterWithRoutes(['/radars']);
61
+ const route = router.routes[0];
62
+
63
+ const prefetch = router.prefetch('/radars');
64
+ const navigationRoute = await router['load'](route);
65
+ await prefetch;
66
+
67
+ assert.deepEqual(loads, ['/radars']);
68
+ assert.equal(navigationRoute.path, '/radars');
69
+ });
70
+
71
+ test('prefetch ignores unknown paths and leaves failures to the navigation', async () => {
72
+ let updates = 0;
73
+ const { router, loads } = createRouterWithRoutes(['/radars'], { failing: ['/radars'] });
74
+ router.app.handleUpdate = () => updates++;
75
+
76
+ await router.prefetch('https://example.com/elsewhere');
77
+ await router.prefetch('/unknown');
78
+ await router.prefetch('/radars');
79
+
80
+ assert.deepEqual(loads, ['/radars']);
81
+ assert.equal(updates, 0);
82
+ // Still unresolved: the next navigation loads it again and reports a failure itself
83
+ assert.equal('load' in router.routes[0], true);
84
+ });
85
+
86
+ test('the router config prefetch list loads at idle, one chunk at a time', async () => {
87
+ const idleCallbacks = [];
88
+ window.requestIdleCallback = (callback) => idleCallbacks.push(callback);
89
+
90
+ try {
91
+ const { router, loads } = createRouterWithRoutes(['/radars', '/sales', '/tlds'], {
92
+ config: { prefetch: ['/sales', '/radars'] },
93
+ });
94
+ let releaseSales;
95
+ const salesLoad = router.routes[1].load;
96
+ router.routes[1].load = () => new Promise((resolve) => (releaseSales = () => resolve(salesLoad())));
97
+
98
+ router['prefetchAtIdle']();
99
+ assert.equal(idleCallbacks.length, 1);
100
+ await flush();
101
+ assert.deepEqual(loads, []);
102
+
103
+ idleCallbacks[0]();
104
+ await flush();
105
+ // The second chunk waits for the first
106
+ assert.deepEqual(loads, []);
107
+ releaseSales();
108
+ await flush();
109
+ assert.deepEqual(loads, ['/sales', '/radars']);
110
+
111
+ // Nothing to schedule without a list
112
+ createRouterWithRoutes(['/radars']).router['prefetchAtIdle']();
113
+ assert.equal(idleCallbacks.length, 1);
114
+ } finally {
115
+ delete window.requestIdleCallback;
116
+ }
117
+ });
118
+
119
+ test('without requestIdleCallback the prefetch waits a real delay, not one millisecond', async () => {
120
+ const timers = [];
121
+ window.setTimeout = (callback, delay) => timers.push({ callback, delay });
122
+
123
+ try {
124
+ const { router, loads } = createRouterWithRoutes(['/radars'], { config: { prefetch: ['/radars'] } });
125
+ router['prefetchAtIdle']();
126
+
127
+ assert.equal(timers.length, 1);
128
+ // Safari and iOS have no idle callback: the page's own requests go first
129
+ assert.ok(timers[0].delay >= 1000, `fallback delay ${timers[0].delay}ms`);
130
+ assert.deepEqual(loads, []);
131
+
132
+ timers[0].callback();
133
+ await flush();
134
+ assert.deepEqual(loads, ['/radars']);
135
+ } finally {
136
+ // The harness window has no timers of its own
137
+ delete window.setTimeout;
138
+ }
139
+ });
140
+
141
+ test('data saver turns the idle prefetch off', () => {
142
+ const idleCallbacks = [];
143
+ window.requestIdleCallback = (callback) => idleCallbacks.push(callback);
144
+ window.navigator = { connection: { saveData: true } };
145
+
146
+ try {
147
+ createRouterWithRoutes(['/radars'], { config: { prefetch: ['/radars'] } }).router['prefetchAtIdle']();
148
+ assert.equal(idleCallbacks.length, 0);
149
+
150
+ // Saver off, or no Network Information API at all: the prefetch is scheduled
151
+ window.navigator = { connection: { saveData: false } };
152
+ createRouterWithRoutes(['/radars'], { config: { prefetch: ['/radars'] } }).router['prefetchAtIdle']();
153
+ window.navigator = {};
154
+ createRouterWithRoutes(['/radars'], { config: { prefetch: ['/radars'] } }).router['prefetchAtIdle']();
155
+ assert.equal(idleCallbacks.length, 2);
156
+ } finally {
157
+ delete window.requestIdleCallback;
158
+ window.navigator = {};
159
+ }
160
+ });
161
+
162
+ test('Link prefetch loads the route on hover and focus, and keeps the caller handlers', async () => {
163
+ const { router, loads } = createRouterWithRoutes(['/radars']);
164
+ const anchors = [];
165
+ const previousVnodeHook = options.vnode;
166
+ options.vnode = (vnode) => {
167
+ if (vnode.type === 'a') anchors.push(vnode.props);
168
+ previousVnodeHook?.(vnode);
169
+ };
170
+
171
+ let hovered = 0;
172
+ try {
173
+ renderToString(
174
+ h(
175
+ ReactClientContext.Provider,
176
+ { value: { Router: router } },
177
+ h(Link, { to: '/radars', prefetch: true, onMouseEnter: () => hovered++ }, 'Radars'),
178
+ h(Link, { to: '/radars' }, 'Plain'),
179
+ ),
180
+ );
181
+ } finally {
182
+ options.vnode = previousVnodeHook;
183
+ }
184
+
185
+ const [prefetching, plain] = anchors;
186
+ assert.equal(plain.onMouseEnter, undefined);
187
+ assert.equal(plain.onFocus, undefined);
188
+
189
+ prefetching.onMouseEnter({});
190
+ prefetching.onFocus({});
191
+ await flush();
192
+
193
+ assert.equal(hovered, 1);
194
+ assert.deepEqual(loads, ['/radars']);
195
+ });
196
+
197
+ afterAll(restore);
@@ -0,0 +1,70 @@
1
+ const Module = require('node:module');
2
+ const path = require('node:path');
3
+
4
+ const coreRoot = path.join(__dirname, '..');
5
+ require('module-alias').addAliases({
6
+ '@client': path.join(coreRoot, 'client'),
7
+ '@common': path.join(coreRoot, 'common'),
8
+ '@server': path.join(coreRoot, 'server'),
9
+ });
10
+ process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
11
+ process.env.TS_NODE_TRANSPILE_ONLY = '1';
12
+ require('ts-node/register/transpile-only');
13
+
14
+ const { createContext } = require('preact');
15
+
16
+ const ReactClientContext = createContext(undefined);
17
+ // What the default `useContext()` export returns: a test that mounts the router component sets it
18
+ const clientContext = { value: {} };
19
+ const stubs = {
20
+ '@/client/context': Object.assign(() => clientContext.value, { ReactClientContext }),
21
+ '@client/app/component': { default: () => null },
22
+ '@client/pages/_layout': { default: () => null },
23
+ '@generated/client/layouts': { default: {}, layoutOrder: [] },
24
+ '@generated/client/routes': { default: {} },
25
+ '@generated/common/controllers': { __esModule: true, default: () => ({}) },
26
+ 'react-dom': { hydrate: () => {} },
27
+ };
28
+
29
+ const originalLoad = Module._load;
30
+ Module._load = function load(request, parent, isMain) {
31
+ if (request in stubs) return stubs[request];
32
+ if (request === 'react') return originalLoad.call(this, 'preact/compat', parent, isMain);
33
+
34
+ return originalLoad.call(this, request, parent, isMain);
35
+ };
36
+
37
+ const previous = { window: global.window, document: global.document, dev: global.__DEV__ };
38
+ global.__DEV__ = false;
39
+ global.window = {
40
+ addEventListener: () => {},
41
+ removeEventListener: () => {},
42
+ history: { state: {}, pushState: () => {}, replaceState: () => {} },
43
+ location: { hash: '', host: 'localhost', origin: 'http://localhost', pathname: '/', protocol: 'http:', search: '' },
44
+ navigator: {},
45
+ scrollTo: () => {},
46
+ };
47
+ global.document = { defaultView: global.window };
48
+
49
+ const ClientRouter = require('../client/services/router/index.tsx').default;
50
+
51
+ // A router instance without the app boot: tests set routes and config themselves
52
+ const createRouter = (config = {}) => {
53
+ const app = { registerService: () => {}, handleUpdate: () => {} };
54
+ return new ClientRouter(app, { preload: [], context: () => ({}), ...config });
55
+ };
56
+
57
+ const restore = () => {
58
+ Module._load = originalLoad;
59
+ for (const [key, value] of Object.entries(previous)) {
60
+ const name = key === 'dev' ? '__DEV__' : key;
61
+ if (value === undefined) delete global[name];
62
+ else global[name] = value;
63
+ }
64
+ };
65
+
66
+ const setClientContext = (value) => {
67
+ clientContext.value = value;
68
+ };
69
+
70
+ module.exports = { ClientRouter, ReactClientContext, createRouter, restore, setClientContext };
@@ -28,7 +28,8 @@ const {
28
28
  schema,
29
29
  } = require('../server/app/controller/index.ts');
30
30
  const Service = require('../server/app/service/index.ts').default;
31
- const { expressHandler, registerRouteDefinition } = require('../common/router/definitions.ts');
31
+ const { definePageRoute, expressHandler, registerRouteDefinition } = require('../common/router/definitions.ts');
32
+ const { validatePageDataResult } = require('../common/router/pageData.ts');
32
33
  const { parseProteumEnvConfig } = require('../common/env/proteumEnv.ts');
33
34
 
34
35
  const createTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-definition-contracts-'));
@@ -153,6 +154,49 @@ export default definePageRoute({
153
154
  assert.deepEqual(definition.normalizedOptionKeys, ['auth', 'authTracking']);
154
155
  });
155
156
 
157
+ test('route indexer accepts the navigation page option', () => {
158
+ const root = createTempDir();
159
+ const filepath = path.join(root, 'client/pages/radars.tsx');
160
+
161
+ writeFile(
162
+ filepath,
163
+ `import { definePageRoute } from '@common/router/definitions';
164
+
165
+ export default definePageRoute({
166
+ path: '/radars',
167
+ options: { auth: true, navigation: 'deferred' },
168
+ data: ({ Radars }) => ({ radars: Radars.list() }),
169
+ render: ({ radars, navigation }) => null,
170
+ });
171
+ `,
172
+ );
173
+
174
+ const [definition] = indexRouteDefinitions({ side: 'client', sourceFilepath: filepath });
175
+
176
+ assert.deepEqual(definition.normalizedOptionKeys, ['auth', 'navigation']);
177
+ assert.deepEqual(definition.invalidOptionKeys, []);
178
+ });
179
+
180
+ test('definePageRoute keeps the navigation option and page data keeps "navigation" for blocking pages only', () => {
181
+ const render = () => null;
182
+ const definition = definePageRoute({ path: '/radars', options: { navigation: 'deferred' }, data: () => ({}), render });
183
+
184
+ assert.equal(definition.kind, 'page');
185
+ assert.equal(definition.options.navigation, 'deferred');
186
+ assert.equal(definition.render, render);
187
+
188
+ const route = (options) => ({ path: '/radars', options: { filepath: 'client/pages/radars.tsx', ...options } });
189
+ const data = { navigation: { menu: [] }, rows: [] };
190
+
191
+ // Blocking pages already return a "navigation" data key
192
+ assert.equal(validatePageDataResult(route({}), data), data);
193
+ assert.equal(validatePageDataResult(route({ navigation: 'blocking' }), data), data);
194
+ assert.throws(() => validatePageDataResult(route({ navigation: 'deferred' }), data), /cannot return key "navigation"/);
195
+ assert.equal(validatePageDataResult(route({ navigation: 'deferred' }), { rows: [] }).rows.length, 0);
196
+ // Other route option keys stay reserved
197
+ assert.throws(() => validatePageDataResult(route({}), { auth: true }), /reserved key "auth"/);
198
+ });
199
+
156
200
  test('router port override updates absolute runtime URLs', () => {
157
201
  const root = createTempDir();
158
202
 
@@ -0,0 +1,90 @@
1
+ /*
2
+ * The smallest document Preact's `render` needs to mount and update a tree in Node: element and text nodes,
3
+ * child lists, attributes. No layout, no events: a test that clicks drives the component's handlers itself.
4
+ */
5
+
6
+ class FakeNode {
7
+ constructor(nodeType) {
8
+ this.nodeType = nodeType;
9
+ this.parentNode = null;
10
+ this.childNodes = [];
11
+ }
12
+
13
+ get firstChild() {
14
+ return this.childNodes[0] || null;
15
+ }
16
+
17
+ get nextSibling() {
18
+ if (!this.parentNode) return null;
19
+ const siblings = this.parentNode.childNodes;
20
+ return siblings[siblings.indexOf(this) + 1] || null;
21
+ }
22
+
23
+ insertBefore(node, reference) {
24
+ if (node.parentNode) node.parentNode.removeChild(node);
25
+ const index = reference ? this.childNodes.indexOf(reference) : -1;
26
+ if (index < 0) this.childNodes.push(node);
27
+ else this.childNodes.splice(index, 0, node);
28
+ node.parentNode = this;
29
+ return node;
30
+ }
31
+
32
+ appendChild(node) {
33
+ return this.insertBefore(node, null);
34
+ }
35
+
36
+ removeChild(node) {
37
+ this.childNodes.splice(this.childNodes.indexOf(node), 1);
38
+ node.parentNode = null;
39
+ return node;
40
+ }
41
+
42
+ get textContent() {
43
+ return this.childNodes.map((child) => child.textContent).join('');
44
+ }
45
+ }
46
+
47
+ class FakeText extends FakeNode {
48
+ constructor(data) {
49
+ super(3);
50
+ this.data = String(data);
51
+ }
52
+
53
+ get textContent() {
54
+ return this.data;
55
+ }
56
+ }
57
+
58
+ class FakeElement extends FakeNode {
59
+ constructor(localName, namespaceURI) {
60
+ super(1);
61
+ this.localName = localName;
62
+ this.namespaceURI = namespaceURI;
63
+ this.attributes = {};
64
+ this.style = {};
65
+ }
66
+
67
+ setAttribute(name, value) {
68
+ this.attributes[name] = String(value);
69
+ }
70
+
71
+ removeAttribute(name) {
72
+ delete this.attributes[name];
73
+ }
74
+
75
+ getAttribute(name) {
76
+ return name in this.attributes ? this.attributes[name] : null;
77
+ }
78
+
79
+ addEventListener() {}
80
+
81
+ removeEventListener() {}
82
+ }
83
+
84
+ const createFakeDocument = () => ({
85
+ createElement: (name) => new FakeElement(name, 'http://www.w3.org/1999/xhtml'),
86
+ createElementNS: (namespace, name) => new FakeElement(name, namespace),
87
+ createTextNode: (data) => new FakeText(data),
88
+ });
89
+
90
+ module.exports = { createFakeDocument };
@@ -0,0 +1,115 @@
1
+ const assert = require('node:assert/strict');
2
+ const Module = require('node:module');
3
+ const path = require('node:path');
4
+
5
+ const coreRoot = path.join(__dirname, '..');
6
+ require('module-alias').addAliases({
7
+ '@client': path.join(coreRoot, 'client'),
8
+ '@common': path.join(coreRoot, 'common'),
9
+ '@server': path.join(coreRoot, 'server'),
10
+ });
11
+ process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
12
+ process.env.TS_NODE_TRANSPILE_ONLY = '1';
13
+ require('ts-node/register/transpile-only');
14
+
15
+ // The app container reads the app's proteum.config.ts at import; the HTTP server only needs it at runtime.
16
+ const httpServerModulePath = path.join(coreRoot, 'server/services/router/http/index.ts');
17
+ const originalLoad = Module._load;
18
+ Module._load = function patchedLoad(request, parent, isMain) {
19
+ if (parent?.filename === httpServerModulePath && request === '@server/app/container') {
20
+ return { __esModule: true, default: {} };
21
+ }
22
+
23
+ return originalLoad.call(this, request, parent, isMain);
24
+ };
25
+
26
+ let HttpServer;
27
+ let httpDrainDeadlineMs;
28
+ try {
29
+ ({ default: HttpServer, httpDrainDeadlineMs } = require('../server/services/router/http/index.ts'));
30
+ } finally {
31
+ Module._load = originalLoad;
32
+ }
33
+
34
+ /** A real HttpServer wired to a fake app, with its node server swapped for a recorder. */
35
+ const createDrainingServer = () => {
36
+ const hooks = {};
37
+ const app = {
38
+ env: { name: 'local' },
39
+ on: (name, callback) => {
40
+ hooks[name] = callback;
41
+ },
42
+ };
43
+ const server = new HttpServer({ domain: 'localhost', port: 0, ssl: false }, { app });
44
+
45
+ const calls = [];
46
+ let closeCallback;
47
+ server.http = {
48
+ close: (callback) => {
49
+ calls.push('close');
50
+ closeCallback = callback;
51
+ },
52
+ closeIdleConnections: () => calls.push('closeIdleConnections'),
53
+ closeAllConnections: () => calls.push('closeAllConnections'),
54
+ };
55
+
56
+ let settled = false;
57
+ const runCleanupHook = () => {
58
+ const drained = hooks.cleanup();
59
+ void drained.then(() => {
60
+ settled = true;
61
+ });
62
+ return drained;
63
+ };
64
+
65
+ return { calls, runCleanupHook, isSettled: () => settled, finishClose: () => closeCallback() };
66
+ };
67
+
68
+ const flushMicrotasks = async () => {
69
+ for (let index = 0; index < 5; index++) await Promise.resolve();
70
+ };
71
+
72
+ afterEach(() => {
73
+ vi.useRealTimers();
74
+ });
75
+
76
+ test('http server cleanup waits for close and clears the drain deadline', async () => {
77
+ vi.useFakeTimers();
78
+ const server = createDrainingServer();
79
+
80
+ const drained = server.runCleanupHook();
81
+ await flushMicrotasks();
82
+
83
+ assert.equal(server.isSettled(), false, 'cleanup must not resolve while responses are still in flight');
84
+ assert.deepEqual(server.calls, ['close', 'closeIdleConnections']);
85
+ assert.equal(vi.getTimerCount(), 1);
86
+
87
+ server.finishClose();
88
+ await drained;
89
+
90
+ assert.equal(server.isSettled(), true);
91
+ assert.equal(vi.getTimerCount(), 0, 'the drain deadline must be cleared once close() calls back');
92
+ assert.deepEqual(server.calls, ['close', 'closeIdleConnections']);
93
+ });
94
+
95
+ test('http server cleanup force-closes connections when the drain deadline passes', async () => {
96
+ vi.useFakeTimers();
97
+ const server = createDrainingServer();
98
+
99
+ const drained = server.runCleanupHook();
100
+
101
+ await vi.advanceTimersByTimeAsync(httpDrainDeadlineMs - 1);
102
+ assert.equal(server.isSettled(), false);
103
+ assert.deepEqual(server.calls, ['close', 'closeIdleConnections']);
104
+
105
+ await vi.advanceTimersByTimeAsync(1);
106
+ await drained;
107
+
108
+ assert.equal(server.isSettled(), true);
109
+ assert.deepEqual(server.calls, ['close', 'closeIdleConnections', 'closeAllConnections']);
110
+ });
111
+
112
+ test('http server drain deadline stays under the 20 second host drain window', () => {
113
+ assert.equal(httpDrainDeadlineMs, 10_000);
114
+ assert.ok(httpDrainDeadlineMs < 20_000);
115
+ });