@dokebi/template-inertia 0.4.0 → 0.4.2

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/AI.md CHANGED
@@ -1,6 +1,6 @@
1
- # AI guide — @dokebi/template-inertia 0.4.0
1
+ # AI guide — @dokebi/template-inertia 0.4.2
2
2
 
3
- An Inertia (v3) server adapter. The first request is answered with HTML (plus `script[data-page]` JSON), later `X-Inertia` requests with page JSON. It reads the Vite manifest to fill in scripts, styles and the version, and serves the build files itself. Optionally it server-renders the first HTML in the same process (SSR, D-0039). Vue and Vite are not server dependencies (they are the app's dev dependencies). Node >=24.0.0, core ^0.4.0, @dokebi/web (peer). Details are in the [README](README.md).
3
+ An Inertia (v3) server adapter. The first request is answered with HTML (plus `script[data-page]` JSON), later `X-Inertia` requests with page JSON. It reads the Vite manifest to fill in scripts, styles and the version, and serves the build files itself. Optionally it server-renders the first HTML in the same process (SSR, D-0039). Vue and Vite are not server dependencies (they are the app's dev dependencies). Node >=24.0.0, core ^0.4.0, @dokebi/web ^0.4.2 (peer). Details are in the [README](README.md).
4
4
 
5
5
  ## 1. Install and project layout
6
6
 
@@ -45,7 +45,8 @@ route.post('/orders', async (request, reply, page) => {
45
45
  ```
46
46
 
47
47
  - `render(request, reply, component, props?, { encryptHistory?, clearHistory? }?)`: GET/HEAD only. The fifth argument sets history handling for this response (otherwise the scope setting and the session signal). Return or await it in the handler. component is `/`-separated parts matching `[A-Za-z0-9][A-Za-z0-9_-]*`.
48
- - props: JSON values, async lazy functions or the helpers of §3.1. A partial reload (`only`, `except`) evaluates only the selected ones; `errors` is always included. Non-JSON values (`undefined`, functions, symbols, `NaN`, `Infinity`) fail with `TypeError: Page props must be JSON serializable`, BigInt and cycles with `JSON.stringify`'s `TypeError`. Development and tests (`NODE_ENV` not `production`) check every value of the page; production checks only the top-level props and leaves the rest to `JSON.stringify` (D-0068; a 30 KB page went from 112 to 24 µs), so this mistake is caught during development.
48
+ - props: JSON values, async lazy functions or the helpers of §3.1. A partial reload (`only`, `except`) evaluates only the selected ones; `errors` is always included. Non-JSON values (`undefined`, functions, symbols, `NaN`, `Infinity`) fail with `TypeError: Page props must be JSON serializable`, BigInt with `JSON.stringify`'s `TypeError`, cycles with a `TypeError` `Converting circular structure to JSON` (outside production at once, naming the prop that closes the cycle; an object used twice without a cycle is fine). Development and tests (`NODE_ENV` not `production`) check every value of the page; production checks only the top-level props and leaves the rest to `JSON.stringify` (D-0068; a 30 KB page went from 112 to 24 µs), so this mistake is caught during development.
49
+ - Prop types in TypeScript: describe props with `type` aliases (`type Post = { id: number; title: string }`), not `interface`. An interface has no index signature, so TypeScript refuses it where `Json` is required (`npm run typecheck` fails with "Index signature for type 'string' is missing"); a `type` with the same fields is accepted (AI-M04).
49
50
  - `redirect(request, reply, localUrl)`: 302 for GET/HEAD, 303 for mutations; an Inertia `#` navigation gets 409 + `X-Inertia-Redirect`.
50
51
  - `location(request, reply, url)`: a local or external http(s) navigation. Inertia requests get 409 + `X-Inertia-Location`.
51
52
 
@@ -93,7 +94,7 @@ route.post('/orders', async (request, reply, page) => {
93
94
 
94
95
  **Refuse input with a validation error, never with your own 422.** A refused route schema (`schema.body`, `schema.querystring`) and `throw validationError({ name: 'Already taken' })` (from `@dokebi/web`) in the handler are the same 422 `VALIDATION_FAILED` with `fields` and `errors`. For an Inertia request (`X-Inertia`) this plugin answers it like Laravel does:
95
96
 
96
- - **With @dokebi/session**: the `errors` (`{ field: message }`) and the `old` input (the body's top-level strings, numbers and booleans, without password-, token-, secret- or card-like fields; left out above 8 KB) go into the session flash, and the answer is **303 back** to the `Referer` when it is this host (another origin is never followed), otherwise to `/` for a POST/PUT/PATCH/DELETE. The next page gets the flashed `errors` as its **`errors` prop** (under errors the handler passed itself) and `old` stays in the page `flash` (`usePage().flash.old`). The client keeps the form's own state on a non-GET visit, so `useForm().errors.name` just shows the message. A GET refused by its query schema without a usable referer (a redirect to itself would loop) gets the JSON below.
97
+ - **With @dokebi/session**: the `errors` (`{ field: message }`) and the `old` input (the body's top-level strings, numbers and booleans, without password-, token-, secret- or card-like fields; left out above 8 KB) go into the session flash, and the answer is **303 back** to the `Referer` when it is this host (another origin is never followed, nor a path that reads as another host such as `//other.host/…`), otherwise to `/` for a POST/PUT/PATCH/DELETE. The next page gets the flashed `errors` as its **`errors` prop** (under errors the handler passed itself) and `old` stays in the page `flash` (`usePage().flash.old`). The client keeps the form's own state on a non-GET visit, so `useForm().errors.name` just shows the message. A GET refused by its query schema without a usable referer (a redirect to itself would loop) gets the JSON below. The target and the old input come from @dokebi/web's form helpers (`formBackTarget`, `formOldInput`, 0.4.1), the same rules @dokebi/app uses for HTML forms.
97
98
  - **Without a session** the answer is the 422 JSON with `errors`: Inertia hands a non-page answer to `onHttpException`, so read it there: `form.post(url, { onHttpException: (response) => { form.setError(response.data.errors); return false } })`.
98
99
  - **Submit with `only`.** The GET after the 303 is a new visit, and a full visit must send every prop of the page again (Inertia replaces all props, so the server cannot trim it by itself). Pass the props the submit changes and Inertia keeps the partial headers across the redirect; `errors` always comes along:
99
100
 
@@ -109,7 +110,7 @@ form.post('/guestbook', { only: ['entries'], onSuccess: () => form.reset('text')
109
110
 
110
111
  ## 4. Serving build files
111
112
 
112
- With `vite`, the manifest (at most 4 MiB) is read once in setup, and only the files reachable from the entry (static and dynamic chunks, CSS, assets) are served at the exact path `<base><file>` (`cache-control: public, max-age=31536000, immutable`). The manifest itself, files outside that list and `dist/ssr/` are never served. A missing manifest fails the start (`Vite manifest not found: … Build the views first (npm run build).`), and so does a missing entry.
113
+ With `vite`, the manifest (at most 4 MiB) is read once in setup, and only the files reachable from the entry (static and dynamic chunks, CSS, assets) are served at the exact path `<base><file>` (`cache-control: public, max-age=31536000, immutable`). Each file is read on its first request and kept in memory (files above 4 MiB are streamed per request), so rebuild with new hashed names and restart instead of changing a served file in place. The manifest itself, files outside that list and `dist/ssr/` are never served. A missing manifest fails the start (`Vite manifest not found: … Build the views first (npm run build).`), and so does a missing entry.
113
114
 
114
115
  ## 5. SSR (D-0039)
115
116
 
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @dokebi/template-inertia 0.4.0
1
+ # @dokebi/template-inertia 0.4.2
2
2
 
3
3
  An optional package for Node >=24.0.0 and core ^0.4.0 (public on npm). Its ID is `dokebi-template-inertia` and it depends on `dokebi-web`. `@dokebi/web` is a peer dependency (not the pinned dependency it used to be). EJS, Vue and Vite are not installed as required dependencies of the server package.
4
4
 
@@ -21,7 +21,7 @@ const pages = createInertia({
21
21
 
22
22
  The x-user header above only illustrates the API; it is not authentication. Build user props from the app's authentication result. Call `render(request, reply, component, props?)` only for GET/HEAD and always return or await it in the handler. The first visit is answered with HTML and a JSON page script, later X-Inertia requests with page JSON, in Inertia v3's `script[data-page="app"]` format. `rootId` defaults to app, title to an empty string and version to development. Without a configured version a warn log (`code: INERTIA_VERSION_DEFAULT`) is written once in setup: clients then cannot notice changed assets, so real deployments set a per-build version and that manifest's scripts and styles. With a Vite build, `vite: { manifest, entry, base? }` does this instead. Setup reads the manifest (`.vite/manifest.json`, relative to the working folder or absolute, at most 4 MiB) once and fills in the entry's script, the CSS of the entry and the chunks it imports, and a version from the manifest's content hash (the first 32 characters of SHA-256). `base` (default `/`) is the URL prefix of the build files. `vite` together with `version`, `scripts` or `styles` is refused. When the entry is not in the manifest the start fails and the error names the entry ([ISSUE-0005](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/issues/ISSUE-0005-playground-findings.md) PG-07). A missing manifest file fails with `Vite manifest not found: <path>. Build the views first (npm run build).`.
23
23
 
24
- This plugin serves the build files itself through dokebi-web ([D-0031](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0031-project-layout.md), supplement 1). Only files reachable from the entry are served: static and dynamic import chunks, CSS and assets. Each file is registered as a GET route at the exact path `<base><file>`; no wildcard routes are used, so the manifest itself and files outside the list are never served. When several apps use the same chunk it is registered once. The files are in outDir when the manifest is `<outDir>/.vite/manifest.json`, otherwise in the manifest's folder. Responses get a content type per extension and `cache-control: public, max-age=31536000, immutable`, because build files are expected to have a hash in their names. To serve them from a CDN or another static server, put them at the same paths.
24
+ This plugin serves the build files itself through dokebi-web ([D-0031](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0031-project-layout.md), supplement 1). Only files reachable from the entry are served: static and dynamic import chunks, CSS and assets. Each file is registered as a GET route at the exact path `<base><file>`; no wildcard routes are used, so the manifest itself and files outside the list are never served. When several apps use the same chunk it is registered once. The files are in outDir when the manifest is `<outDir>/.vite/manifest.json`, otherwise in the manifest's folder. Responses get a content type per extension and `cache-control: public, max-age=31536000, immutable`, because build files are expected to have a hash in their names. Each file is read on its first request and kept in memory (one read shared by concurrent requests; a failed read is not kept); files above 4 MiB are streamed on every request instead. To serve them from a CDN or another static server, put them at the same paths.
25
25
 
26
26
  ```yaml
27
27
  dokebi-template-inertia:
@@ -47,7 +47,7 @@ Supported: the initial HTML, page navigation, shared/lazy/partial props, deferre
47
47
 
48
48
  ## Forms and validation
49
49
 
50
- Validation follows [D-0074](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0074-error-responses.md). A refused route schema and `throw validationError({ field: 'message' })` (from `@dokebi/web`) are the same 422 `VALIDATION_FAILED` answer. For an Inertia request with [@dokebi/session](https://git.nyx-zone.com/dokebi/framework/-/blob/main/plugins/dokebi-session/README.md) this plugin flashes `errors` (and `old` input without password-like fields) and answers 303 back to the same-origin `Referer`; the next page gets them in its `errors` prop, so `useForm().errors.name` shows the message. Without a session the answer is the 422 JSON with `errors` (read it in `onHttpException`).
50
+ Validation follows [D-0074](https://git.nyx-zone.com/dokebi/framework/-/blob/main/docs/decisions/D-0074-error-responses.md). A refused route schema and `throw validationError({ field: 'message' })` (from `@dokebi/web`) are the same 422 `VALIDATION_FAILED` answer. For an Inertia request with [@dokebi/session](https://git.nyx-zone.com/dokebi/framework/-/blob/main/plugins/dokebi-session/README.md) this plugin flashes `errors` (and `old` input without password-like fields) and answers 303 back to the same-origin `Referer`; the next page gets them in its `errors` prop, so `useForm().errors.name` shows the message. Without a session the answer is the 422 JSON with `errors` (read it in `onHttpException`). The target and the old input follow @dokebi/web's form helpers (shared with @dokebi/app's HTML forms; 0.4.2 refuses a Referer path that reads as another host, such as `//other.host/…`), so this version needs @dokebi/web ^0.4.2.
51
51
 
52
52
  Submit forms with `only` (the props the submit changes, for example `form.post('/posts', { only: ['posts'] })`): the GET after the redirect is a new visit, and a full visit must send every prop again because Inertia replaces all props, so the server cannot trim it by itself. Inertia keeps the partial headers across the redirect, and `errors` is always included. `dokebi check` warns about a submit without `only` (`inertia-form-only`).
53
53
 
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync } from 'node:fs';
2
+ import { createReadStream, existsSync } from 'node:fs';
3
3
  import { readFile, stat } from 'node:fs/promises';
4
4
  import { basename, dirname, extname, join, resolve } from 'node:path';
5
5
  import { pathToFileURL } from 'node:url';
6
6
  import { types } from 'node:util';
7
- import { definePlugin, optionalDependency, } from '@dokebi/core';
7
+ import { definePlugin, optionalDependency, providerDependency, } from '@dokebi/core';
8
8
  import { diagnostics } from '@dokebi/core/libraries';
9
- import { dependency as web } from '@dokebi/web/dependency';
9
+ import { formBackTarget, formOldInput } from '@dokebi/web';
10
10
  import { viewEngines } from '@dokebi/web/points';
11
11
  import { InertiaError, InertiaUsageError } from './errors.js';
12
12
  import { options } from './options.js';
@@ -16,6 +16,13 @@ export { InertiaError, InertiaUsageError, } from './errors.js';
16
16
  export { options } from './options.js';
17
17
  export { deepMerge, defer, InertiaProp, isInertiaProp, merge, once, } from './props.js';
18
18
  const MANIFEST_MAX_BYTES = 4 * 1024 * 1024;
19
+ /** Built files up to this size are kept in memory after their first request; larger ones are streamed. */
20
+ const ASSET_CACHE_MAX_BYTES = 4 * 1024 * 1024;
21
+ /** The bytes of a built file to keep, or null when it is too large to keep (streamed per request). */
22
+ async function builtAsset(file) {
23
+ const info = await stat(file);
24
+ return info.size > ASSET_CACHE_MAX_BYTES ? null : readFile(file);
25
+ }
19
26
  const { describeError, Repeats } = diagnostics;
20
27
  function parseVite(input) {
21
28
  if (input === null || typeof input !== 'object' || Array.isArray(input) || types.isProxy(input))
@@ -154,54 +161,17 @@ function hasCookie(header, name) {
154
161
  return text.split(';').some((part) => part.slice(0, part.indexOf('=')).trim() === name);
155
162
  }
156
163
  /**
157
- * Where a validation redirect goes (D-0074 §3): the `Referer` when it is this host (another
158
- * origin is never followed); otherwise `/` for a POST, PUT, PATCH or DELETE. A GET whose referer
159
- * is missing or is itself has no way back (a redirect to itself would fail again, forever).
164
+ * dokebi-web 0.4.2 or later: the validation redirect uses its form helpers (`formBackTarget`,
165
+ * `formOldInput`, D-0074 addendum 1), shared with @dokebi/app; 0.4.2 never answers a Referer
166
+ * path that reads as another host (ISSUE-0006 A02). A local descriptor instead of
167
+ * '@dokebi/web/dependency' (whose range stays ^0.4.0 for the plugins that need nothing newer).
160
168
  */
161
- function backTarget(request, host) {
162
- const referer = request.headers.referer;
163
- if (typeof referer === 'string' && referer.length <= 4096) {
164
- let url;
165
- if (URL.canParse(referer))
166
- url = new URL(referer);
167
- if (url &&
168
- (url.protocol === 'http:' || url.protocol === 'https:') &&
169
- url.host.toLowerCase() === host.toLowerCase()) {
170
- const target = url.pathname + url.search;
171
- if (!(['GET', 'HEAD'].includes(request.method) && target === request.url))
172
- return target;
173
- }
174
- }
175
- return ['GET', 'HEAD'].includes(request.method) ? undefined : '/';
176
- }
177
- /** Field names whose values never go into the session as old input. */
178
- const SECRET_FIELD = /pass(?:word|wd|phrase)?|secret|token|api[_-]?key|card|cvc|cvv|otp|pin$/i;
179
- /** Old input kept at most (as JSON text): a larger body is not flashed. */
180
- const OLD_MAX = 8192;
181
- /**
182
- * The old input of a refused form (D-0074 §3): the body's top-level strings, numbers, booleans
183
- * and nulls, without password-like fields; undefined when nothing is left or it is too large.
184
- */
185
- function oldInput(body) {
186
- if (body === null || typeof body !== 'object' || Array.isArray(body))
187
- return undefined;
188
- const old = {};
189
- let kept = 0;
190
- for (const [key, value] of Object.entries(body)) {
191
- if (key === '__proto__' || SECRET_FIELD.test(key))
192
- continue;
193
- if (value === null ||
194
- typeof value === 'boolean' ||
195
- (typeof value === 'number' && Number.isFinite(value)) ||
196
- typeof value === 'string') {
197
- old[key] = value;
198
- kept++;
199
- }
200
- }
201
- if (!kept || JSON.stringify(old).length > OLD_MAX)
202
- return undefined;
203
- return old;
204
- }
169
+ const web = providerDependency({
170
+ name: 'dokebi-web',
171
+ version: '^0.4.2',
172
+ packageName: '@dokebi/web',
173
+ load: () => import('@dokebi/web'),
174
+ });
205
175
  const session = optionalDependency({
206
176
  name: 'dokebi-session',
207
177
  version: '^0.4.0',
@@ -292,7 +262,7 @@ class InertiaRuntime {
292
262
  // D-0074 §3: flash errors and old input, 303 back; the next page gets `errors`.
293
263
  validation: async (request, reply, errors) => {
294
264
  const current = sessionOf(request);
295
- const target = backTarget(request, server.requestHost(request));
265
+ const target = formBackTarget(request, server.requestHost(request), '/');
296
266
  if (!current?.flash || target === undefined) {
297
267
  if (logger.isEnabled('debug'))
298
268
  logger.log('debug', 'Inertia validation answered as JSON', {
@@ -303,7 +273,7 @@ class InertiaRuntime {
303
273
  return false;
304
274
  }
305
275
  await current.flash('errors', { ...errors });
306
- const old = oldInput(request.body);
276
+ const old = formOldInput(request.body);
307
277
  if (old)
308
278
  await current.flash('old', old);
309
279
  if (logger.isEnabled('debug'))
@@ -438,6 +408,7 @@ class InertiaRuntime {
438
408
  };
439
409
  // Built files are served at their exact URL (no wildcard), once even if apps share chunks.
440
410
  const served = new Set();
411
+ const loaded = new Set();
441
412
  const built = async (options) => {
442
413
  if (develop) {
443
414
  await viteDev(options.base);
@@ -461,13 +432,38 @@ class InertiaRuntime {
461
432
  continue;
462
433
  served.add(url);
463
434
  const type = CONTENT_TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream';
435
+ // Built files are hashed and immutable, and the manifest is read once at start: each is
436
+ // read on its first request and kept (one read shared by concurrent requests). A file
437
+ // above ASSET_CACHE_MAX_BYTES is streamed instead of held. A failed read is not kept.
438
+ let content;
464
439
  server.route({
465
440
  method: 'GET',
466
441
  url,
467
- handler: async (_request, reply) => reply
468
- .type(type)
469
- .header('cache-control', 'public, max-age=31536000, immutable')
470
- .send(await readFile(file)),
442
+ handler: async (_request, reply) => {
443
+ const loading = content ?? builtAsset(file);
444
+ content = loading;
445
+ let body;
446
+ try {
447
+ body = await loading;
448
+ }
449
+ catch (error) {
450
+ if (content === loading)
451
+ content = undefined;
452
+ throw error;
453
+ }
454
+ if (logger.isEnabled('debug') && !loaded.has(url)) {
455
+ loaded.add(url);
456
+ logger.log('debug', 'Vite built file loaded', {
457
+ url,
458
+ cached: body !== null,
459
+ bytes: body?.length ?? null,
460
+ });
461
+ }
462
+ return reply
463
+ .type(type)
464
+ .header('cache-control', 'public, max-age=31536000, immutable')
465
+ .send(body ?? createReadStream(file));
466
+ },
471
467
  });
472
468
  }
473
469
  return assets;
@@ -622,7 +618,7 @@ class InertiaRuntime {
622
618
  }
623
619
  export const createPlugin = definePlugin({
624
620
  name: 'dokebi-template-inertia',
625
- version: '0.4.0',
621
+ version: '0.4.2',
626
622
  core: '^0.4.0',
627
623
  options,
628
624
  entry: parseEntry,
package/dist/responder.js CHANGED
@@ -68,43 +68,70 @@ export function normalizeInertiaOptions(options = {}) {
68
68
  return Object.freeze(normalized);
69
69
  }
70
70
  const UNSUPPORTED = 'Page props must be JSON serializable';
71
+ /** Deeper than this the walk stops; JSON.stringify reports what is left (abuse, not a page). */
72
+ const MAX_DEPTH = 512;
73
+ /**
74
+ * Objects the development check visits at most per page; the rest goes to JSON.stringify
75
+ * unchecked, as in production. A page of 30 KB has a few thousand values; more than this is an
76
+ * object shared down many levels (`a.left = a.right = b`, …), whose JSON doubles with each level.
77
+ */
78
+ const MAX_VISITS = 1_000_000;
71
79
  /**
72
80
  * Fails on a value JSON.stringify would drop or change silently (undefined, a function, a
73
- * symbol, NaN or ±Infinity; a BigInt or a cycle fails in stringify itself). A plain walk before
74
- * a stringify without replacer: a replacer takes V8 off its fast path (4.8× the stringify time
75
- * for a 30 KB page, D-0068). Objects with toJSON are checked as what toJSON returns.
81
+ * symbol, NaN or ±Infinity; a BigInt fails in stringify itself) and on a cycle, with
82
+ * stringify's TypeError. A plain walk before a stringify without replacer: a replacer takes V8
83
+ * off its fast path (4.8× the stringify time for a 30 KB page, D-0068). An object with toJSON
84
+ * is checked as what toJSON returns, as stringify serializes it (toJSON is called once).
85
+ *
86
+ * `walk.path` holds the objects from the page down to `value`, entered and left (ISSUE-0006
87
+ * A13): a cycle is refused at once, where it used to be walked down to the depth limit through
88
+ * every branch (`a.left = a; a.right = a` is 2^512 visits), while an object used twice side by
89
+ * side is no cycle and is checked each time, as JSON repeats it. `walk.visits` bounds the rest.
76
90
  */
77
- function checkJson(value, depth) {
78
- if (depth > 512)
79
- return; // deeper than this is a cycle or abuse; stringify reports it
91
+ function checkJson(value, depth, walk) {
92
+ if (depth > MAX_DEPTH || ++walk.visits > MAX_VISITS)
93
+ return;
94
+ walk.path.add(value);
80
95
  if (Array.isArray(value)) {
81
96
  for (let index = 0; index < value.length; index++)
82
- checkValue(value[index], index, depth);
83
- return;
97
+ checkValue(value[index], index, depth, walk, false);
98
+ }
99
+ else {
100
+ const object = value;
101
+ for (const key in object) {
102
+ if (!Object.hasOwn(object, key))
103
+ continue;
104
+ const item = object[key];
105
+ // Only an object a toJSON returned can have its own toJSON here: stringify does not call
106
+ // it again and leaves the method out, which is no data.
107
+ if (key === 'toJSON' && typeof item === 'function')
108
+ continue;
109
+ checkValue(item, key, depth, walk, false);
110
+ }
84
111
  }
85
- const object = value;
86
- for (const key in object)
87
- if (Object.hasOwn(object, key))
88
- checkValue(object[key], key, depth);
112
+ walk.path.delete(value);
89
113
  }
90
- function checkValue(value, key, depth) {
91
- const type = typeof value;
92
- if (type === 'string' || type === 'boolean')
114
+ function checkValue(value, key, depth, walk, fromToJSON) {
115
+ if (typeof value !== 'object' || value === null) {
116
+ checkScalar(value);
93
117
  return;
94
- if (type === 'number') {
95
- if (Number.isFinite(value))
96
- return;
97
118
  }
98
- else if (type === 'object') {
99
- if (value === null)
100
- return;
101
- const json = value.toJSON;
102
- if (typeof json === 'function')
103
- checkValue(json.call(value, String(key)), key, depth);
104
- else
105
- checkJson(value, depth + 1);
119
+ const json = fromToJSON ? undefined : value.toJSON;
120
+ if (typeof json === 'function') {
121
+ checkValue(json.call(value, String(key)), key, depth, walk, true);
106
122
  return;
107
123
  }
124
+ if (walk.path.has(value))
125
+ throw new TypeError(`Converting circular structure to JSON: page prop ${JSON.stringify(String(key))} leads back to an object that holds it`);
126
+ checkJson(value, depth + 1, walk);
127
+ }
128
+ /** A value that is not an object (or is null): JSON keeps it as it is, or it is refused. */
129
+ function checkScalar(value) {
130
+ const type = typeof value;
131
+ if (type === 'string' || type === 'boolean' || value === null)
132
+ return;
133
+ if (type === 'number' && Number.isFinite(value))
134
+ return;
108
135
  throw new InertiaUsageError('INVALID_PROPS', UNSUPPORTED);
109
136
  }
110
137
  export class InertiaResponder {
@@ -292,15 +319,22 @@ export class InertiaResponder {
292
319
  ...(pageFlash && Object.keys(pageFlash).length ? { flash: pageFlash } : {}),
293
320
  };
294
321
  // Reject unsupported values rather than silently dropping application data (D-0047).
295
- if (this.#deepCheck)
296
- checkJson(page, 0);
322
+ if (this.#deepCheck) {
323
+ const walk = { path: new WeakSet(), visits: 0 };
324
+ checkJson(page, 0, walk);
325
+ if (walk.visits > MAX_VISITS && logger?.isEnabled('debug'))
326
+ logger.log('debug', 'Inertia props check stopped at its visit limit', {
327
+ component,
328
+ visits: MAX_VISITS,
329
+ });
330
+ }
297
331
  else
298
332
  for (const key in resolved) {
299
333
  // Shallow: a missing (undefined) or non-JSON top-level prop, the usual mistake.
300
334
  const value = resolved[key];
301
335
  if (value === null || typeof value === 'object')
302
336
  continue;
303
- checkValue(value, key, 1);
337
+ checkScalar(value);
304
338
  }
305
339
  const json = JSON.stringify(page);
306
340
  if (logger?.isEnabled('debug') && (sealer || clearHistory))
@@ -413,6 +447,24 @@ export class InertiaResponder {
413
447
  }
414
448
  }
415
449
  const RESCUED = Symbol('rescued');
450
+ /**
451
+ * An ordinary object with the Map's entries as own keys (JSON.stringify keeps its fast path, see
452
+ * the page props); `__proto__` is defined as an own key, never assigned.
453
+ */
454
+ function record(entries) {
455
+ const result = {};
456
+ for (const [key, value] of entries)
457
+ if (key === '__proto__')
458
+ Object.defineProperty(result, key, {
459
+ value,
460
+ enumerable: true,
461
+ writable: true,
462
+ configurable: true,
463
+ });
464
+ else
465
+ result[key] = value;
466
+ return result;
467
+ }
416
468
  /**
417
469
  * Which props this response computes and the page fields that announce the others, following
418
470
  * the official adapters (D-0052): a partial reload selects by only/except and announces only the
@@ -423,10 +475,12 @@ function buildMetadata(props, partial, only, except, reset, held) {
423
475
  const selected = [];
424
476
  const lazy = [];
425
477
  const skippedOnce = [];
426
- const deferredProps = {};
478
+ // Group names and once keys come from the app: Maps, so `constructor`, `toString` or
479
+ // `__proto__` are names like any other, not Object.prototype members.
480
+ const deferredProps = new Map();
427
481
  const lists = { append: [], prepend: [], deep: [] };
428
482
  const matchPropsOn = [];
429
- const onceProps = {};
483
+ const onceProps = new Map();
430
484
  let announced = false;
431
485
  const now = Date.now();
432
486
  for (const [key, input] of Object.entries(props)) {
@@ -447,10 +501,10 @@ function buildMetadata(props, partial, only, except, reset, held) {
447
501
  }
448
502
  const onceKey = prop.once ? (prop.once.key ?? key) : undefined;
449
503
  if (prop.once && onceKey !== undefined)
450
- onceProps[onceKey] = {
504
+ onceProps.set(onceKey, {
451
505
  prop: key,
452
506
  expiresAt: prop.once.ttlMs === undefined ? null : now + prop.once.ttlMs,
453
- };
507
+ });
454
508
  if (!partial) {
455
509
  // The client keeps its value (also for a deferred once prop: nothing to fetch).
456
510
  if (onceKey !== undefined && held.has(onceKey)) {
@@ -458,9 +512,11 @@ function buildMetadata(props, partial, only, except, reset, held) {
458
512
  continue;
459
513
  }
460
514
  if (prop.group !== undefined) {
461
- const group = deferredProps[prop.group] ?? [];
462
- group.push(key);
463
- deferredProps[prop.group] = group;
515
+ const group = deferredProps.get(prop.group);
516
+ if (group)
517
+ group.push(key);
518
+ else
519
+ deferredProps.set(prop.group, [key]);
464
520
  continue;
465
521
  }
466
522
  }
@@ -470,8 +526,8 @@ function buildMetadata(props, partial, only, except, reset, held) {
470
526
  selected.push([key, value, prop]);
471
527
  }
472
528
  const page = {};
473
- if (Object.keys(deferredProps).length)
474
- page.deferredProps = deferredProps;
529
+ if (deferredProps.size)
530
+ page.deferredProps = record(deferredProps);
475
531
  if (lists.append.length)
476
532
  page.mergeProps = lists.append;
477
533
  if (lists.prepend.length)
@@ -480,7 +536,7 @@ function buildMetadata(props, partial, only, except, reset, held) {
480
536
  page.deepMergeProps = lists.deep;
481
537
  if (matchPropsOn.length)
482
538
  page.matchPropsOn = matchPropsOn;
483
- if (Object.keys(onceProps).length)
484
- page.onceProps = onceProps;
539
+ if (onceProps.size)
540
+ page.onceProps = record(onceProps);
485
541
  return { selected, lazy, skippedOnce, page, announced, rescued: [] };
486
542
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dokebi/template-inertia",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Inertia v3 server adapter for Dokebi: page JSON, Vite manifest assets and optional server-side rendering.",
5
5
  "keywords": [
6
6
  "dokebi",
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@dokebi/core": "^0.4.0",
44
- "@dokebi/web": "^0.4.0",
44
+ "@dokebi/web": "^0.4.2",
45
45
  "fastify": "^5.12.7"
46
46
  },
47
47
  "dokebi": {
@@ -51,7 +51,7 @@
51
51
  "dependencies": [
52
52
  {
53
53
  "name": "dokebi-web",
54
- "version": "^0.4.0"
54
+ "version": "^0.4.2"
55
55
  },
56
56
  {
57
57
  "name": "dokebi-session",
@@ -113,8 +113,8 @@
113
113
  ]
114
114
  },
115
115
  "devDependencies": {
116
- "@dokebi/session": "0.4.0",
117
- "@dokebi/web": "0.4.0"
116
+ "@dokebi/session": "0.4.1",
117
+ "@dokebi/web": "0.4.2"
118
118
  },
119
119
  "publishConfig": {
120
120
  "access": "public",