@hops-ops/distributed 4.8.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.
- package/dist/generation.d.ts +15 -0
- package/dist/generation.js +41 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/protocol.d.ts +2 -0
- package/dist/protocol.js +5 -0
- package/dist/replica/command-runtime/create.js +11 -0
- package/dist/replica/command-runtime/errors.js +2 -0
- package/dist/replica/command-runtime/types.d.ts +7 -1
- package/dist/sveltekit/index.d.ts +2 -0
- package/dist/sveltekit/index.js +2 -0
- package/dist/sveltekit/lifecycle.d.ts +57 -0
- package/dist/sveltekit/lifecycle.js +454 -0
- package/dist/sveltekit/replica.d.ts +5 -2
- package/dist/sveltekit/replica.js +30 -1
- package/dist/sveltekit/vite.d.ts +36 -0
- package/dist/sveltekit/vite.js +351 -13
- package/package.json +3 -2
|
@@ -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
|
});
|
package/dist/sveltekit/vite.d.ts
CHANGED
|
@@ -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. */
|