@dokebi/template-inertia 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -4,15 +4,22 @@ 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, providerDependency, } from '@dokebi/core';
8
- import { errorText, Repeats } from './repeats.js';
9
- import { InertiaResponder, normalizeInertiaOptions, } from './responder.js';
10
- import { startViteDev, viteDevRequested, viteFallthrough } from './vite-dev.js';
7
+ import { definePlugin, optionalDependency, } from '@dokebi/core';
8
+ import { diagnostics } from '@dokebi/core/libraries';
9
+ import { dependency as web } from '@dokebi/web/dependency';
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 TypeError('Invalid Inertia vite options');
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 TypeError('Invalid Inertia vite options');
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 TypeError('Invalid Inertia ssr options');
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 TypeError('Invalid Inertia ssr options');
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 Error(`Inertia SSR bundle not found: ${bundle}. Build the views first (npm run build).`, { cause });
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 Error(`Inertia SSR bundle is not a file: ${bundle}`);
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 Error(`Inertia SSR bundle ${bundle} does not export render(page)`);
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 Error(`Vite manifest not found: ${vite.manifest}. Build the views first (npm run build).`, { cause });
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 Error('Invalid Vite manifest file');
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 Error('Vite manifest has no entry ' + JSON.stringify(vite.entry));
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,80 @@ 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
+ * Where a validation redirect goes (D-0074 §3): the `Referer` when it is this host (another
158
+ * origin is never followed); otherwise `/` for a POST, PUT, PATCH or DELETE. A GET whose referer
159
+ * is missing or is itself has no way back (a redirect to itself would fail again, forever).
160
+ */
161
+ function backTarget(request, host) {
162
+ const referer = request.headers.referer;
163
+ if (typeof referer === 'string' && referer.length <= 4096) {
164
+ let url;
165
+ if (URL.canParse(referer))
166
+ url = new URL(referer);
167
+ if (url &&
168
+ (url.protocol === 'http:' || url.protocol === 'https:') &&
169
+ url.host.toLowerCase() === host.toLowerCase()) {
170
+ const target = url.pathname + url.search;
171
+ if (!(['GET', 'HEAD'].includes(request.method) && target === request.url))
172
+ return target;
173
+ }
174
+ }
175
+ return ['GET', 'HEAD'].includes(request.method) ? undefined : '/';
176
+ }
177
+ /** Field names whose values never go into the session as old input. */
178
+ const SECRET_FIELD = /pass(?:word|wd|phrase)?|secret|token|api[_-]?key|card|cvc|cvv|otp|pin$/i;
179
+ /** Old input kept at most (as JSON text): a larger body is not flashed. */
180
+ const OLD_MAX = 8192;
181
+ /**
182
+ * The old input of a refused form (D-0074 §3): the body's top-level strings, numbers, booleans
183
+ * and nulls, without password-like fields; undefined when nothing is left or it is too large.
184
+ */
185
+ function oldInput(body) {
186
+ if (body === null || typeof body !== 'object' || Array.isArray(body))
187
+ return undefined;
188
+ const old = {};
189
+ let kept = 0;
190
+ for (const [key, value] of Object.entries(body)) {
191
+ if (key === '__proto__' || SECRET_FIELD.test(key))
192
+ continue;
193
+ if (value === null ||
194
+ typeof value === 'boolean' ||
195
+ (typeof value === 'number' && Number.isFinite(value)) ||
196
+ typeof value === 'string') {
197
+ old[key] = value;
198
+ kept++;
199
+ }
200
+ }
201
+ if (!kept || JSON.stringify(old).length > OLD_MAX)
202
+ return undefined;
203
+ return old;
204
+ }
156
205
  const session = optionalDependency({
157
206
  name: 'dokebi-session',
158
- version: '^0.1.0 || ^0.2.0',
207
+ version: '^0.4.0',
159
208
  });
160
209
  /** SSR writes props as plain HTML: it cannot serve a sealed scope (D-0062). */
161
210
  function sealSsrConflict() {
162
- return Object.assign(new Error('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.'), { code: 'SEAL_SSR_CONFLICT' });
211
+ 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
212
  }
164
- function parseOptions(input) {
165
- const { vite, ssr: ssrInput, ...rest } = (input ?? {});
166
- const ssr = parseSsr(ssrInput);
167
- if (vite === undefined) {
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');
213
+ /** Settings of the parsed options (the declaration checked them, D-0063 §3). */
214
+ function settings(parsed) {
215
+ const { vite, ssr, ...rest } = parsed;
216
+ const document = normalizeInertiaOptions(rest);
178
217
  return Object.freeze({
179
- document: normalizeInertiaOptions(rest),
180
- defaultVersion: false,
181
- vite: parseVite(vite),
182
- ...(ssr ? { ssr } : {}),
218
+ document,
219
+ defaultVersion: vite === undefined && rest.version === undefined,
220
+ ...(vite ? { vite: Object.freeze({ ...vite }) } : {}),
221
+ ...(ssr
222
+ ? {
223
+ ssr: Object.freeze({
224
+ bundle: ssr.bundle,
225
+ failure: ssr.failure,
226
+ ...(ssr.entry ? { entry: ssr.entry } : {}),
227
+ }),
228
+ }
229
+ : {}),
183
230
  });
184
231
  }
185
232
  /** Accepts a plain object or the namespace of the user entry module (src/inertia/shared.ts). */
@@ -191,7 +238,7 @@ function parseEntry(input) {
191
238
  Array.isArray(input) ||
192
239
  types.isProxy(input) ||
193
240
  ![Object.prototype, null].includes(Object.getPrototypeOf(input)))
194
- throw new TypeError('Invalid Inertia entry');
241
+ throw new InertiaUsageError('INVALID_ENTRY', 'Invalid Inertia entry');
195
242
  let shared;
196
243
  for (const key of Reflect.ownKeys(input)) {
197
244
  // Module namespaces carry Symbol.toStringTag; only string exports are entry fields.
@@ -199,11 +246,11 @@ function parseEntry(input) {
199
246
  continue;
200
247
  const descriptor = Object.getOwnPropertyDescriptor(input, key);
201
248
  if (key !== 'shared' || !descriptor || !('value' in descriptor))
202
- throw new TypeError('Invalid Inertia entry');
249
+ throw new InertiaUsageError('INVALID_ENTRY', 'Invalid Inertia entry');
203
250
  shared = descriptor.value;
204
251
  }
205
252
  if (shared !== undefined && typeof shared !== 'function')
206
- throw new TypeError('Inertia shared must be a function');
253
+ throw new InertiaUsageError('INVALID_ENTRY', 'Inertia shared must be a function');
207
254
  return shared === undefined ? {} : { shared: shared };
208
255
  }
209
256
  class InertiaRuntime {
@@ -242,6 +289,35 @@ class InertiaRuntime {
242
289
  resets.set(request, { served: now, read: carried });
243
290
  return now || carried;
244
291
  },
292
+ // D-0074 §3: flash errors and old input, 303 back; the next page gets `errors`.
293
+ validation: async (request, reply, errors) => {
294
+ const current = sessionOf(request);
295
+ const target = backTarget(request, server.requestHost(request));
296
+ if (!current?.flash || target === undefined) {
297
+ if (logger.isEnabled('debug'))
298
+ logger.log('debug', 'Inertia validation answered as JSON', {
299
+ reason: current?.flash ? 'no-way-back' : 'no-session',
300
+ method: request.method,
301
+ route: request.routeOptions.url ?? 'unmatched',
302
+ });
303
+ return false;
304
+ }
305
+ await current.flash('errors', { ...errors });
306
+ const old = oldInput(request.body);
307
+ if (old)
308
+ await current.flash('old', old);
309
+ if (logger.isEnabled('debug'))
310
+ logger.log('debug', 'Inertia validation redirect', {
311
+ method: request.method,
312
+ route: request.routeOptions.url ?? 'unmatched',
313
+ fields: Object.keys(errors).slice(0, 32),
314
+ old: old ? Object.keys(old).length : 0,
315
+ target: target.split('?', 1)[0]?.slice(0, 200),
316
+ });
317
+ vary(reply);
318
+ reply.redirect(target, 303);
319
+ return true;
320
+ },
245
321
  }
246
322
  : {}),
247
323
  });
@@ -265,7 +341,8 @@ class InertiaRuntime {
265
341
  carry[Symbol.for('skip-override')] = true;
266
342
  server.register(carry, {});
267
343
  }
268
- // Development (D-0045): one Vite dev server for every scope, started by the first `vite`.
344
+ // Development (D-0045): one Vite for every scope, reached by the first `vite` (the host of
345
+ // `dokebi dev`, shared by every worker, or one started in this process).
269
346
  const develop = viteDevRequested();
270
347
  logger.log('debug', 'Inertia settings resolved', {
271
348
  develop,
@@ -276,6 +353,8 @@ class InertiaRuntime {
276
353
  flash: flash !== undefined,
277
354
  historyReset: hooks.historyReset !== undefined,
278
355
  encryptHistory: this.settings.document.encryptHistory,
356
+ // D-0068: every page value is checked for JSON outside production, top-level props in it.
357
+ propsCheck: process.env.NODE_ENV === 'production' ? 'top-level' : 'deep',
279
358
  });
280
359
  // Per-request failures are logged once per interval with the count of repeats (D-0047).
281
360
  const repeats = new Repeats();
@@ -306,12 +385,13 @@ class InertiaRuntime {
306
385
  if (!devServer) {
307
386
  const begin = performance.now();
308
387
  // A start failure reaches setup or the request that awaits the server.
309
- devServer = startViteDev().then((started) => {
310
- logger.log('debug', 'Vite dev server started', {
388
+ devServer = openViteDev(logger).then((opened) => {
389
+ logger.log('debug', 'Vite dev server ready', {
311
390
  ms: Math.round(performance.now() - begin),
391
+ shared: process.env.DOKEBI_VITE_DEV_URL !== undefined,
312
392
  hosts: [...pageHosts].slice(0, 64),
313
393
  });
314
- return started;
394
+ return opened;
315
395
  });
316
396
  context.logger.log('info', 'Inertia views are served by the Vite dev server', {
317
397
  code: 'INERTIA_VITE_DEV',
@@ -339,16 +419,17 @@ class InertiaRuntime {
339
419
  }
340
420
  const vite = await current;
341
421
  reply.hijack();
342
- vite.middlewares(request.raw, reply.raw, (error) => viteFallthrough(reply.raw, error, (failure) => {
422
+ // Status, headers and body as the Vite host sent them (504 included).
423
+ vite.forward(request.raw, reply.raw, (failure) => {
343
424
  const repeated = repeats.take(`vite ${failure instanceof Error ? failure.name : typeof failure}`);
344
425
  if (repeated !== undefined)
345
- logger.log('error', 'Vite dev middleware failed', {
426
+ logger.log('error', 'Vite dev request failed', {
346
427
  code: 'INERTIA_VITE_DEV_FAILED',
347
428
  base,
348
- error: errorText(failure),
429
+ error: describeError(failure),
349
430
  ...(repeated ? { repeated } : {}),
350
431
  });
351
- }));
432
+ });
352
433
  },
353
434
  });
354
435
  logger.log('debug', 'Vite dev route registered', { url: `${base}*` });
@@ -409,19 +490,15 @@ class InertiaRuntime {
409
490
  const fallback = vite ? `${dirname(vite.entry)}/ssr.js` : undefined;
410
491
  const source = ssr.entry ?? (fallback && existsSync(resolve(fallback)) ? fallback : undefined);
411
492
  if (!source || !vite)
412
- throw new Error(`Inertia SSR in development needs the source of ${ssr.bundle}: set ssr.entry`);
493
+ throw new InertiaError('SSR_ENTRY_MISSING', `Inertia SSR in development needs the source of ${ssr.bundle}: set ssr.entry`);
413
494
  const dev = viteDev(vite.base);
414
495
  logger.log('debug', 'Inertia SSR renders the source through Vite', {
415
496
  source,
416
497
  bundle: ssr.bundle,
417
498
  entrySet: ssr.entry !== undefined,
418
499
  });
419
- render = Promise.resolve(async (page) => {
420
- const module = await (await dev).ssrLoadModule(`/${source}`);
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
- });
500
+ // The Vite host loads the source and renders (one Vite for every worker).
501
+ render = Promise.resolve(async (page) => (await (await dev).render(source, page)));
425
502
  }
426
503
  else {
427
504
  const path = resolve(ssr.bundle);
@@ -454,7 +531,7 @@ class InertiaRuntime {
454
531
  bundle: ssr.bundle,
455
532
  component,
456
533
  fallback: ssr.failure === 'fallback',
457
- error: errorText(error),
534
+ error: describeError(error),
458
535
  ...(repeated ? { repeated } : {}),
459
536
  });
460
537
  },
@@ -477,19 +554,28 @@ class InertiaRuntime {
477
554
  : render,
478
555
  redirect: responder.redirect.bind(responder),
479
556
  location: responder.location.bind(responder),
557
+ answerError: responder.answerError.bind(responder),
480
558
  });
481
559
  };
482
560
  // Prop evaluation (debug) and rescued deferred props (error) are logged by every scope.
483
561
  const diagnostics = Object.freeze({ logger, repeats });
484
562
  const root = api(new InertiaResponder(document, this.shared, await serverRendering(this.settings.ssr, this.settings.vite), diagnostics, flash, hooks));
563
+ // D-0074 §3: Inertia requests refused by validation go back with `errors` (with a session);
564
+ // the plugin's `errorPage` answers the rest. dokebi-app adds its own responder later, which
565
+ // dokebi-web asks first, so an app's requests use the app's scope.
566
+ server.errorResponder((response) => root.answerError(response));
567
+ logger.log('debug', 'Inertia error answers', {
568
+ validationRedirect: hooks.validation !== undefined,
569
+ errorPage: document.errorPage,
570
+ });
485
571
  const shared = this.shared;
486
- return Object.freeze({
572
+ const plugin = Object.freeze({
487
573
  ...root,
488
574
  ssrStats: () => Object.freeze({ ...stats }),
489
575
  scope: async (options) => {
490
576
  const { shared: extra, vite, ssr: ssrInput, sealed = false, ...fields } = (options ?? {});
491
577
  if (typeof sealed !== 'boolean')
492
- throw new TypeError('Inertia scope sealed must be a boolean');
578
+ throw new InertiaUsageError('INVALID_SCOPE', 'Inertia scope sealed must be a boolean');
493
579
  const ssr = ssrInput === undefined ? this.settings.ssr : parseSsr(ssrInput);
494
580
  if (sealed && ssr) {
495
581
  logger.log('error', 'Inertia scope of seal hosts with SSR', {
@@ -500,9 +586,9 @@ class InertiaRuntime {
500
586
  throw sealSsrConflict();
501
587
  }
502
588
  if (extra !== undefined && typeof extra !== 'function')
503
- throw new TypeError('Inertia scope shared must be a function');
589
+ throw new InertiaUsageError('INVALID_SCOPE', 'Inertia scope shared must be a function');
504
590
  if (vite !== undefined && ['version', 'scripts', 'styles'].some((key) => key in fields))
505
- throw new TypeError('Inertia vite options replace version, scripts and styles');
591
+ throw new InertiaUsageError('INVALID_SCOPE', 'Inertia vite options replace version, scripts and styles');
506
592
  const scoped = normalizeInertiaOptions({
507
593
  ...document,
508
594
  ...fields,
@@ -521,18 +607,26 @@ class InertiaRuntime {
521
607
  shared: merged !== undefined,
522
608
  sealed,
523
609
  encryptHistory: scoped.encryptHistory,
610
+ errorPage: scoped.errorPage,
524
611
  });
525
612
  return api(responder);
526
613
  },
527
614
  });
615
+ // D-0063 §5: the view engine `inertia` for dokebi-app (inert until a plugin collects it).
616
+ context.contribute(viewEngines, Object.freeze({
617
+ name: 'inertia',
618
+ scope: (input) => plugin.scope(input),
619
+ }));
620
+ return plugin;
528
621
  }
529
622
  }
530
623
  export const createPlugin = definePlugin({
531
624
  name: 'dokebi-template-inertia',
532
- version: '0.2.0',
533
- core: '^0.2.0',
534
- options: parseOptions,
625
+ version: '0.4.0',
626
+ core: '^0.4.0',
627
+ options,
535
628
  entry: parseEntry,
629
+ contributes: [viewEngines],
536
630
  dependencies: () => [web, session],
537
- instance: ({ options, entry }) => new InertiaRuntime(options, entry?.shared),
631
+ instance: ({ options, entry }) => new InertiaRuntime(settings(options), entry?.shared),
538
632
  });
@@ -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>;
@@ -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
+ });