@hops-ops/distributed 4.8.0 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,17 @@
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 { cp, 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';
6
7
  const GENERATED_SVELTEKIT_MODULE = 'sveltekit.ts';
7
8
  const MAX_COMMAND_OUTPUT_BYTES = 16 * 1024 * 1024;
9
+ const MAX_GENERATED_COMPARE_FILES = 20_000;
10
+ const MAX_GENERATED_COMPARE_BYTES = 128 * 1024 * 1024;
8
11
  const MODULE_NAME = /^\$distributed(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/;
9
12
  const COMPILER_LOCK = join('.svelte-kit', 'distributed', 'compiler.lock');
13
+ const GENERATION_META = 'distributed-generation';
14
+ const MAX_LIFECYCLE_STATE_BYTES = 1024 * 1024;
10
15
  const COMPILER_COORDINATORS = Symbol.for('@hops-ops/distributed/sveltekit/compiler-coordinators/v1');
11
16
  /** Vite proxy config for GraphQL HTTP and WebSocket traffic. */
12
17
  export function distributedGraphqlProxy(options) {
@@ -22,6 +27,22 @@ export function distributedGraphqlProxy(options) {
22
27
  }
23
28
  return { [path]: { target, changeOrigin: true, ws: true } };
24
29
  }
30
+ /** Lifecycle-only side channel for projects using committed generated clients. */
31
+ export function distributedLifecycle() {
32
+ let frameworkDist;
33
+ return {
34
+ name: '@hops-ops/distributed:lifecycle',
35
+ enforce: 'pre',
36
+ configResolved(config) {
37
+ frameworkDist = localFrameworkDist(config.root);
38
+ },
39
+ configureServer: configureLifecycleServer,
40
+ transformIndexHtml: lifecycleGenerationMeta,
41
+ handleHotUpdate(context) {
42
+ return suppressFrameworkHotUpdate(context, frameworkDist);
43
+ }
44
+ };
45
+ }
25
46
  /** Generate every configured surface through the same transaction used by Vite. */
26
47
  export async function generateDistributedSvelteKit(options) {
27
48
  await runCompilerOnce(options, 'generate');
@@ -38,12 +59,14 @@ export async function checkDistributedSvelteKit(options) {
38
59
  * coalesced, abortable, and always invoked without a shell.
39
60
  */
40
61
  export function distributedSvelteKit(options) {
62
+ const lifecycleOwnsCompile = process.env.DISTRIBUTED_LIFECYCLE_DIR !== undefined;
41
63
  let resolved;
42
64
  let dirty = false;
43
65
  let running;
44
66
  let completedGeneration = 0;
45
67
  let reloadedGeneration = 0;
46
68
  let stopped = false;
69
+ let frameworkDist;
47
70
  const children = new Set();
48
71
  const cancellation = new AbortController();
49
72
  let lock;
@@ -106,7 +129,17 @@ export function distributedSvelteKit(options) {
106
129
  throw new Error('Distributed SvelteKit Vite plugin was configured more than once');
107
130
  }
108
131
  resolved = resolveIntegration(options, options.cwd ?? config.root);
132
+ frameworkDist = localFrameworkDist(config.root);
109
133
  await validateResolvedPaths(resolved);
134
+ /*
135
+ * `distributed dev` has already staged this generation and owns every
136
+ * compiler input through its application watcher. Recompiling here would
137
+ * race the API process for Cargo and mutate generated output outside the
138
+ * lifecycle transaction. The virtual modules still resolve the staged
139
+ * files, while compiler inputs below are held for the supervisor reload.
140
+ */
141
+ if (lifecycleOwnsCompile)
142
+ return;
110
143
  /*
111
144
  * SvelteKit post-build analysis loads Vite config in an isolated
112
145
  * worker marked with SVELTEKIT_FORK. That pass reads framework
@@ -126,18 +159,26 @@ export function distributedSvelteKit(options) {
126
159
  }
127
160
  },
128
161
  configureServer(server) {
162
+ configureLifecycleServer(server);
129
163
  const integration = requireResolved(resolved);
130
- const roots = integration.clients.flatMap((client) => client.watchRoots);
131
- if (roots.length > 0)
132
- server.watcher.add(roots);
164
+ if (!lifecycleOwnsCompile) {
165
+ const roots = integration.clients.flatMap((client) => [
166
+ ...client.watchRoots,
167
+ ...client.manifestWatchRoots
168
+ ]);
169
+ if (roots.length > 0)
170
+ server.watcher.add(roots);
171
+ }
133
172
  server.httpServer?.once('close', () => {
134
173
  void stop();
135
174
  });
136
175
  },
137
176
  buildStart() {
138
177
  const integration = requireResolved(resolved);
178
+ if (lifecycleOwnsCompile)
179
+ return;
139
180
  for (const client of integration.clients) {
140
- for (const root of client.watchRoots)
181
+ for (const root of [...client.watchRoots, ...client.manifestWatchRoots])
141
182
  this.addWatchFile(root);
142
183
  }
143
184
  },
@@ -151,10 +192,16 @@ export function distributedSvelteKit(options) {
151
192
  return undefined;
152
193
  return `export * from ${JSON.stringify(portablePath(client.entry))};\n`;
153
194
  },
195
+ transformIndexHtml: lifecycleGenerationMeta,
154
196
  async handleHotUpdate(context) {
197
+ const suppressed = suppressFrameworkHotUpdate(context, frameworkDist);
198
+ if (suppressed !== undefined)
199
+ return suppressed;
155
200
  const integration = requireResolved(resolved);
156
- if (!isGraphqlInput(context.file, integration))
201
+ if (!isCompilerInput(context.file, integration))
157
202
  return undefined;
203
+ if (lifecycleOwnsCompile)
204
+ return [];
158
205
  try {
159
206
  await compile(`GraphQL change ${context.file}`);
160
207
  }
@@ -173,19 +220,220 @@ export function distributedSvelteKit(options) {
173
220
  context.server.moduleGraph.invalidateModule(module);
174
221
  }
175
222
  }
176
- context.server.ws.send({ type: 'full-reload', path: '*' });
223
+ if (process.env.DISTRIBUTED_LIFECYCLE_DIR === undefined) {
224
+ context.server.ws.send({ type: 'full-reload', path: '*' });
225
+ }
177
226
  reloadedGeneration = completedGeneration;
178
227
  return [];
179
228
  },
180
229
  async watchChange(id) {
181
230
  const integration = requireResolved(resolved);
182
- if (isGraphqlInput(id, integration)) {
231
+ if (lifecycleOwnsCompile)
232
+ return;
233
+ if (isCompilerInput(id, integration)) {
183
234
  await compile(`GraphQL watch change ${id}`);
184
235
  }
185
236
  },
186
237
  closeBundle: stop
187
238
  };
188
239
  }
240
+ function localFrameworkDist(root) {
241
+ try {
242
+ const candidate = join(root, 'node_modules', '@hops-ops', 'distributed', 'dist');
243
+ return existsSync(candidate) ? realpathSync(candidate) : undefined;
244
+ }
245
+ catch {
246
+ return undefined;
247
+ }
248
+ }
249
+ function suppressFrameworkHotUpdate(context, frameworkDist) {
250
+ if (process.env.DISTRIBUTED_LIFECYCLE_DIR === undefined ||
251
+ frameworkDist === undefined ||
252
+ !isWithin(frameworkDist, canonicalExistingPath(context.file)))
253
+ return undefined;
254
+ for (const module of context.modules ?? []) {
255
+ context.server.moduleGraph.invalidateModule(module);
256
+ }
257
+ return [];
258
+ }
259
+ function canonicalExistingPath(value) {
260
+ try {
261
+ return realpathSync(value);
262
+ }
263
+ catch {
264
+ return resolve(value);
265
+ }
266
+ }
267
+ const CONTROL_ID = /^[A-Za-z0-9_:-]{16,128}$/;
268
+ const MAX_ACK_BYTES = 4096;
269
+ function lifecycleGenerationMeta() {
270
+ const configured = process.env.DISTRIBUTED_LIFECYCLE_DIR;
271
+ if (configured === undefined || !isAbsolute(configured))
272
+ return [];
273
+ try {
274
+ const encoded = readFileSync(join(resolve(configured), 'dev.json'));
275
+ if (encoded.byteLength > MAX_LIFECYCLE_STATE_BYTES)
276
+ return [];
277
+ const state = JSON.parse(encoded.toString('utf8'));
278
+ const generationId = state.active?.generationId;
279
+ if (typeof generationId !== 'string' ||
280
+ generationId.length === 0 ||
281
+ generationId.length > 512 ||
282
+ generationId !== generationId.trim() ||
283
+ /[\u0000-\u001f\u007f]/.test(generationId))
284
+ return [];
285
+ return [Object.freeze({
286
+ tag: 'meta',
287
+ attrs: Object.freeze({ name: GENERATION_META, content: generationId }),
288
+ injectTo: 'head-prepend'
289
+ })];
290
+ }
291
+ catch {
292
+ // A lifecycle file is atomically replaced. Missing or malformed state
293
+ // simply omits the hint; the browser falls back to its first poll.
294
+ return [];
295
+ }
296
+ }
297
+ function configureLifecycleServer(server) {
298
+ const configured = process.env.DISTRIBUTED_LIFECYCLE_DIR;
299
+ if (configured === undefined || !isAbsolute(configured))
300
+ return;
301
+ const lifecycleRoot = resolve(configured);
302
+ server.middlewares?.use('/__distributed/lifecycle', (request, response) => {
303
+ void handleLifecycleRequest(lifecycleRoot, request, response).catch(() => {
304
+ if (response.statusCode < 400)
305
+ response.statusCode = 500;
306
+ response.end();
307
+ });
308
+ });
309
+ }
310
+ async function handleLifecycleRequest(lifecycleRoot, request, response) {
311
+ response.setHeader('cache-control', 'no-store');
312
+ if (request.method === 'GET') {
313
+ const participant = singleHeader(request.headers['x-distributed-participant']);
314
+ if (participant !== undefined && CONTROL_ID.test(participant)) {
315
+ await writeParticipantHeartbeat(lifecycleRoot, participant).catch(() => undefined);
316
+ }
317
+ const state = await readLifecycleState(lifecycleRoot);
318
+ if (state === undefined) {
319
+ response.statusCode = 404;
320
+ response.end();
321
+ return;
322
+ }
323
+ response.statusCode = 200;
324
+ response.setHeader('content-type', 'application/json');
325
+ response.end(state);
326
+ return;
327
+ }
328
+ if (request.method === 'POST') {
329
+ const contentType = singleHeader(request.headers['content-type']);
330
+ if (contentType?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json') {
331
+ response.statusCode = 415;
332
+ response.end();
333
+ return;
334
+ }
335
+ if (!sameOriginLifecycleRequest(request)) {
336
+ response.statusCode = 403;
337
+ response.end();
338
+ return;
339
+ }
340
+ const body = JSON.parse(await readRequestBody(request, MAX_ACK_BYTES));
341
+ if (!CONTROL_ID.test(String(body.transitionId ?? '')) ||
342
+ !CONTROL_ID.test(String(body.participantId ?? '')) ||
343
+ typeof body.ok !== 'boolean') {
344
+ response.statusCode = 400;
345
+ response.end();
346
+ return;
347
+ }
348
+ const stateSource = await readLifecycleState(lifecycleRoot);
349
+ const state = stateSource === undefined ? undefined : JSON.parse(stateSource);
350
+ if (state?.phase !== 'preparing' || state.transitionId !== body.transitionId) {
351
+ response.statusCode = 409;
352
+ response.end();
353
+ return;
354
+ }
355
+ await writeControlJson(join(lifecycleRoot, 'dev-control', 'acks', String(body.transitionId)), `${String(body.participantId)}.json`, { ok: body.ok });
356
+ response.statusCode = 204;
357
+ response.end();
358
+ return;
359
+ }
360
+ response.statusCode = 405;
361
+ response.end();
362
+ }
363
+ function writeParticipantHeartbeat(root, participant) {
364
+ return writeControlJson(join(root, 'dev-control', 'participants'), `${participant}.json`, { seenAtUnixMs: Date.now() });
365
+ }
366
+ async function readLifecycleState(root) {
367
+ const path = join(root, 'dev.json');
368
+ let metadata;
369
+ try {
370
+ metadata = await lstat(path);
371
+ }
372
+ catch (error) {
373
+ if (error.code === 'ENOENT')
374
+ return undefined;
375
+ throw error;
376
+ }
377
+ if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.size > MAX_LIFECYCLE_STATE_BYTES) {
378
+ throw new Error('invalid Distributed lifecycle state file');
379
+ }
380
+ return readFile(path, 'utf8');
381
+ }
382
+ async function writeControlJson(directory, name, value) {
383
+ await mkdir(directory, { recursive: true });
384
+ const temporary = join(directory, `.${name}.${process.pid}.${Date.now()}.${randomUUID()}`);
385
+ await writeFile(temporary, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 });
386
+ await rename(temporary, join(directory, name));
387
+ }
388
+ function sameOriginLifecycleRequest(request) {
389
+ const origin = singleHeader(request.headers.origin);
390
+ const host = singleHeader(request.headers.host);
391
+ if (origin === undefined || host === undefined)
392
+ return false;
393
+ try {
394
+ const parsed = new URL(origin);
395
+ const forwarded = singleHeader(request.headers['x-forwarded-proto']);
396
+ const socket = request.socket;
397
+ const protocol = forwarded === 'http' || forwarded === 'https'
398
+ ? `${forwarded}:`
399
+ : socket?.encrypted === true
400
+ ? 'https:'
401
+ : 'http:';
402
+ return (parsed.protocol === protocol &&
403
+ parsed.host === host &&
404
+ parsed.origin === origin);
405
+ }
406
+ catch {
407
+ return false;
408
+ }
409
+ }
410
+ function singleHeader(value) {
411
+ return typeof value === 'string' ? value : value?.[0];
412
+ }
413
+ function readRequestBody(request, maximum) {
414
+ return new Promise((resolvePromise, reject) => {
415
+ const chunks = [];
416
+ let bytes = 0;
417
+ let exceeded = false;
418
+ request.on('data', (chunk) => {
419
+ if (exceeded)
420
+ return;
421
+ bytes += chunk.byteLength;
422
+ if (bytes > maximum) {
423
+ exceeded = true;
424
+ chunks.length = 0;
425
+ reject(new Error('lifecycle acknowledgement exceeds bound'));
426
+ return;
427
+ }
428
+ chunks.push(chunk);
429
+ });
430
+ request.on('error', reject);
431
+ request.on('end', () => {
432
+ if (!exceeded)
433
+ resolvePromise(Buffer.concat(chunks).toString('utf8'));
434
+ });
435
+ });
436
+ }
189
437
  async function runCompilerOnce(options, mode) {
190
438
  const integration = resolveIntegration(options, options.cwd ?? process.cwd());
191
439
  await validateResolvedPaths(integration);
@@ -287,7 +535,8 @@ function resolveIntegration(options, fallbackCwd) {
287
535
  routes: Object.freeze(routes),
288
536
  out,
289
537
  entry: join(out, GENERATED_SVELTEKIT_MODULE),
290
- watchRoots: Object.freeze(documentWatchRoots(cwd, documents, out))
538
+ watchRoots: Object.freeze(documentWatchRoots(cwd, documents, out)),
539
+ manifestWatchRoots: Object.freeze(manifestWatchRoots(cwd, client.manifest))
291
540
  });
292
541
  });
293
542
  return Object.freeze({
@@ -352,6 +601,32 @@ function documentWatchRoots(cwd, documents, out) {
352
601
  }
353
602
  return [...roots].sort();
354
603
  }
604
+ function manifestWatchRoots(cwd, manifest) {
605
+ const manifestPath = typeof manifest === 'string'
606
+ ? resolve(cwd, manifest)
607
+ : (() => {
608
+ const index = manifest.args.indexOf('--manifest-path');
609
+ return index === -1 || manifest.args[index + 1] === undefined
610
+ ? undefined
611
+ : resolve(cwd, manifest.args[index + 1]);
612
+ })();
613
+ if (manifestPath === undefined)
614
+ return [];
615
+ const root = dirname(manifestPath);
616
+ return [manifestPath, join(root, 'src'), join(root, 'crates')]
617
+ .filter((candidate) => existsSync(candidate))
618
+ .sort();
619
+ }
620
+ function isCompilerInput(file, integration) {
621
+ if (isGraphqlInput(file, integration))
622
+ return true;
623
+ const absolute = resolve(integration.cwd, file);
624
+ const basenameValue = basename(absolute);
625
+ if (!absolute.endsWith('.rs') && basenameValue !== 'Cargo.toml' && basenameValue !== 'Cargo.lock') {
626
+ return false;
627
+ }
628
+ return integration.clients.some((client) => client.manifestWatchRoots.some((root) => isWithin(root, absolute)));
629
+ }
355
630
  function isGraphqlInput(file, integration) {
356
631
  const absolute = resolve(integration.cwd, file);
357
632
  if ((!absolute.endsWith('.graphql') && !absolute.endsWith('.gql')) ||
@@ -366,7 +641,8 @@ function isGraphqlInput(file, integration) {
366
641
  async function compileTransaction(integration, children, signal) {
367
642
  throwIfAborted(signal);
368
643
  await validateResolvedPaths(integration);
369
- const transaction = await mkdtemp(join(integration.cwd, '.distributed-sveltekit-'));
644
+ const transactionRoot = await compilerTransactionRoot(integration);
645
+ const transaction = await mkdtemp(join(transactionRoot, '.distributed-sveltekit-'));
370
646
  const staged = [];
371
647
  try {
372
648
  for (const [index, client] of integration.clients.entries()) {
@@ -406,7 +682,7 @@ async function compileTransaction(integration, children, signal) {
406
682
  output
407
683
  ];
408
684
  await runCommand(integration, args, children, signal);
409
- await validateGeneratedEntrypoint(integration.cwd, output, client.module);
685
+ await validateGeneratedEntrypoint(transaction, output, client.module);
410
686
  staged.push({
411
687
  client,
412
688
  output,
@@ -424,7 +700,8 @@ async function compileTransaction(integration, children, signal) {
424
700
  async function checkTransaction(integration, children, signal) {
425
701
  throwIfAborted(signal);
426
702
  await validateResolvedPaths(integration);
427
- const transaction = await mkdtemp(join(integration.cwd, '.distributed-sveltekit-check-'));
703
+ const transactionRoot = await compilerTransactionRoot(integration);
704
+ const transaction = await mkdtemp(join(transactionRoot, '.distributed-sveltekit-check-'));
428
705
  try {
429
706
  for (const [index, client] of integration.clients.entries()) {
430
707
  throwIfAborted(signal);
@@ -450,6 +727,14 @@ async function checkTransaction(integration, children, signal) {
450
727
  await rm(transaction, { recursive: true, force: true });
451
728
  }
452
729
  }
730
+ async function compilerTransactionRoot(integration) {
731
+ const lifecycle = process.env.DISTRIBUTED_LIFECYCLE_DIR;
732
+ const root = lifecycle !== undefined && isAbsolute(lifecycle)
733
+ ? resolve(lifecycle)
734
+ : integration.cwd;
735
+ await mkdir(root, { recursive: true });
736
+ return root;
737
+ }
453
738
  async function materializeManifest(integration, client, transaction, index, children, signal) {
454
739
  throwIfAborted(signal);
455
740
  if (typeof client.manifest === 'string') {
@@ -499,6 +784,9 @@ async function commitOutputs(integration, staged, signal) {
499
784
  throwIfAborted(signal);
500
785
  await mkdir(dirname(item.client.out), { recursive: true });
501
786
  await validateNearestExistingParent(integration.cwd, item.client.out);
787
+ if (item.hadOutput && await generatedTreesEqual(item.client.out, item.output)) {
788
+ continue;
789
+ }
502
790
  if (item.hadOutput)
503
791
  await rename(item.client.out, item.backup);
504
792
  try {
@@ -522,6 +810,56 @@ async function commitOutputs(integration, staged, signal) {
522
810
  throw error;
523
811
  }
524
812
  }
813
+ async function generatedTreesEqual(left, right) {
814
+ const budget = { files: 0, bytes: 0 };
815
+ const compare = async (leftDirectory, rightDirectory) => {
816
+ const [leftEntries, rightEntries] = await Promise.all([
817
+ readdir(leftDirectory, { withFileTypes: true }),
818
+ readdir(rightDirectory, { withFileTypes: true })
819
+ ]);
820
+ leftEntries.sort((a, b) => a.name.localeCompare(b.name));
821
+ rightEntries.sort((a, b) => a.name.localeCompare(b.name));
822
+ if (leftEntries.length !== rightEntries.length)
823
+ return false;
824
+ for (let index = 0; index < leftEntries.length; index += 1) {
825
+ const leftEntry = leftEntries[index];
826
+ const rightEntry = rightEntries[index];
827
+ if (leftEntry.name !== rightEntry.name ||
828
+ leftEntry.isDirectory() !== rightEntry.isDirectory() ||
829
+ leftEntry.isFile() !== rightEntry.isFile())
830
+ return false;
831
+ if (!leftEntry.isDirectory() && !leftEntry.isFile())
832
+ return false;
833
+ const leftPath = join(leftDirectory, leftEntry.name);
834
+ const rightPath = join(rightDirectory, rightEntry.name);
835
+ if (leftEntry.isDirectory()) {
836
+ if (!await compare(leftPath, rightPath))
837
+ return false;
838
+ continue;
839
+ }
840
+ budget.files += 1;
841
+ if (budget.files > MAX_GENERATED_COMPARE_FILES)
842
+ return false;
843
+ const [leftMetadata, rightMetadata] = await Promise.all([
844
+ lstat(leftPath),
845
+ lstat(rightPath)
846
+ ]);
847
+ if (leftMetadata.size !== rightMetadata.size)
848
+ return false;
849
+ budget.bytes += leftMetadata.size;
850
+ if (budget.bytes > MAX_GENERATED_COMPARE_BYTES)
851
+ return false;
852
+ const [leftBytes, rightBytes] = await Promise.all([
853
+ readFile(leftPath),
854
+ readFile(rightPath)
855
+ ]);
856
+ if (!leftBytes.equals(rightBytes))
857
+ return false;
858
+ }
859
+ return true;
860
+ };
861
+ return compare(left, right);
862
+ }
525
863
  async function runCommand(integration, args, children, signal) {
526
864
  throwIfAborted(signal);
527
865
  const argv = [...integration.commandArgs, ...args];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hops-ops/distributed",
3
- "version": "4.8.0",
3
+ "version": "4.9.0",
4
4
  "description": "Typed GraphQL client, causal replica, command runtime, and framework adapters for Distributed services",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -51,7 +51,8 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@graphql-typed-document-node/core": "^3.2.0",
54
- "graphql": "^16.14.0"
54
+ "graphql": "^16.14.0",
55
+ "wasm-pack": "0.15.0"
55
56
  },
56
57
  "peerDependencies": {
57
58
  "react": "^18.2.0 || ^19.0.0",