@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
@@ -1,12 +1,20 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { existsSync, lstatSync, realpathSync } from 'node:fs';
3
- import { cp, lstat, mkdir, mkdtemp, open, readFile, realpath, rename, rm, unlink, writeFile } from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
4
+ import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, unlink, writeFile } from 'node:fs/promises';
4
5
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
6
  import { isMainThread } from 'node:worker_threads';
7
+ import { analyzeDistributedSvelteKitBoundaries, validateDistributedSvelteKitBoundaryPlan } from './islands/boundaries.js';
8
+ export { analyzeDistributedSvelteKitBoundaries, validateDistributedSvelteKitBoundaryPlan } from './islands/boundaries.js';
6
9
  const GENERATED_SVELTEKIT_MODULE = 'sveltekit.ts';
10
+ const GENERATED_BOUNDARIES_MODULE = 'boundaries.ts';
7
11
  const MAX_COMMAND_OUTPUT_BYTES = 16 * 1024 * 1024;
12
+ const MAX_GENERATED_COMPARE_FILES = 20_000;
13
+ const MAX_GENERATED_COMPARE_BYTES = 128 * 1024 * 1024;
8
14
  const MODULE_NAME = /^\$distributed(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/;
9
15
  const COMPILER_LOCK = join('.svelte-kit', 'distributed', 'compiler.lock');
16
+ const GENERATION_META = 'distributed-generation';
17
+ const MAX_LIFECYCLE_STATE_BYTES = 1024 * 1024;
10
18
  const COMPILER_COORDINATORS = Symbol.for('@hops-ops/distributed/sveltekit/compiler-coordinators/v1');
11
19
  /** Vite proxy config for GraphQL HTTP and WebSocket traffic. */
12
20
  export function distributedGraphqlProxy(options) {
@@ -22,6 +30,22 @@ export function distributedGraphqlProxy(options) {
22
30
  }
23
31
  return { [path]: { target, changeOrigin: true, ws: true } };
24
32
  }
33
+ /** Lifecycle-only side channel for projects using committed generated clients. */
34
+ export function distributedLifecycle() {
35
+ let frameworkDist;
36
+ return {
37
+ name: '@hops-ops/distributed:lifecycle',
38
+ enforce: 'pre',
39
+ configResolved(config) {
40
+ frameworkDist = localFrameworkDist(config.root);
41
+ },
42
+ configureServer: configureLifecycleServer,
43
+ transformIndexHtml: lifecycleGenerationMeta,
44
+ handleHotUpdate(context) {
45
+ return suppressFrameworkHotUpdate(context, frameworkDist);
46
+ }
47
+ };
48
+ }
25
49
  /** Generate every configured surface through the same transaction used by Vite. */
26
50
  export async function generateDistributedSvelteKit(options) {
27
51
  await runCompilerOnce(options, 'generate');
@@ -38,12 +62,14 @@ export async function checkDistributedSvelteKit(options) {
38
62
  * coalesced, abortable, and always invoked without a shell.
39
63
  */
40
64
  export function distributedSvelteKit(options) {
65
+ const lifecycleOwnsCompile = process.env.DISTRIBUTED_LIFECYCLE_DIR !== undefined;
41
66
  let resolved;
42
67
  let dirty = false;
43
68
  let running;
44
69
  let completedGeneration = 0;
45
70
  let reloadedGeneration = 0;
46
71
  let stopped = false;
72
+ let frameworkDist;
47
73
  const children = new Set();
48
74
  const cancellation = new AbortController();
49
75
  let lock;
@@ -106,7 +132,17 @@ export function distributedSvelteKit(options) {
106
132
  throw new Error('Distributed SvelteKit Vite plugin was configured more than once');
107
133
  }
108
134
  resolved = resolveIntegration(options, options.cwd ?? config.root);
135
+ frameworkDist = localFrameworkDist(config.root);
109
136
  await validateResolvedPaths(resolved);
137
+ /*
138
+ * `distributed dev` has already staged this generation and owns every
139
+ * compiler input through its application watcher. Recompiling here would
140
+ * race the API process for Cargo and mutate generated output outside the
141
+ * lifecycle transaction. The virtual modules still resolve the staged
142
+ * files, while compiler inputs below are held for the supervisor reload.
143
+ */
144
+ if (lifecycleOwnsCompile)
145
+ return;
110
146
  /*
111
147
  * SvelteKit post-build analysis loads Vite config in an isolated
112
148
  * worker marked with SVELTEKIT_FORK. That pass reads framework
@@ -126,18 +162,32 @@ export function distributedSvelteKit(options) {
126
162
  }
127
163
  },
128
164
  configureServer(server) {
165
+ configureLifecycleServer(server);
129
166
  const integration = requireResolved(resolved);
130
- const roots = integration.clients.flatMap((client) => client.watchRoots);
131
- if (roots.length > 0)
132
- server.watcher.add(roots);
167
+ if (!lifecycleOwnsCompile) {
168
+ const roots = [
169
+ integration.routesDir,
170
+ integration.libDir,
171
+ ...integration.clients.flatMap((client) => [
172
+ ...client.watchRoots,
173
+ ...client.manifestWatchRoots
174
+ ])
175
+ ];
176
+ if (roots.length > 0)
177
+ server.watcher.add(roots);
178
+ }
133
179
  server.httpServer?.once('close', () => {
134
180
  void stop();
135
181
  });
136
182
  },
137
183
  buildStart() {
138
184
  const integration = requireResolved(resolved);
185
+ if (lifecycleOwnsCompile)
186
+ return;
187
+ this.addWatchFile(integration.routesDir);
188
+ this.addWatchFile(integration.libDir);
139
189
  for (const client of integration.clients) {
140
- for (const root of client.watchRoots)
190
+ for (const root of [...client.watchRoots, ...client.manifestWatchRoots])
141
191
  this.addWatchFile(root);
142
192
  }
143
193
  },
@@ -151,12 +201,18 @@ export function distributedSvelteKit(options) {
151
201
  return undefined;
152
202
  return `export * from ${JSON.stringify(portablePath(client.entry))};\n`;
153
203
  },
204
+ transformIndexHtml: lifecycleGenerationMeta,
154
205
  async handleHotUpdate(context) {
206
+ const suppressed = suppressFrameworkHotUpdate(context, frameworkDist);
207
+ if (suppressed !== undefined)
208
+ return suppressed;
155
209
  const integration = requireResolved(resolved);
156
- if (!isGraphqlInput(context.file, integration))
210
+ if (!isCompilerInput(context.file, integration))
157
211
  return undefined;
212
+ if (lifecycleOwnsCompile)
213
+ return [];
158
214
  try {
159
- await compile(`GraphQL change ${context.file}`);
215
+ await compile(`GraphQL/Svelte change ${context.file}`);
160
216
  }
161
217
  catch (error) {
162
218
  context.server.ws.send({
@@ -173,19 +229,220 @@ export function distributedSvelteKit(options) {
173
229
  context.server.moduleGraph.invalidateModule(module);
174
230
  }
175
231
  }
176
- context.server.ws.send({ type: 'full-reload', path: '*' });
232
+ if (process.env.DISTRIBUTED_LIFECYCLE_DIR === undefined) {
233
+ context.server.ws.send({ type: 'full-reload', path: '*' });
234
+ }
177
235
  reloadedGeneration = completedGeneration;
178
236
  return [];
179
237
  },
180
238
  async watchChange(id) {
181
239
  const integration = requireResolved(resolved);
182
- if (isGraphqlInput(id, integration)) {
183
- await compile(`GraphQL watch change ${id}`);
240
+ if (lifecycleOwnsCompile)
241
+ return;
242
+ if (isCompilerInput(id, integration)) {
243
+ await compile(`GraphQL/Svelte watch change ${id}`);
184
244
  }
185
245
  },
186
246
  closeBundle: stop
187
247
  };
188
248
  }
249
+ function localFrameworkDist(root) {
250
+ try {
251
+ const candidate = join(root, 'node_modules', '@hops-ops', 'distributed', 'dist');
252
+ return existsSync(candidate) ? realpathSync(candidate) : undefined;
253
+ }
254
+ catch {
255
+ return undefined;
256
+ }
257
+ }
258
+ function suppressFrameworkHotUpdate(context, frameworkDist) {
259
+ if (process.env.DISTRIBUTED_LIFECYCLE_DIR === undefined ||
260
+ frameworkDist === undefined ||
261
+ !isWithin(frameworkDist, canonicalExistingPath(context.file)))
262
+ return undefined;
263
+ for (const module of context.modules ?? []) {
264
+ context.server.moduleGraph.invalidateModule(module);
265
+ }
266
+ return [];
267
+ }
268
+ function canonicalExistingPath(value) {
269
+ try {
270
+ return realpathSync(value);
271
+ }
272
+ catch {
273
+ return resolve(value);
274
+ }
275
+ }
276
+ const CONTROL_ID = /^[A-Za-z0-9_:-]{16,128}$/;
277
+ const MAX_ACK_BYTES = 4096;
278
+ function lifecycleGenerationMeta() {
279
+ const configured = process.env.DISTRIBUTED_LIFECYCLE_DIR;
280
+ if (configured === undefined || !isAbsolute(configured))
281
+ return [];
282
+ try {
283
+ const encoded = readFileSync(join(resolve(configured), 'dev.json'));
284
+ if (encoded.byteLength > MAX_LIFECYCLE_STATE_BYTES)
285
+ return [];
286
+ const state = JSON.parse(encoded.toString('utf8'));
287
+ const generationId = state.active?.generationId;
288
+ if (typeof generationId !== 'string' ||
289
+ generationId.length === 0 ||
290
+ generationId.length > 512 ||
291
+ generationId !== generationId.trim() ||
292
+ /[\u0000-\u001f\u007f]/.test(generationId))
293
+ return [];
294
+ return [Object.freeze({
295
+ tag: 'meta',
296
+ attrs: Object.freeze({ name: GENERATION_META, content: generationId }),
297
+ injectTo: 'head-prepend'
298
+ })];
299
+ }
300
+ catch {
301
+ // A lifecycle file is atomically replaced. Missing or malformed state
302
+ // simply omits the hint; the browser falls back to its first poll.
303
+ return [];
304
+ }
305
+ }
306
+ function configureLifecycleServer(server) {
307
+ const configured = process.env.DISTRIBUTED_LIFECYCLE_DIR;
308
+ if (configured === undefined || !isAbsolute(configured))
309
+ return;
310
+ const lifecycleRoot = resolve(configured);
311
+ server.middlewares?.use('/__distributed/lifecycle', (request, response) => {
312
+ void handleLifecycleRequest(lifecycleRoot, request, response).catch(() => {
313
+ if (response.statusCode < 400)
314
+ response.statusCode = 500;
315
+ response.end();
316
+ });
317
+ });
318
+ }
319
+ async function handleLifecycleRequest(lifecycleRoot, request, response) {
320
+ response.setHeader('cache-control', 'no-store');
321
+ if (request.method === 'GET') {
322
+ const participant = singleHeader(request.headers['x-distributed-participant']);
323
+ if (participant !== undefined && CONTROL_ID.test(participant)) {
324
+ await writeParticipantHeartbeat(lifecycleRoot, participant).catch(() => undefined);
325
+ }
326
+ const state = await readLifecycleState(lifecycleRoot);
327
+ if (state === undefined) {
328
+ response.statusCode = 404;
329
+ response.end();
330
+ return;
331
+ }
332
+ response.statusCode = 200;
333
+ response.setHeader('content-type', 'application/json');
334
+ response.end(state);
335
+ return;
336
+ }
337
+ if (request.method === 'POST') {
338
+ const contentType = singleHeader(request.headers['content-type']);
339
+ if (contentType?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json') {
340
+ response.statusCode = 415;
341
+ response.end();
342
+ return;
343
+ }
344
+ if (!sameOriginLifecycleRequest(request)) {
345
+ response.statusCode = 403;
346
+ response.end();
347
+ return;
348
+ }
349
+ const body = JSON.parse(await readRequestBody(request, MAX_ACK_BYTES));
350
+ if (!CONTROL_ID.test(String(body.transitionId ?? '')) ||
351
+ !CONTROL_ID.test(String(body.participantId ?? '')) ||
352
+ typeof body.ok !== 'boolean') {
353
+ response.statusCode = 400;
354
+ response.end();
355
+ return;
356
+ }
357
+ const stateSource = await readLifecycleState(lifecycleRoot);
358
+ const state = stateSource === undefined ? undefined : JSON.parse(stateSource);
359
+ if (state?.phase !== 'preparing' || state.transitionId !== body.transitionId) {
360
+ response.statusCode = 409;
361
+ response.end();
362
+ return;
363
+ }
364
+ await writeControlJson(join(lifecycleRoot, 'dev-control', 'acks', String(body.transitionId)), `${String(body.participantId)}.json`, { ok: body.ok });
365
+ response.statusCode = 204;
366
+ response.end();
367
+ return;
368
+ }
369
+ response.statusCode = 405;
370
+ response.end();
371
+ }
372
+ function writeParticipantHeartbeat(root, participant) {
373
+ return writeControlJson(join(root, 'dev-control', 'participants'), `${participant}.json`, { seenAtUnixMs: Date.now() });
374
+ }
375
+ async function readLifecycleState(root) {
376
+ const path = join(root, 'dev.json');
377
+ let metadata;
378
+ try {
379
+ metadata = await lstat(path);
380
+ }
381
+ catch (error) {
382
+ if (error.code === 'ENOENT')
383
+ return undefined;
384
+ throw error;
385
+ }
386
+ if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.size > MAX_LIFECYCLE_STATE_BYTES) {
387
+ throw new Error('invalid Distributed lifecycle state file');
388
+ }
389
+ return readFile(path, 'utf8');
390
+ }
391
+ async function writeControlJson(directory, name, value) {
392
+ await mkdir(directory, { recursive: true });
393
+ const temporary = join(directory, `.${name}.${process.pid}.${Date.now()}.${randomUUID()}`);
394
+ await writeFile(temporary, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 });
395
+ await rename(temporary, join(directory, name));
396
+ }
397
+ function sameOriginLifecycleRequest(request) {
398
+ const origin = singleHeader(request.headers.origin);
399
+ const host = singleHeader(request.headers.host);
400
+ if (origin === undefined || host === undefined)
401
+ return false;
402
+ try {
403
+ const parsed = new URL(origin);
404
+ const forwarded = singleHeader(request.headers['x-forwarded-proto']);
405
+ const socket = request.socket;
406
+ const protocol = forwarded === 'http' || forwarded === 'https'
407
+ ? `${forwarded}:`
408
+ : socket?.encrypted === true
409
+ ? 'https:'
410
+ : 'http:';
411
+ return (parsed.protocol === protocol &&
412
+ parsed.host === host &&
413
+ parsed.origin === origin);
414
+ }
415
+ catch {
416
+ return false;
417
+ }
418
+ }
419
+ function singleHeader(value) {
420
+ return typeof value === 'string' ? value : value?.[0];
421
+ }
422
+ function readRequestBody(request, maximum) {
423
+ return new Promise((resolvePromise, reject) => {
424
+ const chunks = [];
425
+ let bytes = 0;
426
+ let exceeded = false;
427
+ request.on('data', (chunk) => {
428
+ if (exceeded)
429
+ return;
430
+ bytes += chunk.byteLength;
431
+ if (bytes > maximum) {
432
+ exceeded = true;
433
+ chunks.length = 0;
434
+ reject(new Error('lifecycle acknowledgement exceeds bound'));
435
+ return;
436
+ }
437
+ chunks.push(chunk);
438
+ });
439
+ request.on('error', reject);
440
+ request.on('end', () => {
441
+ if (!exceeded)
442
+ resolvePromise(Buffer.concat(chunks).toString('utf8'));
443
+ });
444
+ });
445
+ }
189
446
  async function runCompilerOnce(options, mode) {
190
447
  const integration = resolveIntegration(options, options.cwd ?? process.cwd());
191
448
  await validateResolvedPaths(integration);
@@ -240,6 +497,14 @@ function resolveIntegration(options, fallbackCwd) {
240
497
  }
241
498
  const modules = new Set();
242
499
  const outputs = [];
500
+ const routesDir = containedPath(cwd, options.routesDir ?? 'src/routes', 'routesDir');
501
+ const libDir = containedPath(cwd, options.libDir ?? 'src/lib', 'libDir');
502
+ const aliases = Object.freeze(Object.fromEntries(Object.entries(options.aliases ?? {}).map(([key, value]) => {
503
+ if (!/^\$[A-Za-z0-9_-]+$/.test(key)) {
504
+ throw new TypeError(`Distributed SvelteKit alias \`${key}\` must be a single $name segment`);
505
+ }
506
+ return [key, portablePath(relative(cwd, containedPath(cwd, value, `alias ${key}`)))];
507
+ })));
243
508
  const clients = options.clients.map((client, index) => {
244
509
  if (client === null || typeof client !== 'object') {
245
510
  throw new TypeError(`Distributed client[${index}] must be an object`);
@@ -267,7 +532,21 @@ function resolveIntegration(options, fallbackCwd) {
267
532
  throw new TypeError(`Distributed client \`${client.module}\` requires at least one GraphQL document glob`);
268
533
  }
269
534
  const documents = client.documents.map((document, documentIndex) => nonempty(document, `${client.module} documents[${documentIndex}]`));
270
- const routes = (client.routes ?? []).map((route, routeIndex) => nonempty(route, `${client.module} routes[${routeIndex}]`));
535
+ const boundaries = (client.boundaries ?? []).map((boundary, boundaryIndex) => {
536
+ if (boundary === null ||
537
+ typeof boundary !== 'object' ||
538
+ (boundary.kind !== 'page' && boundary.kind !== 'layout')) {
539
+ throw new TypeError(`${client.module} boundaries[${boundaryIndex}] is invalid`);
540
+ }
541
+ return Object.freeze({
542
+ operation: nonempty(boundary.operation, `${client.module} boundaries[${boundaryIndex}].operation`),
543
+ route: nonempty(boundary.route, `${client.module} boundaries[${boundaryIndex}].route`),
544
+ kind: boundary.kind,
545
+ ...(boundary.variables === undefined
546
+ ? {}
547
+ : { variables: boundary.variables })
548
+ });
549
+ });
271
550
  validateManifestSource(client.module, client.manifest);
272
551
  const out = containedPath(cwd, client.out, `${client.module} generated output`);
273
552
  if (out === cwd) {
@@ -279,15 +558,18 @@ function resolveIntegration(options, fallbackCwd) {
279
558
  }
280
559
  }
281
560
  outputs.push(out);
561
+ const adapterOut = join(cwd, '.svelte-kit', 'distributed', 'clients', Buffer.from(client.module).toString('base64url'));
282
562
  return Object.freeze({
283
563
  module: client.module,
284
564
  manifest: client.manifest,
285
565
  selector,
286
566
  documents: Object.freeze(documents),
287
- routes: Object.freeze(routes),
567
+ boundaries: Object.freeze(boundaries),
288
568
  out,
289
569
  entry: join(out, GENERATED_SVELTEKIT_MODULE),
290
- watchRoots: Object.freeze(documentWatchRoots(cwd, documents, out))
570
+ adapterOut,
571
+ watchRoots: Object.freeze(documentWatchRoots(cwd, documents, out)),
572
+ manifestWatchRoots: Object.freeze(manifestWatchRoots(cwd, client.manifest))
291
573
  });
292
574
  });
293
575
  return Object.freeze({
@@ -299,6 +581,9 @@ function resolveIntegration(options, fallbackCwd) {
299
581
  }
300
582
  return argument;
301
583
  })),
584
+ routesDir,
585
+ libDir,
586
+ aliases,
302
587
  clients: Object.freeze(clients)
303
588
  });
304
589
  }
@@ -352,8 +637,39 @@ function documentWatchRoots(cwd, documents, out) {
352
637
  }
353
638
  return [...roots].sort();
354
639
  }
640
+ function manifestWatchRoots(cwd, manifest) {
641
+ const manifestPath = typeof manifest === 'string'
642
+ ? resolve(cwd, manifest)
643
+ : (() => {
644
+ const index = manifest.args.indexOf('--manifest-path');
645
+ return index === -1 || manifest.args[index + 1] === undefined
646
+ ? undefined
647
+ : resolve(cwd, manifest.args[index + 1]);
648
+ })();
649
+ if (manifestPath === undefined)
650
+ return [];
651
+ const root = dirname(manifestPath);
652
+ return [manifestPath, join(root, 'src'), join(root, 'crates')]
653
+ .filter((candidate) => existsSync(candidate))
654
+ .sort();
655
+ }
656
+ function isCompilerInput(file, integration) {
657
+ if (isGraphqlInput(file, integration))
658
+ return true;
659
+ const absolute = resolve(integration.cwd, file);
660
+ const basenameValue = basename(absolute);
661
+ if (!absolute.endsWith('.rs') && basenameValue !== 'Cargo.toml' && basenameValue !== 'Cargo.lock') {
662
+ return false;
663
+ }
664
+ return integration.clients.some((client) => client.manifestWatchRoots.some((root) => isWithin(root, absolute)));
665
+ }
355
666
  function isGraphqlInput(file, integration) {
356
667
  const absolute = resolve(integration.cwd, file);
668
+ if (absolute.endsWith('.svelte') &&
669
+ (isWithin(integration.routesDir, absolute) ||
670
+ isWithin(integration.libDir, absolute))) {
671
+ return true;
672
+ }
357
673
  if ((!absolute.endsWith('.graphql') && !absolute.endsWith('.gql')) ||
358
674
  !isWithin(integration.cwd, absolute)) {
359
675
  return false;
@@ -366,7 +682,8 @@ function isGraphqlInput(file, integration) {
366
682
  async function compileTransaction(integration, children, signal) {
367
683
  throwIfAborted(signal);
368
684
  await validateResolvedPaths(integration);
369
- const transaction = await mkdtemp(join(integration.cwd, '.distributed-sveltekit-'));
685
+ const transactionRoot = await compilerTransactionRoot(integration);
686
+ const transaction = await mkdtemp(join(transactionRoot, '.distributed-sveltekit-'));
370
687
  const staged = [];
371
688
  try {
372
689
  for (const [index, client] of integration.clients.entries()) {
@@ -380,17 +697,12 @@ async function compileTransaction(integration, children, signal) {
380
697
  throw new Error(`generated output ${client.out} must be a real directory`);
381
698
  }
382
699
  hadOutput = true;
383
- await cp(client.out, output, {
384
- recursive: true,
385
- errorOnExist: true,
386
- force: false,
387
- dereference: false
388
- });
389
700
  }
390
701
  catch (error) {
391
702
  if (!isMissing(error))
392
703
  throw error;
393
704
  }
705
+ await mkdir(output, { recursive: true });
394
706
  const args = [
395
707
  'client',
396
708
  '--manifest',
@@ -401,19 +713,33 @@ async function compileTransaction(integration, children, signal) {
401
713
  '--documents',
402
714
  document
403
715
  ]),
404
- ...client.routes.flatMap((route) => ['--route', route]),
405
716
  '--out',
406
717
  output
407
718
  ];
408
719
  await runCommand(integration, args, children, signal);
409
- await validateGeneratedEntrypoint(integration.cwd, output, client.module);
720
+ await validateGeneratedEntrypoint(transaction, output, client.module);
410
721
  staged.push({
411
722
  client,
412
723
  output,
413
724
  backup: join(transaction, `backup-${index}`),
414
- hadOutput
725
+ hadOutput,
726
+ adapterOutput: join(transaction, `adapter-${index}`),
727
+ adapterBackup: join(transaction, `adapter-backup-${index}`),
728
+ hadAdapterOutput: await realDirectoryExists(client.adapterOut)
415
729
  });
416
730
  }
731
+ const plans = await analyzeStagedBoundaries(integration, staged);
732
+ const plansByModule = new Map(plans.map((plan) => [plan.module, plan]));
733
+ for (const item of staged) {
734
+ const plan = plansByModule.get(item.client.module);
735
+ if (plan === undefined) {
736
+ throw new Error(`Distributed SvelteKit boundary analysis returned no plan for ${item.client.module}`);
737
+ }
738
+ await writeFile(join(item.output, GENERATED_BOUNDARIES_MODULE), boundaryModuleSource(plan), { encoding: 'utf8', flag: 'wx' });
739
+ await exposeBoundaryModule(item.output);
740
+ await mkdir(item.adapterOutput, { recursive: true });
741
+ await writeFile(join(item.adapterOutput, 'boundaries.json'), boundaryPlanSource(plan), { encoding: 'utf8', flag: 'wx' });
742
+ }
417
743
  throwIfAborted(signal);
418
744
  await commitOutputs(integration, staged, signal);
419
745
  }
@@ -424,14 +750,17 @@ async function compileTransaction(integration, children, signal) {
424
750
  async function checkTransaction(integration, children, signal) {
425
751
  throwIfAborted(signal);
426
752
  await validateResolvedPaths(integration);
427
- const transaction = await mkdtemp(join(integration.cwd, '.distributed-sveltekit-check-'));
753
+ const transactionRoot = await compilerTransactionRoot(integration);
754
+ const transaction = await mkdtemp(join(transactionRoot, '.distributed-sveltekit-check-'));
428
755
  try {
756
+ const staged = [];
429
757
  for (const [index, client] of integration.clients.entries()) {
430
758
  throwIfAborted(signal);
431
759
  const manifest = await materializeManifest(integration, client, transaction, index, children, signal);
760
+ const output = join(transaction, `output-${index}`);
761
+ await mkdir(output, { recursive: true });
432
762
  await runCommand(integration, [
433
763
  'client',
434
- '--check',
435
764
  '--manifest',
436
765
  manifest,
437
766
  client.selector[0],
@@ -440,16 +769,227 @@ async function checkTransaction(integration, children, signal) {
440
769
  '--documents',
441
770
  document
442
771
  ]),
443
- ...client.routes.flatMap((route) => ['--route', route]),
444
772
  '--out',
445
- client.out
773
+ output
446
774
  ], children, signal);
775
+ await validateGeneratedEntrypoint(integration.cwd, output, client.module);
776
+ staged.push(Object.freeze({ client, output }));
777
+ }
778
+ const plans = await analyzeStagedBoundaries(integration, staged);
779
+ const plansByModule = new Map(plans.map((plan) => [plan.module, plan]));
780
+ for (const [index, client] of integration.clients.entries()) {
781
+ const output = staged[index].output;
782
+ const plan = plansByModule.get(client.module);
783
+ if (plan === undefined) {
784
+ throw new Error(`Distributed SvelteKit boundary analysis returned no plan for ${client.module}`);
785
+ }
786
+ await writeFile(join(output, GENERATED_BOUNDARIES_MODULE), boundaryModuleSource(plan), { encoding: 'utf8', flag: 'wx' });
787
+ await exposeBoundaryModule(output);
788
+ await compareGeneratedTrees(client.out, output, client.module);
789
+ await validateAdapterBoundaryPlan(client, plan);
447
790
  }
448
791
  }
449
792
  finally {
450
793
  await rm(transaction, { recursive: true, force: true });
451
794
  }
452
795
  }
796
+ async function compilerTransactionRoot(integration) {
797
+ const lifecycle = process.env.DISTRIBUTED_LIFECYCLE_DIR;
798
+ const root = lifecycle !== undefined && isAbsolute(lifecycle)
799
+ ? resolve(lifecycle)
800
+ : integration.cwd;
801
+ await mkdir(root, { recursive: true });
802
+ return root;
803
+ }
804
+ async function validateAdapterBoundaryPlan(client, plan) {
805
+ let actual;
806
+ try {
807
+ actual = await readFile(join(client.adapterOut, 'boundaries.json'), 'utf8');
808
+ }
809
+ catch (error) {
810
+ /*
811
+ * `.svelte-kit` is SvelteKit-owned build state. A production build may
812
+ * replace it after our Vite startup generation, while the durable client
813
+ * tree (including boundaries.ts) remains current and was checked above.
814
+ */
815
+ if (isMissing(error))
816
+ return;
817
+ throw error;
818
+ }
819
+ let persisted;
820
+ try {
821
+ persisted = JSON.parse(actual);
822
+ }
823
+ catch {
824
+ throw new Error(`[distributed.island.boundary_plan_invalid] ${client.module} boundaries.json is not valid JSON`);
825
+ }
826
+ validateDistributedSvelteKitBoundaryPlan(persisted, client.module);
827
+ const expected = boundaryPlanSource(plan);
828
+ if (actual !== expected) {
829
+ throw new Error(`Distributed SvelteKit boundary plan for ${client.module} is stale; run generation without check`);
830
+ }
831
+ }
832
+ async function analyzeStagedBoundaries(integration, staged) {
833
+ return await analyzeDistributedSvelteKitBoundaries({
834
+ cwd: integration.cwd,
835
+ routesDir: portablePath(relative(integration.cwd, integration.routesDir)),
836
+ libDir: portablePath(relative(integration.cwd, integration.libDir)),
837
+ aliases: integration.aliases,
838
+ clients: await Promise.all(staged.map(async ({ client, output }) => ({
839
+ module: client.module,
840
+ inventory: await readIslandInventory(integration.cwd, output),
841
+ explicitBoundaries: client.boundaries
842
+ })))
843
+ });
844
+ }
845
+ async function readIslandInventory(cwd, output) {
846
+ const path = join(output, 'islands.json');
847
+ const metadata = await lstat(path);
848
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
849
+ throw new Error(`Distributed island inventory ${portablePath(relative(cwd, path))} must be a regular file`);
850
+ }
851
+ const canonicalRoot = await realpath(cwd);
852
+ const canonical = await realpath(path);
853
+ if (!isWithin(canonicalRoot, canonical)) {
854
+ throw new Error('Distributed island inventory escaped the project root');
855
+ }
856
+ return JSON.parse(await readFile(canonical, 'utf8'));
857
+ }
858
+ function boundaryPlanSource(plan) {
859
+ return `${JSON.stringify(plan, null, 2)}\n`;
860
+ }
861
+ function boundaryModuleSource(plan) {
862
+ validateDistributedSvelteKitBoundaryPlan(plan, plan.module);
863
+ const occurrences = plan.boundaries.flatMap((boundary) => boundary.islands.map((island) => ({ boundary, island })));
864
+ const artifacts = new Map();
865
+ for (const { island } of occurrences) {
866
+ if (!/^operations\/[A-Za-z0-9._-]+\.ts$/.test(island.modulePath) ||
867
+ !/^[_A-Za-z][_0-9A-Za-z]*$/.test(island.exportName)) {
868
+ throw new Error(`[distributed.island.boundary_plan_invalid] ${island.graphqlSource} has an unsafe generated artifact reference`);
869
+ }
870
+ const key = `${island.modulePath}\u0000${island.exportName}`;
871
+ if (!artifacts.has(key)) {
872
+ artifacts.set(key, Object.freeze({
873
+ alias: `DistributedBoundaryArtifact_${artifacts.size}`,
874
+ module: island.modulePath.slice(0, -3),
875
+ exportName: island.exportName
876
+ }));
877
+ }
878
+ }
879
+ const imports = [...artifacts.values()].map(({ alias, module, exportName }) => `import { ${exportName} as ${alias} } from './${module}.js';`);
880
+ const definitions = occurrences.map(({ boundary, island }, index) => {
881
+ const artifact = artifacts.get(`${island.modulePath}\u0000${island.exportName}`);
882
+ const discovery = island.reason === 'static_component_import' ? 'component' : island.reason;
883
+ return [
884
+ `const DistributedBoundaryBinding_${index} = defineDistributedBoundaryBinding(`,
885
+ ` ${artifact.alias},`,
886
+ ` ${JSON.stringify(island.binding.sources, null, 2)} as const`,
887
+ `);`,
888
+ `const DistributedBoundaryOperation_${index} = defineDistributedBoundaryOperation(`,
889
+ ` ${JSON.stringify({
890
+ operation: island.operation,
891
+ route: boundary.route,
892
+ kind: boundary.kind,
893
+ sourcePath: island.graphqlSource,
894
+ discovery
895
+ }, null, 2)} as const,`,
896
+ ` ${artifact.alias},`,
897
+ ` DistributedBoundaryBinding_${index}`,
898
+ `);`
899
+ ].join('\n');
900
+ });
901
+ const operations = occurrences.map((_, index) => ` DistributedBoundaryOperation_${index}`);
902
+ return [
903
+ '/** GENERATED by the Distributed SvelteKit boundary planner. Do not edit. */',
904
+ "import { defineDistributedBoundaryBinding, defineDistributedBoundaryOperation } from '@hops-ops/distributed/sveltekit';",
905
+ ...imports,
906
+ '',
907
+ `export const DISTRIBUTED_BOUNDARY_PLAN = ${JSON.stringify(plan, null, 2)} as const;`,
908
+ '',
909
+ ...definitions,
910
+ '',
911
+ '/** Executable SSR/browser ownership assembled from the same boundary plan. */',
912
+ `export const DISTRIBUTED_BOUNDARY_OPERATIONS = ${operations.length === 0 ? '[]' : `[\n${operations.join(',\n')}\n]`} as const;`,
913
+ ''
914
+ ].join('\n');
915
+ }
916
+ async function exposeBoundaryModule(output) {
917
+ const path = join(output, GENERATED_SVELTEKIT_MODULE);
918
+ const source = await readFile(path, 'utf8');
919
+ if (!source.startsWith('/** GENERATED by distributed client. Do not edit. */')) {
920
+ throw new Error('generated SvelteKit entrypoint is missing its ownership marker');
921
+ }
922
+ await writeFile(path, `${source.trimEnd()}\n\nexport { DISTRIBUTED_BOUNDARY_OPERATIONS, DISTRIBUTED_BOUNDARY_PLAN } from './boundaries.js';\n`, 'utf8');
923
+ }
924
+ async function compareGeneratedTrees(actualRoot, expectedRoot, module) {
925
+ const [actual, expected] = await Promise.all([
926
+ readGeneratedTree(actualRoot),
927
+ readGeneratedTree(expectedRoot)
928
+ ]);
929
+ const drift = [];
930
+ for (const [path, contents] of expected) {
931
+ const current = actual.get(path);
932
+ if (current === undefined)
933
+ drift.push(`missing ${path}`);
934
+ else if (current !== contents)
935
+ drift.push(`changed ${path}`);
936
+ }
937
+ for (const path of actual.keys()) {
938
+ if (!expected.has(path))
939
+ drift.push(`unexpected ${path}`);
940
+ }
941
+ if (drift.length > 0) {
942
+ throw new Error(`Distributed SvelteKit client ${module} is stale:\n ${drift.sort().join('\n ')}\nrun generation without check`);
943
+ }
944
+ }
945
+ async function readGeneratedTree(root) {
946
+ const rootMetadata = await lstat(root);
947
+ if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
948
+ throw new Error(`generated output ${root} must be a real directory`);
949
+ }
950
+ const files = new Map();
951
+ const pending = [
952
+ { absolute: root, relative: '' }
953
+ ];
954
+ while (pending.length > 0) {
955
+ const directory = pending.pop();
956
+ for (const entry of await readdir(directory.absolute, { withFileTypes: true })) {
957
+ const relativePath = directory.relative.length === 0
958
+ ? entry.name
959
+ : `${directory.relative}/${entry.name}`;
960
+ const absolutePath = join(directory.absolute, entry.name);
961
+ if (entry.isSymbolicLink()) {
962
+ throw new Error(`generated output contains unsupported symlink ${relativePath}`);
963
+ }
964
+ if (entry.isDirectory()) {
965
+ pending.push({ absolute: absolutePath, relative: relativePath });
966
+ continue;
967
+ }
968
+ if (!entry.isFile()) {
969
+ throw new Error(`generated output contains unsupported entry ${relativePath}`);
970
+ }
971
+ files.set(relativePath, await readFile(absolutePath, 'utf8'));
972
+ if (files.size > 8_192) {
973
+ throw new Error('generated output exceeds 8192 files');
974
+ }
975
+ }
976
+ }
977
+ return files;
978
+ }
979
+ async function realDirectoryExists(path) {
980
+ try {
981
+ const metadata = await lstat(path);
982
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
983
+ throw new Error(`Distributed SvelteKit adapter output ${path} must be a real directory`);
984
+ }
985
+ return true;
986
+ }
987
+ catch (error) {
988
+ if (isMissing(error))
989
+ return false;
990
+ throw error;
991
+ }
992
+ }
453
993
  async function materializeManifest(integration, client, transaction, index, children, signal) {
454
994
  throwIfAborted(signal);
455
995
  if (typeof client.manifest === 'string') {
@@ -493,20 +1033,37 @@ async function validateGeneratedEntrypoint(cwd, output, module) {
493
1033
  async function commitOutputs(integration, staged, signal) {
494
1034
  throwIfAborted(signal);
495
1035
  await validateResolvedPaths(integration);
1036
+ const outputs = staged.flatMap((item) => [
1037
+ {
1038
+ target: item.client.out,
1039
+ output: item.output,
1040
+ backup: item.backup,
1041
+ hadOutput: item.hadOutput
1042
+ },
1043
+ {
1044
+ target: item.client.adapterOut,
1045
+ output: item.adapterOutput,
1046
+ backup: item.adapterBackup,
1047
+ hadOutput: item.hadAdapterOutput
1048
+ }
1049
+ ]);
496
1050
  const applied = [];
497
1051
  try {
498
- for (const item of staged) {
1052
+ for (const item of outputs) {
499
1053
  throwIfAborted(signal);
500
- await mkdir(dirname(item.client.out), { recursive: true });
501
- await validateNearestExistingParent(integration.cwd, item.client.out);
1054
+ await mkdir(dirname(item.target), { recursive: true });
1055
+ await validateNearestExistingParent(integration.cwd, item.target);
1056
+ if (item.hadOutput && await generatedTreesEqual(item.target, item.output)) {
1057
+ continue;
1058
+ }
502
1059
  if (item.hadOutput)
503
- await rename(item.client.out, item.backup);
1060
+ await rename(item.target, item.backup);
504
1061
  try {
505
- await rename(item.output, item.client.out);
1062
+ await rename(item.output, item.target);
506
1063
  }
507
1064
  catch (error) {
508
1065
  if (item.hadOutput)
509
- await rename(item.backup, item.client.out);
1066
+ await rename(item.backup, item.target);
510
1067
  throw error;
511
1068
  }
512
1069
  applied.push(item);
@@ -515,13 +1072,63 @@ async function commitOutputs(integration, staged, signal) {
515
1072
  }
516
1073
  catch (error) {
517
1074
  for (const item of [...applied].reverse()) {
518
- await rm(item.client.out, { recursive: true, force: true });
1075
+ await rm(item.target, { recursive: true, force: true });
519
1076
  if (item.hadOutput)
520
- await rename(item.backup, item.client.out);
1077
+ await rename(item.backup, item.target);
521
1078
  }
522
1079
  throw error;
523
1080
  }
524
1081
  }
1082
+ async function generatedTreesEqual(left, right) {
1083
+ const budget = { files: 0, bytes: 0 };
1084
+ const compare = async (leftDirectory, rightDirectory) => {
1085
+ const [leftEntries, rightEntries] = await Promise.all([
1086
+ readdir(leftDirectory, { withFileTypes: true }),
1087
+ readdir(rightDirectory, { withFileTypes: true })
1088
+ ]);
1089
+ leftEntries.sort((a, b) => a.name.localeCompare(b.name));
1090
+ rightEntries.sort((a, b) => a.name.localeCompare(b.name));
1091
+ if (leftEntries.length !== rightEntries.length)
1092
+ return false;
1093
+ for (let index = 0; index < leftEntries.length; index += 1) {
1094
+ const leftEntry = leftEntries[index];
1095
+ const rightEntry = rightEntries[index];
1096
+ if (leftEntry.name !== rightEntry.name ||
1097
+ leftEntry.isDirectory() !== rightEntry.isDirectory() ||
1098
+ leftEntry.isFile() !== rightEntry.isFile())
1099
+ return false;
1100
+ if (!leftEntry.isDirectory() && !leftEntry.isFile())
1101
+ return false;
1102
+ const leftPath = join(leftDirectory, leftEntry.name);
1103
+ const rightPath = join(rightDirectory, rightEntry.name);
1104
+ if (leftEntry.isDirectory()) {
1105
+ if (!await compare(leftPath, rightPath))
1106
+ return false;
1107
+ continue;
1108
+ }
1109
+ budget.files += 1;
1110
+ if (budget.files > MAX_GENERATED_COMPARE_FILES)
1111
+ return false;
1112
+ const [leftMetadata, rightMetadata] = await Promise.all([
1113
+ lstat(leftPath),
1114
+ lstat(rightPath)
1115
+ ]);
1116
+ if (leftMetadata.size !== rightMetadata.size)
1117
+ return false;
1118
+ budget.bytes += leftMetadata.size;
1119
+ if (budget.bytes > MAX_GENERATED_COMPARE_BYTES)
1120
+ return false;
1121
+ const [leftBytes, rightBytes] = await Promise.all([
1122
+ readFile(leftPath),
1123
+ readFile(rightPath)
1124
+ ]);
1125
+ if (!leftBytes.equals(rightBytes))
1126
+ return false;
1127
+ }
1128
+ return true;
1129
+ };
1130
+ return compare(left, right);
1131
+ }
525
1132
  async function runCommand(integration, args, children, signal) {
526
1133
  throwIfAborted(signal);
527
1134
  const argv = [...integration.commandArgs, ...args];