@hops-ops/distributed 4.8.0 → 4.10.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/README.md +46 -8
- 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/replica/distributed-replica/impl-protocol.d.ts +1 -0
- package/dist/replica/distributed-replica/impl-protocol.js +1 -0
- package/dist/replica/distributed-replica/impl.js +11 -0
- package/dist/replica/distributed-replica/watch.js +13 -1
- package/dist/replica/index.d.ts +1 -1
- package/dist/replica/types.d.ts +38 -0
- package/dist/sveltekit/boundary-lifecycle.d.ts +37 -0
- package/dist/sveltekit/boundary-lifecycle.js +355 -0
- package/dist/sveltekit/boundary-variables.d.ts +57 -0
- package/dist/sveltekit/boundary-variables.js +290 -0
- package/dist/sveltekit/context.d.ts +3 -0
- package/dist/sveltekit/context.js +8 -0
- package/dist/sveltekit/index.d.ts +7 -3
- package/dist/sveltekit/index.js +6 -2
- package/dist/sveltekit/islands/boundaries.d.ts +104 -0
- package/dist/sveltekit/islands/boundaries.js +734 -0
- package/dist/sveltekit/lifecycle.d.ts +57 -0
- package/dist/sveltekit/lifecycle.js +454 -0
- package/dist/sveltekit/operation-identity.d.ts +4 -0
- package/dist/sveltekit/operation-identity.js +10 -0
- package/dist/sveltekit/replica.d.ts +29 -2
- package/dist/sveltekit/replica.js +102 -6
- package/dist/sveltekit/server-replica.d.ts +10 -26
- package/dist/sveltekit/server-replica.js +159 -132
- package/dist/sveltekit/vite.d.ts +46 -3
- package/dist/sveltekit/vite.js +643 -36
- package/package.json +4 -3
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { DistributedReplica, ReplicaDehydratedState } from '../replica/index.js';
|
|
2
|
+
export type DistributedReloadStateDeclaration = Readonly<{
|
|
3
|
+
/** Stable application-owned partition name. */
|
|
4
|
+
key: string;
|
|
5
|
+
/** Changes when the serialized representation becomes incompatible. */
|
|
6
|
+
fingerprint: string;
|
|
7
|
+
capture(): unknown;
|
|
8
|
+
restore(value: unknown): void | Promise<void>;
|
|
9
|
+
}>;
|
|
10
|
+
export type DistributedReloadOptions = Readonly<{
|
|
11
|
+
/** Compiler-owned surface key; generated clients provide this automatically. */
|
|
12
|
+
key: string;
|
|
13
|
+
/** Explicitly declared serializable application state. Nothing else is captured. */
|
|
14
|
+
state?: readonly DistributedReloadStateDeclaration[];
|
|
15
|
+
/** Recover ambiguous receipts by ID; commands are never replayed by the framework. */
|
|
16
|
+
recoverPendingCommands?: (commandIds: readonly string[]) => void | Promise<void>;
|
|
17
|
+
}>;
|
|
18
|
+
type ReloadParticipant = Readonly<{
|
|
19
|
+
key: string;
|
|
20
|
+
prepare(): Readonly<{
|
|
21
|
+
replica?: ReplicaDehydratedState;
|
|
22
|
+
pendingCommandIds: readonly string[];
|
|
23
|
+
state: readonly Readonly<{
|
|
24
|
+
key: string;
|
|
25
|
+
fingerprint: string;
|
|
26
|
+
value: unknown;
|
|
27
|
+
}>[];
|
|
28
|
+
}>;
|
|
29
|
+
/** Return false when restoration is valid but must be retried later. */
|
|
30
|
+
restore(value: ReloadParticipantCapsule, compatible: boolean): boolean | Promise<boolean>;
|
|
31
|
+
}>;
|
|
32
|
+
type ReloadParticipantCapsule = Readonly<{
|
|
33
|
+
key: string;
|
|
34
|
+
replica?: ReplicaDehydratedState;
|
|
35
|
+
pendingCommandIds: readonly string[];
|
|
36
|
+
state: readonly Readonly<{
|
|
37
|
+
key: string;
|
|
38
|
+
fingerprint: string;
|
|
39
|
+
value: unknown;
|
|
40
|
+
}>[];
|
|
41
|
+
}>;
|
|
42
|
+
export interface DistributedReloadLifecycle {
|
|
43
|
+
assertDispatchOpen(): void;
|
|
44
|
+
register(participant: ReloadParticipant): () => void;
|
|
45
|
+
destroy(): void;
|
|
46
|
+
}
|
|
47
|
+
/** Validate one explicitly declared application-state partition before capture. */
|
|
48
|
+
export declare function validateDistributedReloadState(value: unknown, path?: string): unknown;
|
|
49
|
+
/** Preserve browser location only when it cannot copy an auth callback secret. */
|
|
50
|
+
export declare function validateDistributedReloadLocation(location: URL): string;
|
|
51
|
+
/** Register one generated client with the shared browser reload transaction. */
|
|
52
|
+
export declare function registerDistributedReloadClient(replica: DistributedReplica, runtime: Readonly<{
|
|
53
|
+
pendingCommandIds?(): readonly string[];
|
|
54
|
+
}> | undefined, options: DistributedReloadOptions): () => void;
|
|
55
|
+
/** Browser singleton used by every generated SvelteKit surface in one page. */
|
|
56
|
+
export declare function distributedReloadLifecycle(): DistributedReloadLifecycle;
|
|
57
|
+
export {};
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ReplicaOperationArtifact } from '../replica/index.js';
|
|
2
|
+
import type { GraphqlVariables } from '../types.js';
|
|
3
|
+
/** Build the canonical identity shared by SSR scheduling and browser retention. */
|
|
4
|
+
export declare function boundaryOperationIdentity(artifact: ReplicaOperationArtifact<unknown, GraphqlVariables>, variables: GraphqlVariables): string;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Build the canonical identity shared by SSR scheduling and browser retention. */
|
|
2
|
+
export function boundaryOperationIdentity(artifact, variables) {
|
|
3
|
+
return JSON.stringify([
|
|
4
|
+
artifact.protocol.version,
|
|
5
|
+
artifact.protocol.schemaHash,
|
|
6
|
+
artifact.protocol.surface,
|
|
7
|
+
artifact.id,
|
|
8
|
+
variables
|
|
9
|
+
]);
|
|
10
|
+
}
|
|
@@ -3,6 +3,9 @@ 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 DistributedBoundaryPlan, type DistributedBoundaryOperation, type DistributedBoundaryVariableSources, type DistributedBoundaryVariableContext } from './boundary-variables.js';
|
|
7
|
+
import { type DistributedSvelteKitBoundaryInstance, type DistributedSvelteKitBoundaryLocation, type DistributedSvelteKitLocationContext, type SveltekitBoundaryLifecycleDiagnostic, type SveltekitBoundaryRetention } from './boundary-lifecycle.js';
|
|
8
|
+
import { type DistributedReloadOptions } from './lifecycle.js';
|
|
6
9
|
type UnknownCommandEntries = Readonly<Record<string, never>>;
|
|
7
10
|
export type SveltekitSessionSource = Readonly<{
|
|
8
11
|
/** Current credential. HTTP, WS, and commands all call this exact source. */
|
|
@@ -27,6 +30,8 @@ export type SveltekitReplicaHydration = Readonly<{
|
|
|
27
30
|
version: 1;
|
|
28
31
|
state: import('../replica/index.js').ReplicaDehydratedState;
|
|
29
32
|
readonly operations?: readonly string[];
|
|
33
|
+
/** Exact variable-binding fingerprints used to create this SSR seed. */
|
|
34
|
+
readonly bindings?: readonly string[];
|
|
30
35
|
}>;
|
|
31
36
|
/**
|
|
32
37
|
* Independently trusted SSR/session authority for one hydration transfer.
|
|
@@ -42,11 +47,11 @@ export type SveltekitDistributedPageData = PageGraphqlData & Readonly<{
|
|
|
42
47
|
distributed?: SveltekitReplicaHydration;
|
|
43
48
|
distributedAuthority?: SveltekitReplicaAuthority;
|
|
44
49
|
}>;
|
|
45
|
-
export type SveltekitCommandRuntimeLike<TCommands> = Pick<ReplicaCommandRuntime<UnknownCommandEntries>, 'dispose'> & Readonly<{
|
|
50
|
+
export type SveltekitCommandRuntimeLike<TCommands> = Pick<ReplicaCommandRuntime<UnknownCommandEntries>, 'dispose' | 'pendingCommandIds'> & Readonly<{
|
|
46
51
|
commands: TCommands;
|
|
47
52
|
}>;
|
|
48
53
|
export type SveltekitCommandRuntimeFactory<TCommands> = (replica: DistributedReplica, transport: ReplicaGraphqlTransport, options: SveltekitCommandRuntimeFactoryOptions) => SveltekitCommandRuntimeLike<TCommands>;
|
|
49
|
-
export type SveltekitCommandRuntimeFactoryOptions = Pick<ReplicaCommandRuntimeOptions, 'diagnostics'>;
|
|
54
|
+
export type SveltekitCommandRuntimeFactoryOptions = Pick<ReplicaCommandRuntimeOptions, 'diagnostics' | 'lifecycle'>;
|
|
50
55
|
export type CreateDistributedSvelteKitOptions<TCommands = Readonly<Record<never, never>>> = Readonly<{
|
|
51
56
|
session: SveltekitSessionSource;
|
|
52
57
|
/** Same-origin `/graphql` by default. */
|
|
@@ -63,7 +68,13 @@ export type CreateDistributedSvelteKitOptions<TCommands = Readonly<Record<never,
|
|
|
63
68
|
authority?: SveltekitReplicaAuthority;
|
|
64
69
|
createCommands?: SveltekitCommandRuntimeFactory<TCommands>;
|
|
65
70
|
replica?: Omit<DistributedReplicaOptions, 'transport'>;
|
|
71
|
+
/** Generated boundary operations accepted by this component-tree client. */
|
|
72
|
+
boundaries: readonly DistributedBoundaryOperation[];
|
|
73
|
+
/** Redacted structural lifecycle events for boundary ownership diagnostics. */
|
|
74
|
+
onBoundaryDiagnostic?: (event: SveltekitBoundaryLifecycleDiagnostic) => void;
|
|
66
75
|
onAuthError?: (error: unknown) => void;
|
|
76
|
+
/** Generated clients supply the surface key; apps may declare safe state partitions. */
|
|
77
|
+
reload?: DistributedReloadOptions;
|
|
67
78
|
}>;
|
|
68
79
|
export type SveltekitQuerySnapshot<TData> = ReplicaSnapshot<TData> & Readonly<{
|
|
69
80
|
loading: boolean;
|
|
@@ -107,12 +118,21 @@ export type SveltekitBoundOperation<TData, TVariables extends GraphqlVariables>
|
|
|
107
118
|
read(variables: TVariables): ReplicaSnapshot<TData>;
|
|
108
119
|
/** Client-side hover/nav warmup; no-ops when the replica already has a complete snapshot. */
|
|
109
120
|
prefetch(variables: TVariables): Promise<void>;
|
|
121
|
+
/** One typed binding shared by SSR, navigation, prefetch, hydration, and live use. */
|
|
122
|
+
boundary<TSession = unknown, TProps = Readonly<Record<string, unknown>>>(plan: DistributedBoundaryPlan, sources: DistributedBoundaryVariableSources<TVariables>): DistributedBoundaryOperation<TData, TVariables, TSession, TProps>;
|
|
110
123
|
}>;
|
|
111
124
|
export type DistributedSvelteKitClient<TCommands> = Readonly<{
|
|
112
125
|
replica: DistributedReplica;
|
|
113
126
|
transport: ReplicaGraphqlTransport;
|
|
114
127
|
commands: TCommands;
|
|
115
128
|
operation<TData, TVariables extends GraphqlVariables>(artifact: ReplicaOperationArtifact<TData, TVariables>): SveltekitBoundOperation<TData, TVariables>;
|
|
129
|
+
boundary<TData, TVariables extends GraphqlVariables, TSession, TProps>(operation: DistributedBoundaryOperation<TData, TVariables, TSession, TProps>): SveltekitBoundBoundaryOperation<TData, TVariables, TSession, TProps>;
|
|
130
|
+
/** Retain every generated selection owned by one mounted page/layout instance. */
|
|
131
|
+
retainBoundary<TSession, TProps>(instance: DistributedSvelteKitBoundaryInstance, context: DistributedBoundaryVariableContext<TSession, TProps>): SveltekitBoundaryRetention;
|
|
132
|
+
/** Retain the nearest generated page/layout boundary at this location. */
|
|
133
|
+
retainLocation<TSession, TProps>(location: DistributedSvelteKitBoundaryLocation, context: DistributedSvelteKitLocationContext<TSession, TProps>): SveltekitBoundaryRetention;
|
|
134
|
+
/** Prefetch the generated page and owning layout selections for a target URL. */
|
|
135
|
+
prefetchLocation<TSession, TProps>(pathname: string, context: DistributedSvelteKitLocationContext<TSession, TProps>): Promise<void>;
|
|
116
136
|
/**
|
|
117
137
|
* Apply a server seed. A malformed or mismatched seed closes the old
|
|
118
138
|
* generation and returns false so the bound operation refetches.
|
|
@@ -122,6 +142,13 @@ export type DistributedSvelteKitClient<TCommands> = Readonly<{
|
|
|
122
142
|
invalidateAuthorization(): void;
|
|
123
143
|
destroy(): void;
|
|
124
144
|
}>;
|
|
145
|
+
export type SveltekitBoundBoundaryOperation<TData, TVariables extends GraphqlVariables, TSession, TProps> = Readonly<{
|
|
146
|
+
operation: DistributedBoundaryOperation<TData, TVariables, TSession, TProps>;
|
|
147
|
+
variables(context: DistributedBoundaryVariableContext<TSession, TProps>): TVariables;
|
|
148
|
+
use(context: DistributedBoundaryVariableContext<TSession, TProps>, options?: UseSveltekitOperationOptions): SveltekitQueryStore<TData>;
|
|
149
|
+
read(context: DistributedBoundaryVariableContext<TSession, TProps>): ReplicaSnapshot<TData>;
|
|
150
|
+
prefetch(context: DistributedBoundaryVariableContext<TSession, TProps>): Promise<void>;
|
|
151
|
+
}>;
|
|
125
152
|
/**
|
|
126
153
|
* Bind the framework-neutral replica to Svelte's readable-store lifecycle.
|
|
127
154
|
*
|