@dokebi/template-inertia 0.4.1 → 0.4.4
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 +138 -25
- package/README.md +23 -7
- package/dist/boot-fallback.d.ts +61 -0
- package/dist/boot-fallback.js +131 -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 +458 -61
- 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 +166 -51
- 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/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,56 +76,86 @@ 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);
|
|
68
92
|
return Object.freeze(normalized);
|
|
69
93
|
}
|
|
70
94
|
const UNSUPPORTED = 'Page props must be JSON serializable';
|
|
95
|
+
/** Deeper than this the walk stops; JSON.stringify reports what is left (abuse, not a page). */
|
|
96
|
+
const MAX_DEPTH = 512;
|
|
97
|
+
/**
|
|
98
|
+
* Objects the development check visits at most per page; the rest goes to JSON.stringify
|
|
99
|
+
* unchecked, as in production. A page of 30 KB has a few thousand values; more than this is an
|
|
100
|
+
* object shared down many levels (`a.left = a.right = b`, …), whose JSON doubles with each level.
|
|
101
|
+
*/
|
|
102
|
+
const MAX_VISITS = 1_000_000;
|
|
71
103
|
/**
|
|
72
104
|
* Fails on a value JSON.stringify would drop or change silently (undefined, a function, a
|
|
73
|
-
* symbol, NaN or ±Infinity; a BigInt
|
|
74
|
-
* a stringify without replacer: a replacer takes V8
|
|
75
|
-
* for a 30 KB page, D-0068).
|
|
105
|
+
* symbol, NaN or ±Infinity; a BigInt fails in stringify itself) and on a cycle, with
|
|
106
|
+
* stringify's TypeError. A plain walk before a stringify without replacer: a replacer takes V8
|
|
107
|
+
* off its fast path (4.8× the stringify time for a 30 KB page, D-0068). An object with toJSON
|
|
108
|
+
* is checked as what toJSON returns, as stringify serializes it (toJSON is called once).
|
|
109
|
+
*
|
|
110
|
+
* `walk.path` holds the objects from the page down to `value`, entered and left (ISSUE-0006
|
|
111
|
+
* A13): a cycle is refused at once, where it used to be walked down to the depth limit through
|
|
112
|
+
* every branch (`a.left = a; a.right = a` is 2^512 visits), while an object used twice side by
|
|
113
|
+
* side is no cycle and is checked each time, as JSON repeats it. `walk.visits` bounds the rest.
|
|
76
114
|
*/
|
|
77
|
-
function checkJson(value, depth) {
|
|
78
|
-
if (depth >
|
|
79
|
-
return;
|
|
115
|
+
function checkJson(value, depth, walk) {
|
|
116
|
+
if (depth > MAX_DEPTH || ++walk.visits > MAX_VISITS)
|
|
117
|
+
return;
|
|
118
|
+
walk.path.add(value);
|
|
80
119
|
if (Array.isArray(value)) {
|
|
81
120
|
for (let index = 0; index < value.length; index++)
|
|
82
|
-
checkValue(value[index], index, depth);
|
|
83
|
-
return;
|
|
121
|
+
checkValue(value[index], index, depth, walk, false);
|
|
84
122
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
123
|
+
else {
|
|
124
|
+
const object = value;
|
|
125
|
+
for (const key in object) {
|
|
126
|
+
if (!Object.hasOwn(object, key))
|
|
127
|
+
continue;
|
|
128
|
+
const item = object[key];
|
|
129
|
+
// Only an object a toJSON returned can have its own toJSON here: stringify does not call
|
|
130
|
+
// it again and leaves the method out, which is no data.
|
|
131
|
+
if (key === 'toJSON' && typeof item === 'function')
|
|
132
|
+
continue;
|
|
133
|
+
checkValue(item, key, depth, walk, false);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
walk.path.delete(value);
|
|
89
137
|
}
|
|
90
|
-
function checkValue(value, key, depth) {
|
|
91
|
-
|
|
92
|
-
|
|
138
|
+
function checkValue(value, key, depth, walk, fromToJSON) {
|
|
139
|
+
if (typeof value !== 'object' || value === null) {
|
|
140
|
+
checkScalar(value);
|
|
93
141
|
return;
|
|
94
|
-
if (type === 'number') {
|
|
95
|
-
if (Number.isFinite(value))
|
|
96
|
-
return;
|
|
97
142
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const json = value.toJSON;
|
|
102
|
-
if (typeof json === 'function')
|
|
103
|
-
checkValue(json.call(value, String(key)), key, depth);
|
|
104
|
-
else
|
|
105
|
-
checkJson(value, depth + 1);
|
|
143
|
+
const json = fromToJSON ? undefined : value.toJSON;
|
|
144
|
+
if (typeof json === 'function') {
|
|
145
|
+
checkValue(json.call(value, String(key)), key, depth, walk, true);
|
|
106
146
|
return;
|
|
107
147
|
}
|
|
148
|
+
if (walk.path.has(value))
|
|
149
|
+
throw new TypeError(`Converting circular structure to JSON: page prop ${JSON.stringify(String(key))} leads back to an object that holds it`);
|
|
150
|
+
checkJson(value, depth + 1, walk);
|
|
151
|
+
}
|
|
152
|
+
/** A value that is not an object (or is null): JSON keeps it as it is, or it is refused. */
|
|
153
|
+
function checkScalar(value) {
|
|
154
|
+
const type = typeof value;
|
|
155
|
+
if (type === 'string' || type === 'boolean' || value === null)
|
|
156
|
+
return;
|
|
157
|
+
if (type === 'number' && Number.isFinite(value))
|
|
158
|
+
return;
|
|
108
159
|
throw new InertiaUsageError('INVALID_PROPS', UNSUPPORTED);
|
|
109
160
|
}
|
|
110
161
|
export class InertiaResponder {
|
|
@@ -151,8 +202,9 @@ export class InertiaResponder {
|
|
|
151
202
|
/**
|
|
152
203
|
* The error answer of an Inertia request or page load (D-0074 §3), for dokebi-web's error
|
|
153
204
|
* responders: the validation redirect (an Inertia request refused with 422 VALIDATION_FAILED,
|
|
154
|
-
* with
|
|
155
|
-
*
|
|
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).
|
|
156
208
|
*/
|
|
157
209
|
async answerError(response) {
|
|
158
210
|
if (this.#closed)
|
|
@@ -169,6 +221,12 @@ export class InertiaResponder {
|
|
|
169
221
|
// No session or no way back: the 422 JSON with `errors` (onHttpException reads it).
|
|
170
222
|
return false;
|
|
171
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;
|
|
172
230
|
const component = this.options.errorPage;
|
|
173
231
|
if (component === false || validation)
|
|
174
232
|
return false;
|
|
@@ -183,19 +241,36 @@ export class InertiaResponder {
|
|
|
183
241
|
return false;
|
|
184
242
|
const text = (value) => (typeof value === 'string' ? value : null);
|
|
185
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
|
+
});
|
|
186
256
|
await this.#page(request, reply, component, {
|
|
187
257
|
status,
|
|
188
258
|
error: body.error,
|
|
189
259
|
message: text(body.message),
|
|
190
260
|
hint: text(body.hint),
|
|
191
261
|
...(typeof body.fix === 'string' ? { fix: body.fix } : {}),
|
|
192
|
-
}, {}, false);
|
|
262
|
+
}, {}, false, !bare);
|
|
193
263
|
return true;
|
|
194
264
|
}
|
|
195
|
-
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) {
|
|
196
268
|
vary(reply);
|
|
197
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.
|
|
198
272
|
if (inertia &&
|
|
273
|
+
(request.method === 'GET' || request.method === 'HEAD') &&
|
|
199
274
|
request.headers['x-inertia-version'] !== undefined &&
|
|
200
275
|
request.headers['x-inertia-version'] !== this.options.version) {
|
|
201
276
|
return reply
|
|
@@ -208,7 +283,7 @@ export class InertiaResponder {
|
|
|
208
283
|
const sealer = this.hooks.sealOf?.(request);
|
|
209
284
|
if (sealer && this.ssr && !inertia)
|
|
210
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.');
|
|
211
|
-
const props = { ...(await this.shared?.(request)), ...input };
|
|
286
|
+
const props = { ...(withShared ? await this.shared?.(request) : undefined), ...input };
|
|
212
287
|
const partial = inertia && request.headers['x-inertia-partial-component'] === component;
|
|
213
288
|
const list = (key) => typeof request.headers[key] === 'string'
|
|
214
289
|
? new Set(request.headers[key]
|
|
@@ -292,15 +367,22 @@ export class InertiaResponder {
|
|
|
292
367
|
...(pageFlash && Object.keys(pageFlash).length ? { flash: pageFlash } : {}),
|
|
293
368
|
};
|
|
294
369
|
// Reject unsupported values rather than silently dropping application data (D-0047).
|
|
295
|
-
if (this.#deepCheck)
|
|
296
|
-
|
|
370
|
+
if (this.#deepCheck) {
|
|
371
|
+
const walk = { path: new WeakSet(), visits: 0 };
|
|
372
|
+
checkJson(page, 0, walk);
|
|
373
|
+
if (walk.visits > MAX_VISITS && logger?.isEnabled('debug'))
|
|
374
|
+
logger.log('debug', 'Inertia props check stopped at its visit limit', {
|
|
375
|
+
component,
|
|
376
|
+
visits: MAX_VISITS,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
297
379
|
else
|
|
298
380
|
for (const key in resolved) {
|
|
299
381
|
// Shallow: a missing (undefined) or non-JSON top-level prop, the usual mistake.
|
|
300
382
|
const value = resolved[key];
|
|
301
383
|
if (value === null || typeof value === 'object')
|
|
302
384
|
continue;
|
|
303
|
-
|
|
385
|
+
checkScalar(value);
|
|
304
386
|
}
|
|
305
387
|
const json = JSON.stringify(page);
|
|
306
388
|
if (logger?.isEnabled('debug') && (sealer || clearHistory))
|
|
@@ -314,13 +396,21 @@ export class InertiaResponder {
|
|
|
314
396
|
if (inertia)
|
|
315
397
|
return reply.header('X-Inertia', 'true').type('application/json; charset=utf-8').send(json);
|
|
316
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;
|
|
317
407
|
if (sealer) {
|
|
318
408
|
// `E:<ts>:<base64>` holds no HTML specials; the client opens it before Inertia starts
|
|
319
409
|
// (`sealInertia` of @dokebi/seal/http). No SSR here (refused above).
|
|
320
410
|
const frame = sealer.encode(json);
|
|
321
411
|
return reply
|
|
322
412
|
.type('text/html; charset=utf-8')
|
|
323
|
-
.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>`);
|
|
324
414
|
}
|
|
325
415
|
// Only the first HTML is server rendered; Inertia visits above stay JSON.
|
|
326
416
|
const rendered = this.ssr ? await this.#serverRender(JSON.parse(json), rootId) : undefined;
|
|
@@ -330,11 +420,11 @@ export class InertiaResponder {
|
|
|
330
420
|
.replace(/\u2028/g, '\\u2028')
|
|
331
421
|
.replace(/\u2029/g, '\\u2029');
|
|
332
422
|
// A <title> from the page's <Head> replaces the configured one.
|
|
333
|
-
const head = rendered?.head ?? [];
|
|
423
|
+
const head = (rendered?.head ?? []).map(safeTitleTag);
|
|
334
424
|
const titled = head.some((tag) => /^<title[\s>]/i.test(tag));
|
|
335
425
|
return reply
|
|
336
426
|
.type('text/html; charset=utf-8')
|
|
337
|
-
.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>`);
|
|
338
428
|
}
|
|
339
429
|
/**
|
|
340
430
|
* The value of one selected prop. A function runs here; a rescued deferred prop that throws is
|
|
@@ -402,9 +492,12 @@ export class InertiaResponder {
|
|
|
402
492
|
}
|
|
403
493
|
location(request, reply, url) {
|
|
404
494
|
this.#check();
|
|
405
|
-
if (url.startsWith('/'))
|
|
495
|
+
if (typeof url === 'string' && url.startsWith('/'))
|
|
406
496
|
localUrl(url);
|
|
407
|
-
else if (
|
|
497
|
+
else if (typeof url !== 'string' ||
|
|
498
|
+
!URL.canParse(url) ||
|
|
499
|
+
!/^https?:$/.test(new URL(url).protocol) ||
|
|
500
|
+
/[\r\n]/.test(url))
|
|
408
501
|
throw new InertiaUsageError('INVALID_URL', 'Invalid location');
|
|
409
502
|
vary(reply);
|
|
410
503
|
return request.headers['x-inertia'] === 'true'
|
|
@@ -413,6 +506,24 @@ export class InertiaResponder {
|
|
|
413
506
|
}
|
|
414
507
|
}
|
|
415
508
|
const RESCUED = Symbol('rescued');
|
|
509
|
+
/**
|
|
510
|
+
* An ordinary object with the Map's entries as own keys (JSON.stringify keeps its fast path, see
|
|
511
|
+
* the page props); `__proto__` is defined as an own key, never assigned.
|
|
512
|
+
*/
|
|
513
|
+
function record(entries) {
|
|
514
|
+
const result = {};
|
|
515
|
+
for (const [key, value] of entries)
|
|
516
|
+
if (key === '__proto__')
|
|
517
|
+
Object.defineProperty(result, key, {
|
|
518
|
+
value,
|
|
519
|
+
enumerable: true,
|
|
520
|
+
writable: true,
|
|
521
|
+
configurable: true,
|
|
522
|
+
});
|
|
523
|
+
else
|
|
524
|
+
result[key] = value;
|
|
525
|
+
return result;
|
|
526
|
+
}
|
|
416
527
|
/**
|
|
417
528
|
* Which props this response computes and the page fields that announce the others, following
|
|
418
529
|
* the official adapters (D-0052): a partial reload selects by only/except and announces only the
|
|
@@ -423,10 +534,12 @@ function buildMetadata(props, partial, only, except, reset, held) {
|
|
|
423
534
|
const selected = [];
|
|
424
535
|
const lazy = [];
|
|
425
536
|
const skippedOnce = [];
|
|
426
|
-
|
|
537
|
+
// Group names and once keys come from the app: Maps, so `constructor`, `toString` or
|
|
538
|
+
// `__proto__` are names like any other, not Object.prototype members.
|
|
539
|
+
const deferredProps = new Map();
|
|
427
540
|
const lists = { append: [], prepend: [], deep: [] };
|
|
428
541
|
const matchPropsOn = [];
|
|
429
|
-
const onceProps =
|
|
542
|
+
const onceProps = new Map();
|
|
430
543
|
let announced = false;
|
|
431
544
|
const now = Date.now();
|
|
432
545
|
for (const [key, input] of Object.entries(props)) {
|
|
@@ -447,10 +560,10 @@ function buildMetadata(props, partial, only, except, reset, held) {
|
|
|
447
560
|
}
|
|
448
561
|
const onceKey = prop.once ? (prop.once.key ?? key) : undefined;
|
|
449
562
|
if (prop.once && onceKey !== undefined)
|
|
450
|
-
onceProps
|
|
563
|
+
onceProps.set(onceKey, {
|
|
451
564
|
prop: key,
|
|
452
565
|
expiresAt: prop.once.ttlMs === undefined ? null : now + prop.once.ttlMs,
|
|
453
|
-
};
|
|
566
|
+
});
|
|
454
567
|
if (!partial) {
|
|
455
568
|
// The client keeps its value (also for a deferred once prop: nothing to fetch).
|
|
456
569
|
if (onceKey !== undefined && held.has(onceKey)) {
|
|
@@ -458,9 +571,11 @@ function buildMetadata(props, partial, only, except, reset, held) {
|
|
|
458
571
|
continue;
|
|
459
572
|
}
|
|
460
573
|
if (prop.group !== undefined) {
|
|
461
|
-
const group = deferredProps
|
|
462
|
-
group
|
|
463
|
-
|
|
574
|
+
const group = deferredProps.get(prop.group);
|
|
575
|
+
if (group)
|
|
576
|
+
group.push(key);
|
|
577
|
+
else
|
|
578
|
+
deferredProps.set(prop.group, [key]);
|
|
464
579
|
continue;
|
|
465
580
|
}
|
|
466
581
|
}
|
|
@@ -470,8 +585,8 @@ function buildMetadata(props, partial, only, except, reset, held) {
|
|
|
470
585
|
selected.push([key, value, prop]);
|
|
471
586
|
}
|
|
472
587
|
const page = {};
|
|
473
|
-
if (
|
|
474
|
-
page.deferredProps = deferredProps;
|
|
588
|
+
if (deferredProps.size)
|
|
589
|
+
page.deferredProps = record(deferredProps);
|
|
475
590
|
if (lists.append.length)
|
|
476
591
|
page.mergeProps = lists.append;
|
|
477
592
|
if (lists.prepend.length)
|
|
@@ -480,7 +595,7 @@ function buildMetadata(props, partial, only, except, reset, held) {
|
|
|
480
595
|
page.deepMergeProps = lists.deep;
|
|
481
596
|
if (matchPropsOn.length)
|
|
482
597
|
page.matchPropsOn = matchPropsOn;
|
|
483
|
-
if (
|
|
484
|
-
page.onceProps = onceProps;
|
|
598
|
+
if (onceProps.size)
|
|
599
|
+
page.onceProps = record(onceProps);
|
|
485
600
|
return { selected, lazy, skippedOnce, page, announced, rescued: [] };
|
|
486
601
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Logger } from '@dokebi/core';
|
|
2
|
+
/** One optimized dependency or chunk as Vite's optimizer holds it (absolute `file`). */
|
|
3
|
+
interface DepInfoShape {
|
|
4
|
+
readonly file: string;
|
|
5
|
+
readonly fileHash?: string;
|
|
6
|
+
}
|
|
7
|
+
/** The part of Vite's dependency optimizer read here. */
|
|
8
|
+
interface DepsOptimizerShape {
|
|
9
|
+
readonly metadata: {
|
|
10
|
+
readonly hash: string;
|
|
11
|
+
readonly optimized: Readonly<Record<string, DepInfoShape>>;
|
|
12
|
+
readonly chunks: Readonly<Record<string, DepInfoShape>>;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** The part of Vite's dev server the cache check uses. */
|
|
16
|
+
export interface DepsServerShape {
|
|
17
|
+
readonly config: {
|
|
18
|
+
readonly cacheDir: string;
|
|
19
|
+
};
|
|
20
|
+
readonly environments?: {
|
|
21
|
+
readonly client?: {
|
|
22
|
+
readonly depsOptimizer?: DepsOptimizerShape;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
restart(forceOptimize?: boolean): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
/** Why the folder no longer holds what the optimizer serves. */
|
|
28
|
+
export type DepsDrift = 'missing' | 'rewritten';
|
|
29
|
+
export interface DepsState {
|
|
30
|
+
/** Undefined when the folder holds every file the optimizer serves, as it wrote them. */
|
|
31
|
+
readonly drift: DepsDrift | undefined;
|
|
32
|
+
/** Files the optimizer serves (optimized dependencies and their chunks). */
|
|
33
|
+
readonly files: number;
|
|
34
|
+
/** Served files that are not on disk (absolute paths). */
|
|
35
|
+
readonly missing: readonly string[];
|
|
36
|
+
/** The optimizer metadata that was compared (Vite replaces the object on every commit). */
|
|
37
|
+
readonly metadata?: object;
|
|
38
|
+
}
|
|
39
|
+
/** What asked for the check (logged). */
|
|
40
|
+
export type DepsTrigger = 'start' | 'client' | 'outdated';
|
|
41
|
+
export interface DependencyCacheOptions {
|
|
42
|
+
/** Least time between two repairs in ms (`devDepsRepairIntervalMs`). */
|
|
43
|
+
readonly repairIntervalMs: number;
|
|
44
|
+
/** The clock (tests). */
|
|
45
|
+
readonly now?: () => number;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The host's check of its dependency cache. `ensure` re-optimizes when the folder no longer holds
|
|
49
|
+
* what the optimizer serves; one check or repair runs at a time (callers share it).
|
|
50
|
+
*/
|
|
51
|
+
export declare class DependencyCache {
|
|
52
|
+
#private;
|
|
53
|
+
constructor(logger: Logger, server: DepsServerShape, options: DependencyCacheOptions);
|
|
54
|
+
/** Repairs so far. */
|
|
55
|
+
get repairs(): number;
|
|
56
|
+
/** The deps folder of the browser environment (`<cacheDir>/deps`, Vite's default name). */
|
|
57
|
+
get folder(): string;
|
|
58
|
+
/**
|
|
59
|
+
* Compares the optimizer's state with the folder: every served file is there, and the
|
|
60
|
+
* metadata on disk is the one this optimizer wrote or loaded (same hash and file hashes).
|
|
61
|
+
*/
|
|
62
|
+
inspect(): Promise<DepsState>;
|
|
63
|
+
/**
|
|
64
|
+
* Checks the cache and re-optimizes when it drifted; resolves true after a repair. A check or
|
|
65
|
+
* repair already running is shared. Failures reject (the caller logs them).
|
|
66
|
+
*/
|
|
67
|
+
ensure(trigger: DepsTrigger): Promise<boolean>;
|
|
68
|
+
/**
|
|
69
|
+
* Records this host in the cache folder and warns when another live host of `dokebi dev`
|
|
70
|
+
* recorded itself there: two optimizers on one folder can break each other's files.
|
|
71
|
+
*/
|
|
72
|
+
claim(pid?: number): Promise<void>;
|
|
73
|
+
/** Removes the marker when it is still this host's. */
|
|
74
|
+
release(pid?: number): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
export {};
|