@dokebi/template-inertia 0.4.2 → 0.4.5
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 +136 -23
- package/README.md +23 -7
- package/dist/boot-fallback.d.ts +61 -0
- package/dist/boot-fallback.js +134 -0
- package/dist/built-files.d.ts +9 -0
- package/dist/built-files.js +44 -0
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +3 -1
- package/dist/i18n-store.d.ts +64 -0
- package/dist/i18n-store.js +368 -0
- package/dist/i18n.d.ts +216 -0
- package/dist/i18n.js +901 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +426 -63
- package/dist/options.d.ts +28 -0
- package/dist/options.js +97 -1
- package/dist/prune.d.ts +30 -0
- package/dist/prune.js +159 -0
- package/dist/responder.d.ts +14 -4
- package/dist/responder.js +70 -11
- package/dist/vite-deps.d.ts +76 -0
- package/dist/vite-deps.js +249 -0
- package/dist/vite-dev.d.ts +42 -4
- package/dist/vite-dev.js +162 -16
- package/dist/vite-files.d.ts +129 -0
- package/dist/vite-files.js +633 -0
- package/dist/vite-host.js +67 -10
- package/dist/vite-plugin.d.ts +69 -0
- package/dist/vite-plugin.js +173 -0
- package/dist/vite-watch.d.ts +44 -0
- package/dist/vite-watch.js +176 -0
- package/options.schema.json +127 -1
- package/package.json +38 -11
package/dist/options.d.ts
CHANGED
|
@@ -1,13 +1,40 @@
|
|
|
1
1
|
import { type OptionsOf } from '@dokebi/core';
|
|
2
|
+
/**
|
|
3
|
+
* An asset version: empty, or 1 to 128 printable ASCII characters that do not start or end with a
|
|
4
|
+
* space. The client sends it back in the X-Inertia-Version header, where other characters cannot
|
|
5
|
+
* travel (a browser refuses them, a line break is refused by Node) and edge spaces are trimmed,
|
|
6
|
+
* so the version would never match and every visit would reload (409).
|
|
7
|
+
*/
|
|
8
|
+
export declare const VERSION = "^(?:[\\x21-\\x7e](?:[\\x20-\\x7e]{0,126}[\\x21-\\x7e])?)?$";
|
|
2
9
|
export declare const options: import("@dokebi/core").OptionsDeclaration<{
|
|
10
|
+
readonly assetCacheMaxBytes: number;
|
|
11
|
+
readonly bootFallback: {
|
|
12
|
+
readonly enabled: boolean;
|
|
13
|
+
readonly logo?: string;
|
|
14
|
+
readonly messages?: Readonly<Record<string, {
|
|
15
|
+
readonly failed?: string;
|
|
16
|
+
readonly loading?: string;
|
|
17
|
+
readonly reload?: string;
|
|
18
|
+
}>>;
|
|
19
|
+
readonly stylesheet?: string;
|
|
20
|
+
readonly timeoutMs: number;
|
|
21
|
+
};
|
|
22
|
+
readonly devDepsRepairIntervalMs: number;
|
|
23
|
+
readonly devHosts: readonly string[];
|
|
3
24
|
readonly encryptHistory: "sealed" | boolean;
|
|
4
25
|
readonly errorPage?: string;
|
|
26
|
+
readonly logRepeatWindowMs: number;
|
|
5
27
|
readonly rootId?: string;
|
|
28
|
+
readonly routeIds?: {
|
|
29
|
+
readonly assets?: string;
|
|
30
|
+
readonly viteDev?: string;
|
|
31
|
+
};
|
|
6
32
|
readonly scripts?: readonly string[];
|
|
7
33
|
readonly ssr?: {
|
|
8
34
|
readonly bundle: string;
|
|
9
35
|
readonly entry?: string;
|
|
10
36
|
readonly failure: "error" | "fallback";
|
|
37
|
+
readonly timeoutMs: number;
|
|
11
38
|
};
|
|
12
39
|
readonly styles?: readonly string[];
|
|
13
40
|
readonly title?: string;
|
|
@@ -17,6 +44,7 @@ export declare const options: import("@dokebi/core").OptionsDeclaration<{
|
|
|
17
44
|
readonly entry: string;
|
|
18
45
|
readonly manifest: string;
|
|
19
46
|
};
|
|
47
|
+
readonly viteManifestMaxBytes: number;
|
|
20
48
|
}>;
|
|
21
49
|
/** The parsed options of dokebi-template-inertia (`options.dokebi-template-inertia`). */
|
|
22
50
|
export type InertiaPluginOptions = OptionsOf<typeof options>;
|
package/dist/options.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineOptions, option as o } from '@dokebi/core';
|
|
2
|
+
import { routeIdsOption } from '@dokebi/web/points';
|
|
2
3
|
/*
|
|
3
4
|
* Options of dokebi-template-inertia (D-0063 §3): one declaration is the parser and, at build
|
|
4
5
|
* time, options.schema.json. The rules are the hand parser's before wave B; the asset URL rule
|
|
@@ -14,9 +15,18 @@ const localAsset = o.string({
|
|
|
14
15
|
},
|
|
15
16
|
],
|
|
16
17
|
});
|
|
18
|
+
/**
|
|
19
|
+
* An asset version: empty, or 1 to 128 printable ASCII characters that do not start or end with a
|
|
20
|
+
* space. The client sends it back in the X-Inertia-Version header, where other characters cannot
|
|
21
|
+
* travel (a browser refuses them, a line break is refused by Node) and edge spaces are trimmed,
|
|
22
|
+
* so the version would never match and every visit would reload (409).
|
|
23
|
+
*/
|
|
24
|
+
export const VERSION = '^(?:[\\x21-\\x7e](?:[\\x20-\\x7e]{0,126}[\\x21-\\x7e])?)?$';
|
|
17
25
|
export const options = defineOptions({
|
|
18
26
|
version: o.string({
|
|
19
|
-
|
|
27
|
+
// It travels back in the X-Inertia-Version header: printable ASCII, no edge spaces.
|
|
28
|
+
pattern: VERSION,
|
|
29
|
+
description: 'Asset version sent with every page; a client with another one reloads (409). Unset: development (and a warning). Up to 128 printable ASCII characters without leading or trailing spaces: the client sends it back in the X-Inertia-Version header.',
|
|
20
30
|
}),
|
|
21
31
|
rootId: o.string({
|
|
22
32
|
pattern: '^[A-Za-z][A-Za-z0-9_-]*$',
|
|
@@ -59,17 +69,103 @@ export const options = defineOptions({
|
|
|
59
69
|
pattern: '^(?!.*\\.\\.)',
|
|
60
70
|
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
71
|
}),
|
|
72
|
+
timeoutMs: o.integer({
|
|
73
|
+
minimum: 1,
|
|
74
|
+
maximum: 600_000,
|
|
75
|
+
default: 5000,
|
|
76
|
+
description: 'How long one render(page) may take in ms; past it the render fails with SSR_TIMEOUT, handled as failure says (fallback: the client-rendered page). Size it above your slowest page render (D-0107)',
|
|
77
|
+
}),
|
|
62
78
|
}, {
|
|
63
79
|
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
80
|
}),
|
|
81
|
+
devHosts: o.array(o.string({
|
|
82
|
+
maxLength: 254,
|
|
83
|
+
pattern: '^\\.?[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$',
|
|
84
|
+
}), {
|
|
85
|
+
maxItems: 256,
|
|
86
|
+
default: [],
|
|
87
|
+
description: 'Development (DOKEBI_VITE_DEV=1): host names besides loopback names, IP addresses and @dokebi/app domains that may load the Vite dev routes, such as a tunnel host; .example.com also allows its subdomains (D-0107)',
|
|
88
|
+
}),
|
|
89
|
+
devDepsRepairIntervalMs: o.integer({
|
|
90
|
+
minimum: 1000,
|
|
91
|
+
maximum: 3_600_000,
|
|
92
|
+
default: 10_000,
|
|
93
|
+
description: 'Development (dokebi dev): the Vite dev host re-optimizes the pre-bundled dependencies (node_modules/.vite/deps) when they changed on disk under it, at most once per this many ms; a cache that keeps changing faster is reported with INERTIA_VITE_DEPS_CHANGING (D-0128). Read by the host when dokebi dev starts.',
|
|
94
|
+
}),
|
|
65
95
|
encryptHistory: o.union([o.boolean(), o.literal('sealed')], {
|
|
66
96
|
default: 'sealed',
|
|
67
97
|
description: 'Inertia history encryption (D-0062): true, false, or sealed (default) = on for requests of seal hosts',
|
|
68
98
|
}),
|
|
99
|
+
viteManifestMaxBytes: o.integer({
|
|
100
|
+
minimum: 1024,
|
|
101
|
+
maximum: 67_108_864,
|
|
102
|
+
default: 4_194_304,
|
|
103
|
+
description: 'Largest Vite build manifest read at start; a larger one stops the start with VITE_MANIFEST_INVALID (D-0114). An app with thousands of chunks may need more.',
|
|
104
|
+
}),
|
|
105
|
+
assetCacheMaxBytes: o.integer({
|
|
106
|
+
minimum: 0,
|
|
107
|
+
maximum: 67_108_864,
|
|
108
|
+
default: 4_194_304,
|
|
109
|
+
description: 'Built files up to this size are kept in memory after their first request; larger ones are streamed from disk on every request (D-0114). 0 streams every file. Memory is at most the sum of the built files at or below it.',
|
|
110
|
+
}),
|
|
111
|
+
logRepeatWindowMs: o.integer({
|
|
112
|
+
minimum: 1000,
|
|
113
|
+
maximum: 3_600_000,
|
|
114
|
+
default: 10_000,
|
|
115
|
+
description: 'Window of the repeated per-request failure logs (an SSR render that failed, a shared-data or flash hook that threw) in ms: the first is logged in full, the rest of the window are counted (D-0114).',
|
|
116
|
+
}),
|
|
117
|
+
bootFallback: o.object({
|
|
118
|
+
enabled: o.boolean({
|
|
119
|
+
default: true,
|
|
120
|
+
description: 'Show the text in the root of the first HTML (false: an empty root, as before)',
|
|
121
|
+
}),
|
|
122
|
+
timeoutMs: o.integer({
|
|
123
|
+
minimum: 1000,
|
|
124
|
+
maximum: 120_000,
|
|
125
|
+
default: 10_000,
|
|
126
|
+
description: 'How long "loading" shows before "could not load — reload" (ms)',
|
|
127
|
+
}),
|
|
128
|
+
messages: o.record(o.object({
|
|
129
|
+
loading: o.string({ minLength: 1, maxLength: 200 }),
|
|
130
|
+
failed: o.string({ minLength: 1, maxLength: 200 }),
|
|
131
|
+
reload: o.string({ minLength: 1, maxLength: 200 }),
|
|
132
|
+
}), {
|
|
133
|
+
description: 'Texts by language tag of <html lang> (ko, en, pt-BR) over the built-in Korean and English ones',
|
|
134
|
+
}),
|
|
135
|
+
stylesheet: o.string({
|
|
136
|
+
pattern: '^/[^/]',
|
|
137
|
+
maxLength: 2048,
|
|
138
|
+
refine: [
|
|
139
|
+
{
|
|
140
|
+
test: (value) => ![...value].some((char) => '\\"\'<>'.includes(char) || char.charCodeAt(0) <= 32),
|
|
141
|
+
message: 'must be a local absolute URL without backslash, quote, angle bracket, space or control characters',
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
description: "A local stylesheet (/assets/boot.css) linked right after the built-in style: the app's own look of the box ([data-dokebi-boot] selectors), shown before any script of the app runs, also under dokebi dev",
|
|
145
|
+
}),
|
|
146
|
+
logo: o.string({
|
|
147
|
+
pattern: '^/[^/]',
|
|
148
|
+
maxLength: 2048,
|
|
149
|
+
refine: [
|
|
150
|
+
{
|
|
151
|
+
test: (value) => ![...value].some((char) => '\\"\'<>'.includes(char) || char.charCodeAt(0) <= 32),
|
|
152
|
+
message: 'must be a local absolute URL without backslash, quote, angle bracket, space or control characters',
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
description: 'A local image (/assets/logo.svg) above the spinner and the could-not-load text of the box, decorative (alt is empty)',
|
|
156
|
+
}),
|
|
157
|
+
}, {
|
|
158
|
+
default: { enabled: true, timeoutMs: 10_000 },
|
|
159
|
+
description: 'The first HTML while the app loads (Studio v2): a centered spinner ("loading" for screen readers), then "could not load" with a reload button after timeoutMs (by CSS, also under a CSP without inline scripts) or at once when the entry script fails to load; stylesheet restyles it. Not on a server-rendered page.',
|
|
160
|
+
}),
|
|
69
161
|
errorPage: o.string({
|
|
70
162
|
pattern: '^[A-Za-z0-9][A-Za-z0-9_-]*(?:/[A-Za-z0-9][A-Za-z0-9_-]*)*$',
|
|
71
163
|
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
164
|
}),
|
|
165
|
+
routeIds: routeIdsOption('dokebi-template-inertia', {
|
|
166
|
+
assets: 'the GET routes of the built files of vite.manifest (one per file)',
|
|
167
|
+
viteDev: 'the GET <vite base>* routes of the Vite dev server (dokebi dev, one per base)',
|
|
168
|
+
}),
|
|
73
169
|
}, {
|
|
74
170
|
checks: [
|
|
75
171
|
{
|
package/dist/prune.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface PruneBuildOptions {
|
|
2
|
+
/** The current build's manifest, as `inertia.vite.manifest` names it (`dist/.vite/manifest.json`). */
|
|
3
|
+
readonly manifest: string;
|
|
4
|
+
/** Only files unchanged this long are removed, in ms (default 7 days; 0: every unnamed one). */
|
|
5
|
+
readonly olderThanMs?: number;
|
|
6
|
+
/** List what would be removed, remove nothing. */
|
|
7
|
+
readonly dryRun?: boolean;
|
|
8
|
+
/** A larger manifest is refused (default 4 MiB, the plugin's `viteManifestMaxBytes`). */
|
|
9
|
+
readonly maxManifestBytes?: number;
|
|
10
|
+
/** The time to count ages from, in ms since the epoch (default now). */
|
|
11
|
+
readonly now?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface PruneBuildResult {
|
|
14
|
+
/** The build folders looked at: those of the manifest's files (`dist/assets`), as served. */
|
|
15
|
+
readonly folders: readonly string[];
|
|
16
|
+
/** The files removed (with `dryRun`: that would be). */
|
|
17
|
+
readonly removed: readonly string[];
|
|
18
|
+
/** How many files of those folders stay. */
|
|
19
|
+
readonly kept: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Removes the old build files from the folders a Vite manifest's files sit in — the folders
|
|
23
|
+
* @dokebi/template-inertia serves for rolling deploys (`dist/assets`). A file stays when the
|
|
24
|
+
* manifest names it (or it is the source map of one), when its name is not content-hashed (not a
|
|
25
|
+
* build output: `app-3fa8c1d2.js` is one, `robots.txt` is not), when it is hidden, a link or
|
|
26
|
+
* changed within `olderThanMs`. Files directly in the output folder (the manifest's `.vite/`, the
|
|
27
|
+
* SSR build's `ssr/`) are never looked at. A manifest that cannot be read, is not an object of
|
|
28
|
+
* chunks or names no file is an error and nothing is removed.
|
|
29
|
+
*/
|
|
30
|
+
export declare function pruneBuildFiles(options: PruneBuildOptions): Promise<PruneBuildResult>;
|
package/dist/prune.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { lstat, readdir, readFile, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
3
|
+
import { HASHED } from './built-files.js';
|
|
4
|
+
import { InertiaError, InertiaUsageError } from './errors.js';
|
|
5
|
+
/*
|
|
6
|
+
* Removing the chunks of builds that no page uses any more (audit r5 review). A rolling deploy
|
|
7
|
+
* keeps the previous builds' files (vite `build.emptyOutDir: false`, D-0160 §5), so the build
|
|
8
|
+
* folders grow with every deploy. A cleanup by age alone (`find -mtime +7 -delete`) also removed
|
|
9
|
+
* the current build's files when no deploy came for a week. Here the current manifest decides:
|
|
10
|
+
* every file it names stays whatever its age; only content-hashed files it does not name, unchanged
|
|
11
|
+
* for `olderThanMs`, go.
|
|
12
|
+
*/
|
|
13
|
+
/** A week: the pages of a previous build are long gone by then (a tab left open for days). */
|
|
14
|
+
const DEFAULT_OLDER_THAN_MS = 7 * 86_400_000;
|
|
15
|
+
/** The default of the plugin's `viteManifestMaxBytes`. */
|
|
16
|
+
const DEFAULT_MAX_MANIFEST_BYTES = 4_194_304;
|
|
17
|
+
/**
|
|
18
|
+
* Removes the old build files from the folders a Vite manifest's files sit in — the folders
|
|
19
|
+
* @dokebi/template-inertia serves for rolling deploys (`dist/assets`). A file stays when the
|
|
20
|
+
* manifest names it (or it is the source map of one), when its name is not content-hashed (not a
|
|
21
|
+
* build output: `app-3fa8c1d2.js` is one, `robots.txt` is not), when it is hidden, a link or
|
|
22
|
+
* changed within `olderThanMs`. Files directly in the output folder (the manifest's `.vite/`, the
|
|
23
|
+
* SSR build's `ssr/`) are never looked at. A manifest that cannot be read, is not an object of
|
|
24
|
+
* chunks or names no file is an error and nothing is removed.
|
|
25
|
+
*/
|
|
26
|
+
export async function pruneBuildFiles(options) {
|
|
27
|
+
const olderThanMs = options.olderThanMs ?? DEFAULT_OLDER_THAN_MS;
|
|
28
|
+
const maxBytes = options.maxManifestBytes ?? DEFAULT_MAX_MANIFEST_BYTES;
|
|
29
|
+
const now = options.now ?? Date.now();
|
|
30
|
+
if (typeof options.manifest !== 'string' || !options.manifest)
|
|
31
|
+
throw new InertiaUsageError('INVALID_PRUNE', 'pruneBuildFiles: manifest must be a file path');
|
|
32
|
+
for (const [name, value] of [
|
|
33
|
+
['olderThanMs', olderThanMs],
|
|
34
|
+
['maxManifestBytes', maxBytes],
|
|
35
|
+
['now', now],
|
|
36
|
+
])
|
|
37
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
38
|
+
throw new InertiaUsageError('INVALID_PRUNE', `pruneBuildFiles: ${name} must be whole milliseconds or bytes from 0, not ${String(value)}`);
|
|
39
|
+
const path = resolve(options.manifest);
|
|
40
|
+
const named = await namedFiles(path, maxBytes);
|
|
41
|
+
// Vite writes <outDir>/.vite/manifest.json; a custom manifest name sits in outDir itself.
|
|
42
|
+
const outDir = basename(dirname(path)) === '.vite' ? dirname(dirname(path)) : dirname(path);
|
|
43
|
+
const folders = buildFolders(outDir, named);
|
|
44
|
+
const cutoff = now - olderThanMs;
|
|
45
|
+
const removed = [];
|
|
46
|
+
let kept = 0;
|
|
47
|
+
const walk = async (folder) => {
|
|
48
|
+
let entries;
|
|
49
|
+
try {
|
|
50
|
+
entries = await readdir(folder, { withFileTypes: true });
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
// A folder the manifest names but the build did not write (or removed meanwhile).
|
|
54
|
+
if (error.code === 'ENOENT')
|
|
55
|
+
return;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
const file = join(folder, entry.name);
|
|
60
|
+
if (entry.name.startsWith('.')) {
|
|
61
|
+
kept++;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
// A link (to a file or folder) is never followed or removed: a Dirent of a link is neither.
|
|
65
|
+
if (entry.isDirectory()) {
|
|
66
|
+
await walk(file);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (!entry.isFile() || current(file, named) || !built(entry.name)) {
|
|
70
|
+
kept++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if ((await lstat(file)).mtimeMs > cutoff) {
|
|
74
|
+
kept++;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
removed.push(file);
|
|
78
|
+
// force: another cleanup that removed it first is no failure.
|
|
79
|
+
if (options.dryRun !== true)
|
|
80
|
+
await rm(file, { force: true });
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
for (const folder of folders)
|
|
84
|
+
await walk(folder);
|
|
85
|
+
return { folders, removed, kept };
|
|
86
|
+
}
|
|
87
|
+
/** The files the manifest names (every chunk's file, CSS and assets), as absolute paths. */
|
|
88
|
+
async function namedFiles(path, maxBytes) {
|
|
89
|
+
let size;
|
|
90
|
+
try {
|
|
91
|
+
const info = await stat(path);
|
|
92
|
+
if (!info.isFile())
|
|
93
|
+
throw new InertiaError('VITE_MANIFEST_INVALID', `Vite manifest ${path} is not a file`);
|
|
94
|
+
size = info.size;
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (error.code === 'ENOENT')
|
|
98
|
+
throw new InertiaError('VITE_MANIFEST_MISSING', `Vite manifest not found: ${path}`, {
|
|
99
|
+
cause: error,
|
|
100
|
+
});
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
if (size > maxBytes)
|
|
104
|
+
throw new InertiaError('VITE_MANIFEST_INVALID', `Vite manifest ${path} is larger than maxManifestBytes (${maxBytes})`);
|
|
105
|
+
let manifest;
|
|
106
|
+
try {
|
|
107
|
+
manifest = JSON.parse(await readFile(path, 'utf8'));
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
throw new InertiaError('VITE_MANIFEST_INVALID', `Vite manifest ${path} is not JSON`, {
|
|
111
|
+
cause: error,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest))
|
|
115
|
+
throw new InertiaError('VITE_MANIFEST_INVALID', `Vite manifest ${path} is not an object of chunks`);
|
|
116
|
+
const outDir = basename(dirname(path)) === '.vite' ? dirname(dirname(path)) : dirname(path);
|
|
117
|
+
const named = new Set();
|
|
118
|
+
const add = (file) => {
|
|
119
|
+
if (typeof file !== 'string' || file.split('/').some((part) => part === '..' || !part))
|
|
120
|
+
return;
|
|
121
|
+
named.add(join(outDir, ...file.split('/')));
|
|
122
|
+
};
|
|
123
|
+
for (const chunk of Object.values(manifest)) {
|
|
124
|
+
if (chunk === null || typeof chunk !== 'object')
|
|
125
|
+
continue;
|
|
126
|
+
const { file, css, assets } = chunk;
|
|
127
|
+
add(file);
|
|
128
|
+
for (const item of Array.isArray(css) ? css : [])
|
|
129
|
+
add(item);
|
|
130
|
+
for (const item of Array.isArray(assets) ? assets : [])
|
|
131
|
+
add(item);
|
|
132
|
+
}
|
|
133
|
+
if (named.size === 0)
|
|
134
|
+
throw new InertiaError('VITE_MANIFEST_INVALID', `Vite manifest ${path} names no file: nothing is removed`);
|
|
135
|
+
return named;
|
|
136
|
+
}
|
|
137
|
+
/** The folders of the named files under outDir (not outDir itself, no hidden one), outermost. */
|
|
138
|
+
function buildFolders(outDir, named) {
|
|
139
|
+
const folders = new Set();
|
|
140
|
+
for (const file of named) {
|
|
141
|
+
const folder = dirname(file);
|
|
142
|
+
const inside = folder.slice(outDir.length + 1);
|
|
143
|
+
if (!folder.startsWith(outDir + sep) || inside.split(sep).some((part) => part.startsWith('.')))
|
|
144
|
+
continue;
|
|
145
|
+
folders.add(folder);
|
|
146
|
+
}
|
|
147
|
+
// A folder inside another is walked with it.
|
|
148
|
+
return [...folders]
|
|
149
|
+
.sort()
|
|
150
|
+
.filter((folder, _index, all) => !all.some((other) => folder.startsWith(other + sep)));
|
|
151
|
+
}
|
|
152
|
+
/** A file the manifest names, or the source map of one (`build.sourcemap: 'hidden'`). */
|
|
153
|
+
function current(file, named) {
|
|
154
|
+
return named.has(file) || (/\.map$/i.test(file) && named.has(file.slice(0, -4)));
|
|
155
|
+
}
|
|
156
|
+
/** A build output by its name: content-hashed (`app-3fa8c1d2.js`), or the source map of one. */
|
|
157
|
+
function built(name) {
|
|
158
|
+
return HASHED.test(/\.map$/i.test(name) ? name.slice(0, -4) : name);
|
|
159
|
+
}
|
package/dist/responder.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Awaitable, Logger } from '@dokebi/core';
|
|
2
2
|
import { diagnostics } from '@dokebi/core/libraries';
|
|
3
|
-
import type
|
|
3
|
+
import { type WebErrorResponse } from '@dokebi/web';
|
|
4
4
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
5
|
+
import { type InertiaBootFallback, type NormalizedBootFallback } from './boot-fallback.js';
|
|
5
6
|
import { type InertiaProp } from './props.js';
|
|
6
7
|
export type Json = null | boolean | number | string | readonly Json[] | {
|
|
7
8
|
readonly [key: string]: Json;
|
|
@@ -25,6 +26,12 @@ export interface InertiaOptions {
|
|
|
25
26
|
* unset: JSON for visits, dokebi-web's small HTML page for page loads.
|
|
26
27
|
*/
|
|
27
28
|
errorPage?: string | false;
|
|
29
|
+
/**
|
|
30
|
+
* Text in the root of the first HTML while the app loads, then "could not load — reload" after
|
|
31
|
+
* `timeoutMs` or at once when the entry script fails to load (Studio v2); `false` turns it off.
|
|
32
|
+
* Not on a server-rendered page.
|
|
33
|
+
*/
|
|
34
|
+
bootFallback?: InertiaBootFallback | NormalizedBootFallback | false;
|
|
28
35
|
}
|
|
29
36
|
/** What `answerError` reads of a dokebi-web error answer (`WebErrorResponse`). */
|
|
30
37
|
export type InertiaErrorResponse = Pick<WebErrorResponse, 'request' | 'reply' | 'status' | 'body' | 'format' | 'sealed'>;
|
|
@@ -116,7 +123,9 @@ export interface InertiaApi {
|
|
|
116
123
|
answerError(response: InertiaErrorResponse): Promise<boolean>;
|
|
117
124
|
}
|
|
118
125
|
export declare function vary(reply: FastifyReply): void;
|
|
119
|
-
export type NormalizedInertiaOptions = Readonly<Required<InertiaOptions
|
|
126
|
+
export type NormalizedInertiaOptions = Readonly<Required<Omit<InertiaOptions, 'bootFallback'>> & {
|
|
127
|
+
bootFallback: NormalizedBootFallback;
|
|
128
|
+
}>;
|
|
120
129
|
/** Validates and fills defaults; the result is accepted again unchanged. */
|
|
121
130
|
export declare function normalizeInertiaOptions(options?: InertiaOptions): NormalizedInertiaOptions;
|
|
122
131
|
export declare class InertiaResponder implements InertiaApi {
|
|
@@ -133,8 +142,9 @@ export declare class InertiaResponder implements InertiaApi {
|
|
|
133
142
|
/**
|
|
134
143
|
* The error answer of an Inertia request or page load (D-0074 §3), for dokebi-web's error
|
|
135
144
|
* responders: the validation redirect (an Inertia request refused with 422 VALIDATION_FAILED,
|
|
136
|
-
* with
|
|
137
|
-
*
|
|
145
|
+
* or with another 4xx whose `data.formErrors` asks for it — D-0076 §2.5, dokebi-csrf's expired
|
|
146
|
+
* token — with a session), then the `errorPage` component. False leaves the answer to
|
|
147
|
+
* dokebi-web (JSON for visits, a small HTML page for page loads).
|
|
138
148
|
*/
|
|
139
149
|
answerError(response: InertiaErrorResponse): Promise<boolean>;
|
|
140
150
|
redirect(request: FastifyRequest, reply: FastifyReply, url: string): FastifyReply<import("fastify").RouteGenericInterface, import("fastify").RawServerDefault, import("http").IncomingMessage, import("http").ServerResponse<import("http").IncomingMessage>, unknown, import("fastify").FastifySchema, import("fastify").FastifyTypeProviderDefault, unknown>;
|
package/dist/responder.js
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
import { diagnostics } from '@dokebi/core/libraries';
|
|
2
|
+
import { formFieldErrors } from '@dokebi/web';
|
|
3
|
+
import { bootMarkup, normalizeBootFallback, } from './boot-fallback.js';
|
|
2
4
|
import { InertiaError, InertiaUsageError } from './errors.js';
|
|
5
|
+
import { VERSION } from './options.js';
|
|
3
6
|
import { isInertiaProp } from './props.js';
|
|
4
7
|
const escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char] ?? char);
|
|
8
|
+
/**
|
|
9
|
+
* A `<title>` head tag of the SSR bundle with its text made safe. @inertiajs/vue3 escapes the
|
|
10
|
+
* `title` prop of `<Head>` but not the text of a `<title>` child (`<title>{{ product.name }}</title>`
|
|
11
|
+
* renders the raw text), so a name holding `</title><script>` became markup in the page (stored or
|
|
12
|
+
* reflected XSS, AI-M49). A title holds text only: every `<` inside it is written as `<`; text
|
|
13
|
+
* the bundle escaped already has none, so nothing is escaped twice.
|
|
14
|
+
*/
|
|
15
|
+
function safeTitleTag(tag) {
|
|
16
|
+
const open = /^<title\b[^>]*>/i.exec(tag);
|
|
17
|
+
if (!open)
|
|
18
|
+
return tag;
|
|
19
|
+
// The bundle closes the tag itself; any other `</TITLE>`, `</title >` inside is text.
|
|
20
|
+
const rest = tag.slice(open[0].length);
|
|
21
|
+
const close = /<\/title\s*>$/i.exec(rest);
|
|
22
|
+
const text = close ? rest.slice(0, close.index) : rest;
|
|
23
|
+
return text.includes('<') || !close ? `${open[0]}${text.replace(/</g, '<')}</title>` : tag;
|
|
24
|
+
}
|
|
5
25
|
function localUrl(value) {
|
|
6
26
|
if (typeof value !== 'string' ||
|
|
7
27
|
!value.startsWith('/') ||
|
|
@@ -40,6 +60,7 @@ export function normalizeInertiaOptions(options = {}) {
|
|
|
40
60
|
'styles',
|
|
41
61
|
'encryptHistory',
|
|
42
62
|
'errorPage',
|
|
63
|
+
'bootFallback',
|
|
43
64
|
].includes(key)))
|
|
44
65
|
throw new InertiaUsageError('INVALID_SCOPE', 'Invalid Inertia options');
|
|
45
66
|
for (const assets of [options.scripts, options.styles]) {
|
|
@@ -55,13 +76,16 @@ export function normalizeInertiaOptions(options = {}) {
|
|
|
55
76
|
styles: Object.freeze([...(options.styles ?? [])]),
|
|
56
77
|
encryptHistory: options.encryptHistory ?? 'sealed',
|
|
57
78
|
errorPage: options.errorPage ?? false,
|
|
79
|
+
bootFallback: normalizeBootFallback(options.bootFallback),
|
|
58
80
|
};
|
|
59
81
|
if (typeof normalized.version !== 'string' ||
|
|
82
|
+
!new RegExp(VERSION).test(normalized.version) ||
|
|
60
83
|
typeof normalized.title !== 'string' ||
|
|
61
84
|
typeof normalized.rootId !== 'string' ||
|
|
62
85
|
!/^[A-Za-z][A-Za-z0-9_-]*$/.test(normalized.rootId) ||
|
|
63
86
|
(typeof normalized.encryptHistory !== 'boolean' && normalized.encryptHistory !== 'sealed') ||
|
|
64
|
-
(normalized.errorPage !== false && !validComponent(normalized.errorPage))
|
|
87
|
+
(normalized.errorPage !== false && !validComponent(normalized.errorPage)) ||
|
|
88
|
+
normalized.bootFallback === undefined)
|
|
65
89
|
throw new InertiaUsageError('INVALID_SCOPE', 'Invalid Inertia document settings');
|
|
66
90
|
for (const asset of [...normalized.scripts, ...normalized.styles])
|
|
67
91
|
localUrl(asset);
|
|
@@ -178,8 +202,9 @@ export class InertiaResponder {
|
|
|
178
202
|
/**
|
|
179
203
|
* The error answer of an Inertia request or page load (D-0074 §3), for dokebi-web's error
|
|
180
204
|
* responders: the validation redirect (an Inertia request refused with 422 VALIDATION_FAILED,
|
|
181
|
-
* with
|
|
182
|
-
*
|
|
205
|
+
* or with another 4xx whose `data.formErrors` asks for it — D-0076 §2.5, dokebi-csrf's expired
|
|
206
|
+
* token — with a session), then the `errorPage` component. False leaves the answer to
|
|
207
|
+
* dokebi-web (JSON for visits, a small HTML page for page loads).
|
|
183
208
|
*/
|
|
184
209
|
async answerError(response) {
|
|
185
210
|
if (this.#closed)
|
|
@@ -196,6 +221,12 @@ export class InertiaResponder {
|
|
|
196
221
|
// No session or no way back: the 422 JSON with `errors` (onHttpException reads it).
|
|
197
222
|
return false;
|
|
198
223
|
}
|
|
224
|
+
// Another 4xx that asks for the form redirect (`data.formErrors`); without a session or a
|
|
225
|
+
// way back it gets the error page like any other error.
|
|
226
|
+
const requested = inertia && !validation && status >= 400 && status <= 499 ? formFieldErrors(body) : undefined;
|
|
227
|
+
if (requested && this.hooks.validation)
|
|
228
|
+
if (await this.hooks.validation(request, reply, requested))
|
|
229
|
+
return true;
|
|
199
230
|
const component = this.options.errorPage;
|
|
200
231
|
if (component === false || validation)
|
|
201
232
|
return false;
|
|
@@ -210,19 +241,36 @@ export class InertiaResponder {
|
|
|
210
241
|
return false;
|
|
211
242
|
const text = (value) => (typeof value === 'string' ? value : null);
|
|
212
243
|
reply.code(status);
|
|
244
|
+
// D-0102: a 503 is a store, database or Redis that is down or overloaded (a session store's
|
|
245
|
+
// STORE_FAILED under a full Redis connection, DB_UNAVAILABLE). Its error page is built from
|
|
246
|
+
// the error alone: the shared props (auth, csrf, i18n and the app's own) may read the very
|
|
247
|
+
// store that failed, which would fail the page too (WEB_ERROR_RESPONDER_FAILED) and load the
|
|
248
|
+
// backend again. The page gets `status`, `error`, `message`, `hint` and `errors: {}`.
|
|
249
|
+
const bare = status === 503;
|
|
250
|
+
if (bare && this.diagnostics?.logger.isEnabled('debug'))
|
|
251
|
+
this.diagnostics.logger.log('debug', 'Inertia error page without shared props', {
|
|
252
|
+
component,
|
|
253
|
+
status,
|
|
254
|
+
errorCode: body.error,
|
|
255
|
+
});
|
|
213
256
|
await this.#page(request, reply, component, {
|
|
214
257
|
status,
|
|
215
258
|
error: body.error,
|
|
216
259
|
message: text(body.message),
|
|
217
260
|
hint: text(body.hint),
|
|
218
261
|
...(typeof body.fix === 'string' ? { fix: body.fix } : {}),
|
|
219
|
-
}, {}, false);
|
|
262
|
+
}, {}, false, !bare);
|
|
220
263
|
return true;
|
|
221
264
|
}
|
|
222
|
-
async #page(request, reply, component, input, history, session
|
|
265
|
+
async #page(request, reply, component, input, history, session,
|
|
266
|
+
/** Evaluate the shared props (false for the error page of a 503, D-0102). */
|
|
267
|
+
withShared = true) {
|
|
223
268
|
vary(reply);
|
|
224
269
|
const inertia = request.headers['x-inertia'] === 'true';
|
|
270
|
+
// The protocol's version check is for GET visits only (the official adapters'): the 409 makes
|
|
271
|
+
// the client load X-Inertia-Location, so an error page of a POST must not turn into a GET.
|
|
225
272
|
if (inertia &&
|
|
273
|
+
(request.method === 'GET' || request.method === 'HEAD') &&
|
|
226
274
|
request.headers['x-inertia-version'] !== undefined &&
|
|
227
275
|
request.headers['x-inertia-version'] !== this.options.version) {
|
|
228
276
|
return reply
|
|
@@ -235,7 +283,7 @@ export class InertiaResponder {
|
|
|
235
283
|
const sealer = this.hooks.sealOf?.(request);
|
|
236
284
|
if (sealer && this.ssr && !inertia)
|
|
237
285
|
throw new InertiaError('SEAL_SSR_CONFLICT', 'A sealed page cannot be server rendered: SSR writes the props as plain HTML. Turn off ssr for this app or seal for its hosts.');
|
|
238
|
-
const props = { ...(await this.shared?.(request)), ...input };
|
|
286
|
+
const props = { ...(withShared ? await this.shared?.(request) : undefined), ...input };
|
|
239
287
|
const partial = inertia && request.headers['x-inertia-partial-component'] === component;
|
|
240
288
|
const list = (key) => typeof request.headers[key] === 'string'
|
|
241
289
|
? new Set(request.headers[key]
|
|
@@ -348,13 +396,21 @@ export class InertiaResponder {
|
|
|
348
396
|
if (inertia)
|
|
349
397
|
return reply.header('X-Inertia', 'true').type('application/json; charset=utf-8').send(json);
|
|
350
398
|
const { rootId, title, scripts, styles } = this.options;
|
|
399
|
+
const language = reply.getHeader('content-language');
|
|
400
|
+
const htmlLanguage = typeof language === 'string' && /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(language)
|
|
401
|
+
? ` lang="${escapeHtml(language)}"`
|
|
402
|
+
: '';
|
|
403
|
+
// Studio v2: "loading", then "could not load — reload", while the app has not mounted.
|
|
404
|
+
const boot = this.options.bootFallback.enabled
|
|
405
|
+
? bootMarkup(this.options.bootFallback, rootId, typeof language === 'string' ? language : undefined)
|
|
406
|
+
: undefined;
|
|
351
407
|
if (sealer) {
|
|
352
408
|
// `E:<ts>:<base64>` holds no HTML specials; the client opens it before Inertia starts
|
|
353
409
|
// (`sealInertia` of @dokebi/seal/http). No SSR here (refused above).
|
|
354
410
|
const frame = sealer.encode(json);
|
|
355
411
|
return reply
|
|
356
412
|
.type('text/html; charset=utf-8')
|
|
357
|
-
.send(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title>${styles.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join('')}${scripts.map((url) => `<script type="module" src="${escapeHtml(url)}"></script>`).join('')}</head><body><script data-page="${rootId}" type="application/json" data-sealed="true">${escapeHtml(frame)}</script><div id="${rootId}"
|
|
413
|
+
.send(`<!doctype html><html${htmlLanguage}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title>${styles.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join('')}${boot?.head ?? ''}${scripts.map((url) => `<script type="module" src="${escapeHtml(url)}"></script>`).join('')}</head><body><script data-page="${rootId}" type="application/json" data-sealed="true">${escapeHtml(frame)}</script><div id="${rootId}">${boot?.root ?? ''}</div></body></html>`);
|
|
358
414
|
}
|
|
359
415
|
// Only the first HTML is server rendered; Inertia visits above stay JSON.
|
|
360
416
|
const rendered = this.ssr ? await this.#serverRender(JSON.parse(json), rootId) : undefined;
|
|
@@ -364,11 +420,11 @@ export class InertiaResponder {
|
|
|
364
420
|
.replace(/\u2028/g, '\\u2028')
|
|
365
421
|
.replace(/\u2029/g, '\\u2029');
|
|
366
422
|
// A <title> from the page's <Head> replaces the configured one.
|
|
367
|
-
const head = rendered?.head ?? [];
|
|
423
|
+
const head = (rendered?.head ?? []).map(safeTitleTag);
|
|
368
424
|
const titled = head.some((tag) => /^<title[\s>]/i.test(tag));
|
|
369
425
|
return reply
|
|
370
426
|
.type('text/html; charset=utf-8')
|
|
371
|
-
.send(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${titled ? '' : `<title>${escapeHtml(title)}</title>`}${styles.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join('')}${scripts.map((url) => `<script type="module" src="${escapeHtml(url)}"></script>`).join('')}${head.join('')}</head><body>${rendered?.body ?? `<script data-page="${rootId}" type="application/json">${safeJson}</script><div id="${rootId}"
|
|
427
|
+
.send(`<!doctype html><html${htmlLanguage}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${titled ? '' : `<title>${escapeHtml(title)}</title>`}${styles.map((url) => `<link rel="stylesheet" href="${escapeHtml(url)}">`).join('')}${rendered ? '' : (boot?.head ?? '')}${scripts.map((url) => `<script type="module" src="${escapeHtml(url)}"></script>`).join('')}${head.join('')}</head><body>${rendered?.body ?? `<script data-page="${rootId}" type="application/json">${safeJson}</script><div id="${rootId}">${boot?.root ?? ''}</div>`}</body></html>`);
|
|
372
428
|
}
|
|
373
429
|
/**
|
|
374
430
|
* The value of one selected prop. A function runs here; a rescued deferred prop that throws is
|
|
@@ -436,9 +492,12 @@ export class InertiaResponder {
|
|
|
436
492
|
}
|
|
437
493
|
location(request, reply, url) {
|
|
438
494
|
this.#check();
|
|
439
|
-
if (url.startsWith('/'))
|
|
495
|
+
if (typeof url === 'string' && url.startsWith('/'))
|
|
440
496
|
localUrl(url);
|
|
441
|
-
else if (
|
|
497
|
+
else if (typeof url !== 'string' ||
|
|
498
|
+
!URL.canParse(url) ||
|
|
499
|
+
!/^https?:$/.test(new URL(url).protocol) ||
|
|
500
|
+
/[\r\n]/.test(url))
|
|
442
501
|
throw new InertiaUsageError('INVALID_URL', 'Invalid location');
|
|
443
502
|
vary(reply);
|
|
444
503
|
return request.headers['x-inertia'] === 'true'
|