@hops-ops/distributed 4.9.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/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 +5 -3
- package/dist/sveltekit/index.js +4 -2
- package/dist/sveltekit/islands/boundaries.d.ts +104 -0
- package/dist/sveltekit/islands/boundaries.js +734 -0
- package/dist/sveltekit/operation-identity.d.ts +4 -0
- package/dist/sveltekit/operation-identity.js +10 -0
- package/dist/sveltekit/replica.d.ts +24 -0
- package/dist/sveltekit/replica.js +72 -5
- package/dist/sveltekit/server-replica.d.ts +10 -26
- package/dist/sveltekit/server-replica.js +159 -132
- package/dist/sveltekit/vite.d.ts +10 -3
- package/dist/sveltekit/vite.js +298 -29
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ The generated, end-to-end typed client for
|
|
|
5
5
|
|
|
6
6
|
Rust table, relationship, role, and command definitions produce one authorized
|
|
7
7
|
client surface. `distributed client` combines that surface with application GraphQL
|
|
8
|
-
documents and emits typed operations, live companions,
|
|
8
|
+
documents and emits typed operations, live companions, island metadata, and
|
|
9
9
|
commands. This package executes those artifacts through one normalized,
|
|
10
10
|
causally consistent browser replica.
|
|
11
11
|
|
|
@@ -67,7 +67,7 @@ injects wire-only identity and revision fields, and emits:
|
|
|
67
67
|
- an exact typed operation and optional live companion;
|
|
68
68
|
- normalization, identity, relationship, filter, order, and pagination plans;
|
|
69
69
|
- the closed variable codec used before cache lookup or transport;
|
|
70
|
-
-
|
|
70
|
+
- framework-neutral island metadata plus an inspectable SvelteKit boundary plan;
|
|
71
71
|
- an SSR-safe SvelteKit wrapper with static operation bindings and tree-local
|
|
72
72
|
client/command access;
|
|
73
73
|
- a nested command tree with input defaults, optimistic effects, and causal
|
|
@@ -100,7 +100,10 @@ export const distributedClients = [
|
|
|
100
100
|
module: '$distributed',
|
|
101
101
|
manifest: { args: serviceManifestArgs },
|
|
102
102
|
surface: 'e2e-ui',
|
|
103
|
-
documents: [
|
|
103
|
+
documents: [
|
|
104
|
+
'src/routes/(app)/**/*.graphql',
|
|
105
|
+
'src/lib/components/**/*.graphql'
|
|
106
|
+
],
|
|
104
107
|
out: 'src/lib/generated/distributed'
|
|
105
108
|
},
|
|
106
109
|
{
|
|
@@ -128,6 +131,13 @@ keeps ordinary application documents out of the admin tree; each trust boundary
|
|
|
128
131
|
has its own Rust manifest entrypoint, generated directory, virtual module, and
|
|
129
132
|
request-local replica. A single-surface application can omit the second entry.
|
|
130
133
|
|
|
134
|
+
A component can own a sibling `Component.graphql` island. The adapter walks
|
|
135
|
+
static Svelte imports and promotes `@load` work to the nearest page/layout.
|
|
136
|
+
Route-local `+page.graphql` and `+layout.graphql` remain first-class. When a
|
|
137
|
+
required variable cannot be proved from route/search/session/forwarded-prop
|
|
138
|
+
sources, add one typed `boundaries` registration here; that generated binding
|
|
139
|
+
is reused by SSR, hover prefetch, navigation, hydration, and live work.
|
|
140
|
+
|
|
131
141
|
The Vite integration runs `distributed client` at startup/build, watches GraphQL
|
|
132
142
|
documents, stages all surfaces, commits a rollback-capable multi-output
|
|
133
143
|
transaction, then triggers one reload. It exposes the generated Svelte wrapper
|
|
@@ -194,11 +204,11 @@ import {
|
|
|
194
204
|
createDistributedSvelteKitServer
|
|
195
205
|
} from '@hops-ops/distributed/sveltekit';
|
|
196
206
|
import {
|
|
197
|
-
|
|
207
|
+
DISTRIBUTED_BOUNDARY_OPERATIONS
|
|
198
208
|
} from '$distributed';
|
|
199
209
|
|
|
200
210
|
const distributed = createDistributedSvelteKitServer({
|
|
201
|
-
|
|
211
|
+
boundaries: DISTRIBUTED_BOUNDARY_OPERATIONS,
|
|
202
212
|
getSession: ({ locals }) => locals.auth(),
|
|
203
213
|
getRole: (session) => roleFromSession(session)
|
|
204
214
|
});
|
|
@@ -212,15 +222,20 @@ authorization lifecycle. The generated module retains no client singleton:
|
|
|
212
222
|
```ts
|
|
213
223
|
// src/routes/+layout.svelte
|
|
214
224
|
import { browser } from '$app/environment';
|
|
225
|
+
import { page } from '$app/state';
|
|
215
226
|
import {
|
|
216
227
|
createPageDataSessionSource
|
|
217
228
|
} from '@hops-ops/distributed/sveltekit';
|
|
218
|
-
import {
|
|
229
|
+
import {
|
|
230
|
+
DISTRIBUTED_BOUNDARY_OPERATIONS,
|
|
231
|
+
provideDistributed
|
|
232
|
+
} from '$distributed';
|
|
219
233
|
|
|
220
234
|
let { data, children } = $props();
|
|
221
235
|
const pageData = createPageDataSessionSource(data);
|
|
222
236
|
|
|
223
237
|
const client = provideDistributed({
|
|
238
|
+
boundaries: DISTRIBUTED_BOUNDARY_OPERATIONS,
|
|
224
239
|
browser,
|
|
225
240
|
session: pageData.session,
|
|
226
241
|
...(data.distributed !== undefined &&
|
|
@@ -233,6 +248,26 @@ const client = provideDistributed({
|
|
|
233
248
|
});
|
|
234
249
|
|
|
235
250
|
$effect(() => pageData.set(data));
|
|
251
|
+
|
|
252
|
+
$effect(() => {
|
|
253
|
+
const retained = client.retainLocation(
|
|
254
|
+
{ id: 'active-page', pathname: page.url.pathname, kind: 'page' },
|
|
255
|
+
{
|
|
256
|
+
search: page.url.searchParams,
|
|
257
|
+
session: data.session,
|
|
258
|
+
props: data
|
|
259
|
+
}
|
|
260
|
+
);
|
|
261
|
+
return () => retained.release();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// A delegated link-hover handler can warm any generated target without an
|
|
265
|
+
// operation-name switch or a second variable map.
|
|
266
|
+
await client.prefetchLocation(target.pathname, {
|
|
267
|
+
search: target.searchParams,
|
|
268
|
+
session: data.session,
|
|
269
|
+
props: data
|
|
270
|
+
});
|
|
236
271
|
```
|
|
237
272
|
|
|
238
273
|
Route components import only their generated surface. Static operation wrappers
|
|
@@ -296,7 +331,10 @@ deduplicates work, and optionally maintains the generated live operation.
|
|
|
296
331
|
`read()` is side-effect-free. `dehydrate()` and `hydrate()` transfer confirmed
|
|
297
332
|
state without exposing a public storage schema. Cold `hydrate` seeds an empty
|
|
298
333
|
client; warm same-scope `hydrate` merges so soft navigation cannot discard
|
|
299
|
-
confirmed session data the next
|
|
334
|
+
confirmed session data the next boundary did not re-dehydrate. Layout
|
|
335
|
+
retention survives child navigation, page retention is replaced on page exit,
|
|
336
|
+
and exact layout/page duplicates share one replica watch until the final owner
|
|
337
|
+
releases it.
|
|
300
338
|
|
|
301
339
|
The replica stores normalized records and exact argument-sensitive indexes,
|
|
302
340
|
not GraphQL response blobs. Generated selection metadata reconstructs each
|
|
@@ -423,7 +461,7 @@ duplicated as decision documents in this package.
|
|
|
423
461
|
command runtime, query-plan helpers, and optional persistence.
|
|
424
462
|
- `@hops-ops/distributed/diagnostics` — redacted support snapshots and artifact
|
|
425
463
|
inspection.
|
|
426
|
-
- `@hops-ops/distributed/sveltekit` — Svelte stores, SSR
|
|
464
|
+
- `@hops-ops/distributed/sveltekit` — Svelte stores, island SSR composition,
|
|
427
465
|
hydration, auth lifecycle, and tree-local generated bindings.
|
|
428
466
|
- `@hops-ops/distributed/sveltekit/vite` — Node-only one-shot/check/watch
|
|
429
467
|
generation, virtual module aliases, and GraphQL HTTP/WebSocket proxy helpers.
|
|
@@ -34,6 +34,7 @@ export type ProtocolHost = {
|
|
|
34
34
|
setProtocolGeneration(value: ProtocolGeneration | undefined): void;
|
|
35
35
|
getProtocolGenerationSequence(): number;
|
|
36
36
|
bumpProtocolGenerationSequence(): void;
|
|
37
|
+
disposeAuthorizationGeneration(): void;
|
|
37
38
|
getTrustedPresets(): readonly DistributedTrustedPreset[];
|
|
38
39
|
setTrustedPresets(value: readonly DistributedTrustedPreset[]): void;
|
|
39
40
|
getCommandAuthorityContract(): RegisteredCommandAuthorityContract | undefined;
|
|
@@ -107,6 +107,7 @@ export function purgeProtocolGeneration(host) {
|
|
|
107
107
|
}
|
|
108
108
|
export function closeAuthorizationGeneration(host) {
|
|
109
109
|
host.bumpProtocolGenerationSequence();
|
|
110
|
+
host.disposeAuthorizationGeneration();
|
|
110
111
|
host.abortAuthorization();
|
|
111
112
|
host.closeActiveTransports();
|
|
112
113
|
}
|
|
@@ -22,6 +22,7 @@ export class DistributedReplicaImpl {
|
|
|
22
22
|
#engine;
|
|
23
23
|
#transport;
|
|
24
24
|
#reportObserverError;
|
|
25
|
+
#onAuthorizationGenerationDispose;
|
|
25
26
|
#diagnostics;
|
|
26
27
|
#diagnosticLayers;
|
|
27
28
|
#inFlight = new Map();
|
|
@@ -68,6 +69,8 @@ export class DistributedReplicaImpl {
|
|
|
68
69
|
constructor(options = {}) {
|
|
69
70
|
this.#transport = options.transport;
|
|
70
71
|
this.#reportObserverError = options.onObserverError ?? reportUnhandledObserverError;
|
|
72
|
+
this.#onAuthorizationGenerationDispose =
|
|
73
|
+
options.onAuthorizationGenerationDispose;
|
|
71
74
|
this.#diagnostics = options.diagnostics;
|
|
72
75
|
this.#diagnosticLayers =
|
|
73
76
|
options.diagnostics === undefined ? undefined : new Map();
|
|
@@ -209,6 +212,14 @@ export class DistributedReplicaImpl {
|
|
|
209
212
|
bumpProtocolGenerationSequence: () => {
|
|
210
213
|
self.#protocolGenerationSequence += 1;
|
|
211
214
|
},
|
|
215
|
+
disposeAuthorizationGeneration: () => {
|
|
216
|
+
try {
|
|
217
|
+
self.#onAuthorizationGenerationDispose?.();
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
self._reportObserverErrors([error]);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
212
223
|
getTrustedPresets: () => self.#trustedPresets,
|
|
213
224
|
setTrustedPresets: (value) => {
|
|
214
225
|
self.#trustedPresets = value;
|
|
@@ -11,6 +11,7 @@ export class ReplicaWatchState {
|
|
|
11
11
|
#snapshot;
|
|
12
12
|
#identitySignature;
|
|
13
13
|
#destroyed = false;
|
|
14
|
+
#autoFetchScheduled = false;
|
|
14
15
|
#unregister;
|
|
15
16
|
constructor(owner, artifact, variables, options) {
|
|
16
17
|
this.#owner = owner;
|
|
@@ -57,7 +58,8 @@ export class ReplicaWatchState {
|
|
|
57
58
|
if (this.#destroyed)
|
|
58
59
|
return;
|
|
59
60
|
this.materialized = materialized;
|
|
60
|
-
this.#sync(
|
|
61
|
+
this.#sync(false);
|
|
62
|
+
this.#scheduleAutoFetch();
|
|
61
63
|
}
|
|
62
64
|
_stateChanged(allowFetch) {
|
|
63
65
|
if (this.#destroyed)
|
|
@@ -92,4 +94,14 @@ export class ReplicaWatchState {
|
|
|
92
94
|
if (allowFetch)
|
|
93
95
|
void this.#owner._fetch(this, false);
|
|
94
96
|
}
|
|
97
|
+
#scheduleAutoFetch() {
|
|
98
|
+
if (this.#autoFetchScheduled || this.#destroyed)
|
|
99
|
+
return;
|
|
100
|
+
this.#autoFetchScheduled = true;
|
|
101
|
+
queueMicrotask(() => {
|
|
102
|
+
this.#autoFetchScheduled = false;
|
|
103
|
+
if (!this.#destroyed)
|
|
104
|
+
void this.#owner._fetch(this, false);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
95
107
|
}
|
package/dist/replica/index.d.ts
CHANGED
|
@@ -17,6 +17,6 @@ export { compareReplicaOrder, decideReplicaPaginationMaintenance, evaluateReplic
|
|
|
17
17
|
export { createReplicaIndexMaintenanceRegistry, formatReplicaIndexStaleReason } from './index-maintenance.js';
|
|
18
18
|
export type { ReplicaIndexDependencyChange, ReplicaIndexMaintenanceDecision, ReplicaIndexMaintenanceIndex, ReplicaIndexMaintenanceReason, ReplicaIndexMaintenanceReasonCode, ReplicaIndexMaintenanceRecord, ReplicaIndexMaintenanceRegistry, ReplicaIndexMaintenanceSnapshot, ReplicaIndexPlanRegistration, ReplicaIndexRecordChange, ReplicaIndexRelationshipChange, ReplicaIndexSemanticChange, ReplicaIndexSemanticLayer } from './index-maintenance.js';
|
|
19
19
|
export type { ReplicaFilterEvaluation, ReplicaFilterEvaluationOptions, ReplicaOrderComparison, ReplicaPaginationChange, ReplicaPaginationMaintenanceDecision, ReplicaQueryPlanPath, ReplicaQueryPlanReason, ReplicaQueryPlanReasonCode, ReplicaRelationshipFilterRequest } from './query-plan.js';
|
|
20
|
-
export type { DistributedReplicaOptions, DistributedReplica, ReplicaArgumentsArtifact, ReplicaArgumentValue, ReplicaAuthoritativeScope, ReplicaBaseWriter, ReplicaBranchSemantic, ReplicaClientSurface, ReplicaCoverageArtifact, ReplicaDehydratedState, ReplicaFilterArtifact, ReplicaFilterExpression, ReplicaFilterFieldArtifact, ReplicaFilterLiteral, ReplicaFilterOperand, ReplicaFilterOperator, ReplicaIdentity, ReplicaIndexCoverage, ReplicaIndexInspection, ReplicaIndexTarget, ReplicaListValue, ReplicaLiveObserver, ReplicaLiveState, ReplicaLiteralValue, ReplicaModelArtifact, ReplicaObjectBranch, ReplicaObjectMember, ReplicaObjectSelection, ReplicaObjectValue, ReplicaOperationArtifact, ReplicaOperationSourceLocation, ReplicaOperationProtocol, ReplicaOrderArtifact, ReplicaOrderFieldArtifact, ReplicaOrderTieBreakerArtifact, ReplicaOptimisticWriter, ReplicaPaginationArtifact, ReplicaPaginationDisposition, ReplicaProtocolOperationArtifact, ReplicaRecordInspection, ReplicaRecordPatch, ReplicaRevalidationPlan, ReplicaRevalidationRelationship, ReplicaRevision, ReplicaRelationshipArtifact, ReplicaRelationshipKeyMapping, ReplicaRelationshipKind, ReplicaResultEnvelope, ReplicaRowPolicyArtifact, ReplicaRootSelection, ReplicaScalarSelection, ReplicaSelectionStorage, ReplicaSparse, ReplicaSnapshot, ReplicaStatus, ReplicaTransport, ReplicaTransportRequest, ReplicaVariableValue, ReplicaVariableCodecArtifact, ReplicaVariableEnumInputRef, ReplicaVariableFilterInputDefinition, ReplicaVariableFilterInputField, ReplicaVariableFilterInputRelationship, ReplicaVariableFilterInputTarget, ReplicaVariableInputDefinition, ReplicaVariableInputRef, ReplicaVariableListInputRef, ReplicaVariableNamedInputRef, ReplicaVariableOrderInputDefinition, ReplicaVariableOrderInputField, ReplicaVariableScalarInputRef, ReplicaValue, ReplicaWatch, ReplicaWriteSource, WatchReplicaOptions } from './types.js';
|
|
20
|
+
export type { DistributedReplicaOptions, DistributedReplica, ReplicaArgumentsArtifact, ReplicaArgumentValue, ReplicaAuthoritativeScope, ReplicaBaseWriter, ReplicaBranchSemantic, ReplicaClientSurface, ReplicaCoverageArtifact, ReplicaDehydratedState, ReplicaFilterArtifact, ReplicaFilterExpression, ReplicaFilterFieldArtifact, ReplicaFilterLiteral, ReplicaFilterOperand, ReplicaFilterOperator, ReplicaIdentity, ReplicaIndexCoverage, ReplicaIndexInspection, ReplicaIndexTarget, ReplicaIslandMetadata, ReplicaIslandOperation, ReplicaListValue, ReplicaLiveObserver, ReplicaLiveState, ReplicaLiteralValue, ReplicaModelArtifact, ReplicaObjectBranch, ReplicaObjectMember, ReplicaObjectSelection, ReplicaObjectValue, ReplicaOperationArtifact, ReplicaOperationSourceLocation, ReplicaOperationProtocol, ReplicaOrderArtifact, ReplicaOrderFieldArtifact, ReplicaOrderTieBreakerArtifact, ReplicaOptimisticWriter, ReplicaPaginationArtifact, ReplicaPaginationDisposition, ReplicaProtocolOperationArtifact, ReplicaRecordInspection, ReplicaRecordPatch, ReplicaRevalidationPlan, ReplicaRevalidationRelationship, ReplicaRevision, ReplicaRelationshipArtifact, ReplicaRelationshipKeyMapping, ReplicaRelationshipKind, ReplicaResultEnvelope, ReplicaRowPolicyArtifact, ReplicaRootSelection, ReplicaScalarSelection, ReplicaSelectionStorage, ReplicaSparse, ReplicaSnapshot, ReplicaStatus, ReplicaTransport, ReplicaTransportRequest, ReplicaVariableValue, ReplicaVariableCodecArtifact, ReplicaVariableEnumInputRef, ReplicaVariableFilterInputDefinition, ReplicaVariableFilterInputField, ReplicaVariableFilterInputRelationship, ReplicaVariableFilterInputTarget, ReplicaVariableInputDefinition, ReplicaVariableInputRef, ReplicaVariableListInputRef, ReplicaVariableNamedInputRef, ReplicaVariableOrderInputDefinition, ReplicaVariableOrderInputField, ReplicaVariableScalarInputRef, ReplicaValue, ReplicaWatch, ReplicaWriteSource, WatchReplicaOptions } from './types.js';
|
|
21
21
|
export { lowerMutationCache, MUTATION_CACHE_VISIBILITY_FULL, MUTATION_CACHE_VISIBILITY_UNAUTHORIZED, } from './mutation-cache.js';
|
|
22
22
|
export type { MutationCacheEffect, MutationCacheProgram, MutationCacheVisibility, MutationField, MutationOperation, MutationProgram, MutationTarget, } from './mutation-cache.js';
|
package/dist/replica/types.d.ts
CHANGED
|
@@ -444,6 +444,39 @@ export type ReplicaProtocolOperationArtifact<TData = Record<string, unknown>, TV
|
|
|
444
444
|
readonly variableCodec: ReplicaVariableCodecArtifact;
|
|
445
445
|
};
|
|
446
446
|
export type ReplicaOperationArtifact<TData = Record<string, unknown>, TVariables extends GraphqlVariables = GraphqlVariables> = ReplicaProtocolOperationArtifact<TData, TVariables>;
|
|
447
|
+
/** Framework-neutral compiler metadata consumed by placement adapters. */
|
|
448
|
+
export type ReplicaIslandMetadata = {
|
|
449
|
+
readonly version: 1;
|
|
450
|
+
readonly id: string;
|
|
451
|
+
readonly operation: string;
|
|
452
|
+
readonly operationHash: string;
|
|
453
|
+
readonly modulePath: string;
|
|
454
|
+
readonly exportName: string;
|
|
455
|
+
readonly source: ReplicaOperationSourceLocation;
|
|
456
|
+
readonly directives: {
|
|
457
|
+
readonly load: boolean;
|
|
458
|
+
readonly live: boolean;
|
|
459
|
+
};
|
|
460
|
+
readonly variableSchema: {
|
|
461
|
+
readonly reference: string;
|
|
462
|
+
readonly codecVersion: number;
|
|
463
|
+
readonly variables: readonly {
|
|
464
|
+
readonly name: string;
|
|
465
|
+
readonly graphqlType: string;
|
|
466
|
+
}[];
|
|
467
|
+
};
|
|
468
|
+
readonly liveCoverage: {
|
|
469
|
+
readonly requested: boolean;
|
|
470
|
+
readonly finite: boolean;
|
|
471
|
+
readonly kind: string;
|
|
472
|
+
readonly maxItems?: number;
|
|
473
|
+
};
|
|
474
|
+
};
|
|
475
|
+
/** One adapter-consumable island plan bound to its executable operation. */
|
|
476
|
+
export type ReplicaIslandOperation<TData = Record<string, unknown>, TVariables extends GraphqlVariables = GraphqlVariables> = {
|
|
477
|
+
readonly plan: ReplicaIslandMetadata;
|
|
478
|
+
readonly artifact: ReplicaOperationArtifact<TData, TVariables>;
|
|
479
|
+
};
|
|
447
480
|
export type ReplicaWriteSource = 'network' | 'live' | 'ssr' | 'restore' | 'atomic';
|
|
448
481
|
export type ReplicaResultEnvelope<TData = unknown> = {
|
|
449
482
|
readonly data?: TData | null;
|
|
@@ -532,6 +565,11 @@ export type WatchReplicaOptions = {
|
|
|
532
565
|
export type DistributedReplicaOptions = {
|
|
533
566
|
readonly transport?: ReplicaTransport;
|
|
534
567
|
readonly onObserverError?: (error: AggregateError) => void;
|
|
568
|
+
/**
|
|
569
|
+
* Runs after the old generation is fenced and before its transports/state
|
|
570
|
+
* are purged. Framework adapters use this to release generation-owned views.
|
|
571
|
+
*/
|
|
572
|
+
readonly onAuthorizationGenerationDispose?: () => void;
|
|
535
573
|
/** Opt-in framework-neutral diagnostics; absent in production by default. */
|
|
536
574
|
readonly diagnostics?: ReplicaDiagnosticsSink;
|
|
537
575
|
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { DistributedReplica } from '../replica/index.js';
|
|
2
|
+
import type { DistributedBoundaryOperation, DistributedBoundaryVariableContext } from './boundary-variables.js';
|
|
3
|
+
export type DistributedSvelteKitBoundaryInstance = Readonly<{
|
|
4
|
+
/** Opaque identity for one mounted SvelteKit page or layout instance. */
|
|
5
|
+
id: string;
|
|
6
|
+
route: string;
|
|
7
|
+
kind: 'layout' | 'page';
|
|
8
|
+
}>;
|
|
9
|
+
export type DistributedSvelteKitBoundaryLocation = Readonly<{
|
|
10
|
+
/** Opaque identity for one mounted page or layout instance. */
|
|
11
|
+
id: string;
|
|
12
|
+
pathname: string;
|
|
13
|
+
kind: 'layout' | 'page';
|
|
14
|
+
}>;
|
|
15
|
+
export type DistributedSvelteKitLocationContext<TSession = unknown, TProps = Readonly<Record<string, unknown>>> = Omit<DistributedBoundaryVariableContext<TSession, TProps>, 'params'>;
|
|
16
|
+
export type SveltekitBoundaryLifecycleDiagnostic = Readonly<{
|
|
17
|
+
action: 'acquire' | 'retain' | 'release' | 'scope-dispose' | 'final-unsubscribe';
|
|
18
|
+
boundary: string;
|
|
19
|
+
operation?: string;
|
|
20
|
+
live?: boolean;
|
|
21
|
+
owners: number;
|
|
22
|
+
}>;
|
|
23
|
+
export type SveltekitBoundaryRetention = Readonly<{
|
|
24
|
+
release(): void;
|
|
25
|
+
}>;
|
|
26
|
+
export declare class DistributedSvelteKitBoundaryController {
|
|
27
|
+
#private;
|
|
28
|
+
constructor(replica: DistributedReplica, operations: readonly DistributedBoundaryOperation[], diagnostic?: (event: SveltekitBoundaryLifecycleDiagnostic) => void);
|
|
29
|
+
retain<TSession, TProps>(instance: DistributedSvelteKitBoundaryInstance, context: DistributedBoundaryVariableContext<TSession, TProps>): SveltekitBoundaryRetention;
|
|
30
|
+
/** Retain the nearest generated page/layout boundary at a browser location. */
|
|
31
|
+
retainLocation<TSession, TProps>(location: DistributedSvelteKitBoundaryLocation, context: DistributedSvelteKitLocationContext<TSession, TProps>): SveltekitBoundaryRetention;
|
|
32
|
+
/** Warm every generated page and owning layout selection for one target URL. */
|
|
33
|
+
prefetchLocation<TSession, TProps>(pathname: string, context: DistributedSvelteKitLocationContext<TSession, TProps>): Promise<void>;
|
|
34
|
+
/** Close every old-scope owner while keeping the controller reusable. */
|
|
35
|
+
disposeScope(): void;
|
|
36
|
+
destroy(): void;
|
|
37
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { boundaryOperationIdentity } from './operation-identity.js';
|
|
2
|
+
const MAX_BOUNDARY_INSTANCES = 4_096;
|
|
3
|
+
const MAX_INSTANCE_ID_BYTES = 512;
|
|
4
|
+
const MAX_LOCATION_PATHNAME_BYTES = 8_192;
|
|
5
|
+
const MAX_LOCATION_SEGMENTS = 256;
|
|
6
|
+
export class DistributedSvelteKitBoundaryController {
|
|
7
|
+
#replica;
|
|
8
|
+
#operations;
|
|
9
|
+
#diagnostic;
|
|
10
|
+
#instances = new Map();
|
|
11
|
+
#identityOwners = new Map();
|
|
12
|
+
#destroyed = false;
|
|
13
|
+
constructor(replica, operations, diagnostic) {
|
|
14
|
+
this.#replica = replica;
|
|
15
|
+
this.#operations = operations;
|
|
16
|
+
this.#diagnostic = diagnostic;
|
|
17
|
+
}
|
|
18
|
+
retain(instance, context) {
|
|
19
|
+
if (this.#destroyed) {
|
|
20
|
+
throw new Error('Distributed SvelteKit boundary controller is destroyed');
|
|
21
|
+
}
|
|
22
|
+
const validated = validateInstance(instance);
|
|
23
|
+
const resolved = this.#resolve(validated, context);
|
|
24
|
+
const signature = JSON.stringify(resolved.map(({ identity }) => identity));
|
|
25
|
+
const existing = this.#instances.get(validated.id);
|
|
26
|
+
if (existing !== undefined) {
|
|
27
|
+
if (existing.signature !== signature ||
|
|
28
|
+
existing.boundary !== validated.boundary) {
|
|
29
|
+
throw new Error('Distributed SvelteKit boundary instance changed ownership while retained');
|
|
30
|
+
}
|
|
31
|
+
existing.owners += 1;
|
|
32
|
+
this.#emit({
|
|
33
|
+
action: 'retain',
|
|
34
|
+
boundary: existing.boundary,
|
|
35
|
+
owners: existing.owners
|
|
36
|
+
});
|
|
37
|
+
return this.#lease(validated.id, existing);
|
|
38
|
+
}
|
|
39
|
+
if (this.#instances.size >= MAX_BOUNDARY_INSTANCES) {
|
|
40
|
+
throw new Error(`Distributed SvelteKit cannot retain more than ${MAX_BOUNDARY_INSTANCES} boundary instances`);
|
|
41
|
+
}
|
|
42
|
+
const watches = [];
|
|
43
|
+
try {
|
|
44
|
+
for (const item of resolved) {
|
|
45
|
+
const watch = this.#replica.watch(item.operation.artifact, item.variables, { live: item.live });
|
|
46
|
+
watches.push(Object.freeze({
|
|
47
|
+
watch,
|
|
48
|
+
identity: item.identity,
|
|
49
|
+
operation: item.operation.plan.operation,
|
|
50
|
+
live: item.live
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
for (const { watch } of watches)
|
|
56
|
+
watch.destroy();
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
const retained = {
|
|
60
|
+
signature,
|
|
61
|
+
boundary: validated.boundary,
|
|
62
|
+
owners: 1,
|
|
63
|
+
watches: Object.freeze(watches)
|
|
64
|
+
};
|
|
65
|
+
this.#instances.set(validated.id, retained);
|
|
66
|
+
for (const item of watches) {
|
|
67
|
+
const owners = (this.#identityOwners.get(item.identity) ?? 0) + 1;
|
|
68
|
+
this.#identityOwners.set(item.identity, owners);
|
|
69
|
+
this.#emit({
|
|
70
|
+
action: 'acquire',
|
|
71
|
+
boundary: retained.boundary,
|
|
72
|
+
operation: item.operation,
|
|
73
|
+
live: item.live,
|
|
74
|
+
owners
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return this.#lease(validated.id, retained);
|
|
78
|
+
}
|
|
79
|
+
/** Retain the nearest generated page/layout boundary at a browser location. */
|
|
80
|
+
retainLocation(location, context) {
|
|
81
|
+
const matched = matchNearestBoundary(this.#operations, location.pathname, location.kind);
|
|
82
|
+
if (matched === undefined) {
|
|
83
|
+
return Object.freeze({ release: () => undefined });
|
|
84
|
+
}
|
|
85
|
+
return this.retain({
|
|
86
|
+
id: location.id,
|
|
87
|
+
route: matched.route,
|
|
88
|
+
kind: location.kind
|
|
89
|
+
}, Object.freeze({ ...context, params: matched.params }));
|
|
90
|
+
}
|
|
91
|
+
/** Warm every generated page and owning layout selection for one target URL. */
|
|
92
|
+
async prefetchLocation(pathname, context) {
|
|
93
|
+
if (this.#destroyed) {
|
|
94
|
+
throw new Error('Distributed SvelteKit boundary controller is destroyed');
|
|
95
|
+
}
|
|
96
|
+
const matches = matchLocationBoundaries(this.#operations, pathname);
|
|
97
|
+
const scheduled = new Map();
|
|
98
|
+
for (const matched of matches) {
|
|
99
|
+
for (const item of this.#resolve({
|
|
100
|
+
id: 'prefetch',
|
|
101
|
+
route: matched.route,
|
|
102
|
+
kind: matched.kind,
|
|
103
|
+
boundary: `${matched.kind}:${matched.route}`
|
|
104
|
+
}, Object.freeze({ ...context, params: matched.params }))) {
|
|
105
|
+
scheduled.set(item.identity, item);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
await Promise.all([...scheduled.values()].map(async (item) => {
|
|
109
|
+
const snapshot = this.#replica.read(item.operation.artifact, item.variables);
|
|
110
|
+
if (snapshot.complete && !snapshot.stale)
|
|
111
|
+
return;
|
|
112
|
+
const watch = this.#replica.watch(item.operation.artifact, item.variables, { live: false });
|
|
113
|
+
try {
|
|
114
|
+
await watch.refresh();
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
watch.destroy();
|
|
118
|
+
}
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
/** Close every old-scope owner while keeping the controller reusable. */
|
|
122
|
+
disposeScope() {
|
|
123
|
+
if (this.#destroyed)
|
|
124
|
+
return;
|
|
125
|
+
this.#disposeInstances(true);
|
|
126
|
+
}
|
|
127
|
+
destroy() {
|
|
128
|
+
if (this.#destroyed)
|
|
129
|
+
return;
|
|
130
|
+
this.#destroyed = true;
|
|
131
|
+
this.#disposeInstances(false);
|
|
132
|
+
}
|
|
133
|
+
#resolve(instance, context) {
|
|
134
|
+
const selected = this.#operations.filter(({ plan }) => plan.kind === instance.kind && normalizeRoute(plan.route) === instance.route);
|
|
135
|
+
if (selected.length === 0) {
|
|
136
|
+
throw new Error(`Distributed SvelteKit boundary plan has no ${instance.boundary} selection`);
|
|
137
|
+
}
|
|
138
|
+
return Object.freeze(selected.map((operation) => {
|
|
139
|
+
const variables = operation.binding.resolve(context);
|
|
140
|
+
return Object.freeze({
|
|
141
|
+
operation,
|
|
142
|
+
variables,
|
|
143
|
+
identity: boundaryOperationIdentity(operation.artifact, variables),
|
|
144
|
+
live: operation.artifact.live !== undefined
|
|
145
|
+
});
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
#lease(instanceId, retained) {
|
|
149
|
+
let released = false;
|
|
150
|
+
return Object.freeze({
|
|
151
|
+
release: () => {
|
|
152
|
+
if (released)
|
|
153
|
+
return;
|
|
154
|
+
released = true;
|
|
155
|
+
if (this.#instances.get(instanceId) !== retained)
|
|
156
|
+
return;
|
|
157
|
+
retained.owners -= 1;
|
|
158
|
+
this.#emit({
|
|
159
|
+
action: 'release',
|
|
160
|
+
boundary: retained.boundary,
|
|
161
|
+
owners: retained.owners
|
|
162
|
+
});
|
|
163
|
+
if (retained.owners > 0)
|
|
164
|
+
return;
|
|
165
|
+
this.#instances.delete(instanceId);
|
|
166
|
+
this.#releaseWatches(retained);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
#disposeInstances(scope) {
|
|
171
|
+
for (const [instanceId, retained] of [...this.#instances]) {
|
|
172
|
+
this.#instances.delete(instanceId);
|
|
173
|
+
if (scope) {
|
|
174
|
+
this.#emit({
|
|
175
|
+
action: 'scope-dispose',
|
|
176
|
+
boundary: retained.boundary,
|
|
177
|
+
owners: 0
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
this.#releaseWatches(retained);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
#releaseWatches(retained) {
|
|
184
|
+
for (const item of retained.watches) {
|
|
185
|
+
item.watch.destroy();
|
|
186
|
+
const owners = (this.#identityOwners.get(item.identity) ?? 1) - 1;
|
|
187
|
+
if (owners > 0) {
|
|
188
|
+
this.#identityOwners.set(item.identity, owners);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
this.#identityOwners.delete(item.identity);
|
|
192
|
+
if (item.live) {
|
|
193
|
+
this.#emit({
|
|
194
|
+
action: 'final-unsubscribe',
|
|
195
|
+
boundary: retained.boundary,
|
|
196
|
+
operation: item.operation,
|
|
197
|
+
live: true,
|
|
198
|
+
owners: 0
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
#emit(event) {
|
|
204
|
+
try {
|
|
205
|
+
this.#diagnostic?.(Object.freeze(event));
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// Diagnostics are observational and cannot alter lifecycle ownership.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function validateInstance(instance) {
|
|
213
|
+
if (instance === null || typeof instance !== 'object') {
|
|
214
|
+
throw new TypeError('Distributed SvelteKit boundary instance is required');
|
|
215
|
+
}
|
|
216
|
+
const id = typeof instance.id === 'string' ? instance.id.trim() : undefined;
|
|
217
|
+
if (typeof id !== 'string' ||
|
|
218
|
+
id.length === 0 ||
|
|
219
|
+
new TextEncoder().encode(id).byteLength > MAX_INSTANCE_ID_BYTES) {
|
|
220
|
+
throw new TypeError('Distributed SvelteKit boundary instance id is invalid');
|
|
221
|
+
}
|
|
222
|
+
if (instance.kind !== 'layout' && instance.kind !== 'page') {
|
|
223
|
+
throw new TypeError('Distributed SvelteKit boundary instance kind is invalid');
|
|
224
|
+
}
|
|
225
|
+
const route = normalizeRoute(instance.route);
|
|
226
|
+
return Object.freeze({
|
|
227
|
+
id,
|
|
228
|
+
route,
|
|
229
|
+
kind: instance.kind,
|
|
230
|
+
boundary: `${instance.kind}:${route}`
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
function normalizeRoute(value) {
|
|
234
|
+
if (typeof value !== 'string' || !value.startsWith('/')) {
|
|
235
|
+
throw new TypeError('Distributed SvelteKit boundary route must start with /');
|
|
236
|
+
}
|
|
237
|
+
const normalized = value.length === 1 ? value : value.replace(/\/+$/, '');
|
|
238
|
+
return normalized.length === 0 ? '/' : normalized;
|
|
239
|
+
}
|
|
240
|
+
function matchNearestBoundary(operations, pathname, kind) {
|
|
241
|
+
const matches = matchLocationBoundaries(operations, pathname).filter((candidate) => candidate.kind === kind);
|
|
242
|
+
if (matches.length === 0)
|
|
243
|
+
return undefined;
|
|
244
|
+
const mostSpecific = matches[0];
|
|
245
|
+
if (matches[1] !== undefined &&
|
|
246
|
+
matches[1].specificity === mostSpecific.specificity) {
|
|
247
|
+
throw new Error(`Distributed SvelteKit boundary plan is ambiguous for this ${kind} location`);
|
|
248
|
+
}
|
|
249
|
+
return mostSpecific;
|
|
250
|
+
}
|
|
251
|
+
function matchLocationBoundaries(operations, pathname) {
|
|
252
|
+
const routes = new Map();
|
|
253
|
+
for (const { plan } of operations) {
|
|
254
|
+
routes.set(`${plan.kind}\u0000${plan.route}`, {
|
|
255
|
+
route: plan.route,
|
|
256
|
+
kind: plan.kind
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
const matches = [];
|
|
260
|
+
for (const candidate of routes.values()) {
|
|
261
|
+
const matched = matchRoutePattern(candidate.route, pathname, candidate.kind === 'layout');
|
|
262
|
+
if (matched !== undefined) {
|
|
263
|
+
matches.push(Object.freeze({ ...candidate, ...matched }));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return Object.freeze(matches.sort((left, right) => right.specificity - left.specificity ||
|
|
267
|
+
left.kind.localeCompare(right.kind) ||
|
|
268
|
+
left.route.localeCompare(right.route)));
|
|
269
|
+
}
|
|
270
|
+
function matchRoutePattern(pattern, pathname, prefix) {
|
|
271
|
+
const route = normalizeRoute(pattern);
|
|
272
|
+
const path = normalizePathname(pathname);
|
|
273
|
+
const routeSegments = route
|
|
274
|
+
.split('/')
|
|
275
|
+
.filter((segment) => segment.length > 0 && !/^\(.+\)$/.test(segment));
|
|
276
|
+
const pathSegments = path.split('/').filter((segment) => segment.length > 0);
|
|
277
|
+
const params = Object.create(null);
|
|
278
|
+
let pathIndex = 0;
|
|
279
|
+
let specificity = 0;
|
|
280
|
+
for (let routeIndex = 0; routeIndex < routeSegments.length; routeIndex += 1) {
|
|
281
|
+
const segment = routeSegments[routeIndex];
|
|
282
|
+
// SvelteKit matcher functions are application code. The browser adapter
|
|
283
|
+
// cannot execute or guess them from a generated route pattern.
|
|
284
|
+
if (segment.startsWith('[') && segment.includes('='))
|
|
285
|
+
return undefined;
|
|
286
|
+
const rest = /^\[\[?\.\.\.([^\]=]+)(?:=[^\]]+)?\]?\]$/.exec(segment);
|
|
287
|
+
if (rest !== null) {
|
|
288
|
+
const values = pathSegments.slice(pathIndex).map(decodePathSegment);
|
|
289
|
+
if (values.some((value) => value === undefined))
|
|
290
|
+
return undefined;
|
|
291
|
+
params[rest[1]] =
|
|
292
|
+
values.length === 0 ? undefined : values.join('/');
|
|
293
|
+
pathIndex = pathSegments.length;
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
const optional = /^\[\[([^\]=]+)(?:=[^\]]+)?\]\]$/.exec(segment);
|
|
297
|
+
if (optional !== null) {
|
|
298
|
+
const remainingRequired = routeSegments
|
|
299
|
+
.slice(routeIndex + 1)
|
|
300
|
+
.filter((part) => !/^\[\[/.test(part)).length;
|
|
301
|
+
if (pathSegments.length - pathIndex > remainingRequired) {
|
|
302
|
+
const value = decodePathSegment(pathSegments[pathIndex++]);
|
|
303
|
+
if (value === undefined)
|
|
304
|
+
return undefined;
|
|
305
|
+
params[optional[1]] = value;
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
params[optional[1]] = undefined;
|
|
309
|
+
}
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const dynamic = /^\[([^\]=]+)(?:=[^\]]+)?\]$/.exec(segment);
|
|
313
|
+
if (dynamic !== null) {
|
|
314
|
+
if (pathSegments[pathIndex] === undefined)
|
|
315
|
+
return undefined;
|
|
316
|
+
const value = decodePathSegment(pathSegments[pathIndex++]);
|
|
317
|
+
if (value === undefined)
|
|
318
|
+
return undefined;
|
|
319
|
+
params[dynamic[1]] = value;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (pathSegments[pathIndex] === undefined)
|
|
323
|
+
return undefined;
|
|
324
|
+
const actual = decodePathSegment(pathSegments[pathIndex++]);
|
|
325
|
+
if (actual === undefined || actual !== segment)
|
|
326
|
+
return undefined;
|
|
327
|
+
specificity += 1;
|
|
328
|
+
}
|
|
329
|
+
if (!prefix && pathIndex !== pathSegments.length)
|
|
330
|
+
return undefined;
|
|
331
|
+
return Object.freeze({
|
|
332
|
+
params: Object.freeze(params),
|
|
333
|
+
specificity: specificity * 1_000 + routeSegments.length
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
function normalizePathname(value) {
|
|
337
|
+
if (typeof value !== 'string' ||
|
|
338
|
+
!value.startsWith('/') ||
|
|
339
|
+
new TextEncoder().encode(value).byteLength > MAX_LOCATION_PATHNAME_BYTES ||
|
|
340
|
+
value.split('/').length - 1 > MAX_LOCATION_SEGMENTS) {
|
|
341
|
+
throw new TypeError('Distributed SvelteKit location pathname is invalid or exceeds adapter limits');
|
|
342
|
+
}
|
|
343
|
+
const withoutQuery = value.split(/[?#]/u, 1)[0];
|
|
344
|
+
return withoutQuery.length === 1
|
|
345
|
+
? withoutQuery
|
|
346
|
+
: withoutQuery.replace(/\/+$/, '');
|
|
347
|
+
}
|
|
348
|
+
function decodePathSegment(value) {
|
|
349
|
+
try {
|
|
350
|
+
return decodeURIComponent(value);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
}
|