@hops-ops/distributed 4.7.0 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,454 @@
1
+ const STATE_ENDPOINT = '/__distributed/lifecycle';
2
+ const CAPSULE_KEY = '@hops-ops/distributed/reload-capsule/v1';
3
+ const GENERATION_META = 'distributed-generation';
4
+ const MAX_CAPSULE_BYTES = 1024 * 1024;
5
+ const MAX_STATE_DEPTH = 32;
6
+ const PARTICIPANT_ID = /^[A-Za-z0-9_-]{16,128}$/;
7
+ const SECRET_KEY = /(?:authorization|cookie|password|secret|token|credential)/i;
8
+ const AUTH_QUERY_KEY = /^(?:code|samlresponse|session|state)$/i;
9
+ const STATE_KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
10
+ const RESTORE_TIMEOUT_MS = 3_000;
11
+ const CAPSULE_MIN_LIFETIME_MS = 30_000;
12
+ // Preparation is followed by a bounded process-readiness transaction. Keep
13
+ // the capsule alive through the CLI's 90-second aggregate readiness budget,
14
+ // restoration, and ordinary browser scheduling delay.
15
+ const CAPSULE_ACTIVATION_GRACE_MS = 120_000;
16
+ /** Validate one explicitly declared application-state partition before capture. */
17
+ export function validateDistributedReloadState(value, path = 'reloadState') {
18
+ assertSafeSerializable(value, path, 0, new Set(), true);
19
+ const encoded = JSON.stringify(value);
20
+ if (new TextEncoder().encode(encoded).length > MAX_CAPSULE_BYTES) {
21
+ throw new TypeError(`${path} exceeds 1 MiB`);
22
+ }
23
+ return value;
24
+ }
25
+ /** Preserve browser location only when it cannot copy an auth callback secret. */
26
+ export function validateDistributedReloadLocation(location) {
27
+ const parameters = [location.searchParams];
28
+ if (location.hash.length > 1) {
29
+ parameters.push(new URLSearchParams(location.hash.slice(1)));
30
+ }
31
+ for (const key of parameters.flatMap((candidate) => [...candidate.keys()])) {
32
+ if (SECRET_KEY.test(key) || AUTH_QUERY_KEY.test(key)) {
33
+ throw new TypeError(`reload location parameter ${key} is auth-secret-like`);
34
+ }
35
+ }
36
+ return `${location.pathname}${location.search}${location.hash}`;
37
+ }
38
+ /** Register one generated client with the shared browser reload transaction. */
39
+ export function registerDistributedReloadClient(replica, runtime, options) {
40
+ const state = new Map((options.state ?? []).map((declaration) => {
41
+ identity(declaration.key, 'reload state key');
42
+ identity(declaration.fingerprint, 'reload state fingerprint');
43
+ if (!STATE_KEY.test(declaration.key) || declaration.key.includes('..')) {
44
+ throw new TypeError('Distributed reload state keys must be distinct portable names');
45
+ }
46
+ return [declaration.key, declaration];
47
+ }));
48
+ if (state.size !== (options.state ?? []).length) {
49
+ throw new TypeError('duplicate Distributed reload state declaration');
50
+ }
51
+ return distributedReloadLifecycle().register(Object.freeze({
52
+ key: identity(options.key, 'reload participant key'),
53
+ prepare() {
54
+ const application = [...state.values()].map((declaration) => {
55
+ const value = declaration.capture();
56
+ validateDistributedReloadState(value, `reloadState.${declaration.key}`);
57
+ return Object.freeze({
58
+ key: declaration.key,
59
+ fingerprint: declaration.fingerprint,
60
+ value
61
+ });
62
+ });
63
+ const dehydrated = replica.scope === undefined ? undefined : replica.dehydrate();
64
+ return Object.freeze({
65
+ ...(dehydrated === undefined ? {} : { replica: dehydrated }),
66
+ pendingCommandIds: runtime?.pendingCommandIds?.() ?? Object.freeze([]),
67
+ state: Object.freeze(application)
68
+ });
69
+ },
70
+ async restore(saved, compatible) {
71
+ // A client-only application learns its replica authority from its first
72
+ // query. Retain the capsule until that authority exists instead of
73
+ // treating a skipped compatible hydrate as successful restoration.
74
+ if (compatible && saved.replica !== undefined && replica.scope === undefined) {
75
+ return false;
76
+ }
77
+ const replicaCaptured = saved.replica !== undefined;
78
+ let replicaRestored = saved.replica === undefined;
79
+ if (compatible && saved.replica !== undefined && replica.scope !== undefined) {
80
+ replicaRestored = replica.hydrate(saved.replica, replica.scope);
81
+ }
82
+ for (const candidate of saved.state) {
83
+ const declaration = state.get(candidate.key);
84
+ if (declaration?.fingerprint === candidate.fingerprint) {
85
+ await declaration.restore(candidate.value);
86
+ }
87
+ }
88
+ if (saved.pendingCommandIds.length > 0) {
89
+ await options.recoverPendingCommands?.(saved.pendingCommandIds);
90
+ }
91
+ window.dispatchEvent(new CustomEvent('distributed:reload-restored', {
92
+ detail: Object.freeze({
93
+ key: options.key,
94
+ replicaCaptured,
95
+ replicaRestored,
96
+ pendingCommandIds: saved.pendingCommandIds
97
+ })
98
+ }));
99
+ return true;
100
+ }
101
+ }));
102
+ }
103
+ let sharedLifecycle;
104
+ // Schedule from the completed request, leaving a full idle window between
105
+ // heartbeats. Besides reducing background work, this preserves browser tooling
106
+ // that defines network-idle as 500 ms without an in-flight request.
107
+ const LIFECYCLE_POLL_INTERVAL_MS = 1_000;
108
+ /** Browser singleton used by every generated SvelteKit surface in one page. */
109
+ export function distributedReloadLifecycle() {
110
+ if (sharedLifecycle === undefined) {
111
+ try {
112
+ sharedLifecycle = createDistributedReloadLifecycle();
113
+ }
114
+ catch {
115
+ // Storage and Web Crypto are optional browser capabilities. Losing
116
+ // them disables coherent reload state transfer, not the application.
117
+ sharedLifecycle = inertLifecycle();
118
+ }
119
+ }
120
+ return sharedLifecycle;
121
+ }
122
+ function createDistributedReloadLifecycle() {
123
+ if (typeof window === 'undefined')
124
+ return inertLifecycle();
125
+ const participants = new Map();
126
+ const participantId = browserParticipantId();
127
+ let blocked = false;
128
+ let destroyed = false;
129
+ let preparing;
130
+ let reloadRequested = false;
131
+ let loadedGenerationId = documentGenerationId();
132
+ let timer;
133
+ let restoration = Promise.resolve();
134
+ const queueRestoration = (active) => {
135
+ const requested = restoration.then(() => restoreAvailableParticipants(participants, active));
136
+ // Keep the queue usable after a storage/restore failure while returning
137
+ // the real result to the caller that owns this attempt.
138
+ restoration = requested.catch(() => undefined);
139
+ return requested;
140
+ };
141
+ const poll = async () => {
142
+ if (destroyed)
143
+ return;
144
+ try {
145
+ const response = await fetch(STATE_ENDPOINT, {
146
+ headers: { 'x-distributed-participant': participantId },
147
+ cache: 'no-store',
148
+ credentials: 'same-origin'
149
+ });
150
+ if (response.status === 404) {
151
+ blocked = false;
152
+ return;
153
+ }
154
+ if (!response.ok)
155
+ throw new Error(`lifecycle state returned ${response.status}`);
156
+ const state = parseLifecycleState(await response.json());
157
+ // The Vite lifecycle integration stamps the generation that served this
158
+ // document into its HTML. Fall back to the first observed active state for
159
+ // consumers that mount the lifecycle client without that integration.
160
+ loadedGenerationId ??= state.active.generationId;
161
+ if (state.phase === 'preparing' && state.pending !== undefined && state.transitionId !== undefined) {
162
+ blocked = true;
163
+ if (preparing !== state.transitionId) {
164
+ preparing = state.transitionId;
165
+ window.dispatchEvent(new CustomEvent('distributed:reload-preparing', {
166
+ detail: Object.freeze({
167
+ transitionId: state.transitionId,
168
+ fromGenerationId: state.active.generationId,
169
+ toGenerationId: state.pending.generationId
170
+ })
171
+ }));
172
+ await prepareReload(state, participantId, participants);
173
+ }
174
+ return;
175
+ }
176
+ if (state.active.generationId !== loadedGenerationId) {
177
+ blocked = true;
178
+ if (!reloadRequested) {
179
+ reloadRequested = true;
180
+ markCapsuleRestoring();
181
+ window.location.reload();
182
+ }
183
+ return;
184
+ }
185
+ preparing = undefined;
186
+ blocked = false;
187
+ await queueRestoration(state.active);
188
+ }
189
+ catch {
190
+ // A missing/failed lifecycle side channel cannot authorize dispatch
191
+ // during an already-observed transition.
192
+ if (preparing !== undefined)
193
+ blocked = true;
194
+ }
195
+ finally {
196
+ if (!destroyed)
197
+ timer = setTimeout(() => void poll(), LIFECYCLE_POLL_INTERVAL_MS);
198
+ }
199
+ };
200
+ void poll();
201
+ return Object.freeze({
202
+ assertDispatchOpen() {
203
+ if (blocked)
204
+ throw new Error('coherent application reload is in progress');
205
+ },
206
+ register(participant) {
207
+ participants.set(participant.key, participant);
208
+ void queueRestoration().catch(() => undefined);
209
+ return () => {
210
+ if (participants.get(participant.key) === participant) {
211
+ participants.delete(participant.key);
212
+ }
213
+ };
214
+ },
215
+ destroy() {
216
+ destroyed = true;
217
+ if (timer !== undefined)
218
+ clearTimeout(timer);
219
+ participants.clear();
220
+ }
221
+ });
222
+ }
223
+ async function prepareReload(state, participantId, participants) {
224
+ try {
225
+ const now = Date.now();
226
+ const capsule = Object.freeze({
227
+ version: 1,
228
+ transitionId: state.transitionId,
229
+ from: state.active,
230
+ to: state.pending,
231
+ location: validateDistributedReloadLocation(new URL(window.location.href)),
232
+ createdAtUnixMs: now,
233
+ expiresAtUnixMs: Math.max(now + CAPSULE_MIN_LIFETIME_MS, state.deadlineUnixMs + CAPSULE_ACTIVATION_GRACE_MS),
234
+ phase: 'prepared',
235
+ participants: Object.freeze([...participants.values()]
236
+ .sort((left, right) => left.key.localeCompare(right.key))
237
+ .map((participant) => Object.freeze({ key: participant.key, ...participant.prepare() })))
238
+ });
239
+ storeCapsule(capsule);
240
+ await acknowledge(state.transitionId, participantId, true);
241
+ }
242
+ catch (error) {
243
+ const message = error instanceof Error ? error.message : 'unknown preparation failure';
244
+ console.error('Distributed reload preparation failed:', message);
245
+ window.dispatchEvent(new CustomEvent('distributed:reload-prepare-failed', {
246
+ detail: Object.freeze({ transitionId: state.transitionId, message })
247
+ }));
248
+ await acknowledge(state.transitionId, participantId, false);
249
+ }
250
+ }
251
+ async function acknowledge(transitionId, participantId, ok) {
252
+ const response = await fetch(STATE_ENDPOINT, {
253
+ method: 'POST',
254
+ headers: { 'content-type': 'application/json' },
255
+ credentials: 'same-origin',
256
+ body: JSON.stringify({ transitionId, participantId, ok })
257
+ });
258
+ if (!response.ok)
259
+ throw new Error(`lifecycle acknowledgement returned ${response.status}`);
260
+ }
261
+ async function restoreAvailableParticipants(participants, active) {
262
+ const capsule = readCapsule();
263
+ if (capsule === undefined ||
264
+ capsule.phase !== 'restoring' ||
265
+ (active !== undefined && capsule.to.generationId !== active.generationId))
266
+ return;
267
+ const remaining = [];
268
+ const compatible = capsule.from.compatibilityId === capsule.to.compatibilityId;
269
+ for (const saved of capsule.participants) {
270
+ const participant = participants.get(saved.key);
271
+ if (participant === undefined) {
272
+ remaining.push(saved);
273
+ continue;
274
+ }
275
+ try {
276
+ const restored = await withDeadline(Promise.resolve(participant.restore(saved, compatible)), Math.min(RESTORE_TIMEOUT_MS, Math.max(1, capsule.expiresAtUnixMs - Date.now())));
277
+ if (!restored)
278
+ remaining.push(saved);
279
+ }
280
+ catch {
281
+ // Incompatible partitions are intentionally dropped; the mounted
282
+ // operation stores perform their ordinary authoritative fetch.
283
+ }
284
+ }
285
+ if (remaining.length === 0) {
286
+ sessionStorage.removeItem(CAPSULE_KEY);
287
+ }
288
+ else {
289
+ storeCapsule(Object.freeze({ ...capsule, participants: Object.freeze(remaining) }));
290
+ }
291
+ }
292
+ function withDeadline(operation, timeoutMs) {
293
+ return new Promise((resolvePromise, reject) => {
294
+ const timer = setTimeout(() => reject(new Error('Distributed reload restoration timed out')), timeoutMs);
295
+ operation.then((value) => {
296
+ clearTimeout(timer);
297
+ resolvePromise(value);
298
+ }, (error) => {
299
+ clearTimeout(timer);
300
+ reject(error);
301
+ });
302
+ });
303
+ }
304
+ function storeCapsule(capsule) {
305
+ assertSafeSerializable(capsule, 'reloadCapsule', 0, new Set(), false);
306
+ const encoded = JSON.stringify(capsule);
307
+ if (new TextEncoder().encode(encoded).length > MAX_CAPSULE_BYTES) {
308
+ throw new TypeError('Distributed reload capsule exceeds 1 MiB');
309
+ }
310
+ sessionStorage.setItem(CAPSULE_KEY, encoded);
311
+ }
312
+ function readCapsule() {
313
+ const encoded = sessionStorage.getItem(CAPSULE_KEY);
314
+ if (encoded === null || new TextEncoder().encode(encoded).length > MAX_CAPSULE_BYTES)
315
+ return undefined;
316
+ try {
317
+ const value = JSON.parse(encoded);
318
+ if (value.version !== 1 || value.expiresAtUnixMs < Date.now()) {
319
+ sessionStorage.removeItem(CAPSULE_KEY);
320
+ return undefined;
321
+ }
322
+ return value;
323
+ }
324
+ catch {
325
+ sessionStorage.removeItem(CAPSULE_KEY);
326
+ return undefined;
327
+ }
328
+ }
329
+ function markCapsuleRestoring() {
330
+ const capsule = readCapsule();
331
+ if (capsule !== undefined)
332
+ storeCapsule(Object.freeze({ ...capsule, phase: 'restoring' }));
333
+ }
334
+ function parseLifecycleState(value) {
335
+ const state = object(value, 'lifecycle');
336
+ if (state.schemaVersion !== 1 || (state.phase !== 'active' && state.phase !== 'preparing')) {
337
+ throw new TypeError('invalid Distributed lifecycle state');
338
+ }
339
+ const active = parseLifecycleGeneration(state.active, 'lifecycle.active');
340
+ const pending = state.pending === undefined
341
+ ? undefined
342
+ : parseLifecycleGeneration(state.pending, 'lifecycle.pending');
343
+ const transitionId = state.transitionId === undefined
344
+ ? undefined
345
+ : identity(state.transitionId, 'lifecycle.transitionId');
346
+ const deadlineUnixMs = state.deadlineUnixMs === undefined
347
+ ? undefined
348
+ : finiteInteger(state.deadlineUnixMs, 'lifecycle.deadlineUnixMs');
349
+ if (state.phase === 'preparing' && (pending === undefined || transitionId === undefined || deadlineUnixMs === undefined)) {
350
+ throw new TypeError('preparing lifecycle state is incomplete');
351
+ }
352
+ return Object.freeze({
353
+ schemaVersion: 1,
354
+ phase: state.phase,
355
+ active,
356
+ ...(pending === undefined ? {} : { pending }),
357
+ ...(transitionId === undefined ? {} : { transitionId }),
358
+ ...(deadlineUnixMs === undefined ? {} : { deadlineUnixMs })
359
+ });
360
+ }
361
+ function parseLifecycleGeneration(value, path) {
362
+ const generation = object(value, path);
363
+ return Object.freeze({
364
+ generationId: identity(generation.generationId, `${path}.generationId`),
365
+ releaseId: identity(generation.releaseId, `${path}.releaseId`),
366
+ topologyId: identity(generation.topologyId, `${path}.topologyId`),
367
+ compatibilityId: identity(generation.compatibilityId, `${path}.compatibilityId`)
368
+ });
369
+ }
370
+ function browserParticipantId() {
371
+ const key = '@hops-ops/distributed/reload-participant/v1';
372
+ const existing = sessionStorage.getItem(key);
373
+ if (existing !== null && PARTICIPANT_ID.test(existing))
374
+ return existing;
375
+ // `crypto.randomUUID()` is restricted to secure contexts, while
376
+ // `getRandomValues()` is intentionally available on ordinary HTTP origins.
377
+ // Local GitOps and LAN development commonly serve Vite through an HTTP
378
+ // service hostname, so requiring randomUUID would silently disable the
379
+ // lifecycle client there.
380
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
381
+ const created = [...bytes]
382
+ .map((byte) => byte.toString(16).padStart(2, '0'))
383
+ .join('');
384
+ sessionStorage.setItem(key, created);
385
+ return created;
386
+ }
387
+ function documentGenerationId() {
388
+ if (typeof document === 'undefined')
389
+ return undefined;
390
+ const candidate = document
391
+ .querySelector(`meta[name="${GENERATION_META}"]`)
392
+ ?.getAttribute('content');
393
+ if (candidate === null || candidate === undefined)
394
+ return undefined;
395
+ try {
396
+ return identity(candidate, 'document generation');
397
+ }
398
+ catch {
399
+ return undefined;
400
+ }
401
+ }
402
+ function inertLifecycle() {
403
+ return Object.freeze({
404
+ assertDispatchOpen() { },
405
+ register() {
406
+ return () => undefined;
407
+ },
408
+ destroy() { }
409
+ });
410
+ }
411
+ function object(value, path) {
412
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
413
+ throw new TypeError(`${path} must be an object`);
414
+ }
415
+ return value;
416
+ }
417
+ function identity(value, path) {
418
+ if (typeof value !== 'string' || value.length === 0 || value.length > 512 || value !== value.trim() || /[\u0000-\u001f\u007f]/.test(value)) {
419
+ throw new TypeError(`${path} must be a bounded stable identity`);
420
+ }
421
+ return value;
422
+ }
423
+ function finiteInteger(value, path) {
424
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
425
+ throw new TypeError(`${path} must be a non-negative safe integer`);
426
+ }
427
+ return value;
428
+ }
429
+ function assertSafeSerializable(value, path, depth, seen, rejectSecretKeys) {
430
+ if (depth > MAX_STATE_DEPTH)
431
+ throw new TypeError(`${path} exceeds maximum depth`);
432
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
433
+ return;
434
+ if (typeof value === 'number' && Number.isFinite(value))
435
+ return;
436
+ if (typeof value !== 'object')
437
+ throw new TypeError(`${path} is not JSON serializable`);
438
+ if (seen.has(value))
439
+ throw new TypeError(`${path} contains a cycle`);
440
+ seen.add(value);
441
+ if (Array.isArray(value)) {
442
+ for (let index = 0; index < value.length; index += 1) {
443
+ assertSafeSerializable(value[index], `${path}[${index}]`, depth + 1, seen, rejectSecretKeys);
444
+ }
445
+ }
446
+ else {
447
+ for (const [key, child] of Object.entries(value)) {
448
+ if (rejectSecretKeys && SECRET_KEY.test(key))
449
+ throw new TypeError(`${path}.${key} is secret-like state`);
450
+ assertSafeSerializable(child, `${path}.${key}`, depth + 1, seen, rejectSecretKeys);
451
+ }
452
+ }
453
+ seen.delete(value);
454
+ }
@@ -3,6 +3,7 @@ import type { GqlAuth, GqlError, GraphqlVariables } from '../types.js';
3
3
  import type { WebSocketConstructor } from '../websocket.js';
4
4
  import type { FetchLike } from '../request.js';
5
5
  import { type PageGraphqlData } from './auth.js';
6
+ import { type DistributedReloadOptions } from './lifecycle.js';
6
7
  type UnknownCommandEntries = Readonly<Record<string, never>>;
7
8
  export type SveltekitSessionSource = Readonly<{
8
9
  /** Current credential. HTTP, WS, and commands all call this exact source. */
@@ -42,11 +43,11 @@ export type SveltekitDistributedPageData = PageGraphqlData & Readonly<{
42
43
  distributed?: SveltekitReplicaHydration;
43
44
  distributedAuthority?: SveltekitReplicaAuthority;
44
45
  }>;
45
- export type SveltekitCommandRuntimeLike<TCommands> = Pick<ReplicaCommandRuntime<UnknownCommandEntries>, 'dispose'> & Readonly<{
46
+ export type SveltekitCommandRuntimeLike<TCommands> = Pick<ReplicaCommandRuntime<UnknownCommandEntries>, 'dispose' | 'pendingCommandIds'> & Readonly<{
46
47
  commands: TCommands;
47
48
  }>;
48
49
  export type SveltekitCommandRuntimeFactory<TCommands> = (replica: DistributedReplica, transport: ReplicaGraphqlTransport, options: SveltekitCommandRuntimeFactoryOptions) => SveltekitCommandRuntimeLike<TCommands>;
49
- export type SveltekitCommandRuntimeFactoryOptions = Pick<ReplicaCommandRuntimeOptions, 'diagnostics'>;
50
+ export type SveltekitCommandRuntimeFactoryOptions = Pick<ReplicaCommandRuntimeOptions, 'diagnostics' | 'lifecycle'>;
50
51
  export type CreateDistributedSvelteKitOptions<TCommands = Readonly<Record<never, never>>> = Readonly<{
51
52
  session: SveltekitSessionSource;
52
53
  /** Same-origin `/graphql` by default. */
@@ -64,6 +65,8 @@ export type CreateDistributedSvelteKitOptions<TCommands = Readonly<Record<never,
64
65
  createCommands?: SveltekitCommandRuntimeFactory<TCommands>;
65
66
  replica?: Omit<DistributedReplicaOptions, 'transport'>;
66
67
  onAuthError?: (error: unknown) => void;
68
+ /** Generated clients supply the surface key; apps may declare safe state partitions. */
69
+ reload?: DistributedReloadOptions;
67
70
  }>;
68
71
  export type SveltekitQuerySnapshot<TData> = ReplicaSnapshot<TData> & Readonly<{
69
72
  loading: boolean;
@@ -2,6 +2,7 @@ import { sameAuthCredential, snapshotAuthCredential } from '../identity.js';
2
2
  import { createDistributedReplica, createReplicaGraphqlTransport } from '../replica/index.js';
3
3
  import { replicaCommandProjectedLifecycleOf } from '../replica/command-runtime.js';
4
4
  import { authFromPageData } from './auth.js';
5
+ import { distributedReloadLifecycle, registerDistributedReloadClient } from './lifecycle.js';
5
6
  /**
6
7
  * Bind the framework-neutral replica to Svelte's readable-store lifecycle.
7
8
  *
@@ -65,8 +66,35 @@ export function createDistributedSvelteKit(options) {
65
66
  const commandRuntime = options.createCommands?.(replica, transport, Object.freeze({
66
67
  ...(options.replica?.diagnostics === undefined
67
68
  ? {}
68
- : { diagnostics: options.replica.diagnostics })
69
+ : { diagnostics: options.replica.diagnostics }),
70
+ ...(options.browser === true && options.reload !== undefined
71
+ ? { lifecycle: distributedReloadLifecycle() }
72
+ : {})
69
73
  }));
74
+ let unregisterReload;
75
+ try {
76
+ unregisterReload =
77
+ options.browser === true && options.reload !== undefined
78
+ ? registerDistributedReloadClient(replica, commandRuntime, options.reload)
79
+ : undefined;
80
+ }
81
+ catch (error) {
82
+ // Client construction is transactional: a malformed reload declaration
83
+ // must not retain its session subscription or command authority.
84
+ try {
85
+ commandRuntime?.dispose();
86
+ }
87
+ catch {
88
+ // Preserve the construction failure; disposal is best effort here.
89
+ }
90
+ try {
91
+ auth.dispose();
92
+ }
93
+ catch {
94
+ // Preserve the construction failure; disposal is best effort here.
95
+ }
96
+ throw error;
97
+ }
70
98
  const commands = commandRuntime === undefined
71
99
  ? Object.freeze({})
72
100
  : wrapCommandTree(commandRuntime.commands, pending);
@@ -102,6 +130,7 @@ export function createDistributedSvelteKit(options) {
102
130
  store.destroy();
103
131
  stores.clear();
104
132
  pending.clear();
133
+ unregisterReload?.();
105
134
  commandRuntime?.dispose();
106
135
  }
107
136
  });
@@ -49,12 +49,26 @@ type ViteModuleGraphLike = Readonly<{
49
49
  getModuleById(id: string): unknown;
50
50
  invalidateModule(module: unknown): void;
51
51
  }>;
52
+ type ViteMiddlewareRequest = Readonly<{
53
+ method?: string;
54
+ headers: Readonly<Record<string, string | readonly string[] | undefined>>;
55
+ on(event: 'data', listener: (chunk: Uint8Array) => void): void;
56
+ on(event: 'end' | 'error', listener: (value?: unknown) => void): void;
57
+ }>;
58
+ type ViteMiddlewareResponse = {
59
+ statusCode: number;
60
+ setHeader(name: string, value: string): void;
61
+ end(body?: string | Uint8Array): void;
62
+ };
52
63
  type ViteServerLike = Readonly<{
53
64
  watcher: Readonly<{
54
65
  add(paths: string | readonly string[]): void;
55
66
  }>;
56
67
  ws: ViteWebSocketLike;
57
68
  moduleGraph: ViteModuleGraphLike;
69
+ middlewares?: Readonly<{
70
+ use(path: string, handler: (request: ViteMiddlewareRequest, response: ViteMiddlewareResponse) => void): void;
71
+ }>;
58
72
  httpServer?: Readonly<{
59
73
  once(event: 'close', listener: () => void): void;
60
74
  }> | null;
@@ -62,6 +76,7 @@ type ViteServerLike = Readonly<{
62
76
  type ViteHotContextLike = Readonly<{
63
77
  file: string;
64
78
  server: ViteServerLike;
79
+ modules?: readonly unknown[];
65
80
  }>;
66
81
  type RollupWatchContextLike = Readonly<{
67
82
  addWatchFile(path: string): void;
@@ -77,10 +92,31 @@ export type DistributedSvelteKitVitePlugin = Readonly<{
77
92
  buildStart(this: RollupWatchContextLike): void;
78
93
  resolveId(source: string): string | undefined;
79
94
  load(id: string): string | undefined;
95
+ transformIndexHtml(): LifecycleHtmlTag[];
80
96
  handleHotUpdate(context: ViteHotContextLike): Promise<never[] | undefined>;
81
97
  watchChange(id: string): Promise<void>;
82
98
  closeBundle(): Promise<void>;
83
99
  }>;
100
+ export type DistributedLifecycleVitePlugin = Readonly<{
101
+ name: string;
102
+ enforce: 'pre';
103
+ configResolved(config: Readonly<{
104
+ root: string;
105
+ }>): void;
106
+ configureServer(server: ViteServerLike): void;
107
+ transformIndexHtml(): LifecycleHtmlTag[];
108
+ handleHotUpdate(context: ViteHotContextLike): never[] | undefined;
109
+ }>;
110
+ type LifecycleHtmlTag = Readonly<{
111
+ tag: 'meta';
112
+ attrs: Readonly<{
113
+ name: string;
114
+ content: string;
115
+ }>;
116
+ injectTo: 'head-prepend';
117
+ }>;
118
+ /** Lifecycle-only side channel for projects using committed generated clients. */
119
+ export declare function distributedLifecycle(): DistributedLifecycleVitePlugin;
84
120
  /** Generate every configured surface through the same transaction used by Vite. */
85
121
  export declare function generateDistributedSvelteKit(options: DistributedSvelteKitViteOptions): Promise<void>;
86
122
  /** Check every configured surface through canonical `distributed client --check`; never write. */