@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 +6 -5
- package/README.md +3 -3
- package/dist/index.js +53 -57
- package/dist/responder.js +96 -40
- package/package.json +5 -5
package/AI.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# AI guide — @dokebi/template-inertia 0.4.
|
|
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
|
|
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.
|
|
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 {
|
|
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
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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 =
|
|
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 =
|
|
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) =>
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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.
|
|
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
|
|
74
|
-
* a stringify without replacer: a replacer takes V8
|
|
75
|
-
* for a 30 KB page, D-0068).
|
|
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 >
|
|
79
|
-
return;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
462
|
-
group
|
|
463
|
-
|
|
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 (
|
|
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 (
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
117
|
-
"@dokebi/web": "0.4.
|
|
116
|
+
"@dokebi/session": "0.4.1",
|
|
117
|
+
"@dokebi/web": "0.4.2"
|
|
118
118
|
},
|
|
119
119
|
"publishConfig": {
|
|
120
120
|
"access": "public",
|