@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/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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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 `&lt;`; 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, '&lt;')}</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 or a cycle fails in stringify itself). A plain walk before
74
- * a stringify without replacer: a replacer takes V8 off its fast path (4.8× the stringify time
75
- * for a 30 KB page, D-0068). Objects with toJSON are checked as what toJSON returns.
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 > 512)
79
- return; // deeper than this is a cycle or abuse; stringify reports it
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
- const object = value;
86
- for (const key in object)
87
- if (Object.hasOwn(object, key))
88
- checkValue(object[key], key, depth);
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
- const type = typeof value;
92
- if (type === 'string' || type === 'boolean')
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
- else if (type === 'object') {
99
- if (value === null)
100
- return;
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 a session), then the `errorPage` component. False leaves the answer to dokebi-web (JSON
155
- * for visits, a small HTML page for page loads).
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
- checkJson(page, 0);
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
- checkValue(value, key, 1);
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}"></div></body></html>`);
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}"></div>`}</body></html>`);
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 (!/^https?:$/.test(new URL(url).protocol) || /[\r\n]/.test(url))
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
- const deferredProps = {};
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[onceKey] = {
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[prop.group] ?? [];
462
- group.push(key);
463
- deferredProps[prop.group] = group;
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 (Object.keys(deferredProps).length)
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 (Object.keys(onceProps).length)
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 {};