@dokebi/template-inertia 0.2.0 → 0.4.1
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 +147 -91
- package/README.md +52 -42
- package/dist/errors.d.ts +19 -0
- package/dist/errors.js +43 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.js +123 -67
- package/dist/options.d.ts +22 -0
- package/dist/options.js +84 -0
- package/dist/props.js +6 -5
- package/dist/responder.d.ts +30 -2
- package/dist/responder.js +174 -37
- package/dist/vite-dev.d.ts +50 -12
- package/dist/vite-dev.js +460 -27
- package/dist/vite-host.d.ts +1 -0
- package/dist/vite-host.js +62 -0
- package/options.schema.json +44 -17
- package/package.json +66 -11
- package/dist/repeats.d.ts +0 -18
- package/dist/repeats.js +0 -52
package/dist/errors.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { DokebiError } from '@dokebi/core';
|
|
2
|
+
const PLUGIN = 'dokebi-template-inertia';
|
|
3
|
+
const FIX = {
|
|
4
|
+
SSR_BUNDLE_MISSING: 'Build the views first (npm run build), or correct ssr.bundle.',
|
|
5
|
+
SSR_BUNDLE_INVALID: 'The SSR bundle must be a file that exports render(page).',
|
|
6
|
+
SSR_ENTRY_MISSING: 'Set ssr.entry (the source module of the SSR bundle, e.g. src/apps/<app>/views/ssr.js) for development, or put ssr.js beside vite.entry.',
|
|
7
|
+
VITE_MANIFEST_MISSING: 'Build the views first (npm run build), or correct vite.manifest.',
|
|
8
|
+
VITE_MANIFEST_INVALID: 'Point vite.manifest at .vite/manifest.json of a Vite build (at most 4 MB) and vite.entry at one of its entries.',
|
|
9
|
+
VITE_DEV_MISSING: 'npm install --save-dev vite (DOKEBI_VITE_DEV=1 serves the views through it).',
|
|
10
|
+
VITE_DEV_UNAVAILABLE: 'Run the app with dokebi dev, which starts one Vite dev host and sets DOKEBI_VITE_DEV_URL and DOKEBI_VITE_DEV_KEY; or unset both to start Vite in this process.',
|
|
11
|
+
SEAL_SSR_CONFLICT: 'Set inertia.ssr: false for the app of a seal host, or remove seal: true from its domains (D-0062).',
|
|
12
|
+
METHOD_NOT_ALLOWED: 'Render pages for GET/HEAD; redirect after POST/PUT/PATCH/DELETE (303).',
|
|
13
|
+
CLOSED: 'Render only while the application runs.',
|
|
14
|
+
INVALID_ENTRY: 'The Inertia entry (src/infra/inertia/shared.ts) exports only shared: (request) => props.',
|
|
15
|
+
INVALID_SCOPE: 'Give scope() document settings (title, rootId, version, scripts, styles or vite, ssr, encryptHistory), shared as a function and sealed as a boolean; vite replaces version, scripts and styles.',
|
|
16
|
+
INVALID_RENDER: 'render(request, reply, component, props?, { encryptHistory?, clearHistory? }): component is names of letters, digits, _ or - joined by /.',
|
|
17
|
+
INVALID_PROPS: 'Page props must be JSON values: no undefined, functions, symbols, NaN or Infinity (use null, or leave the prop out).',
|
|
18
|
+
INVALID_URL: 'Use a local absolute URL such as /posts/1 (not //host, no backslash or spaces).',
|
|
19
|
+
INVALID_PROP_HELPER: 'defer(fn, { group? }), merge(value, { deep?, matchOn? }), once(value, { key? }): a JSON value or a function, with the documented options.',
|
|
20
|
+
SSR_INVALID_RESULT: 'The SSR render(page) must return { head: string[], body: string } whose body has the root element (rootId).',
|
|
21
|
+
};
|
|
22
|
+
/** Failures of @dokebi/template-inertia; compare `code`, not the class. */
|
|
23
|
+
export class InertiaError extends DokebiError {
|
|
24
|
+
constructor(code, message, options = {}) {
|
|
25
|
+
super(code, message, { plugin: PLUGIN, fix: FIX[code], ...options });
|
|
26
|
+
this.name = 'InertiaError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A wrong argument (TypeError, as these were before 0.4.0) with the Dokebi error fields
|
|
31
|
+
* (`code`, `plugin`, `fix`): `isDokebiError` accepts it without inheritance (D-0063 §1).
|
|
32
|
+
*/
|
|
33
|
+
export class InertiaUsageError extends TypeError {
|
|
34
|
+
code;
|
|
35
|
+
plugin = PLUGIN;
|
|
36
|
+
fix;
|
|
37
|
+
constructor(code, message) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = 'TypeError';
|
|
40
|
+
this.code = code;
|
|
41
|
+
this.fix = FIX[code];
|
|
42
|
+
}
|
|
43
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { type Awaitable } from '@dokebi/core';
|
|
2
2
|
import type { FastifyRequest } from 'fastify';
|
|
3
3
|
import { type InertiaApi, type Props } from './responder.js';
|
|
4
|
+
export { InertiaError, type InertiaErrorCode, InertiaUsageError, type InertiaUsageErrorCode, } from './errors.js';
|
|
5
|
+
export { type InertiaPluginOptions, options } from './options.js';
|
|
4
6
|
export { type DeferOptions, deepMerge, defer, InertiaProp, isInertiaProp, type MergeOptions, merge, type OnceOptions, once, type PropValue, } from './props.js';
|
|
5
|
-
export type { InertiaApi, InertiaFlash, InertiaOptions, InertiaPage, InertiaRenderOptions, InertiaSsrResult, Json, Props, } from './responder.js';
|
|
7
|
+
export type { InertiaApi, InertiaErrorResponse, InertiaFlash, InertiaOptions, InertiaPage, InertiaRenderOptions, InertiaSsrResult, Json, Props, } from './responder.js';
|
|
6
8
|
export type InertiaShared = (request: FastifyRequest) => Awaitable<Props>;
|
|
7
9
|
export interface InertiaEntry {
|
|
8
10
|
readonly shared?: InertiaShared;
|
|
@@ -48,12 +50,22 @@ export interface InertiaScopeOptions {
|
|
|
48
50
|
readonly shared?: InertiaShared;
|
|
49
51
|
/** History encryption of this scope's pages (default `'sealed'`: on for sealed requests). */
|
|
50
52
|
readonly encryptHistory?: boolean | 'sealed';
|
|
53
|
+
/**
|
|
54
|
+
* The error page component of this scope (D-0074); unset uses the plugin's `errorPage`,
|
|
55
|
+
* `false` turns it off for this scope.
|
|
56
|
+
*/
|
|
57
|
+
readonly errorPage?: string | false;
|
|
51
58
|
/**
|
|
52
59
|
* This scope answers seal hosts (dokebi-app sets it, D-0062). Server rendering would write the
|
|
53
60
|
* props as plain HTML, so `sealed` with SSR stops the start (`SEAL_SSR_CONFLICT`).
|
|
54
61
|
*/
|
|
55
62
|
readonly sealed?: boolean;
|
|
56
63
|
}
|
|
64
|
+
/** The `inertia` engine contributed to `dokebi-web:view-engine` (web `WebViewEngine` shape). */
|
|
65
|
+
export interface InertiaViewEngine {
|
|
66
|
+
readonly name: 'inertia';
|
|
67
|
+
scope(input: InertiaScopeOptions): Promise<InertiaApi>;
|
|
68
|
+
}
|
|
57
69
|
export interface InertiaPluginApi extends InertiaApi {
|
|
58
70
|
/**
|
|
59
71
|
* A renderer with its own document settings and extra shared props. Reads the Vite manifest
|
package/dist/index.js
CHANGED
|
@@ -5,14 +5,21 @@ import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
|
5
5
|
import { pathToFileURL } from 'node:url';
|
|
6
6
|
import { types } from 'node:util';
|
|
7
7
|
import { definePlugin, optionalDependency, providerDependency, } from '@dokebi/core';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
8
|
+
import { diagnostics } from '@dokebi/core/libraries';
|
|
9
|
+
import { formBackTarget, formOldInput } from '@dokebi/web';
|
|
10
|
+
import { viewEngines } from '@dokebi/web/points';
|
|
11
|
+
import { InertiaError, InertiaUsageError } from './errors.js';
|
|
12
|
+
import { options } from './options.js';
|
|
13
|
+
import { InertiaResponder, normalizeInertiaOptions, vary, } from './responder.js';
|
|
14
|
+
import { openViteDev, viteDevRequested } from './vite-dev.js';
|
|
15
|
+
export { InertiaError, InertiaUsageError, } from './errors.js';
|
|
16
|
+
export { options } from './options.js';
|
|
11
17
|
export { deepMerge, defer, InertiaProp, isInertiaProp, merge, once, } from './props.js';
|
|
12
18
|
const MANIFEST_MAX_BYTES = 4 * 1024 * 1024;
|
|
19
|
+
const { describeError, Repeats } = diagnostics;
|
|
13
20
|
function parseVite(input) {
|
|
14
21
|
if (input === null || typeof input !== 'object' || Array.isArray(input) || types.isProxy(input))
|
|
15
|
-
throw new
|
|
22
|
+
throw new InertiaUsageError('INVALID_SCOPE', 'Invalid Inertia vite options');
|
|
16
23
|
const { manifest, entry, base = '/', ...rest } = input;
|
|
17
24
|
if (Object.keys(rest).length ||
|
|
18
25
|
typeof manifest !== 'string' ||
|
|
@@ -21,21 +28,21 @@ function parseVite(input) {
|
|
|
21
28
|
!entry ||
|
|
22
29
|
typeof base !== 'string' ||
|
|
23
30
|
!/^\/(?:[^/\\\s]+\/)*$/.test(base))
|
|
24
|
-
throw new
|
|
31
|
+
throw new InertiaUsageError('INVALID_SCOPE', 'Invalid Inertia vite options');
|
|
25
32
|
return Object.freeze({ manifest, entry, base });
|
|
26
33
|
}
|
|
27
34
|
function parseSsr(input) {
|
|
28
35
|
if (input === undefined || input === false)
|
|
29
36
|
return undefined;
|
|
30
37
|
if (input === null || typeof input !== 'object' || Array.isArray(input) || types.isProxy(input))
|
|
31
|
-
throw new
|
|
38
|
+
throw new InertiaUsageError('INVALID_SCOPE', 'Invalid Inertia ssr options');
|
|
32
39
|
const { bundle, failure = 'fallback', entry, ...rest } = input;
|
|
33
40
|
if (Object.keys(rest).length ||
|
|
34
41
|
typeof bundle !== 'string' ||
|
|
35
42
|
!bundle ||
|
|
36
43
|
(failure !== 'fallback' && failure !== 'error') ||
|
|
37
44
|
(entry !== undefined && (typeof entry !== 'string' || !entry || entry.includes('..'))))
|
|
38
|
-
throw new
|
|
45
|
+
throw new InertiaUsageError('INVALID_SCOPE', 'Invalid Inertia ssr options');
|
|
39
46
|
return Object.freeze({ bundle, failure, ...(entry ? { entry: entry } : {}) });
|
|
40
47
|
}
|
|
41
48
|
/**
|
|
@@ -47,13 +54,13 @@ async function loadBundle(bundle) {
|
|
|
47
54
|
const info = await stat(path).catch((cause) => {
|
|
48
55
|
if (cause.code !== 'ENOENT')
|
|
49
56
|
throw cause;
|
|
50
|
-
throw new
|
|
57
|
+
throw new InertiaError('SSR_BUNDLE_MISSING', `Inertia SSR bundle not found: ${bundle}. Build the views first (npm run build).`, { cause });
|
|
51
58
|
});
|
|
52
59
|
if (!info.isFile())
|
|
53
|
-
throw new
|
|
60
|
+
throw new InertiaError('SSR_BUNDLE_INVALID', `Inertia SSR bundle is not a file: ${bundle}`);
|
|
54
61
|
const module = (await import(pathToFileURL(path).href));
|
|
55
62
|
if (typeof module.render !== 'function')
|
|
56
|
-
throw new
|
|
63
|
+
throw new InertiaError('SSR_BUNDLE_INVALID', `Inertia SSR bundle ${bundle} does not export render(page)`);
|
|
57
64
|
return module.render;
|
|
58
65
|
}
|
|
59
66
|
const CONTENT_TYPES = {
|
|
@@ -84,17 +91,17 @@ async function viteAssets(vite) {
|
|
|
84
91
|
const info = await stat(path).catch((cause) => {
|
|
85
92
|
if (cause.code !== 'ENOENT')
|
|
86
93
|
throw cause;
|
|
87
|
-
throw new
|
|
94
|
+
throw new InertiaError('VITE_MANIFEST_MISSING', `Vite manifest not found: ${vite.manifest}. Build the views first (npm run build).`, { cause });
|
|
88
95
|
});
|
|
89
96
|
if (!info.isFile() || info.size > MANIFEST_MAX_BYTES)
|
|
90
|
-
throw new
|
|
97
|
+
throw new InertiaError('VITE_MANIFEST_INVALID', 'Invalid Vite manifest file');
|
|
91
98
|
// Vite writes <outDir>/.vite/manifest.json; a custom manifest name sits in outDir itself.
|
|
92
99
|
const outDir = basename(dirname(path)) === '.vite' ? dirname(dirname(path)) : dirname(path);
|
|
93
100
|
const text = await readFile(path, 'utf8');
|
|
94
101
|
const manifest = JSON.parse(text);
|
|
95
102
|
const entry = Object.hasOwn(manifest, vite.entry) ? manifest[vite.entry] : undefined;
|
|
96
103
|
if (entry?.isEntry !== true || typeof entry.file !== 'string')
|
|
97
|
-
throw new
|
|
104
|
+
throw new InertiaError('VITE_MANIFEST_INVALID', 'Vite manifest has no entry ' + JSON.stringify(vite.entry));
|
|
98
105
|
const styles = [];
|
|
99
106
|
const files = new Map();
|
|
100
107
|
const strings = (value) => Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
|
|
@@ -134,13 +141,6 @@ async function viteAssets(vite) {
|
|
|
134
141
|
files,
|
|
135
142
|
};
|
|
136
143
|
}
|
|
137
|
-
// Written here so the provider package resolves from @dokebi/template-inertia, not from core.
|
|
138
|
-
const web = providerDependency({
|
|
139
|
-
name: 'dokebi-web',
|
|
140
|
-
version: '^0.2.0',
|
|
141
|
-
packageName: '@dokebi/web',
|
|
142
|
-
load: () => import('@dokebi/web'),
|
|
143
|
-
});
|
|
144
144
|
/**
|
|
145
145
|
* Carries a history reset (login, logout) to the next page when the request that did it shows
|
|
146
146
|
* no page itself (a POST that redirects): `clearHistory` then goes out with that next page.
|
|
@@ -153,33 +153,42 @@ function hasCookie(header, name) {
|
|
|
153
153
|
return false;
|
|
154
154
|
return text.split(';').some((part) => part.slice(0, part.indexOf('=')).trim() === name);
|
|
155
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* dokebi-web 0.4.1 or later: the validation redirect uses its form helpers (`formBackTarget`,
|
|
158
|
+
* `formOldInput`, D-0074 addendum 1), shared with @dokebi/app. A local descriptor instead of
|
|
159
|
+
* '@dokebi/web/dependency' (whose range stays ^0.4.0 for the plugins that need nothing newer).
|
|
160
|
+
*/
|
|
161
|
+
const web = providerDependency({
|
|
162
|
+
name: 'dokebi-web',
|
|
163
|
+
version: '^0.4.1',
|
|
164
|
+
packageName: '@dokebi/web',
|
|
165
|
+
load: () => import('@dokebi/web'),
|
|
166
|
+
});
|
|
156
167
|
const session = optionalDependency({
|
|
157
168
|
name: 'dokebi-session',
|
|
158
|
-
version: '^0.
|
|
169
|
+
version: '^0.4.0',
|
|
159
170
|
});
|
|
160
171
|
/** SSR writes props as plain HTML: it cannot serve a sealed scope (D-0062). */
|
|
161
172
|
function sealSsrConflict() {
|
|
162
|
-
return
|
|
173
|
+
return new InertiaError('SEAL_SSR_CONFLICT', 'An Inertia scope of seal hosts cannot use SSR: server rendering writes the props as plain HTML. Set ssr: false for this app or drop seal from its hosts.');
|
|
163
174
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const ssr =
|
|
167
|
-
|
|
168
|
-
const document = normalizeInertiaOptions(rest);
|
|
169
|
-
return Object.freeze({
|
|
170
|
-
document,
|
|
171
|
-
defaultVersion: rest.version === undefined,
|
|
172
|
-
...(ssr ? { ssr } : {}),
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
// The manifest decides scripts, styles and version; setting them too would be ambiguous.
|
|
176
|
-
if (['version', 'scripts', 'styles'].some((key) => rest[key] !== undefined))
|
|
177
|
-
throw new TypeError('Inertia vite options replace version, scripts and styles');
|
|
175
|
+
/** Settings of the parsed options (the declaration checked them, D-0063 §3). */
|
|
176
|
+
function settings(parsed) {
|
|
177
|
+
const { vite, ssr, ...rest } = parsed;
|
|
178
|
+
const document = normalizeInertiaOptions(rest);
|
|
178
179
|
return Object.freeze({
|
|
179
|
-
document
|
|
180
|
-
defaultVersion:
|
|
181
|
-
vite:
|
|
182
|
-
...(ssr
|
|
180
|
+
document,
|
|
181
|
+
defaultVersion: vite === undefined && rest.version === undefined,
|
|
182
|
+
...(vite ? { vite: Object.freeze({ ...vite }) } : {}),
|
|
183
|
+
...(ssr
|
|
184
|
+
? {
|
|
185
|
+
ssr: Object.freeze({
|
|
186
|
+
bundle: ssr.bundle,
|
|
187
|
+
failure: ssr.failure,
|
|
188
|
+
...(ssr.entry ? { entry: ssr.entry } : {}),
|
|
189
|
+
}),
|
|
190
|
+
}
|
|
191
|
+
: {}),
|
|
183
192
|
});
|
|
184
193
|
}
|
|
185
194
|
/** Accepts a plain object or the namespace of the user entry module (src/inertia/shared.ts). */
|
|
@@ -191,7 +200,7 @@ function parseEntry(input) {
|
|
|
191
200
|
Array.isArray(input) ||
|
|
192
201
|
types.isProxy(input) ||
|
|
193
202
|
![Object.prototype, null].includes(Object.getPrototypeOf(input)))
|
|
194
|
-
throw new
|
|
203
|
+
throw new InertiaUsageError('INVALID_ENTRY', 'Invalid Inertia entry');
|
|
195
204
|
let shared;
|
|
196
205
|
for (const key of Reflect.ownKeys(input)) {
|
|
197
206
|
// Module namespaces carry Symbol.toStringTag; only string exports are entry fields.
|
|
@@ -199,11 +208,11 @@ function parseEntry(input) {
|
|
|
199
208
|
continue;
|
|
200
209
|
const descriptor = Object.getOwnPropertyDescriptor(input, key);
|
|
201
210
|
if (key !== 'shared' || !descriptor || !('value' in descriptor))
|
|
202
|
-
throw new
|
|
211
|
+
throw new InertiaUsageError('INVALID_ENTRY', 'Invalid Inertia entry');
|
|
203
212
|
shared = descriptor.value;
|
|
204
213
|
}
|
|
205
214
|
if (shared !== undefined && typeof shared !== 'function')
|
|
206
|
-
throw new
|
|
215
|
+
throw new InertiaUsageError('INVALID_ENTRY', 'Inertia shared must be a function');
|
|
207
216
|
return shared === undefined ? {} : { shared: shared };
|
|
208
217
|
}
|
|
209
218
|
class InertiaRuntime {
|
|
@@ -242,6 +251,35 @@ class InertiaRuntime {
|
|
|
242
251
|
resets.set(request, { served: now, read: carried });
|
|
243
252
|
return now || carried;
|
|
244
253
|
},
|
|
254
|
+
// D-0074 §3: flash errors and old input, 303 back; the next page gets `errors`.
|
|
255
|
+
validation: async (request, reply, errors) => {
|
|
256
|
+
const current = sessionOf(request);
|
|
257
|
+
const target = formBackTarget(request, server.requestHost(request), '/');
|
|
258
|
+
if (!current?.flash || target === undefined) {
|
|
259
|
+
if (logger.isEnabled('debug'))
|
|
260
|
+
logger.log('debug', 'Inertia validation answered as JSON', {
|
|
261
|
+
reason: current?.flash ? 'no-way-back' : 'no-session',
|
|
262
|
+
method: request.method,
|
|
263
|
+
route: request.routeOptions.url ?? 'unmatched',
|
|
264
|
+
});
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
await current.flash('errors', { ...errors });
|
|
268
|
+
const old = formOldInput(request.body);
|
|
269
|
+
if (old)
|
|
270
|
+
await current.flash('old', old);
|
|
271
|
+
if (logger.isEnabled('debug'))
|
|
272
|
+
logger.log('debug', 'Inertia validation redirect', {
|
|
273
|
+
method: request.method,
|
|
274
|
+
route: request.routeOptions.url ?? 'unmatched',
|
|
275
|
+
fields: Object.keys(errors).slice(0, 32),
|
|
276
|
+
old: old ? Object.keys(old).length : 0,
|
|
277
|
+
target: target.split('?', 1)[0]?.slice(0, 200),
|
|
278
|
+
});
|
|
279
|
+
vary(reply);
|
|
280
|
+
reply.redirect(target, 303);
|
|
281
|
+
return true;
|
|
282
|
+
},
|
|
245
283
|
}
|
|
246
284
|
: {}),
|
|
247
285
|
});
|
|
@@ -265,7 +303,8 @@ class InertiaRuntime {
|
|
|
265
303
|
carry[Symbol.for('skip-override')] = true;
|
|
266
304
|
server.register(carry, {});
|
|
267
305
|
}
|
|
268
|
-
// Development (D-0045): one Vite
|
|
306
|
+
// Development (D-0045): one Vite for every scope, reached by the first `vite` (the host of
|
|
307
|
+
// `dokebi dev`, shared by every worker, or one started in this process).
|
|
269
308
|
const develop = viteDevRequested();
|
|
270
309
|
logger.log('debug', 'Inertia settings resolved', {
|
|
271
310
|
develop,
|
|
@@ -276,6 +315,8 @@ class InertiaRuntime {
|
|
|
276
315
|
flash: flash !== undefined,
|
|
277
316
|
historyReset: hooks.historyReset !== undefined,
|
|
278
317
|
encryptHistory: this.settings.document.encryptHistory,
|
|
318
|
+
// D-0068: every page value is checked for JSON outside production, top-level props in it.
|
|
319
|
+
propsCheck: process.env.NODE_ENV === 'production' ? 'top-level' : 'deep',
|
|
279
320
|
});
|
|
280
321
|
// Per-request failures are logged once per interval with the count of repeats (D-0047).
|
|
281
322
|
const repeats = new Repeats();
|
|
@@ -306,12 +347,13 @@ class InertiaRuntime {
|
|
|
306
347
|
if (!devServer) {
|
|
307
348
|
const begin = performance.now();
|
|
308
349
|
// A start failure reaches setup or the request that awaits the server.
|
|
309
|
-
devServer =
|
|
310
|
-
logger.log('debug', 'Vite dev server
|
|
350
|
+
devServer = openViteDev(logger).then((opened) => {
|
|
351
|
+
logger.log('debug', 'Vite dev server ready', {
|
|
311
352
|
ms: Math.round(performance.now() - begin),
|
|
353
|
+
shared: process.env.DOKEBI_VITE_DEV_URL !== undefined,
|
|
312
354
|
hosts: [...pageHosts].slice(0, 64),
|
|
313
355
|
});
|
|
314
|
-
return
|
|
356
|
+
return opened;
|
|
315
357
|
});
|
|
316
358
|
context.logger.log('info', 'Inertia views are served by the Vite dev server', {
|
|
317
359
|
code: 'INERTIA_VITE_DEV',
|
|
@@ -339,16 +381,17 @@ class InertiaRuntime {
|
|
|
339
381
|
}
|
|
340
382
|
const vite = await current;
|
|
341
383
|
reply.hijack();
|
|
342
|
-
|
|
384
|
+
// Status, headers and body as the Vite host sent them (504 included).
|
|
385
|
+
vite.forward(request.raw, reply.raw, (failure) => {
|
|
343
386
|
const repeated = repeats.take(`vite ${failure instanceof Error ? failure.name : typeof failure}`);
|
|
344
387
|
if (repeated !== undefined)
|
|
345
|
-
logger.log('error', 'Vite dev
|
|
388
|
+
logger.log('error', 'Vite dev request failed', {
|
|
346
389
|
code: 'INERTIA_VITE_DEV_FAILED',
|
|
347
390
|
base,
|
|
348
|
-
error:
|
|
391
|
+
error: describeError(failure),
|
|
349
392
|
...(repeated ? { repeated } : {}),
|
|
350
393
|
});
|
|
351
|
-
})
|
|
394
|
+
});
|
|
352
395
|
},
|
|
353
396
|
});
|
|
354
397
|
logger.log('debug', 'Vite dev route registered', { url: `${base}*` });
|
|
@@ -409,19 +452,15 @@ class InertiaRuntime {
|
|
|
409
452
|
const fallback = vite ? `${dirname(vite.entry)}/ssr.js` : undefined;
|
|
410
453
|
const source = ssr.entry ?? (fallback && existsSync(resolve(fallback)) ? fallback : undefined);
|
|
411
454
|
if (!source || !vite)
|
|
412
|
-
throw new
|
|
455
|
+
throw new InertiaError('SSR_ENTRY_MISSING', `Inertia SSR in development needs the source of ${ssr.bundle}: set ssr.entry`);
|
|
413
456
|
const dev = viteDev(vite.base);
|
|
414
457
|
logger.log('debug', 'Inertia SSR renders the source through Vite', {
|
|
415
458
|
source,
|
|
416
459
|
bundle: ssr.bundle,
|
|
417
460
|
entrySet: ssr.entry !== undefined,
|
|
418
461
|
});
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (typeof module.render !== 'function')
|
|
422
|
-
throw new Error(`Inertia SSR entry ${source} does not export render(page)`);
|
|
423
|
-
return module.render(page);
|
|
424
|
-
});
|
|
462
|
+
// The Vite host loads the source and renders (one Vite for every worker).
|
|
463
|
+
render = Promise.resolve(async (page) => (await (await dev).render(source, page)));
|
|
425
464
|
}
|
|
426
465
|
else {
|
|
427
466
|
const path = resolve(ssr.bundle);
|
|
@@ -454,7 +493,7 @@ class InertiaRuntime {
|
|
|
454
493
|
bundle: ssr.bundle,
|
|
455
494
|
component,
|
|
456
495
|
fallback: ssr.failure === 'fallback',
|
|
457
|
-
error:
|
|
496
|
+
error: describeError(error),
|
|
458
497
|
...(repeated ? { repeated } : {}),
|
|
459
498
|
});
|
|
460
499
|
},
|
|
@@ -477,19 +516,28 @@ class InertiaRuntime {
|
|
|
477
516
|
: render,
|
|
478
517
|
redirect: responder.redirect.bind(responder),
|
|
479
518
|
location: responder.location.bind(responder),
|
|
519
|
+
answerError: responder.answerError.bind(responder),
|
|
480
520
|
});
|
|
481
521
|
};
|
|
482
522
|
// Prop evaluation (debug) and rescued deferred props (error) are logged by every scope.
|
|
483
523
|
const diagnostics = Object.freeze({ logger, repeats });
|
|
484
524
|
const root = api(new InertiaResponder(document, this.shared, await serverRendering(this.settings.ssr, this.settings.vite), diagnostics, flash, hooks));
|
|
525
|
+
// D-0074 §3: Inertia requests refused by validation go back with `errors` (with a session);
|
|
526
|
+
// the plugin's `errorPage` answers the rest. dokebi-app adds its own responder later, which
|
|
527
|
+
// dokebi-web asks first, so an app's requests use the app's scope.
|
|
528
|
+
server.errorResponder((response) => root.answerError(response));
|
|
529
|
+
logger.log('debug', 'Inertia error answers', {
|
|
530
|
+
validationRedirect: hooks.validation !== undefined,
|
|
531
|
+
errorPage: document.errorPage,
|
|
532
|
+
});
|
|
485
533
|
const shared = this.shared;
|
|
486
|
-
|
|
534
|
+
const plugin = Object.freeze({
|
|
487
535
|
...root,
|
|
488
536
|
ssrStats: () => Object.freeze({ ...stats }),
|
|
489
537
|
scope: async (options) => {
|
|
490
538
|
const { shared: extra, vite, ssr: ssrInput, sealed = false, ...fields } = (options ?? {});
|
|
491
539
|
if (typeof sealed !== 'boolean')
|
|
492
|
-
throw new
|
|
540
|
+
throw new InertiaUsageError('INVALID_SCOPE', 'Inertia scope sealed must be a boolean');
|
|
493
541
|
const ssr = ssrInput === undefined ? this.settings.ssr : parseSsr(ssrInput);
|
|
494
542
|
if (sealed && ssr) {
|
|
495
543
|
logger.log('error', 'Inertia scope of seal hosts with SSR', {
|
|
@@ -500,9 +548,9 @@ class InertiaRuntime {
|
|
|
500
548
|
throw sealSsrConflict();
|
|
501
549
|
}
|
|
502
550
|
if (extra !== undefined && typeof extra !== 'function')
|
|
503
|
-
throw new
|
|
551
|
+
throw new InertiaUsageError('INVALID_SCOPE', 'Inertia scope shared must be a function');
|
|
504
552
|
if (vite !== undefined && ['version', 'scripts', 'styles'].some((key) => key in fields))
|
|
505
|
-
throw new
|
|
553
|
+
throw new InertiaUsageError('INVALID_SCOPE', 'Inertia vite options replace version, scripts and styles');
|
|
506
554
|
const scoped = normalizeInertiaOptions({
|
|
507
555
|
...document,
|
|
508
556
|
...fields,
|
|
@@ -521,18 +569,26 @@ class InertiaRuntime {
|
|
|
521
569
|
shared: merged !== undefined,
|
|
522
570
|
sealed,
|
|
523
571
|
encryptHistory: scoped.encryptHistory,
|
|
572
|
+
errorPage: scoped.errorPage,
|
|
524
573
|
});
|
|
525
574
|
return api(responder);
|
|
526
575
|
},
|
|
527
576
|
});
|
|
577
|
+
// D-0063 §5: the view engine `inertia` for dokebi-app (inert until a plugin collects it).
|
|
578
|
+
context.contribute(viewEngines, Object.freeze({
|
|
579
|
+
name: 'inertia',
|
|
580
|
+
scope: (input) => plugin.scope(input),
|
|
581
|
+
}));
|
|
582
|
+
return plugin;
|
|
528
583
|
}
|
|
529
584
|
}
|
|
530
585
|
export const createPlugin = definePlugin({
|
|
531
586
|
name: 'dokebi-template-inertia',
|
|
532
|
-
version: '0.
|
|
533
|
-
core: '^0.
|
|
534
|
-
options
|
|
587
|
+
version: '0.4.1',
|
|
588
|
+
core: '^0.4.0',
|
|
589
|
+
options,
|
|
535
590
|
entry: parseEntry,
|
|
591
|
+
contributes: [viewEngines],
|
|
536
592
|
dependencies: () => [web, session],
|
|
537
|
-
instance: ({ options, entry }) => new InertiaRuntime(options, entry?.shared),
|
|
593
|
+
instance: ({ options, entry }) => new InertiaRuntime(settings(options), entry?.shared),
|
|
538
594
|
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type OptionsOf } from '@dokebi/core';
|
|
2
|
+
export declare const options: import("@dokebi/core").OptionsDeclaration<{
|
|
3
|
+
readonly encryptHistory: "sealed" | boolean;
|
|
4
|
+
readonly errorPage?: string;
|
|
5
|
+
readonly rootId?: string;
|
|
6
|
+
readonly scripts?: readonly string[];
|
|
7
|
+
readonly ssr?: {
|
|
8
|
+
readonly bundle: string;
|
|
9
|
+
readonly entry?: string;
|
|
10
|
+
readonly failure: "error" | "fallback";
|
|
11
|
+
};
|
|
12
|
+
readonly styles?: readonly string[];
|
|
13
|
+
readonly title?: string;
|
|
14
|
+
readonly version?: string;
|
|
15
|
+
readonly vite?: {
|
|
16
|
+
readonly base: string;
|
|
17
|
+
readonly entry: string;
|
|
18
|
+
readonly manifest: string;
|
|
19
|
+
};
|
|
20
|
+
}>;
|
|
21
|
+
/** The parsed options of dokebi-template-inertia (`options.dokebi-template-inertia`). */
|
|
22
|
+
export type InertiaPluginOptions = OptionsOf<typeof options>;
|
package/dist/options.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { defineOptions, option as o } from '@dokebi/core';
|
|
2
|
+
/*
|
|
3
|
+
* Options of dokebi-template-inertia (D-0063 §3): one declaration is the parser and, at build
|
|
4
|
+
* time, options.schema.json. The rules are the hand parser's before wave B; the asset URL rule
|
|
5
|
+
* (no `//`, backslash, space or control character) is declared as a refinement.
|
|
6
|
+
*/
|
|
7
|
+
/** A local absolute URL: `/x`, not `//host`, without backslash, space or control characters. */
|
|
8
|
+
const localAsset = o.string({
|
|
9
|
+
pattern: '^/[^/]',
|
|
10
|
+
refine: [
|
|
11
|
+
{
|
|
12
|
+
test: (value) => ![...value].some((char) => char === '\\' || char.charCodeAt(0) <= 32),
|
|
13
|
+
message: 'must be a local absolute URL without backslash, space or control characters',
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
});
|
|
17
|
+
export const options = defineOptions({
|
|
18
|
+
version: o.string({
|
|
19
|
+
description: 'Asset version sent with every page; a client with another one reloads (409). Unset: development (and a warning).',
|
|
20
|
+
}),
|
|
21
|
+
rootId: o.string({
|
|
22
|
+
pattern: '^[A-Za-z][A-Za-z0-9_-]*$',
|
|
23
|
+
description: 'Id of the element the client app mounts on (default app).',
|
|
24
|
+
}),
|
|
25
|
+
title: o.string({ description: 'Document title of the first HTML.' }),
|
|
26
|
+
scripts: o.array(localAsset, { description: 'Module scripts of the first HTML.' }),
|
|
27
|
+
styles: o.array(localAsset, { description: 'Stylesheets of the first HTML.' }),
|
|
28
|
+
vite: o.object({
|
|
29
|
+
manifest: o.string({
|
|
30
|
+
minLength: 1,
|
|
31
|
+
required: true,
|
|
32
|
+
description: 'Path of .vite/manifest.json, relative to the working directory',
|
|
33
|
+
}),
|
|
34
|
+
entry: o.string({
|
|
35
|
+
minLength: 1,
|
|
36
|
+
required: true,
|
|
37
|
+
description: 'Manifest key of the entry, e.g. src/apps/<app>/views/app.js',
|
|
38
|
+
}),
|
|
39
|
+
base: o.string({
|
|
40
|
+
pattern: '^/(?:[^/\\\\\\s]+/)*$',
|
|
41
|
+
default: '/',
|
|
42
|
+
description: 'URL prefix of the built files (default /)',
|
|
43
|
+
}),
|
|
44
|
+
}, {
|
|
45
|
+
description: 'Read scripts, styles and version from a Vite build manifest at startup. Replaces version, scripts and styles.',
|
|
46
|
+
}),
|
|
47
|
+
ssr: o.object({
|
|
48
|
+
bundle: o.string({
|
|
49
|
+
minLength: 1,
|
|
50
|
+
required: true,
|
|
51
|
+
description: 'SSR build output that exports render(page), relative to the working directory, e.g. dist/ssr/<app>/ssr.js',
|
|
52
|
+
}),
|
|
53
|
+
failure: o.enum(['fallback', 'error'], {
|
|
54
|
+
default: 'fallback',
|
|
55
|
+
description: 'When render throws: answer the client-rendered page and log (fallback, default) or fail the request (error)',
|
|
56
|
+
}),
|
|
57
|
+
entry: o.string({
|
|
58
|
+
minLength: 1,
|
|
59
|
+
pattern: '^(?!.*\\.\\.)',
|
|
60
|
+
description: 'Development (D-0045): source module of the bundle rendered through Vite, e.g. src/apps/admin/views/ssr.js. Default: ssr.js beside vite.entry',
|
|
61
|
+
}),
|
|
62
|
+
}, {
|
|
63
|
+
description: 'Render the first HTML of each page on the server (D-0039) with the SSR build output, imported once at start. A missing bundle stops the start.',
|
|
64
|
+
}),
|
|
65
|
+
encryptHistory: o.union([o.boolean(), o.literal('sealed')], {
|
|
66
|
+
default: 'sealed',
|
|
67
|
+
description: 'Inertia history encryption (D-0062): true, false, or sealed (default) = on for requests of seal hosts',
|
|
68
|
+
}),
|
|
69
|
+
errorPage: o.string({
|
|
70
|
+
pattern: '^[A-Za-z0-9][A-Za-z0-9_-]*(?:/[A-Za-z0-9][A-Za-z0-9_-]*)*$',
|
|
71
|
+
description: 'Page component (for example Error) rendered with { status, error, message, hint } for error answers of Inertia visits and full page loads (D-0074). Unset: JSON for visits, a small HTML page for page loads.',
|
|
72
|
+
}),
|
|
73
|
+
}, {
|
|
74
|
+
checks: [
|
|
75
|
+
{
|
|
76
|
+
path: 'vite',
|
|
77
|
+
message: 'replaces version, scripts and styles; set only one of them',
|
|
78
|
+
test: (value) => value.vite === undefined ||
|
|
79
|
+
(value.version === undefined &&
|
|
80
|
+
value.scripts === undefined &&
|
|
81
|
+
value.styles === undefined),
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
});
|
package/dist/props.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { InertiaUsageError } from './errors.js';
|
|
1
2
|
const BRAND = Symbol.for('dokebi.template-inertia.prop');
|
|
2
3
|
/**
|
|
3
4
|
* A prop value with Inertia protocol behaviour (D-0052), made by `defer`, `merge`, `deepMerge` or
|
|
@@ -30,7 +31,7 @@ export function isInertiaProp(value) {
|
|
|
30
31
|
const NAME = /^[^,\s]{1,128}$/;
|
|
31
32
|
function checkValue(value, helper) {
|
|
32
33
|
if (value === undefined || isInertiaProp(value))
|
|
33
|
-
throw new
|
|
34
|
+
throw new InertiaUsageError('INVALID_PROP_HELPER', `${helper}() takes a JSON value or a function, not ${String(value)}`);
|
|
34
35
|
return value;
|
|
35
36
|
}
|
|
36
37
|
function mergeSettings(options) {
|
|
@@ -40,7 +41,7 @@ function mergeSettings(options) {
|
|
|
40
41
|
!['append', 'prepend', 'deep'].includes(mode) ||
|
|
41
42
|
!Array.isArray(fields) ||
|
|
42
43
|
fields.some((field) => typeof field !== 'string' || !NAME.test(field)))
|
|
43
|
-
throw new
|
|
44
|
+
throw new InertiaUsageError('INVALID_PROP_HELPER', 'Invalid Inertia merge options');
|
|
44
45
|
return Object.freeze({ mode, matchOn: Object.freeze([...fields]) });
|
|
45
46
|
}
|
|
46
47
|
function onceSettings(options) {
|
|
@@ -48,7 +49,7 @@ function onceSettings(options) {
|
|
|
48
49
|
if (Object.keys(rest).length ||
|
|
49
50
|
(key !== undefined && (typeof key !== 'string' || !NAME.test(key))) ||
|
|
50
51
|
(ttlMs !== undefined && !(typeof ttlMs === 'number' && Number.isFinite(ttlMs) && ttlMs > 0)))
|
|
51
|
-
throw new
|
|
52
|
+
throw new InertiaUsageError('INVALID_PROP_HELPER', 'Invalid Inertia once options');
|
|
52
53
|
return Object.freeze({ key, ttlMs });
|
|
53
54
|
}
|
|
54
55
|
/**
|
|
@@ -57,14 +58,14 @@ function onceSettings(options) {
|
|
|
57
58
|
*/
|
|
58
59
|
export function defer(fn, options) {
|
|
59
60
|
if (typeof fn !== 'function')
|
|
60
|
-
throw new
|
|
61
|
+
throw new InertiaUsageError('INVALID_PROP_HELPER', 'defer() takes a function');
|
|
61
62
|
const settings = typeof options === 'string' ? { group: options } : (options ?? {});
|
|
62
63
|
const { group = 'default', merge, once, rescue = false, ...rest } = settings;
|
|
63
64
|
if (Object.keys(rest).length ||
|
|
64
65
|
typeof group !== 'string' ||
|
|
65
66
|
!NAME.test(group) ||
|
|
66
67
|
typeof rescue !== 'boolean')
|
|
67
|
-
throw new
|
|
68
|
+
throw new InertiaUsageError('INVALID_PROP_HELPER', 'Invalid Inertia defer options');
|
|
68
69
|
return new InertiaProp(fn, group, merge === undefined ? undefined : mergeSettings(merge), once === undefined ? undefined : onceSettings(once), rescue);
|
|
69
70
|
}
|
|
70
71
|
/** A prop the client merges into the value it holds on partial reloads (pagination, feeds). */
|