@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
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// The first HTML's text while the app loads (Studio v2, 10-31): a page whose entry script cannot be
|
|
2
|
+
// fetched (a dev restart, a deploy in progress) stayed blank, and no line of the app ran to say so.
|
|
3
|
+
// The root holds "loading" until the app mounts (Vue's mount empties it); after `timeoutMs` the text
|
|
4
|
+
// turns to "could not load — reload" by CSS alone (an animation delay, so a CSP that blocks inline
|
|
5
|
+
// scripts still gets it). A small inline script shows it at once when the entry script fails to
|
|
6
|
+
// load. A server-rendered page has its markup instead and gets neither.
|
|
7
|
+
// The built-in look is a centered spinner (the text stays for screen readers) and a reload button;
|
|
8
|
+
// `stylesheet` links an app's own look right after it, so it shows before any script of the app
|
|
9
|
+
// runs: also under dokebi dev, where the app's CSS only comes with its scripts.
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
const LANGUAGE_TAG = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/;
|
|
12
|
+
const MAX_TEXT = 200;
|
|
13
|
+
const BUILT_IN = Object.freeze({
|
|
14
|
+
en: { loading: 'Loading…', failed: 'The app could not be loaded.', reload: 'Reload' },
|
|
15
|
+
// docs-check: hangul-begin
|
|
16
|
+
ko: { loading: '불러오는 중…', failed: '앱을 불러오지 못했어요.', reload: '다시 불러오기' },
|
|
17
|
+
// docs-check: hangul-end
|
|
18
|
+
});
|
|
19
|
+
/** Checked and filled; undefined when the input is not a valid setting. */
|
|
20
|
+
export function normalizeBootFallback(input) {
|
|
21
|
+
if (input === false)
|
|
22
|
+
return Object.freeze({ enabled: false, timeoutMs: 10_000, messages: {} });
|
|
23
|
+
const value = input ?? {};
|
|
24
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
25
|
+
return undefined;
|
|
26
|
+
if (Object.keys(value).some((key) => !['enabled', 'timeoutMs', 'messages', 'stylesheet', 'logo'].includes(key)))
|
|
27
|
+
return undefined;
|
|
28
|
+
const { stylesheet, logo } = value;
|
|
29
|
+
if (stylesheet !== undefined && !localUrl(stylesheet))
|
|
30
|
+
return undefined;
|
|
31
|
+
if (logo !== undefined && !localUrl(logo))
|
|
32
|
+
return undefined;
|
|
33
|
+
const enabled = value.enabled ?? true;
|
|
34
|
+
const timeoutMs = value.timeoutMs ?? 10_000;
|
|
35
|
+
if (typeof enabled !== 'boolean' ||
|
|
36
|
+
!Number.isSafeInteger(timeoutMs) ||
|
|
37
|
+
timeoutMs < 1000 ||
|
|
38
|
+
timeoutMs > 120_000)
|
|
39
|
+
return undefined;
|
|
40
|
+
const messages = {};
|
|
41
|
+
const given = value.messages ?? {};
|
|
42
|
+
if (given === null || typeof given !== 'object' || Array.isArray(given))
|
|
43
|
+
return undefined;
|
|
44
|
+
for (const [tag, texts] of Object.entries(given)) {
|
|
45
|
+
if (!LANGUAGE_TAG.test(tag) || texts === null || typeof texts !== 'object')
|
|
46
|
+
return undefined;
|
|
47
|
+
const kept = {};
|
|
48
|
+
for (const [key, text] of Object.entries(texts)) {
|
|
49
|
+
if (!['loading', 'failed', 'reload'].includes(key))
|
|
50
|
+
return undefined;
|
|
51
|
+
if (typeof text !== 'string' || text.length === 0 || text.length > MAX_TEXT)
|
|
52
|
+
return undefined;
|
|
53
|
+
kept[key] = text;
|
|
54
|
+
}
|
|
55
|
+
messages[tag.toLowerCase()] = Object.freeze(kept);
|
|
56
|
+
}
|
|
57
|
+
return Object.freeze({
|
|
58
|
+
enabled,
|
|
59
|
+
timeoutMs,
|
|
60
|
+
messages: Object.freeze(messages),
|
|
61
|
+
...(stylesheet === undefined ? {} : { stylesheet }),
|
|
62
|
+
...(logo === undefined ? {} : { logo }),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/** `/x`, not `//host`, without backslash, quote, angle bracket, space or control character. */
|
|
66
|
+
function localUrl(value) {
|
|
67
|
+
return (typeof value === 'string' &&
|
|
68
|
+
value.length <= 2048 &&
|
|
69
|
+
value.startsWith('/') &&
|
|
70
|
+
!value.startsWith('//') &&
|
|
71
|
+
![...value].some((char) => '\\"\'<>'.includes(char) || char.charCodeAt(0) <= 32));
|
|
72
|
+
}
|
|
73
|
+
const escapeHtml = (value) => value
|
|
74
|
+
.replace(/&/g, '&')
|
|
75
|
+
.replace(/</g, '<')
|
|
76
|
+
.replace(/>/g, '>')
|
|
77
|
+
.replace(/"/g, '"')
|
|
78
|
+
.replace(/'/g, ''');
|
|
79
|
+
/** The texts for `language` (`<html lang>`): exact tag, its primary subtag, then English. */
|
|
80
|
+
function textsFor(settings, language) {
|
|
81
|
+
const tag = (language ?? '').toLowerCase();
|
|
82
|
+
const primary = tag.split('-')[0] ?? '';
|
|
83
|
+
// Own keys only: Content-Language is any text the app set (`constructor`, `__proto__` named
|
|
84
|
+
// Object.prototype members and left the texts undefined: the page answered 500).
|
|
85
|
+
const own = (table, key) => Object.hasOwn(table, key) ? table[key] : undefined;
|
|
86
|
+
const builtIn = own(BUILT_IN, primary) ?? BUILT_IN.en;
|
|
87
|
+
return {
|
|
88
|
+
...builtIn,
|
|
89
|
+
...own(settings.messages, primary),
|
|
90
|
+
...own(settings.messages, tag),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The head part (a style and a script, before the module scripts so a failed load is heard) and
|
|
95
|
+
* the root's content. The head part depends on the root id and the timeout only, so its hashes are
|
|
96
|
+
* fixed for a scope (logged at debug for a CSP that lists hashes).
|
|
97
|
+
*/
|
|
98
|
+
export function bootMarkup(settings, rootId, language) {
|
|
99
|
+
const texts = textsFor(settings, language);
|
|
100
|
+
return {
|
|
101
|
+
head: bootHead(rootId, settings.timeoutMs) +
|
|
102
|
+
(settings.stylesheet
|
|
103
|
+
? `<link rel="stylesheet" href="${escapeHtml(settings.stylesheet)}">`
|
|
104
|
+
: ''),
|
|
105
|
+
root: `<div data-dokebi-boot role="status" aria-live="polite">${settings.logo ? `<img data-dokebi-boot-logo src="${escapeHtml(settings.logo)}" alt="">` : ''}<p data-dokebi-boot-loading>${escapeHtml(texts.loading)}</p><p data-dokebi-boot-failed>${escapeHtml(texts.failed)} <a href="">${escapeHtml(texts.reload)}</a></p></div>`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** The style and the script of the head (fixed per root id and timeout). */
|
|
109
|
+
export function bootHead(rootId, timeoutMs) {
|
|
110
|
+
return `<style>${bootStyle(timeoutMs)}</style><script>${bootScript(rootId)}</script>`;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* "Loading" turns to "could not load" after the delay, or at once once the script marks it. The
|
|
114
|
+
* look: the box fills the window and centers its line; "loading" is a spinner (its text, at size 0,
|
|
115
|
+
* is still read out), "could not load" its text over a reload button, a `logo` above both. Neutral
|
|
116
|
+
* greys; a dark colour scheme gets a dark box (the page behind it is white without the app's CSS),
|
|
117
|
+
* and reduced motion a spinner that stands still (Studio v2 Z1). An app restyles it with
|
|
118
|
+
* `stylesheet`.
|
|
119
|
+
*/
|
|
120
|
+
export function bootStyle(timeoutMs) {
|
|
121
|
+
return `@keyframes dokebi-boot-hide{from{visibility:visible;position:static}to{visibility:hidden;position:absolute}}@keyframes dokebi-boot-show{from{visibility:hidden;position:absolute}to{visibility:visible;position:static}}@keyframes dokebi-boot-spin{to{transform:rotate(360deg)}}[data-dokebi-boot]{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:24px;font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;color:#71717a;text-align:center}[data-dokebi-boot] p{margin:0}[data-dokebi-boot-loading]{width:32px;height:32px;font-size:0;color:transparent;animation:dokebi-boot-hide 0s linear ${timeoutMs}ms both}[data-dokebi-boot-loading]::before{content:"";display:block;box-sizing:border-box;width:100%;height:100%;border:3px solid rgba(113,113,122,.25);border-top-color:#71717a;border-radius:50%;animation:dokebi-boot-spin .8s linear infinite}[data-dokebi-boot-failed]{display:flex;flex-direction:column;align-items:center;gap:12px;max-width:320px;animation:dokebi-boot-show 0s linear ${timeoutMs}ms both}[data-dokebi-boot-failed] a{padding:8px 16px;border-radius:8px;background:#52525b;color:#fff;font-size:14px;font-weight:600;text-decoration:none}[data-dokebi-boot=failed] [data-dokebi-boot-loading],[data-dokebi-boot=failed] [data-dokebi-boot-failed]{animation-delay:0s}[data-dokebi-boot-logo]{display:block;max-width:160px;max-height:64px}@media (prefers-color-scheme:dark){[data-dokebi-boot]{color-scheme:dark;background:#18181b;color:#a1a1aa}[data-dokebi-boot-loading]::before{border-color:rgba(161,161,170,.25);border-top-color:#a1a1aa}[data-dokebi-boot-failed] a{background:#e4e4e7;color:#18181b}}@media (prefers-reduced-motion:reduce){[data-dokebi-boot-loading]::before{animation:none;border-color:rgba(113,113,122,.6)}}`;
|
|
122
|
+
}
|
|
123
|
+
/** A module script of the page that fails to load marks the boot text failed (capture phase). */
|
|
124
|
+
export function bootScript(rootId) {
|
|
125
|
+
return `(function(){var id=${JSON.stringify(rootId)};function fail(){var r=document.getElementById(id),b=r&&r.querySelector("[data-dokebi-boot]");if(b)b.setAttribute("data-dokebi-boot","failed")}addEventListener("error",function(e){var t=e.target;if(t&&t.tagName==="SCRIPT"&&t.type==="module"){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",fail);else fail()}},true)})();`;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The CSP sources of the head's style and script (`'sha256-…'`), for an app whose CSP has no
|
|
129
|
+
* 'unsafe-inline': add them to style-src and script-src, or turn bootFallback off.
|
|
130
|
+
*/
|
|
131
|
+
export function bootHashes(rootId, timeoutMs) {
|
|
132
|
+
const hash = (text) => `'sha256-${createHash('sha256').update(text).digest('base64')}'`;
|
|
133
|
+
return { style: hash(bootStyle(timeoutMs)), script: hash(bootScript(rootId)) };
|
|
134
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** A content-hashed name (`app-3fa8c1d2.js`, `app-BXk2_9aQ.css`): kept by browsers for a year. */
|
|
2
|
+
export declare const HASHED: RegExp;
|
|
3
|
+
/**
|
|
4
|
+
* The file a request names under a served build folder, or undefined (404): segments of
|
|
5
|
+
* `BUILT_SEGMENT` only (no `..`, no hidden name, no backslash, no encoded separator), no source
|
|
6
|
+
* map, and a regular file (no symbolic link, no folder) inside that folder, also after resolving
|
|
7
|
+
* links of the folders on the way (a linked subfolder pointing elsewhere is not followed out).
|
|
8
|
+
*/
|
|
9
|
+
export declare function builtFolderFile(folder: string, rest: string): Promise<string | undefined>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
2
|
+
import { join, sep } from 'node:path';
|
|
3
|
+
/*
|
|
4
|
+
* The hashed files of a build folder (rolling deploys, audit 2026-10-25): a worker serves every
|
|
5
|
+
* file of the folders its manifest's files sit in, not only those files, so the chunks of the
|
|
6
|
+
* build that another worker generation renders stay loadable.
|
|
7
|
+
*/
|
|
8
|
+
/** One path segment of a built file: Vite's `[name]-[hash].[ext]` characters, never hidden. */
|
|
9
|
+
const BUILT_SEGMENT = /^[\w@~+-][\w.@~+-]{0,254}$/;
|
|
10
|
+
/**
|
|
11
|
+
* Source maps are never served from a folder: the manifest names none (they were never served
|
|
12
|
+
* before), and `build.sourcemap: 'hidden'` writes them beside the chunks for error tracking only,
|
|
13
|
+
* so serving them would publish the original sources.
|
|
14
|
+
*/
|
|
15
|
+
const SOURCE_MAP = /\.map$/i;
|
|
16
|
+
/** A content-hashed name (`app-3fa8c1d2.js`, `app-BXk2_9aQ.css`): kept by browsers for a year. */
|
|
17
|
+
export const HASHED = /[-.][A-Za-z0-9_-]{8,}\.[A-Za-z0-9]{1,16}$/;
|
|
18
|
+
/**
|
|
19
|
+
* The file a request names under a served build folder, or undefined (404): segments of
|
|
20
|
+
* `BUILT_SEGMENT` only (no `..`, no hidden name, no backslash, no encoded separator), no source
|
|
21
|
+
* map, and a regular file (no symbolic link, no folder) inside that folder, also after resolving
|
|
22
|
+
* links of the folders on the way (a linked subfolder pointing elsewhere is not followed out).
|
|
23
|
+
*/
|
|
24
|
+
export async function builtFolderFile(folder, rest) {
|
|
25
|
+
if (typeof rest !== 'string' || !rest || rest.length > 1024)
|
|
26
|
+
return undefined;
|
|
27
|
+
const segments = rest.split('/');
|
|
28
|
+
if (!segments.every((segment) => BUILT_SEGMENT.test(segment)))
|
|
29
|
+
return undefined;
|
|
30
|
+
if (SOURCE_MAP.test(rest))
|
|
31
|
+
return undefined;
|
|
32
|
+
const file = join(folder, ...segments);
|
|
33
|
+
if (!file.startsWith(folder + sep))
|
|
34
|
+
return undefined;
|
|
35
|
+
const info = await lstat(file).catch((error) => {
|
|
36
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
|
37
|
+
return undefined;
|
|
38
|
+
throw error;
|
|
39
|
+
});
|
|
40
|
+
if (!info?.isFile())
|
|
41
|
+
return undefined;
|
|
42
|
+
const [real, root] = await Promise.all([realpath(file), realpath(folder)]);
|
|
43
|
+
return real.startsWith(root + sep) ? file : undefined;
|
|
44
|
+
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { DokebiError, type DokebiErrorOptions } from '@dokebi/core';
|
|
2
2
|
/** Codes of `InertiaError` (failures of setup, files and the page flow). */
|
|
3
|
-
export type InertiaErrorCode = 'SSR_BUNDLE_MISSING' | 'SSR_BUNDLE_INVALID' | 'SSR_ENTRY_MISSING' | 'VITE_MANIFEST_MISSING' | 'VITE_MANIFEST_INVALID' | 'VITE_DEV_MISSING' | 'VITE_DEV_UNAVAILABLE' | 'SEAL_SSR_CONFLICT' | 'METHOD_NOT_ALLOWED' | 'CLOSED';
|
|
3
|
+
export type InertiaErrorCode = 'SSR_BUNDLE_MISSING' | 'SSR_BUNDLE_INVALID' | 'SSR_ENTRY_MISSING' | 'SSR_TIMEOUT' | 'VITE_MANIFEST_MISSING' | 'VITE_MANIFEST_INVALID' | 'VITE_DEV_MISSING' | 'VITE_DEV_UNAVAILABLE' | 'SEAL_SSR_CONFLICT' | 'METHOD_NOT_ALLOWED' | 'CLOSED';
|
|
4
4
|
/** Codes of `InertiaUsageError` (a wrong argument: a TypeError, as before 0.4.0). */
|
|
5
|
-
export type InertiaUsageErrorCode = 'INVALID_ENTRY' | 'INVALID_SCOPE' | 'INVALID_RENDER' | 'INVALID_PROPS' | 'INVALID_URL' | 'INVALID_PROP_HELPER' | 'SSR_INVALID_RESULT';
|
|
5
|
+
export type InertiaUsageErrorCode = 'INVALID_ENTRY' | 'INVALID_SCOPE' | 'INVALID_RENDER' | 'INVALID_PROPS' | 'INVALID_URL' | 'INVALID_PROP_HELPER' | 'SSR_INVALID_RESULT' | 'INVALID_PRUNE';
|
|
6
6
|
/** Failures of @dokebi/template-inertia; compare `code`, not the class. */
|
|
7
7
|
export declare class InertiaError extends DokebiError<InertiaErrorCode> {
|
|
8
8
|
constructor(code: InertiaErrorCode, message: string, options?: DokebiErrorOptions);
|
package/dist/errors.js
CHANGED
|
@@ -4,8 +4,9 @@ const FIX = {
|
|
|
4
4
|
SSR_BUNDLE_MISSING: 'Build the views first (npm run build), or correct ssr.bundle.',
|
|
5
5
|
SSR_BUNDLE_INVALID: 'The SSR bundle must be a file that exports render(page).',
|
|
6
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
|
+
SSR_TIMEOUT: 'Find what the page render waits for (a fetch, a slow store) and move it into props, or raise ssr.timeoutMs above the slowest render.',
|
|
7
8
|
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
|
|
9
|
+
VITE_MANIFEST_INVALID: 'Point vite.manifest at .vite/manifest.json of a Vite build (at most viteManifestMaxBytes, 4 MiB by default) and vite.entry at one of its entries.',
|
|
9
10
|
VITE_DEV_MISSING: 'npm install --save-dev vite (DOKEBI_VITE_DEV=1 serves the views through it).',
|
|
10
11
|
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
12
|
SEAL_SSR_CONFLICT: 'Set inertia.ssr: false for the app of a seal host, or remove seal: true from its domains (D-0062).',
|
|
@@ -18,6 +19,7 @@ const FIX = {
|
|
|
18
19
|
INVALID_URL: 'Use a local absolute URL such as /posts/1 (not //host, no backslash or spaces).',
|
|
19
20
|
INVALID_PROP_HELPER: 'defer(fn, { group? }), merge(value, { deep?, matchOn? }), once(value, { key? }): a JSON value or a function, with the documented options.',
|
|
20
21
|
SSR_INVALID_RESULT: 'The SSR render(page) must return { head: string[], body: string } whose body has the root element (rootId).',
|
|
22
|
+
INVALID_PRUNE: "pruneBuildFiles({ manifest: 'dist/.vite/manifest.json', olderThanMs?, dryRun?, maxManifestBytes?, now? }): a manifest path, and whole non-negative numbers.",
|
|
21
23
|
};
|
|
22
24
|
/** Failures of @dokebi/template-inertia; compare `code`, not the class. */
|
|
23
25
|
export class InertiaError extends DokebiError {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Messages of one bundle: { source locale → { flat key → message } } (as the server joins them). */
|
|
2
|
+
export type StoredMessages = Readonly<Record<string, Readonly<Record<string, unknown>>>>;
|
|
3
|
+
/** The limits of `settings.cache` (dokebi-i18n http.pageCache). */
|
|
4
|
+
export interface StoreLimits {
|
|
5
|
+
readonly cookie: string;
|
|
6
|
+
readonly maxCookieBytes: number;
|
|
7
|
+
readonly maxAgeSeconds: number;
|
|
8
|
+
readonly maxBundles: number;
|
|
9
|
+
readonly maxStoreCharacters: number;
|
|
10
|
+
}
|
|
11
|
+
/** The log of the runtime (PageI18nLogger of i18n.ts). */
|
|
12
|
+
export interface StoreLogger {
|
|
13
|
+
isEnabled?(level: 'debug' | 'warn' | 'error'): boolean;
|
|
14
|
+
log(level: 'debug' | 'warn' | 'error', message: string, fields?: Record<string, unknown>): void;
|
|
15
|
+
}
|
|
16
|
+
/** The browser objects the store uses; tests pass their own. */
|
|
17
|
+
export interface StoreHost {
|
|
18
|
+
readonly storage: Pick<Storage, 'length' | 'key' | 'getItem' | 'setItem' | 'removeItem'>;
|
|
19
|
+
readonly document?: {
|
|
20
|
+
cookie: string;
|
|
21
|
+
};
|
|
22
|
+
readonly secure?: boolean;
|
|
23
|
+
/** Runs work when the page is idle (requestIdleCallback, else a timer). */
|
|
24
|
+
readonly later?: (work: () => void) => void;
|
|
25
|
+
}
|
|
26
|
+
/** One kept bundle, read from its key alone. */
|
|
27
|
+
export interface Kept {
|
|
28
|
+
readonly key: string;
|
|
29
|
+
readonly locale: string;
|
|
30
|
+
readonly namespace: string;
|
|
31
|
+
readonly token: string;
|
|
32
|
+
readonly characters: number;
|
|
33
|
+
readonly written: number;
|
|
34
|
+
}
|
|
35
|
+
export declare class BundleStore {
|
|
36
|
+
#private;
|
|
37
|
+
constructor(host: StoreHost, limits: StoreLimits, logger?: StoreLogger);
|
|
38
|
+
set limits(limits: StoreLimits);
|
|
39
|
+
get limits(): StoreLimits;
|
|
40
|
+
get usable(): boolean;
|
|
41
|
+
/** Every kept bundle (from the keys; no value is read). */
|
|
42
|
+
index(): Kept[];
|
|
43
|
+
/**
|
|
44
|
+
* The messages of a kept bundle: the one with `token`, or (no token: a page from history) the
|
|
45
|
+
* newest of `locale`/`namespace`. An item that is not valid JSON messages is removed.
|
|
46
|
+
*/
|
|
47
|
+
read(locale: string, namespace: string, token?: string, index?: readonly Kept[]): {
|
|
48
|
+
token: string;
|
|
49
|
+
messages: StoredMessages;
|
|
50
|
+
} | undefined;
|
|
51
|
+
/** Keeps a bundle the server sent (written when the page is idle). */
|
|
52
|
+
keep(locale: string, namespace: string, token: string, messages: StoredMessages): void;
|
|
53
|
+
/** The locale the page shows: its bundles come first in the cookie. */
|
|
54
|
+
show(locale: string): void;
|
|
55
|
+
/** Work for an idle moment after the pending writes (the runtime's preload). */
|
|
56
|
+
whenIdle(work: () => void): void;
|
|
57
|
+
/**
|
|
58
|
+
* Writes the pending bundles, keeps the limits and rewrites the cookie. Runs when idle; a test
|
|
59
|
+
* calls it directly.
|
|
60
|
+
*/
|
|
61
|
+
flush(): void;
|
|
62
|
+
/** The cookie from what is kept (an empty claim deletes it). */
|
|
63
|
+
writeCookie(index?: readonly Kept[]): void;
|
|
64
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* D-0126: the message bundles a browser keeps between page loads, for the runtime of
|
|
3
|
+
* `@dokebi/template-inertia/i18n`. One localStorage item per bundle (page locale × namespace, one
|
|
4
|
+
* version each), its key carrying everything an index needs, so tabs never share a mutable index:
|
|
5
|
+
*
|
|
6
|
+
* dokebi-i18n:1:<locale>:<namespace>:<token>:<characters>:<written, base 36> → JSON messages
|
|
7
|
+
*
|
|
8
|
+
* The cookie (http.pageCache.cookieName of @dokebi/i18n) names the kept bundles by token, newest
|
|
9
|
+
* of the current locale first, so a full page load leaves them out of the HTML. Everything here
|
|
10
|
+
* is best effort: a browser that refuses storage, a full quota or an item someone changed only
|
|
11
|
+
* means the server sends those messages again. Each such case is logged at debug.
|
|
12
|
+
*/
|
|
13
|
+
const PREFIX = 'dokebi-i18n:1:';
|
|
14
|
+
const TOKEN = 8;
|
|
15
|
+
const TOKENS = /^[A-Za-z0-9_-]+$/;
|
|
16
|
+
const PART = /^[A-Za-z0-9_-]{1,128}$/;
|
|
17
|
+
function describe(error) {
|
|
18
|
+
return error instanceof Error
|
|
19
|
+
? { name: error.name, message: error.message, stack: error.stack }
|
|
20
|
+
: { message: String(error) };
|
|
21
|
+
}
|
|
22
|
+
export class BundleStore {
|
|
23
|
+
#limits;
|
|
24
|
+
#host;
|
|
25
|
+
#logger;
|
|
26
|
+
#pending = new Map();
|
|
27
|
+
#idle = [];
|
|
28
|
+
#scheduled = false;
|
|
29
|
+
/** The locale whose bundles the cookie names first. */
|
|
30
|
+
#locale = '';
|
|
31
|
+
#cookieValue;
|
|
32
|
+
/** Set when the storage itself failed (blocked, gone): nothing is read or written again. */
|
|
33
|
+
#broken = false;
|
|
34
|
+
constructor(host, limits, logger) {
|
|
35
|
+
this.#host = host;
|
|
36
|
+
this.#limits = limits;
|
|
37
|
+
this.#logger = logger;
|
|
38
|
+
}
|
|
39
|
+
set limits(limits) {
|
|
40
|
+
this.#limits = limits;
|
|
41
|
+
}
|
|
42
|
+
get limits() {
|
|
43
|
+
return this.#limits;
|
|
44
|
+
}
|
|
45
|
+
get usable() {
|
|
46
|
+
return !this.#broken;
|
|
47
|
+
}
|
|
48
|
+
/** Every kept bundle (from the keys; no value is read). */
|
|
49
|
+
index() {
|
|
50
|
+
if (this.#broken)
|
|
51
|
+
return [];
|
|
52
|
+
const kept = [];
|
|
53
|
+
try {
|
|
54
|
+
const storage = this.#host.storage;
|
|
55
|
+
for (let index = 0; index < storage.length; index++) {
|
|
56
|
+
const key = storage.key(index);
|
|
57
|
+
if (!key?.startsWith(PREFIX))
|
|
58
|
+
continue;
|
|
59
|
+
const parts = key.slice(PREFIX.length).split(':');
|
|
60
|
+
const [locale, namespace, token, characters, written] = parts;
|
|
61
|
+
if (parts.length !== 5 ||
|
|
62
|
+
!PART.test(locale) ||
|
|
63
|
+
!PART.test(namespace) ||
|
|
64
|
+
token.length !== TOKEN ||
|
|
65
|
+
!TOKENS.test(token))
|
|
66
|
+
continue;
|
|
67
|
+
kept.push({
|
|
68
|
+
key,
|
|
69
|
+
locale: locale,
|
|
70
|
+
namespace: namespace,
|
|
71
|
+
token: token,
|
|
72
|
+
characters: Number.parseInt(characters, 10) || 0,
|
|
73
|
+
written: Number.parseInt(written, 36) || 0,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
this.#fail('index', error);
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
return kept;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The messages of a kept bundle: the one with `token`, or (no token: a page from history) the
|
|
85
|
+
* newest of `locale`/`namespace`. An item that is not valid JSON messages is removed.
|
|
86
|
+
*/
|
|
87
|
+
read(locale, namespace, token, index = this.index()) {
|
|
88
|
+
let best;
|
|
89
|
+
for (const kept of index)
|
|
90
|
+
if (kept.locale === locale &&
|
|
91
|
+
kept.namespace === namespace &&
|
|
92
|
+
(token === undefined || kept.token === token) &&
|
|
93
|
+
(!best || kept.written > best.written))
|
|
94
|
+
best = kept;
|
|
95
|
+
if (!best)
|
|
96
|
+
return undefined;
|
|
97
|
+
let text;
|
|
98
|
+
try {
|
|
99
|
+
text = this.#host.storage.getItem(best.key);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
this.#fail('read', error);
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
if (text === null)
|
|
106
|
+
return undefined;
|
|
107
|
+
let messages;
|
|
108
|
+
try {
|
|
109
|
+
messages = JSON.parse(text);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
this.#debug('Kept page bundle is not JSON; removed', {
|
|
113
|
+
code: 'I18N_STORE_INVALID',
|
|
114
|
+
locale,
|
|
115
|
+
namespace,
|
|
116
|
+
error: describe(error),
|
|
117
|
+
});
|
|
118
|
+
this.#remove(best.key);
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
if (!valid(messages)) {
|
|
122
|
+
this.#debug('Kept page bundle has another shape; removed', {
|
|
123
|
+
code: 'I18N_STORE_INVALID',
|
|
124
|
+
locale,
|
|
125
|
+
namespace,
|
|
126
|
+
});
|
|
127
|
+
this.#remove(best.key);
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
return { token: best.token, messages };
|
|
131
|
+
}
|
|
132
|
+
/** Keeps a bundle the server sent (written when the page is idle). */
|
|
133
|
+
keep(locale, namespace, token, messages) {
|
|
134
|
+
if (this.#broken || !PART.test(locale) || !PART.test(namespace) || !TOKENS.test(token))
|
|
135
|
+
return;
|
|
136
|
+
this.#pending.set(`${locale}:${namespace}`, { locale, namespace, token, messages });
|
|
137
|
+
this.#schedule();
|
|
138
|
+
}
|
|
139
|
+
/** The locale the page shows: its bundles come first in the cookie. */
|
|
140
|
+
show(locale) {
|
|
141
|
+
if (locale === this.#locale)
|
|
142
|
+
return;
|
|
143
|
+
this.#locale = locale;
|
|
144
|
+
this.#schedule();
|
|
145
|
+
}
|
|
146
|
+
/** Work for an idle moment after the pending writes (the runtime's preload). */
|
|
147
|
+
whenIdle(work) {
|
|
148
|
+
this.#idle.push(work);
|
|
149
|
+
this.#schedule();
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Writes the pending bundles, keeps the limits and rewrites the cookie. Runs when idle; a test
|
|
153
|
+
* calls it directly.
|
|
154
|
+
*/
|
|
155
|
+
flush() {
|
|
156
|
+
this.#scheduled = false;
|
|
157
|
+
const pending = [...this.#pending.values()];
|
|
158
|
+
this.#pending.clear();
|
|
159
|
+
let index = this.index();
|
|
160
|
+
for (const bundle of pending)
|
|
161
|
+
index = this.#write(bundle, index);
|
|
162
|
+
index = this.#trim(index);
|
|
163
|
+
this.writeCookie(index);
|
|
164
|
+
for (const work of this.#idle.splice(0))
|
|
165
|
+
work();
|
|
166
|
+
}
|
|
167
|
+
/** The cookie from what is kept (an empty claim deletes it). */
|
|
168
|
+
writeCookie(index = this.index()) {
|
|
169
|
+
const document = this.#host.document;
|
|
170
|
+
if (!document)
|
|
171
|
+
return;
|
|
172
|
+
const room = Math.floor(this.#limits.maxCookieBytes / TOKEN);
|
|
173
|
+
const order = [...index].sort((a, b) => Number(b.locale === this.#locale) - Number(a.locale === this.#locale) ||
|
|
174
|
+
b.written - a.written);
|
|
175
|
+
const value = order
|
|
176
|
+
.slice(0, room)
|
|
177
|
+
.map((kept) => kept.token)
|
|
178
|
+
.join('');
|
|
179
|
+
if (value === this.#cookieValue)
|
|
180
|
+
return;
|
|
181
|
+
this.#cookieValue = value;
|
|
182
|
+
const name = this.#limits.cookie;
|
|
183
|
+
document.cookie = value
|
|
184
|
+
? `${name}=${value}; Path=/; Max-Age=${this.#limits.maxAgeSeconds}; SameSite=Lax${this.#host.secure ? '; Secure' : ''}`
|
|
185
|
+
: `${name}=; Path=/; Max-Age=0; SameSite=Lax${this.#host.secure ? '; Secure' : ''}`;
|
|
186
|
+
this.#debug('Page bundle cookie written', {
|
|
187
|
+
code: 'I18N_STORE_COOKIE',
|
|
188
|
+
bundles: value.length / TOKEN,
|
|
189
|
+
kept: index.length,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
#write(bundle, index) {
|
|
193
|
+
let text;
|
|
194
|
+
try {
|
|
195
|
+
text = JSON.stringify(bundle.messages);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
this.#debug('Page bundle could not be serialized; not kept', {
|
|
199
|
+
code: 'I18N_STORE_SKIPPED',
|
|
200
|
+
locale: bundle.locale,
|
|
201
|
+
namespace: bundle.namespace,
|
|
202
|
+
error: describe(error),
|
|
203
|
+
});
|
|
204
|
+
return index;
|
|
205
|
+
}
|
|
206
|
+
const written = Date.now();
|
|
207
|
+
const key = `${PREFIX}${bundle.locale}:${bundle.namespace}:${bundle.token}:${text.length}:${written.toString(36)}`;
|
|
208
|
+
// One version per locale and namespace: the others go first.
|
|
209
|
+
const rest = [];
|
|
210
|
+
for (const kept of index)
|
|
211
|
+
if (kept.locale === bundle.locale && kept.namespace === bundle.namespace)
|
|
212
|
+
this.#remove(kept.key);
|
|
213
|
+
else
|
|
214
|
+
rest.push(kept);
|
|
215
|
+
if (text.length + key.length > this.#limits.maxStoreCharacters) {
|
|
216
|
+
this.#debug('Page bundle larger than the store; not kept', {
|
|
217
|
+
code: 'I18N_STORE_SKIPPED',
|
|
218
|
+
locale: bundle.locale,
|
|
219
|
+
namespace: bundle.namespace,
|
|
220
|
+
characters: text.length,
|
|
221
|
+
});
|
|
222
|
+
return rest;
|
|
223
|
+
}
|
|
224
|
+
const entry = {
|
|
225
|
+
key,
|
|
226
|
+
locale: bundle.locale,
|
|
227
|
+
namespace: bundle.namespace,
|
|
228
|
+
token: bundle.token,
|
|
229
|
+
characters: text.length,
|
|
230
|
+
written,
|
|
231
|
+
};
|
|
232
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
233
|
+
try {
|
|
234
|
+
this.#host.storage.setItem(key, text);
|
|
235
|
+
rest.push(entry);
|
|
236
|
+
return rest;
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (attempt === 0 && quota(error)) {
|
|
240
|
+
// Room for this one: the oldest bundles of other locales first.
|
|
241
|
+
this.#debug('Page bundle store is full; oldest bundles removed', {
|
|
242
|
+
code: 'I18N_STORE_FULL',
|
|
243
|
+
characters: text.length,
|
|
244
|
+
});
|
|
245
|
+
let freed = 0;
|
|
246
|
+
for (const kept of this.#evictionOrder(rest)) {
|
|
247
|
+
if (freed >= text.length + key.length)
|
|
248
|
+
break;
|
|
249
|
+
this.#remove(kept.key);
|
|
250
|
+
freed += kept.characters + kept.key.length;
|
|
251
|
+
rest.splice(rest.indexOf(kept), 1);
|
|
252
|
+
}
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (quota(error)) {
|
|
256
|
+
this.#debug('Page bundle store is full; bundle not kept', {
|
|
257
|
+
code: 'I18N_STORE_SKIPPED',
|
|
258
|
+
locale: bundle.locale,
|
|
259
|
+
namespace: bundle.namespace,
|
|
260
|
+
error: describe(error),
|
|
261
|
+
});
|
|
262
|
+
return rest;
|
|
263
|
+
}
|
|
264
|
+
this.#fail('write', error);
|
|
265
|
+
return rest;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return rest;
|
|
269
|
+
}
|
|
270
|
+
/** maxBundles and maxStoreCharacters: the oldest bundles of other locales go first. */
|
|
271
|
+
#trim(index) {
|
|
272
|
+
let characters = 0;
|
|
273
|
+
for (const kept of index)
|
|
274
|
+
characters += kept.characters + kept.key.length;
|
|
275
|
+
if (index.length <= this.#limits.maxBundles && characters <= this.#limits.maxStoreCharacters)
|
|
276
|
+
return index;
|
|
277
|
+
const rest = [...index];
|
|
278
|
+
let removed = 0;
|
|
279
|
+
for (const kept of this.#evictionOrder(index)) {
|
|
280
|
+
if (rest.length <= this.#limits.maxBundles && characters <= this.#limits.maxStoreCharacters)
|
|
281
|
+
break;
|
|
282
|
+
this.#remove(kept.key);
|
|
283
|
+
characters -= kept.characters + kept.key.length;
|
|
284
|
+
rest.splice(rest.indexOf(kept), 1);
|
|
285
|
+
removed++;
|
|
286
|
+
}
|
|
287
|
+
this.#debug('Page bundle store trimmed to its limits', {
|
|
288
|
+
code: 'I18N_STORE_TRIMMED',
|
|
289
|
+
removed,
|
|
290
|
+
kept: rest.length,
|
|
291
|
+
});
|
|
292
|
+
return rest;
|
|
293
|
+
}
|
|
294
|
+
#evictionOrder(index) {
|
|
295
|
+
return [...index].sort((a, b) => Number(a.locale === this.#locale) - Number(b.locale === this.#locale) ||
|
|
296
|
+
a.written - b.written);
|
|
297
|
+
}
|
|
298
|
+
#remove(key) {
|
|
299
|
+
try {
|
|
300
|
+
this.#host.storage.removeItem(key);
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
this.#fail('remove', error);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
#schedule() {
|
|
307
|
+
if (this.#scheduled || this.#broken)
|
|
308
|
+
return;
|
|
309
|
+
this.#scheduled = true;
|
|
310
|
+
const later = this.#host.later ?? ((work) => setTimeout(work, 0));
|
|
311
|
+
later(() => {
|
|
312
|
+
try {
|
|
313
|
+
this.flush();
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
// A timer callback has no caller: the failure is logged here, and the store stops.
|
|
317
|
+
this.#fail('flush', error);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
/** The storage refused an operation (blocked, removed): the store stops, the claim is dropped. */
|
|
322
|
+
#fail(operation, error) {
|
|
323
|
+
if (!this.#broken)
|
|
324
|
+
this.#debug('Page bundle store unavailable; bundles are not kept', {
|
|
325
|
+
code: 'I18N_STORE_UNAVAILABLE',
|
|
326
|
+
operation,
|
|
327
|
+
error: describe(error),
|
|
328
|
+
});
|
|
329
|
+
this.#broken = true;
|
|
330
|
+
this.#pending.clear();
|
|
331
|
+
const document = this.#host.document;
|
|
332
|
+
if (document && this.#cookieValue !== '') {
|
|
333
|
+
this.#cookieValue = '';
|
|
334
|
+
document.cookie = `${this.#limits.cookie}=; Path=/; Max-Age=0; SameSite=Lax${this.#host.secure ? '; Secure' : ''}`;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
#debug(message, fields) {
|
|
338
|
+
const logger = this.#logger;
|
|
339
|
+
if (logger && (logger.isEnabled?.('debug') ?? true))
|
|
340
|
+
logger.log('debug', message, fields);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function quota(error) {
|
|
344
|
+
const name = error?.name;
|
|
345
|
+
return name === 'QuotaExceededError' || name === 'NS_ERROR_DOM_QUOTA_REACHED';
|
|
346
|
+
}
|
|
347
|
+
function plain(value) {
|
|
348
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
349
|
+
}
|
|
350
|
+
/** { locale → { key → string | { other: string, … } } }: what the server sends, nothing else. */
|
|
351
|
+
function valid(messages) {
|
|
352
|
+
if (!plain(messages))
|
|
353
|
+
return false;
|
|
354
|
+
for (const byKey of Object.values(messages)) {
|
|
355
|
+
if (!plain(byKey))
|
|
356
|
+
return false;
|
|
357
|
+
for (const message of Object.values(byKey)) {
|
|
358
|
+
if (typeof message === 'string')
|
|
359
|
+
continue;
|
|
360
|
+
if (!plain(message) || typeof message.other !== 'string')
|
|
361
|
+
return false;
|
|
362
|
+
for (const branch of Object.values(message))
|
|
363
|
+
if (typeof branch !== 'string')
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return true;
|
|
368
|
+
}
|