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,128 @@
1
+ # Client Navigation
2
+
3
+ A client navigation runs in one of two modes. **Blocking**, the default, waits for the page data before it swaps the page in. **Deferred** swaps the page in first, with a pending state the page renders as a skeleton, then fills in the data. SSR first loads are the same in both modes: the page arrives with its data.
4
+
5
+ Deferred navigation is opt-in twice: the router must run in deferred mode, and each page must declare it. An app that sets neither compiles and behaves as before.
6
+
7
+ ## Router Config
8
+
9
+ ```ts
10
+ public Router = new Router(this, {
11
+ preload: [],
12
+ // Pages declaring navigation: 'deferred' swap in before their data. Default: { mode: 'blocking' }
13
+ navigation: { mode: 'deferred' },
14
+ // Route paths whose chunks load at idle after the first render
15
+ prefetch: ['/', '/radars', '/sales'],
16
+ context: (context, router) => ({ ...context }),
17
+ });
18
+ ```
19
+
20
+ `preload` keeps its meanings: the router config glob list, `client/pages/preload.json` (chunks bundled with the entry) and the page option `preload?: boolean`. `prefetch` is a different, runtime list: route paths whose chunks load in the background once the first page is rendered.
21
+
22
+ ## Page Option
23
+
24
+ ```tsx
25
+ import { definePageRoute } from '@common/router/definitions';
26
+
27
+ export default definePageRoute({
28
+ path: '/radars',
29
+ options: { auth: true, navigation: 'deferred' },
30
+ data: ({ Radars }) => ({ radars: Radars.list() }),
31
+ render: ({ radars, navigation }) =>
32
+ radars === undefined ? (
33
+ <RadarsSkeleton error={navigation.error} onRetry={navigation.retry} />
34
+ ) : (
35
+ <RadarsPage radars={radars} dimmed={navigation.pending} />
36
+ ),
37
+ });
38
+ ```
39
+
40
+ A page defers only when all three hold: the router mode is `deferred`, the page declares `navigation: 'deferred'`, and the page has a `data` loader. Otherwise it navigates in blocking mode, including a page that declares `deferred` under a blocking router; its render then always sees `status: 'ready'`.
41
+
42
+ `navigation: 'deferred'` changes the render type. `definePageRoute` picks `TDeferredFrontRenderer<T>`: every data key is `T | undefined`, and the render receives a `navigation` prop. Pages without the option keep `TFrontRenderer<T>` and receive no `navigation` prop, so a blocking page may still return a data key named `navigation`. A deferred page may not: its data loader throws if it returns that key. The blocking overload accepts `navigation: 'blocking'` or no option, never `'deferred'`: a deferred page whose render does not fit the deferred renderer (an annotated props type with defined keys) is a type error, not a blocking page in disguise.
43
+
44
+ ## Navigation State
45
+
46
+ `navigation` is a `TPageNavigation`, discriminated on `status`:
47
+
48
+ | status | pending | stale | reloading | error | Meaning |
49
+ | --- | --- | --- | --- | --- | --- |
50
+ | `ready` | `false` | `false` | `false` | `null` | The data of this page is on screen |
51
+ | `pending` | `true` | `boolean` | `boolean` | `null` | The data step runs |
52
+ | `error` | `false` | `boolean` | `boolean` | `Error` | The data step failed |
53
+
54
+ - `stale: true` means the previous data is still on screen. It happens on a same-chunk navigation (the page component is kept, as in blocking mode) when the previous page showed data, and on a retry of a page that showed data. A cross-chunk navigation remounts the page component: every data key is `undefined` while pending.
55
+ - `reloading: true` means the data step runs again for a navigation whose data already reached the screen (`api.reload`, or `retry()` after `ready`): the stale data is this page's own, for this URL. It is `false` on a navigation, even a stale one, and on a retry that recovers a failed first data step. A page whose render reads the URL beside its data can keep its stale data on screen while `reloading`, and only then.
56
+ - `since` is the `Date.now()` timestamp of the last status change, for a delayed skeleton or a slow-network hint.
57
+ - `retry()` re-runs the data step only: no route resolution, no swap, no loader. It does nothing once the page left the screen.
58
+ - `error` is what the data step threw. A request that never reached the server is a `NetworkError` (`@common/errors`); an answer from the server is the error it encoded (`AuthRequired`, `Forbidden`, `Anomaly`...).
59
+
60
+ `api.reload(ids?, params?)` on a deferred page goes through `navigation.retry()`: the page turns `pending` with `stale: true` and `reloading: true`, then `ready` with the reloaded data. A reload merges what it fetched into the page state, so values written with `api.set` or `page.setData` survive. The first data render of a navigation replaces the state instead, and so does a retry that recovers a failed first data step: until then the page component may still hold the previous page's data (a same-chunk navigation keeps it), which a merge would carry over. On a blocking page `api.reload` keeps its previous behavior.
61
+
62
+ Layout data reaches the same render props: on a page declaring `deferred`, a layout `data` loader returning a `navigation` key throws like the page loader would.
63
+
64
+ ## Order Of Operations
65
+
66
+ Both modes start the same way: `page.change` hook, scroll to top, loader on, route chunk load.
67
+
68
+ Blocking:
69
+
70
+ 1. Data providers run and their data is fetched.
71
+ 2. The page swaps in with its data. The swap render releases the loader, then `page.changed` and `page.ready` fire.
72
+
73
+ Deferred:
74
+
75
+ 1. Data providers run, without fetching. A provider that throws aborts the navigation like a blocking data failure.
76
+ 2. The page swaps in with `status: 'pending'`. `page.changed` fires; the loader stays on.
77
+ 3. The data is fetched. On success the data and `status: 'ready'` render in one batch, the loader goes off, and after that render the router refreshes the title, body classes and hash scroll, then fires `page.ready`. On failure the page renders `status: 'error'` and the loader goes off.
78
+
79
+ Whether the swap render was pending is read at that render, not in its effect: data that lands within the frame between the two still fires `page.ready` after the data render, never before it.
80
+
81
+ Body classes are reset twice on a deferred page: `page.updateClient()` rewrites `document.body.className` (with the body id and the title) from the page at the swap, before `page.changed`, and again after the data render, before `page.ready`. A class the app adds to the body itself is gone after each: re-add it in both `page.changed` and `page.ready`. A blocking page resets them once, before both hooks.
82
+
83
+ The router context's `page` is the page on screen. It changes when a navigation swaps its page in, never when the route resolves, so a navigation that never commits (superseded, failed) leaves `api.set` and `api.reload` working on the page still shown.
84
+
85
+ A hash scrolls to the element whose id it names (decoded, looked up with `getElementById`), on a same-page hash change and after the render that shows the data.
86
+
87
+ Every navigation takes a new token and gives up after any await that returns to an older one: a slower chunk, an error page chunk or an older data response never replaces a newer page, and a deferred page whose provider starts another navigation (a redirect) never swaps in. A deferred data step also gives up once its page left the screen, or when a newer data step for the same page started (retry, reload). A route chunk that fails to load is logged, goes to the app error handler (the "new version" notice) and releases the loader instead of leaving it on.
88
+
89
+ ## Hooks
90
+
91
+ `router.on(hook, callback)` returns a remover that removes this callback, by reference: a callback registered twice is removed twice by one call. A listener added while its hook runs first runs on the next call of that hook; one removed while the hook runs still runs in that pass.
92
+
93
+ | Hook | When |
94
+ | --- | --- |
95
+ | `page.change` | A client navigation starts |
96
+ | `page.changed` | The rendered page changed. Fires on the first load too |
97
+ | `page.rendered` | Hydration finished (first load only) |
98
+ | `page.ready` | The page shows its data: once after hydration, then once per navigation (with the swap on a blocking page, when the data lands on a deferred one) |
99
+
100
+ Count page views on `page.ready`: it fires for the landing page and once per navigation in both modes. On a deferred page it fires the first time the data of the navigation is on screen, which includes a `retry()` that recovers a failed first data step; it never fires again for a later retry or a reload. A page replaced in the same render as its data render never reaches the screen with its data and does not fire it, as with a blocking page swapped out in the same render.
101
+
102
+ ## Page Data Fetchers
103
+
104
+ The data step partitions what a `data` loader returns:
105
+
106
+ - api fetchers (controller calls) go to the server in one `POST /api` batch;
107
+ - other promises and thenables resolve in the browser; a rejection goes to the app error handler, like an `/api` failure, then fails the data step;
108
+ - plain values are page data with no round trip.
109
+
110
+ Values resolved in the browser go through JSON (with the circular-safe stringify SSR uses), so a client navigation sees what SSR sees: a `Date` becomes a string, `undefined` keys disappear, `toJSON()` applies, and the page gets its own copy rather than the provider's object.
111
+
112
+ Only an `/api` call reaches the server. A plain value such as a parsed URL filter or a date key is never echoed through `/api`, and a promise that is not an `/api` call resolves to its value instead of serializing to `{}`.
113
+
114
+ Behavior note for 2.6.0: a data loader that returns no api fetcher no longer sends any request on a client navigation. Server-side `/api` request hooks, session refresh and 401 detection therefore do not run for such a navigation; a page that relies on them needs at least one controller call in its loader.
115
+
116
+ ## Prefetch
117
+
118
+ - `router.prefetch(path)` loads the route chunk of `path` and keeps it, without fetching data. A navigation that starts while the chunk loads joins the same load. A failure is silent; the navigation retries the load and reports it.
119
+ - `<Link to="/radars" prefetch>` calls it on hover and focus.
120
+ - The router config `prefetch` list loads at idle after `page.rendered`, one chunk at a time. Without `requestIdleCallback` (Safari, iOS) it starts 2 seconds after hydration instead, so the page's own requests go first. With data saver on (`navigator.connection.saveData`) the list is not loaded at all; `Link` prefetch on hover still runs, since the visitor is pointing at that page.
121
+
122
+ ## Contracts For Agents
123
+
124
+ - Blocking stays the default at both levels. Opting a page into `deferred` means its render must handle every data key as possibly `undefined` and render `navigation.status === 'error'` with a retry.
125
+ - A provider that throws synchronously aborts before the swap in both modes. A rejected fetch on a deferred page becomes an error state on screen (an `/api` failure still reaches the app error handler first).
126
+ - Pages that rely on a redirect or an error page coming from their data, and public full-load pages, should stay blocking.
127
+ - Use `page.ready`, not `page.changed`, for anything that needs the page data on screen, such as analytics page views. Read the page's URL from the request the hook passes, not from `window.location`, which may already be the next navigation's.
128
+ - An app that adds its own classes to `document.body` re-adds them on `page.ready` as well as `page.changed`: the router resets the body classes before each.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "proteum",
3
3
  "description": "LLM-first Opinionated Typescript Framework for web applications.",
4
- "version": "2.5.24",
4
+ "version": "2.6.0",
5
5
  "author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
6
6
  "repository": "git://github.com/gaetanlegac/proteum.git",
7
7
  "license": "MIT",
@@ -125,6 +125,12 @@ const createContentSecurityPolicy = (config: Config['csp']): TContentSecurityPol
125
125
 
126
126
  const connectedProjectBootRetryCount = 10;
127
127
  const connectedProjectBootRetryDelayMs = 5_000;
128
+ /**
129
+ * How long `cleanup()` waits for in-flight responses before force-closing their sockets.
130
+ * Kept under the host's drain window (Railway `drainingSeconds: 20` on the Unique Domains
131
+ * services), so the process exits on its own terms instead of being killed mid-response.
132
+ */
133
+ export const httpDrainDeadlineMs = 10_000;
128
134
 
129
135
  const wait = async (durationMs: number) =>
130
136
  await new Promise<void>((resolve) => {
@@ -588,8 +594,25 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
588
594
  });
589
595
  }
590
596
 
597
+ /**
598
+ * Stop accepting connections and wait for in-flight responses before the process exits.
599
+ * `server/index.ts` calls `process.exit(0)` as soon as this resolves, so an unawaited
600
+ * `close()` cut every response still being written. The wait is bounded because a
601
+ * keep-alive or streaming client can hold a socket open indefinitely.
602
+ */
591
603
  public async cleanup() {
592
- this.http.close();
604
+ await new Promise<void>((resolve) => {
605
+ const deadline = setTimeout(() => {
606
+ this.http.closeAllConnections();
607
+ resolve();
608
+ }, httpDrainDeadlineMs);
609
+ this.http.close(() => {
610
+ clearTimeout(deadline);
611
+ resolve();
612
+ });
613
+ // Idle keep-alive sockets would otherwise hold `close()` open until the deadline.
614
+ this.http.closeIdleConnections();
615
+ });
593
616
  }
594
617
 
595
618
  private registerDevTraceRoutes(routes: express.Express) {
@@ -9,7 +9,7 @@ import renderToString from 'preact-render-to-string';
9
9
  // Core
10
10
  import { type TServerRouter, TRouterContext } from '@server/services/router';
11
11
  import type { Layout, TRoute, TErrorRoute, TClientOrServerContext } from '@common/router';
12
- import PageResponse, { TFrontRenderer, TPageRenderContext } from '@common/router/response/page';
12
+ import PageResponse, { TPageRenderContext, TPageRenderer } from '@common/router/response/page';
13
13
  import { getClientBuildManifest } from './clientManifest';
14
14
  import { buildMetaTags } from './metas';
15
15
  import { buildDefaultJsonLd } from './jsonld';
@@ -41,7 +41,7 @@ export default class ServerPage<TRouter extends TServerRouter = TServerRouter> e
41
41
 
42
42
  public constructor(
43
43
  public route: TRoute | TErrorRoute,
44
- public renderer: TFrontRenderer,
44
+ public renderer: TPageRenderer,
45
45
  context: TRouterContext<TRouter>,
46
46
  public layout?: Layout,
47
47
  ) {
@@ -0,0 +1,180 @@
1
+ const assert = require('node:assert/strict');
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 previousDev = global.__DEV__;
15
+ const previousFetch = global.fetch;
16
+ global.__DEV__ = false;
17
+
18
+ const ApiClient = require('../client/services/router/request/api.ts').default;
19
+
20
+ /*----------------------------------
21
+ - HARNESS
22
+ ----------------------------------*/
23
+
24
+ const createClient = (respond) => {
25
+ const calls = [];
26
+ const handledErrors = [];
27
+ const app = { handleError: (error) => handledErrors.push(error) };
28
+ const request = { router: { url: (requestPath) => requestPath } };
29
+
30
+ global.fetch = async (url, config) => {
31
+ calls.push({ url, method: config.method, body: config.body ? JSON.parse(config.body) : undefined });
32
+ const { status = 200, body } = respond(url, config);
33
+ return { ok: status < 400, status, json: async () => body, headers: new Headers() };
34
+ };
35
+
36
+ return { api: new ApiClient(app, request), calls, handledErrors };
37
+ };
38
+
39
+ const quietly = async (run) => {
40
+ const originalLog = console.log;
41
+ const originalWarn = console.warn;
42
+ console.log = () => {};
43
+ console.warn = () => {};
44
+ try {
45
+ return await run();
46
+ } finally {
47
+ console.log = originalLog;
48
+ console.warn = originalWarn;
49
+ }
50
+ };
51
+
52
+ /*----------------------------------
53
+ - TESTS
54
+ ----------------------------------*/
55
+
56
+ test('fetchSync sends api fetchers in one batch, awaits other promises locally and keeps plain values', async () => {
57
+ const { api, calls } = createClient(() => ({ body: { rows: [1, 2], stats: { total: 2 } } }));
58
+
59
+ const data = await quietly(() =>
60
+ api.fetchSync(
61
+ {
62
+ rows: api.post('/api/Rows/list', { page: 1 }),
63
+ stats: api.post('/api/Rows/stats'),
64
+ local: Promise.resolve({ ok: true }),
65
+ thenable: { then: (resolve) => resolve(5) },
66
+ initialFilters: { q: 'word' },
67
+ dateKey: '2026-10-04',
68
+ skipped: undefined,
69
+ cached: api.post('/api/Rows/cached'),
70
+ },
71
+ { cached: 'kept' },
72
+ ),
73
+ );
74
+
75
+ assert.equal(calls.length, 1);
76
+ assert.equal(calls[0].url, '/api');
77
+ assert.equal(calls[0].method, 'POST');
78
+ assert.deepEqual(Object.keys(calls[0].body.fetchers), ['rows', 'stats']);
79
+ assert.deepEqual(calls[0].body.fetchers.rows, { method: 'POST', path: '/api/Rows/list', data: { page: 1 } });
80
+
81
+ assert.deepEqual(data, {
82
+ cached: 'kept',
83
+ rows: [1, 2],
84
+ stats: { total: 2 },
85
+ // Not an /api call: resolved here instead of serializing to {}
86
+ local: { ok: true },
87
+ thenable: 5,
88
+ initialFilters: { q: 'word' },
89
+ dateKey: '2026-10-04',
90
+ });
91
+ assert.deepEqual(Object.keys(data), ['cached', 'rows', 'stats', 'local', 'thenable', 'initialFilters', 'dateKey']);
92
+ });
93
+
94
+ test('fetchSync makes no request when no entry needs the server', async () => {
95
+ const { api, calls } = createClient(() => ({ body: {} }));
96
+
97
+ const data = await api.fetchSync({ deepLink: { tab: 'sales' }, today: Promise.resolve('2026-10-04') }, {});
98
+
99
+ assert.equal(calls.length, 0);
100
+ assert.deepEqual(data, { deepLink: { tab: 'sales' }, today: '2026-10-04' });
101
+ });
102
+
103
+ test('a failed batch goes through the app error handler and rejects', async () => {
104
+ const { api, handledErrors } = createClient(() => ({ status: 500, body: { code: 500, message: 'Boom' } }));
105
+
106
+ await quietly(() =>
107
+ assert.rejects(() => api.fetchSync({ rows: api.post('/api/Rows/list') }, {}), (error) => error.message === 'Boom'),
108
+ );
109
+ assert.equal(handledErrors.length, 1);
110
+ });
111
+
112
+ test('a rejected local promise goes through the app error handler and rejects', async () => {
113
+ const { api, calls, handledErrors } = createClient(() => ({ body: {} }));
114
+
115
+ await assert.rejects(() => api.fetchSync({ local: Promise.reject(new Error('local failure')) }, {}), /local failure/);
116
+ assert.equal(calls.length, 0);
117
+ assert.equal(handledErrors.length, 1);
118
+ assert.equal(handledErrors[0].message, 'local failure');
119
+ });
120
+
121
+ test('locally resolved values go through JSON, like SSR data and /api responses', async () => {
122
+ const { api } = createClient(() => ({ body: {} }));
123
+ const filters = { q: 'word', page: undefined };
124
+ const day = new Date('2026-10-04T00:00:00.000Z');
125
+
126
+ const data = await api.fetchSync(
127
+ {
128
+ filters,
129
+ day,
130
+ money: { toJSON: () => '12.50' },
131
+ seen: new Set(['a']),
132
+ resolved: Promise.resolve({ at: day, missing: undefined }),
133
+ nothing: Promise.resolve(undefined),
134
+ },
135
+ {},
136
+ );
137
+
138
+ assert.deepEqual(data, {
139
+ filters: { q: 'word' },
140
+ day: '2026-10-04T00:00:00.000Z',
141
+ money: '12.50',
142
+ seen: {},
143
+ resolved: { at: '2026-10-04T00:00:00.000Z' },
144
+ });
145
+ // The page gets its own copy: the provider's object is not shared
146
+ assert.notEqual(data.filters, filters);
147
+ });
148
+
149
+ test('api.reload re-runs the data step through navigation on a deferred page', () => {
150
+ const { api } = createClient(() => ({ body: {} }));
151
+ let retries = 0;
152
+ let fetches = 0;
153
+ const page = {
154
+ fetchers: { rows: {}, stats: {} },
155
+ data: { rows: [1], stats: { total: 1 } },
156
+ context: { request: { data: { page: '1' } } },
157
+ isDeferred: () => true,
158
+ navigationRetry: () => retries++,
159
+ fetchData: () => {
160
+ fetches++;
161
+ return Promise.resolve({});
162
+ },
163
+ };
164
+ api.router = { context: { page } };
165
+
166
+ api.reload('rows', { page: '2' });
167
+
168
+ assert.equal(retries, 1);
169
+ assert.equal(fetches, 0);
170
+ // The retry fetches what reload removed, with the new params
171
+ assert.deepEqual(page.data, { stats: { total: 1 } });
172
+ assert.deepEqual(page.context.request.data, { page: '2' });
173
+ });
174
+
175
+ afterAll(() => {
176
+ if (previousDev === undefined) delete global.__DEV__;
177
+ else global.__DEV__ = previousDev;
178
+ if (previousFetch === undefined) delete global.fetch;
179
+ else global.fetch = previousFetch;
180
+ });
@@ -0,0 +1,73 @@
1
+ const assert = require('node:assert/strict');
2
+
3
+ const { createRouter, restore } = require('./clientRouterHarness.cjs');
4
+
5
+ test('on() removes a listener by reference, whatever was removed before', () => {
6
+ const router = createRouter();
7
+ const calls = [];
8
+ const removeFirst = router.on('page.changed', () => calls.push('first'));
9
+ const removeSecond = router.on('page.changed', () => calls.push('second'));
10
+ router.on('page.changed', () => calls.push('third'));
11
+
12
+ // An index-based remover would drop "third" here, since "second" moved to index 0
13
+ removeFirst();
14
+ removeSecond();
15
+ router.runHook('page.changed', {});
16
+
17
+ assert.deepEqual(calls, ['third']);
18
+ });
19
+
20
+ test('a listener can remove itself while its hook runs', () => {
21
+ const router = createRouter();
22
+ const calls = [];
23
+ const removeOnce = router.on('page.ready', () => {
24
+ calls.push('once');
25
+ removeOnce();
26
+ });
27
+ router.on('page.ready', () => calls.push('always'));
28
+
29
+ router.runHook('page.ready', {});
30
+ router.runHook('page.ready', {});
31
+
32
+ assert.deepEqual(calls, ['once', 'always', 'always']);
33
+ });
34
+
35
+ test('a listener added while its hook runs waits for the next call, and one remover drops every registration', () => {
36
+ const router = createRouter();
37
+ const calls = [];
38
+ const late = () => calls.push('late');
39
+ router.on('page.changed', () => {
40
+ calls.push('first');
41
+ if (!calls.includes('added')) {
42
+ calls.push('added');
43
+ router.on('page.changed', late);
44
+ }
45
+ });
46
+
47
+ router.runHook('page.changed', {});
48
+ assert.deepEqual(calls, ['first', 'added']);
49
+ router.runHook('page.changed', {});
50
+ assert.deepEqual(calls, ['first', 'added', 'first', 'late']);
51
+
52
+ const twice = () => calls.push('twice');
53
+ const removeTwice = router.on('page.ready', twice);
54
+ router.on('page.ready', twice);
55
+ removeTwice();
56
+ router.runHook('page.ready', {});
57
+ assert.equal(calls.includes('twice'), false);
58
+ });
59
+
60
+ test('page.ready listeners receive the request', () => {
61
+ const router = createRouter();
62
+ const request = { path: '/radars' };
63
+ let received;
64
+ router.on('page.ready', (value) => {
65
+ received = value;
66
+ });
67
+
68
+ router.runHook('page.ready', request);
69
+
70
+ assert.equal(received, request);
71
+ });
72
+
73
+ afterAll(restore);