@dokebi/template-inertia 0.4.1 → 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 +5 -5
- package/README.md +3 -3
- package/dist/index.js +43 -9
- 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 ^0.4.
|
|
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,7 @@ 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
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).
|
|
50
50
|
- `redirect(request, reply, localUrl)`: 302 for GET/HEAD, 303 for mutations; an Inertia `#` navigation gets 409 + `X-Inertia-Redirect`.
|
|
51
51
|
- `location(request, reply, url)`: a local or external http(s) navigation. Inertia requests get 409 + `X-Inertia-Location`.
|
|
@@ -94,7 +94,7 @@ route.post('/orders', async (request, reply, page) => {
|
|
|
94
94
|
|
|
95
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:
|
|
96
96
|
|
|
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), 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
|
+
- **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.
|
|
98
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 } })`.
|
|
99
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:
|
|
100
100
|
|
|
@@ -110,7 +110,7 @@ form.post('/guestbook', { only: ['entries'], onSuccess: () => form.reset('text')
|
|
|
110
110
|
|
|
111
111
|
## 4. Serving build files
|
|
112
112
|
|
|
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`). 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.
|
|
114
114
|
|
|
115
115
|
## 5. SSR (D-0039)
|
|
116
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`). The target and the old input follow @dokebi/web's form helpers (
|
|
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,5 +1,5 @@
|
|
|
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';
|
|
@@ -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,13 +161,14 @@ function hasCookie(header, name) {
|
|
|
154
161
|
return text.split(';').some((part) => part.slice(0, part.indexOf('=')).trim() === name);
|
|
155
162
|
}
|
|
156
163
|
/**
|
|
157
|
-
* dokebi-web 0.4.
|
|
158
|
-
* `formOldInput`, D-0074 addendum 1), shared with @dokebi/app.
|
|
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
|
|
159
167
|
* '@dokebi/web/dependency' (whose range stays ^0.4.0 for the plugins that need nothing newer).
|
|
160
168
|
*/
|
|
161
169
|
const web = providerDependency({
|
|
162
170
|
name: 'dokebi-web',
|
|
163
|
-
version: '^0.4.
|
|
171
|
+
version: '^0.4.2',
|
|
164
172
|
packageName: '@dokebi/web',
|
|
165
173
|
load: () => import('@dokebi/web'),
|
|
166
174
|
});
|
|
@@ -400,6 +408,7 @@ class InertiaRuntime {
|
|
|
400
408
|
};
|
|
401
409
|
// Built files are served at their exact URL (no wildcard), once even if apps share chunks.
|
|
402
410
|
const served = new Set();
|
|
411
|
+
const loaded = new Set();
|
|
403
412
|
const built = async (options) => {
|
|
404
413
|
if (develop) {
|
|
405
414
|
await viteDev(options.base);
|
|
@@ -423,13 +432,38 @@ class InertiaRuntime {
|
|
|
423
432
|
continue;
|
|
424
433
|
served.add(url);
|
|
425
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;
|
|
426
439
|
server.route({
|
|
427
440
|
method: 'GET',
|
|
428
441
|
url,
|
|
429
|
-
handler: async (_request, reply) =>
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
+
},
|
|
433
467
|
});
|
|
434
468
|
}
|
|
435
469
|
return assets;
|
|
@@ -584,7 +618,7 @@ class InertiaRuntime {
|
|
|
584
618
|
}
|
|
585
619
|
export const createPlugin = definePlugin({
|
|
586
620
|
name: 'dokebi-template-inertia',
|
|
587
|
-
version: '0.4.
|
|
621
|
+
version: '0.4.2',
|
|
588
622
|
core: '^0.4.0',
|
|
589
623
|
options,
|
|
590
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",
|