@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.
Files changed (37) hide show
  1. package/README.md +46 -8
  2. package/dist/generation.d.ts +15 -0
  3. package/dist/generation.js +41 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +1 -0
  6. package/dist/protocol.d.ts +2 -0
  7. package/dist/protocol.js +5 -0
  8. package/dist/replica/command-runtime/create.js +11 -0
  9. package/dist/replica/command-runtime/errors.js +2 -0
  10. package/dist/replica/command-runtime/types.d.ts +7 -1
  11. package/dist/replica/distributed-replica/impl-protocol.d.ts +1 -0
  12. package/dist/replica/distributed-replica/impl-protocol.js +1 -0
  13. package/dist/replica/distributed-replica/impl.js +11 -0
  14. package/dist/replica/distributed-replica/watch.js +13 -1
  15. package/dist/replica/index.d.ts +1 -1
  16. package/dist/replica/types.d.ts +38 -0
  17. package/dist/sveltekit/boundary-lifecycle.d.ts +37 -0
  18. package/dist/sveltekit/boundary-lifecycle.js +355 -0
  19. package/dist/sveltekit/boundary-variables.d.ts +57 -0
  20. package/dist/sveltekit/boundary-variables.js +290 -0
  21. package/dist/sveltekit/context.d.ts +3 -0
  22. package/dist/sveltekit/context.js +8 -0
  23. package/dist/sveltekit/index.d.ts +7 -3
  24. package/dist/sveltekit/index.js +6 -2
  25. package/dist/sveltekit/islands/boundaries.d.ts +104 -0
  26. package/dist/sveltekit/islands/boundaries.js +734 -0
  27. package/dist/sveltekit/lifecycle.d.ts +57 -0
  28. package/dist/sveltekit/lifecycle.js +454 -0
  29. package/dist/sveltekit/operation-identity.d.ts +4 -0
  30. package/dist/sveltekit/operation-identity.js +10 -0
  31. package/dist/sveltekit/replica.d.ts +29 -2
  32. package/dist/sveltekit/replica.js +102 -6
  33. package/dist/sveltekit/server-replica.d.ts +10 -26
  34. package/dist/sveltekit/server-replica.js +159 -132
  35. package/dist/sveltekit/vite.d.ts +46 -3
  36. package/dist/sveltekit/vite.js +643 -36
  37. package/package.json +4 -3
@@ -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
+ }
@@ -0,0 +1,57 @@
1
+ import { type ReplicaOperationArtifact } from '../replica/index.js';
2
+ import type { GraphqlVariables } from '../types.js';
3
+ export type DistributedBoundaryVariableSource<TValue = unknown> = Readonly<{
4
+ kind: 'route_param';
5
+ name: string;
6
+ }> | Readonly<{
7
+ kind: 'search_param';
8
+ name: string;
9
+ mode?: 'first' | 'all';
10
+ }> | Readonly<{
11
+ kind: 'trusted_session';
12
+ path: readonly string[];
13
+ }> | Readonly<{
14
+ kind: 'constant';
15
+ value: TValue;
16
+ }> | Readonly<{
17
+ kind: 'forwarded_prop';
18
+ path: readonly string[];
19
+ }> | Readonly<{
20
+ kind: 'omit';
21
+ }>;
22
+ export type DistributedBoundaryVariableSources<TVariables extends GraphqlVariables> = Readonly<{
23
+ [K in keyof TVariables]?: DistributedBoundaryVariableSource<TVariables[K]>;
24
+ }>;
25
+ export type DistributedBoundaryVariableContext<TSession = unknown, TProps = Readonly<Record<string, unknown>>> = Readonly<{
26
+ params: Readonly<Record<string, string | undefined>>;
27
+ search: URLSearchParams | Readonly<Record<string, string | readonly string[] | undefined>>;
28
+ session: TSession | null;
29
+ props: TProps;
30
+ }>;
31
+ export type DistributedBoundaryBinding<TVariables extends GraphqlVariables, TSession = unknown, TProps = Readonly<Record<string, unknown>>> = Readonly<{
32
+ version: 1;
33
+ id: string;
34
+ artifactId: string;
35
+ sources: DistributedBoundaryVariableSources<TVariables>;
36
+ resolve(context: DistributedBoundaryVariableContext<TSession, TProps>): TVariables;
37
+ canonicalBytes(context: DistributedBoundaryVariableContext<TSession, TProps>): string;
38
+ }>;
39
+ export type DistributedBoundaryPlan = Readonly<{
40
+ operation: string;
41
+ route: string;
42
+ kind: 'layout' | 'page';
43
+ sourcePath?: string;
44
+ discovery: 'component' | 'route_document' | 'explicit';
45
+ }>;
46
+ export type DistributedBoundaryOperation<TData = unknown, TVariables extends GraphqlVariables = GraphqlVariables, TSession = unknown, TProps = Readonly<Record<string, unknown>>> = Readonly<{
47
+ plan: DistributedBoundaryPlan;
48
+ artifact: ReplicaOperationArtifact<TData, TVariables>;
49
+ binding: DistributedBoundaryBinding<TVariables, TSession, TProps>;
50
+ }>;
51
+ /**
52
+ * Define one closed, inspectable variable binding for every boundary lifecycle.
53
+ * The operation artifact remains the sole owner of coercion and cache identity.
54
+ */
55
+ export declare function defineDistributedBoundaryBinding<TData, TVariables extends GraphqlVariables, TSession = unknown, TProps = Readonly<Record<string, unknown>>>(artifact: ReplicaOperationArtifact<TData, TVariables>, sources: DistributedBoundaryVariableSources<TVariables>): DistributedBoundaryBinding<TVariables, TSession, TProps>;
56
+ export declare function defineDistributedBoundaryOperation<TData, TVariables extends GraphqlVariables, TSession = unknown, TProps = Readonly<Record<string, unknown>>>(plan: DistributedBoundaryPlan, artifact: ReplicaOperationArtifact<TData, TVariables>, binding: DistributedBoundaryBinding<TVariables, TSession, TProps>): DistributedBoundaryOperation<TData, TVariables, TSession, TProps>;
57
+ export declare function resolveDistributedBoundaryVariables<TData, TVariables extends GraphqlVariables, TSession, TProps>(artifact: ReplicaOperationArtifact<TData, TVariables>, sources: DistributedBoundaryVariableSources<TVariables>, context: DistributedBoundaryVariableContext<TSession, TProps>): TVariables;