@volter/world-runtime 3.0.38 → 3.0.39

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 (168) hide show
  1. package/dist/known-external-services.json +3 -10
  2. package/dist/src/actions-runner-cli.js +3 -3
  3. package/dist/src/actions-runner.js +2 -1
  4. package/dist/src/app-url.js +2 -1
  5. package/dist/src/backings.d.ts +10 -1
  6. package/dist/src/backings.js +66 -7
  7. package/dist/src/branch.js +85 -52
  8. package/dist/src/browser-routes.cjs +59 -0
  9. package/dist/src/ca-trust.js +4 -3
  10. package/dist/src/changeset.js +4 -3
  11. package/dist/src/cli-commands.js +25 -9
  12. package/dist/src/cli.js +2 -1
  13. package/dist/src/command-lifetime.d.mts +12 -0
  14. package/dist/src/command-lifetime.d.ts +1 -15
  15. package/dist/src/command-lifetime.js +1 -98
  16. package/dist/src/command-lifetime.mjs +90 -0
  17. package/dist/src/configs.js +1 -1
  18. package/dist/src/console-apart.js +3 -2
  19. package/dist/src/covers.d.ts +3 -2
  20. package/dist/src/covers.js +95 -18
  21. package/dist/src/error-message.d.mts +1 -0
  22. package/dist/src/error-message.d.ts +1 -0
  23. package/dist/src/error-message.js +2 -0
  24. package/dist/src/error-message.mjs +10 -0
  25. package/dist/src/guest-command.d.mts +4 -0
  26. package/dist/src/guest-command.mjs +26 -0
  27. package/dist/src/guest-control.d.mts +1 -0
  28. package/dist/src/guest-control.mjs +17 -0
  29. package/dist/src/guest-entry.mjs +146 -0
  30. package/dist/src/guest-processes.d.mts +9 -0
  31. package/dist/src/guest-processes.mjs +47 -0
  32. package/dist/src/guest-runner.d.ts +6 -0
  33. package/dist/src/guest-runner.js +567 -0
  34. package/dist/src/host.js +5 -4
  35. package/dist/src/index.d.ts +1 -0
  36. package/dist/src/index.js +1 -0
  37. package/dist/src/infra-cli.js +4 -0
  38. package/dist/src/infra.js +133 -23
  39. package/dist/src/init.js +60 -23
  40. package/dist/src/lifecycle-record.d.ts +3 -1
  41. package/dist/src/lifecycle-record.js +29 -6
  42. package/dist/src/livekit-backing.mjs +37 -0
  43. package/dist/src/local-branches.js +11 -3
  44. package/dist/src/mongodb-backing.d.ts +7 -0
  45. package/dist/src/mongodb-backing.js +58 -0
  46. package/dist/src/native-postgres-backing.d.ts +4 -0
  47. package/dist/src/native-postgres-backing.js +404 -0
  48. package/dist/src/origin.js +2 -1
  49. package/dist/src/owned-path.d.ts +4 -0
  50. package/dist/src/owned-path.js +37 -0
  51. package/dist/src/pack-facts.d.ts +1 -0
  52. package/dist/src/pglite-backing.d.ts +4 -13
  53. package/dist/src/pglite-backing.js +198 -106
  54. package/dist/src/pglite-host.mjs +47 -17
  55. package/dist/src/placeholder.d.ts +3 -2
  56. package/dist/src/placeholder.js +9 -4
  57. package/dist/src/process-groups.d.mts +2 -0
  58. package/dist/src/process-groups.d.ts +1 -4
  59. package/dist/src/process-groups.js +1 -49
  60. package/dist/src/process-groups.mjs +59 -0
  61. package/dist/src/process-receipt.d.mts +6 -0
  62. package/dist/src/process-receipt.d.ts +11 -0
  63. package/dist/src/process-receipt.js +3 -0
  64. package/dist/src/process-receipt.mjs +45 -0
  65. package/dist/src/process-signal.d.mts +9 -0
  66. package/dist/src/process-signal.mjs +92 -0
  67. package/dist/src/project-inspect.d.ts +1 -1
  68. package/dist/src/project-inspect.js +4 -2
  69. package/dist/src/proxy-daemon.js +2 -1
  70. package/dist/src/redirect-proxy.js +186 -19
  71. package/dist/src/redis-backing.js +99 -35
  72. package/dist/src/reflect.js +2 -1
  73. package/dist/src/root.js +7 -6
  74. package/dist/src/run-task-worker.js +2 -1
  75. package/dist/src/runtime.d.ts +24 -7
  76. package/dist/src/runtime.js +703 -286
  77. package/dist/src/schema.d.ts +12 -1
  78. package/dist/src/schema.js +20 -5
  79. package/dist/src/served-world.d.ts +1 -0
  80. package/dist/src/served-world.js +26 -14
  81. package/dist/src/service-recorder.js +8 -3
  82. package/dist/src/signals.d.mts +1 -0
  83. package/dist/src/signals.d.ts +1 -1
  84. package/dist/src/signals.js +1 -11
  85. package/dist/src/signals.mjs +10 -0
  86. package/dist/src/tail.d.ts +2 -0
  87. package/dist/src/tail.js +9 -2
  88. package/dist/src/tcp-port.js +27 -13
  89. package/dist/src/temporal-backing.mjs +28 -0
  90. package/dist/src/up-cli.js +8 -3
  91. package/dist/src/up-task-worker.js +2 -1
  92. package/dist/src/websocket-relay.d.ts +1 -1
  93. package/dist/src/websocket-relay.js +23 -10
  94. package/dist/src/world-view.js +2 -2
  95. package/known-external-services.json +3 -10
  96. package/package.json +12 -7
  97. package/src/actions-runner-cli.ts +3 -3
  98. package/src/actions-runner.ts +2 -1
  99. package/src/app-url.ts +2 -1
  100. package/src/backings.ts +65 -7
  101. package/src/branch.ts +73 -34
  102. package/src/browser-routes.cjs +59 -0
  103. package/src/ca-trust.ts +4 -3
  104. package/src/changeset.ts +4 -3
  105. package/src/cli-commands.ts +21 -9
  106. package/src/cli.ts +2 -1
  107. package/src/command-lifetime.d.mts +12 -0
  108. package/src/command-lifetime.mjs +90 -0
  109. package/src/command-lifetime.ts +1 -79
  110. package/src/configs.ts +1 -1
  111. package/src/console-apart.ts +2 -1
  112. package/src/covers.ts +97 -19
  113. package/src/error-message.d.mts +1 -0
  114. package/src/error-message.mjs +10 -0
  115. package/src/error-message.ts +2 -0
  116. package/src/guest-command.d.mts +4 -0
  117. package/src/guest-command.mjs +26 -0
  118. package/src/guest-control.d.mts +1 -0
  119. package/src/guest-control.mjs +17 -0
  120. package/src/guest-entry.mjs +146 -0
  121. package/src/guest-processes.d.mts +9 -0
  122. package/src/guest-processes.mjs +47 -0
  123. package/src/guest-runner.ts +414 -0
  124. package/src/host.ts +5 -4
  125. package/src/index.ts +1 -0
  126. package/src/infra-cli.ts +4 -0
  127. package/src/infra.ts +103 -22
  128. package/src/init.ts +55 -22
  129. package/src/lifecycle-record.ts +21 -6
  130. package/src/livekit-backing.mjs +37 -0
  131. package/src/local-branches.ts +6 -3
  132. package/src/mongodb-backing.ts +55 -0
  133. package/src/native-postgres-backing.ts +329 -0
  134. package/src/origin.ts +2 -1
  135. package/src/owned-path.ts +32 -0
  136. package/src/pack-facts.ts +1 -0
  137. package/src/pglite-backing.ts +155 -79
  138. package/src/pglite-host.mjs +47 -17
  139. package/src/placeholder.ts +10 -5
  140. package/src/process-groups.d.mts +2 -0
  141. package/src/process-groups.mjs +59 -0
  142. package/src/process-groups.ts +1 -33
  143. package/src/process-receipt.d.mts +6 -0
  144. package/src/process-receipt.mjs +45 -0
  145. package/src/process-receipt.ts +5 -0
  146. package/src/process-signal.d.mts +9 -0
  147. package/src/process-signal.mjs +92 -0
  148. package/src/project-inspect.ts +4 -2
  149. package/src/proxy-daemon.ts +2 -1
  150. package/src/redirect-proxy.ts +153 -18
  151. package/src/redis-backing.ts +74 -25
  152. package/src/reflect.ts +2 -1
  153. package/src/root.ts +7 -6
  154. package/src/run-task-worker.ts +2 -1
  155. package/src/runtime.ts +596 -265
  156. package/src/schema.ts +30 -6
  157. package/src/served-world.ts +21 -15
  158. package/src/service-recorder.ts +4 -2
  159. package/src/signals.d.mts +1 -0
  160. package/src/signals.mjs +10 -0
  161. package/src/signals.ts +1 -10
  162. package/src/tail.ts +10 -3
  163. package/src/tcp-port.ts +24 -13
  164. package/src/temporal-backing.mjs +28 -0
  165. package/src/up-cli.ts +6 -3
  166. package/src/up-task-worker.ts +2 -1
  167. package/src/websocket-relay.ts +18 -11
  168. package/src/world-view.ts +2 -2
@@ -209,6 +209,8 @@ export type WorldServiceConfig = {
209
209
  * to loopback services directly while presenting a disguised world to the app/user.
210
210
  */
211
211
  controlPlane?: boolean;
212
+ /** Refuse branching before child compute when this service cannot carry its state to a branch. */
213
+ branchState?: 'unsupported';
212
214
  injectEnv?: string;
213
215
  /**
214
216
  * Additional env vars exported into the generated world env after this service starts.
@@ -293,7 +295,7 @@ export type WorldConfig = {
293
295
  id: string;
294
296
  /** How changesets are made and readied here; omitted: one approval, a summary required. */
295
297
  changesets?: WorldChangesetRules;
296
- /** Real external origins; omitted preserves legacy routing, [] explicitly closes egress. */
298
+ /** HTTPS origin/exact-path/subtree grants with declared phase and explicit build passthrough; [] closes real egress. */
297
299
  network?: WorldNetwork;
298
300
  /** Internal normalized source format and saved discovery selection. */
299
301
  manifestVersion?: 1 | 2;
@@ -335,6 +337,8 @@ export type WorldConfig = {
335
337
  };
336
338
  };
337
339
  export type WorldServiceInstance = {
340
+ /** Explicit boot permission survives config edits and missing-field refusals. */
341
+ branchState?: 'supported' | 'unsupported';
338
342
  id: string;
339
343
  type: WorldServiceType;
340
344
  command: string[];
@@ -434,6 +438,8 @@ export type WorldRunRecord = WorldRunOutcome | {
434
438
  };
435
439
  export type WorldInstance = {
436
440
  name: string;
441
+ /** Unique boot identity, independent of the World clock; ordinary reading completes older records. */
442
+ instance: string;
437
443
  /** Snapshot of the selection in force when this instance booted. */
438
444
  selection?: WorldSelection;
439
445
  config: string;
@@ -448,6 +454,10 @@ export type WorldInstance = {
448
454
  };
449
455
  services: Record<string, WorldServiceInstance>;
450
456
  env: Record<string, string>;
457
+ /** Always written at boot; optional only so cleanup can read incomplete legacy records. */
458
+ networkCeiling?: WorldNetwork | 'unrestricted';
459
+ bootServices?: string[];
460
+ bootOwner?: string | null;
451
461
  envFile: string;
452
462
  pidsFile: string;
453
463
  actors?: Record<string, unknown>;
@@ -477,6 +487,7 @@ export type WorldInstance = {
477
487
  * (docs/contributing/architecture.md, "The catalog: where twins come from"). */
478
488
  packFacts?: string[];
479
489
  };
490
+ export declare function isEnvironmentName(value: unknown): value is string;
480
491
  export declare const DEFAULT_WORLD_SELECTION: WorldSelection;
481
492
  export declare const LEGACY_WORLD_SELECTION: WorldSelection;
482
493
  export declare function assertWorldSelection(value: unknown, path: string): asserts value is WorldSelection;
@@ -1,3 +1,4 @@
1
+ import { errorMessage } from "./error-message.js";
1
2
  import { normalizeWorldNetwork } from '@volter/world-core/network-policy';
2
3
  export const WORLD_SCHEMA_VERSION = 2;
3
4
  export const WORLD_USAGES = ['application', 'dependencies', 'build', 'deployment'];
@@ -10,6 +11,12 @@ export const ISSUE_ENV_PREFIX = '$issue:';
10
11
  * attaches, from the application URL the booter recorded (`volter-world app-url <world> --set`), so the app names
11
12
  * the address it is reached at, which only its booter knows (a browser tab's preview origin, a local port). */
12
13
  export const APP_URL_ENV = '$app-url';
14
+ export function isEnvironmentName(value) {
15
+ return typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
16
+ }
17
+ function environmentMap(value) {
18
+ return record(value) && Object.entries(value).every(([name, entry]) => isEnvironmentName(name) && typeof entry === 'string');
19
+ }
13
20
  function assertStringArray(value, what, path) {
14
21
  if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== 'string')) {
15
22
  throw new Error(`${what} must be a non-empty string array in ${path}`);
@@ -20,7 +27,7 @@ function assertValidRegex(pattern, what, path) {
20
27
  new RegExp(pattern);
21
28
  }
22
29
  catch (error) {
23
- throw new Error(`${what} has an invalid regex (${error instanceof Error ? error.message : String(error)}) in ${path}`);
30
+ throw new Error(`${what} has an invalid regex (${errorMessage(error)}) in ${path}`);
24
31
  }
25
32
  }
26
33
  /** Shared validator for a readiness probe — used by both `external.readyWhen` and a process/twin
@@ -125,7 +132,7 @@ function assertLegacyWorldConfig(value, path) {
125
132
  config.network = normalizeWorldNetwork(config.network);
126
133
  if (config.description !== undefined && typeof config.description !== 'string')
127
134
  throw new Error(`World config description must be a string in ${path}`);
128
- if (config.env !== undefined && (!record(config.env) || Object.values(config.env).some((entry) => typeof entry !== 'string')))
135
+ if (config.env !== undefined && !environmentMap(config.env))
129
136
  throw new Error(`World config env must be a string→string object in ${path}`);
130
137
  if (config.env?.VOLTER_WORLD_NETWORK_POLICY !== undefined)
131
138
  throw new Error(`World network permission belongs in runtime.network, not environment values in ${path}`);
@@ -173,7 +180,13 @@ function assertLegacyWorldConfig(value, path) {
173
180
  throw new Error(`Invalid service in ${path}`);
174
181
  if (!service.id || typeof service.id !== 'string')
175
182
  throw new Error(`Service must define string id in ${path}`);
183
+ if (service.branchState !== undefined && service.branchState !== 'unsupported')
184
+ throw new Error(`Service ${service.id} branchState must be unsupported in ${path}`);
176
185
  const exports = [service.injectEnv, ...Object.keys(service.injectEnvTemplates ?? {}), ...Object.keys(service.cliRedirect ?? {}), ...(Array.isArray(service.external?.discover) ? service.external.discover.map(entry => entry?.as) : [])];
186
+ if (exports.some(name => name !== undefined && !isEnvironmentName(name)))
187
+ throw new Error(`Service ${service.id} bindings must name environment variables in ${path}`);
188
+ if (service.issues !== undefined && (!record(service.issues) || typeof service.issues.path !== 'string' || !record(service.issues.body) || !environmentMap(service.issues.fill)))
189
+ throw new Error(`Service ${service.id} issues must define path, body and string→string fill in ${path}`);
177
190
  if (service.env?.VOLTER_WORLD_NETWORK_POLICY !== undefined || exports.includes('VOLTER_WORLD_NETWORK_POLICY'))
178
191
  throw new Error(`Service ${service.id} cannot replace World network permission in ${path}`);
179
192
  if (!/^[a-z0-9][a-z0-9._-]*$/.test(service.id))
@@ -265,7 +278,7 @@ function assertLegacyWorldConfig(value, path) {
265
278
  }
266
279
  if (service.version !== undefined && !(typeof service.version === 'string' && /^\^?\d+\.\d+(\.\d+)?$/.test(service.version)))
267
280
  throw new Error(`Service "${service.id}" version must pin the twin package as "major.minor.patch" or "^major.minor" in ${path}`);
268
- if (service.env !== undefined && (!record(service.env) || Object.values(service.env).some((entry) => typeof entry !== 'string')))
281
+ if (service.env !== undefined && !environmentMap(service.env))
269
282
  throw new Error(`Service "${service.id}" env must be a string→string object in ${path}`);
270
283
  if (service.injectEnv !== undefined && typeof service.injectEnv !== 'string') {
271
284
  throw new Error(`Service "${service.id}" injectEnv must be a string in ${path}`);
@@ -378,7 +391,7 @@ function serviceV2ToLegacy(value, path) {
378
391
  if (!record(source) || !record(execution) || !record(endpoint) || !record(bindings))
379
392
  throw new Error(`Service "${String(value.id ?? '')}" sections must be objects in ${path}`);
380
393
  assertKeys(source, ['package', 'version'], 'service.source', path);
381
- assertKeys(execution, ['process', 'colocate', 'lifecycle', 'cwd', 'environment', 'preload', 'controlPlane'], 'service.execution', path);
394
+ assertKeys(execution, ['process', 'colocate', 'lifecycle', 'cwd', 'environment', 'preload', 'controlPlane', 'branchState'], 'service.execution', path);
382
395
  assertKeys(endpoint, ['port', 'portReason', 'ready'], 'service.endpoint', path);
383
396
  assertKeys(bindings, ['injectEnv', 'injectEnvTemplates', 'cliRedirect', 'discover'], 'service.bindings', path);
384
397
  const process = execution.process === undefined ? {} : execution.process;
@@ -442,6 +455,7 @@ function serviceV2ToLegacy(value, path) {
442
455
  ...(environment.values === undefined ? {} : { env: environment.values }),
443
456
  ...(execution.preload === undefined ? {} : { preload: execution.preload }),
444
457
  ...(execution.controlPlane === undefined ? {} : { controlPlane: execution.controlPlane }),
458
+ ...(execution.branchState === undefined ? {} : { branchState: execution.branchState }),
445
459
  ...(endpoint.port === undefined ? {} : { port: endpoint.port }),
446
460
  ...(endpoint.portReason === undefined ? {} : { portReason: endpoint.portReason }),
447
461
  ...(endpoint.ready === undefined ? {} : { ready: endpoint.ready }),
@@ -488,7 +502,7 @@ function normalizeV2(value, path) {
488
502
  if (!record(environment))
489
503
  throw new Error(`runtime.environment must be an object in ${path}`);
490
504
  assertKeys(environment, ['values', 'strip'], 'runtime.environment', path);
491
- if (environment.values !== undefined && (!record(environment.values) || Object.values(environment.values).some((entry) => typeof entry !== 'string')))
505
+ if (environment.values !== undefined && !environmentMap(environment.values))
492
506
  throw new Error(`runtime.environment.values must be a string→string object in ${path}`);
493
507
  if (scenario.actors !== undefined && !record(scenario.actors))
494
508
  throw new Error(`scenario.actors must be an object in ${path}`);
@@ -561,6 +575,7 @@ function serviceToV2(service) {
561
575
  ...(service.env === undefined ? {} : { environment: { values: service.env } }),
562
576
  ...(service.preload === undefined ? {} : { preload: service.preload }),
563
577
  ...(service.controlPlane === undefined ? {} : { controlPlane: service.controlPlane }),
578
+ ...(service.branchState === undefined ? {} : { branchState: service.branchState }),
564
579
  };
565
580
  const endpoint = {
566
581
  ...(service.port === undefined ? {} : { port: service.port }),
@@ -46,6 +46,7 @@ export declare function servedName(worldRoot: string, configRef: string): string
46
46
  * `twins()` names the world's twins once booted; `rotate()` mints both tokens anew — the old die
47
47
  * with the call — and keeps them beside the world like `boot` does. */
48
48
  export type MountedWorld = {
49
+ maxRequestBodySize: number;
49
50
  name: string;
50
51
  served: string;
51
52
  root: string;
@@ -1,3 +1,4 @@
1
+ import { errorMessage } from "./error-message.js";
1
2
  import { boardFrameKey, getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant, historyDigest, historyEntries, readHistoryView, worldPaths } from '@volter/world-core/runtime';
2
3
  // A SERVED WORLD (docs/reference/http-api.md#a-served-world): `volter world serve` puts a world at
3
4
  // `http://<host>:<port>/<org>/<world>/`. Each twin's vendor API is under its vendor id — the
@@ -227,6 +228,7 @@ export async function mountWorld(name, opts = {}) {
227
228
  let stopping = null;
228
229
  const mounted = {
229
230
  name, served, root: worldRoot,
231
+ maxRequestBodySize: Math.max(1024 * 1024 * 1024, ...loadWorldConfig(configRef, worldRoot).config.services.map((service) => packFacts()[service.package?.replace(/^@volter\/twin-/, '') ?? service.id]?.maxRequestBodySize ?? 0)),
230
232
  get token() { return world.token; }, get readToken() { return world.readToken; },
231
233
  twins: () => world.twins(),
232
234
  rotate: () => { world.retoken(mintToken('tok_'), mintToken('tok_r_')); keep(); return { token: world.token, readToken: world.readToken }; },
@@ -317,9 +319,9 @@ export async function serveWorld(name, opts = {}) {
317
319
  // stale server on the port must never be mistaken for this one. Until the world is up the
318
320
  // doors answer 503.
319
321
  let consoleOrigin = null;
320
- // a pushed blob (a release video) is one PUT: bodies up to 1 GiB, past a vendor's own media limits
322
+ // Keep the 1 GiB front baseline unless a served twin declares a larger transport cap.
321
323
  // a front for the whole World: its twins and links keep their own journals (architecture: a multiplexing front journals nothing)
322
- const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize: 1024 * 1024 * 1024, twinRequestJournal: false, fetch: (request) => consoleRedirect(request, consoleOrigin) ?? mounted.handle(request) });
324
+ const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize: mounted.maxRequestBodySize, twinRequestJournal: false, fetch: (request) => consoleRedirect(request, consoleOrigin) ?? mounted.handle(request) });
323
325
  const url = `http://${host}:${server.port}`;
324
326
  try {
325
327
  await mounted.boot(url);
@@ -369,12 +371,21 @@ export async function serveWorld(name, opts = {}) {
369
371
  }
370
372
  catch (error) {
371
373
  // Boot succeeded: this call owns the World, but cannot return its stop handle.
374
+ const errors = [error];
372
375
  try {
373
376
  await stopAll();
374
377
  }
375
- finally {
378
+ catch (cleanup) {
379
+ errors.push(cleanup);
380
+ }
381
+ try {
376
382
  await mounted.stop();
377
383
  }
384
+ catch (cleanup) {
385
+ errors.push(cleanup);
386
+ }
387
+ if (errors.length > 1)
388
+ throw new AggregateError(errors, 'World announcement and host cleanup failed');
378
389
  throw error;
379
390
  }
380
391
  return { url, base: `${url}/${mounted.served}`, name: mounted.served, port: server.port ?? 0, token: mounted.token, readToken: mounted.readToken, console: consoleUrl, stop: async () => { await stopAll(); await mounted.stop(); } };
@@ -591,6 +602,7 @@ export class WorldDoors {
591
602
  }
592
603
  if (door === 'open.js' && request.method === 'GET') {
593
604
  const script = `(() => {
605
+ const errorMessage = (${errorMessage.toString()});
594
606
  const world = document.body.dataset.world;
595
607
  const carried = new URLSearchParams(location.hash.slice(1));
596
608
  history.replaceState(null, '', location.pathname);
@@ -608,7 +620,7 @@ export class WorldDoors {
608
620
  if (!r.ok) throw new Error(opened.error || 'This World did not open.');
609
621
  location.replace(to);
610
622
  })
611
- .catch((e) => { document.body.textContent = e.message || 'This World did not answer.'; });
623
+ .catch((e) => { document.body.textContent = errorMessage(e) || 'This World did not answer.'; tell({ error: document.body.textContent }); });
612
624
  })();
613
625
  `;
614
626
  return new Response(script, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
@@ -770,7 +782,7 @@ export class WorldDoors {
770
782
  // the keys are gone whatever a removal answers; a branch that stays is said, not hidden (its parent key no longer opens this World)
771
783
  for (const k of [...gone, ...also])
772
784
  if (k.branch)
773
- await this.host.branches?.remove(k.branch).catch((error) => { console.error(`${this.served}: branch ${k.branch} stays after its key was revoked: ${error instanceof Error ? error.message : String(error)}`); return false; });
785
+ await this.host.branches?.remove(k.branch).catch((error) => { console.error(`${this.served}: branch ${k.branch} stays after its key was revoked: ${errorMessage(error)}`); return false; });
774
786
  return [...ids];
775
787
  }
776
788
  async keysDoor(request, id, scope, via, session) {
@@ -1347,7 +1359,7 @@ export class WorldDoors {
1347
1359
  return Response.json({ error: `not this world: serving /${this.served}/` }, { status: 404 });
1348
1360
  }
1349
1361
  catch (error) {
1350
- return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
1362
+ return Response.json({ error: errorMessage(error) }, { status: 500 });
1351
1363
  }
1352
1364
  }
1353
1365
  /** A twin's workspaces as its discovery door lists them (`GET /twin`'s `screens`: each built workspace screen's id and
@@ -1561,7 +1573,7 @@ export class WorldDoors {
1561
1573
  credential = await openSealedAt(credentialPath(this.worldRoot, name), name, destinationOf(base.origin));
1562
1574
  }
1563
1575
  catch (error) {
1564
- return Response.json({ error: error.message }, { status: 409 });
1576
+ return Response.json({ error: errorMessage(error) }, { status: 409 });
1565
1577
  }
1566
1578
  if (credential === null)
1567
1579
  return Response.json({ error: `the ${name} link has no credential sealed` }, { status: 409 });
@@ -2039,7 +2051,7 @@ export class WorldDoors {
2039
2051
  validateRemoteOrigin(body.origin ?? '');
2040
2052
  }
2041
2053
  catch (error) {
2042
- return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 });
2054
+ return Response.json({ error: errorMessage(error) }, { status: 400 });
2043
2055
  }
2044
2056
  getActiveWorldStore().write(this.linksPath(), `${JSON.stringify({ ...this.links(), [name]: { origin: body.origin } }, null, 2)}\n`);
2045
2057
  return Response.json({ link: name, origin: body.origin });
@@ -2070,7 +2082,7 @@ export class WorldDoors {
2070
2082
  }
2071
2083
  catch (error) {
2072
2084
  store.remove(path);
2073
- return Response.json({ error: `not a check: ${error instanceof Error ? error.message : String(error)}` }, { status: 400 });
2085
+ return Response.json({ error: `not a check: ${errorMessage(error)}` }, { status: 400 });
2074
2086
  }
2075
2087
  return Response.json({ check: name });
2076
2088
  }
@@ -2160,7 +2172,7 @@ export class WorldDoors {
2160
2172
  assertChangesetRules(body?.changesets, path);
2161
2173
  }
2162
2174
  catch (error) {
2163
- return Response.json({ error: error instanceof Error ? error.message.replace(` in ${path}`, '') : String(error) }, { status: 400 });
2175
+ return Response.json({ error: errorMessage(error).replace(` in ${path}`, '') }, { status: 400 });
2164
2176
  }
2165
2177
  const { config } = loadWorldConfig(path, this.worldRoot);
2166
2178
  const rules = body.changesets;
@@ -2506,7 +2518,7 @@ export class WorldDoors {
2506
2518
  await this.host.serveBranch(target);
2507
2519
  }
2508
2520
  catch (error) {
2509
- return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 });
2521
+ return Response.json({ error: errorMessage(error) }, { status: 409 });
2510
2522
  }
2511
2523
  return Response.json({ world: this.served, checkedOut: target });
2512
2524
  }
@@ -2562,7 +2574,7 @@ export class WorldDoors {
2562
2574
  }
2563
2575
  catch (error) {
2564
2576
  this.keepKeys(this.keys().filter((k) => k.id !== keyId)); // no branch, no key
2565
- return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 });
2577
+ return Response.json({ error: errorMessage(error) }, { status: 409 });
2566
2578
  }
2567
2579
  }
2568
2580
  if (rest.length === 2 && request.method === 'DELETE') {
@@ -2914,7 +2926,7 @@ export class WorldDoors {
2914
2926
  return Response.json({ world: this.served, changeset: changeset.name, receipts, position, snapshots, ...(kept?.owner ? { owner: kept.owner } : {}), ...(kept?.pushedBy ? { pushedBy: kept.pushedBy } : {}) });
2915
2927
  }
2916
2928
  catch (error) {
2917
- return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 });
2929
+ return Response.json({ error: errorMessage(error) }, { status: 409 });
2918
2930
  }
2919
2931
  }
2920
2932
  }
@@ -3044,7 +3056,7 @@ export async function scheduleRefreshes(name, worldRoot, instance, log = (l) =>
3044
3056
  const timers = [];
3045
3057
  for (const { vendor, controlRoot, root, everyMs } of await refreshSchedule(worldRoot, instance, log)) {
3046
3058
  const timer = setInterval(() => { refreshTwin({ worldRoot, vendor, controlRoot, root, force: true }).then((r) => { if (!r.refreshed)
3047
- log(`${vendor}: not refreshed: ${r.reason}`); }, (e) => log(`${vendor}: refresh failed: ${e instanceof Error ? e.message : String(e)}`)); }, everyMs);
3059
+ log(`${vendor}: not refreshed: ${r.reason}`); }, (e) => log(`${vendor}: refresh failed: ${errorMessage(e)}`)); }, everyMs);
3048
3060
  timer.unref?.();
3049
3061
  timers.push(timer);
3050
3062
  }
@@ -15,6 +15,8 @@
15
15
  // service's own lines carry none: lines between two marks were written between those times;
16
16
  // - forwards TERM/INT/HUP to the service and exits with its status (128+n for a signal).
17
17
  // The World's NODE_OPTIONS (the injector) is for the service, not this parent: it arrives renamed and is restored.
18
+ import { signalChild } from './process-signal.mjs';
19
+ import { errorMessage } from "./error-message.js";
18
20
  import { spawn } from 'node:child_process';
19
21
  import { constants } from 'node:os';
20
22
  import { appendFileSync, closeSync, fstatSync, openSync, readSync, statSync, truncateSync, writeFileSync } from 'node:fs';
@@ -102,9 +104,12 @@ catch {
102
104
  let forwarded = null;
103
105
  for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'])
104
106
  process.on(signal, () => { forwarded = forwarded || signal; try {
105
- child.kill('SIGTERM');
107
+ if (child.pid)
108
+ signalChild(child, 'SIGTERM');
106
109
  }
107
- catch { /* already gone */ } });
110
+ catch (error) {
111
+ process.stderr.write(`${errorMessage(error)}\n`);
112
+ } });
108
113
  let ended = false;
109
114
  const done = (status, end) => {
110
115
  if (ended)
@@ -114,7 +119,7 @@ const done = (status, end) => {
114
119
  record({ event: 'world.service.exited', status, ...end, pid: child.pid, uptimeMs: Date.now() - started, ...(forwarded ? { stopRequested: forwarded } : {}) }, () => process.exit(status));
115
120
  };
116
121
  child.once('error', (error) => {
117
- const message = String(error?.message || error);
122
+ const message = errorMessage(error);
118
123
  process.stderr.write(`${message}\n`);
119
124
  done(127, { code: null, signal: null, error: message });
120
125
  });
@@ -0,0 +1 @@
1
+ export function offSignal(signal: NodeJS.Signals, listener: (...args: never[]) => void): void;
@@ -1 +1 @@
1
- export declare function offSignal(signal: NodeJS.Signals, listener: (...args: never[]) => void): void;
1
+ export { offSignal } from './signals.mjs';
@@ -1,11 +1 @@
1
- // Removing a signal listener without removing the others. Under Bun (measured on 1.3.10),
2
- // `process.off('SIGINT', fn)` uninstalls the process's OS handler even while other listeners remain
3
- // (`listenerCount` still answers 1): the next SIGINT then takes the default action and kills the
4
- // process, so a caller's own handler (a harness's teardown) never runs. Re-registering the remaining
5
- // listeners reinstalls the handler. Node keeps it either way.
6
- export function offSignal(signal, listener) {
7
- const rest = process.listeners(signal).filter((l) => l !== listener);
8
- process.removeAllListeners(signal);
9
- for (const l of rest)
10
- process.on(signal, l);
11
- }
1
+ export { offSignal } from './signals.mjs';
@@ -0,0 +1,10 @@
1
+ // Removing a signal listener without removing the others. Under Bun (measured on 1.3.10),
2
+ // `process.off('SIGINT', fn)` uninstalls the process's OS handler even while other listeners remain
3
+ // (`listenerCount` still answers 1): the next SIGINT then takes the default action and kills the
4
+ // process, so a caller's own handler (a harness's teardown) never runs. Re-registering the remaining
5
+ // listeners reinstalls the handler. Node keeps it either way.
6
+ export function offSignal(signal, listener) {
7
+ const rest = process.listeners(signal).filter((l) => l !== listener);
8
+ process.removeAllListeners(signal);
9
+ for (const l of rest) process.on(signal, l);
10
+ }
@@ -11,6 +11,8 @@ export type TailWorldOptions = {
11
11
  * status}` rows; world-core/src/serve.ts). Reads are otherwise invisible here: an
12
12
  * empty-catalog GET that 404s leaves no trace in an actions-only tail. */
13
13
  requests?: boolean;
14
+ /** Also read existing service process logs verbatim. These are vendor output, not shape-only journals. */
15
+ logs?: boolean;
14
16
  /** Poll interval while following (ledger appends are cross-process, so this polls rather than
15
17
  * fs.watch — watch descriptors can't see a file that doesn't exist yet). */
16
18
  pollMs?: number;
package/dist/src/tail.js CHANGED
@@ -108,8 +108,10 @@ function entryInstant(entry) {
108
108
  }
109
109
  function formatEntry(entry, json) {
110
110
  if (json)
111
- return JSON.stringify({ ...entry.record, service: entry.service });
111
+ return JSON.stringify({ ...entry.record, service: entry.service, ...(entry.source === 'logs' ? { source: 'logs' } : {}) });
112
112
  const { record } = entry;
113
+ if (entry.source === 'logs')
114
+ return `${entry.service} stdout ${record.message}`;
113
115
  if (entry.source === 'requests') {
114
116
  // a served request, not an action: `HH:MM:SS.mmm <service> <METHOD> <path> <status> in <ms>ms`, and —
115
117
  // when the caller presented one — which credential arrived, named and fingerprinted. The
@@ -181,6 +183,11 @@ export async function tailWorldActions(name, options = {}) {
181
183
  }
182
184
  }
183
185
  }
186
+ const log = instance.services[service]?.log;
187
+ if (options.logs && log) {
188
+ for (const line of readNewLines(log, offsets))
189
+ entries.push({ service, record: { message: line }, source: 'logs' });
190
+ }
184
191
  }
185
192
  return entries.sort(byOccurredAt);
186
193
  };
@@ -192,7 +199,7 @@ export async function tailWorldActions(name, options = {}) {
192
199
  for (const service of services) {
193
200
  if (links.includes(service) && !instance.services[service])
194
201
  continue; // a link records requests, never actions
195
- if (serviceLedgers(instance.dirs.data, service).length === 0) {
202
+ if (serviceLedgers(instance.dirs.data, service).length === 0 && !(options.logs && instance.services[service]?.log)) {
196
203
  notice(`World ${name}: service "${service}" has no action ledger yet${follow
197
204
  ? ' — watching; drive the app (or a vendor SDK) through the world to record actions'
198
205
  : '; drive the app (or a vendor SDK) through the world to record actions, then tail again'}`);
@@ -18,19 +18,33 @@ export async function allocatePort(excluded = new Set()) {
18
18
  }
19
19
  throw new Error('Could not allocate a TCP port after 32 attempts');
20
20
  }
21
- function probePort() {
22
- return new Promise((resolvePort, reject) => {
23
- const server = createServer();
24
- server.once('error', reject);
21
+ async function probePort() {
22
+ const ipv4 = createServer();
23
+ const ipv6 = createServer();
24
+ try {
25
25
  // Match serveHttp's default scope; a loopback-only probe can miss a wildcard listener.
26
- server.listen(0, '0.0.0.0', () => {
27
- const address = server.address();
28
- if (!address || typeof address === 'string') {
29
- server.close(() => reject(new Error('Could not allocate a TCP port')));
30
- return;
31
- }
32
- const port = address.port;
33
- server.close((error) => error ? reject(error) : resolvePort(port));
26
+ await new Promise((resolve, reject) => {
27
+ ipv4.once('error', reject);
28
+ ipv4.listen(0, '0.0.0.0', resolve);
34
29
  });
35
- });
30
+ const address = ipv4.address();
31
+ if (!address || typeof address === 'string')
32
+ throw new Error('Could not allocate a TCP port');
33
+ // Hold the IPv4 probe while checking the same port on IPv6, without a dual-stack bind.
34
+ await new Promise((resolve, reject) => {
35
+ ipv6.once('error', (error) => {
36
+ if (['EAFNOSUPPORT', 'EADDRNOTAVAIL', 'EPROTONOSUPPORT'].includes(error.code ?? ''))
37
+ resolve();
38
+ else
39
+ reject(error);
40
+ });
41
+ ipv6.listen({ port: address.port, host: '::', ipv6Only: true }, resolve);
42
+ });
43
+ return address.port;
44
+ }
45
+ finally {
46
+ await Promise.all([ipv6, ipv4].filter((server) => server.listening).map((server) => new Promise((resolve, reject) => {
47
+ server.close((error) => error ? reject(error) : resolve());
48
+ })));
49
+ }
36
50
  }
@@ -0,0 +1,28 @@
1
+ import { signalChild } from './process-signal.mjs';
2
+ import { errorMessage } from './error-message.mjs';
3
+ import { spawn } from 'node:child_process';
4
+ import { join } from 'node:path';
5
+
6
+ if (!process.env.VOLTER_WORLD) throw new Error('Temporal backing must run as a World service');
7
+ const port = Number(process.env.PORT);
8
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Missing allocated service port');
9
+ const health = process.argv.includes('--health');
10
+ const root = process.argv[process.argv.indexOf('--root') + 1];
11
+ if (!health && (!process.argv.includes('--root') || !root)) throw new Error('Missing World service data directory');
12
+ // Source: https://github.com/temporalio/cli/releases/tag/v1.9.1 (server start-dev --help).
13
+ // SQLite pragma DSNs: https://github.com/temporalio/temporal/blob/v1.32.0/common/persistence/sql/sqlplugin/sqlite/plugin.go
14
+ // The runtime creates --root; SQLite's file and sidecars stay in that owned directory.
15
+ const args = health
16
+ ? ['--address', `127.0.0.1:${port}`, 'operator', 'cluster', 'health']
17
+ : ['server', 'start-dev', '--ip', '127.0.0.1', '--port', String(port), '--headless',
18
+ '--db-filename', join(root, 'temporal.sqlite'), '--sqlite-pragma', 'synchronous=FULL'];
19
+ const child = spawn('temporal', args, { stdio: 'inherit', env: process.env });
20
+ let stopping = false;
21
+ const stop = () => {
22
+ stopping = true;
23
+ try { if (child.pid) signalChild(child, 'SIGTERM'); }
24
+ catch (error) { console.error(errorMessage(error)); process.exitCode = 1; }
25
+ };
26
+ process.once('SIGINT', stop); process.once('SIGTERM', stop);
27
+ child.once('error', error => { console.error(errorMessage(error)); process.exitCode = 1; });
28
+ child.once('exit', (code, signal) => { process.exitCode = stopping ? 0 : code ?? (signal ? 1 : 0); });
@@ -24,10 +24,15 @@ export async function upCommand(subject, rest, root) {
24
24
  // browser tab's image, comes up over its data instead of starting empty, as a fresh `up` does)
25
25
  const keepState = rest.includes('--keep-state');
26
26
  const instance = await superviseWorldUp(subject, { name, root, mode, envFile, forceEnvOutput: rest.includes('--force'), owner: ownerOption(rest), ...(isolation ? { isolation } : {}), ...(keepState ? { keepState: true } : {}) });
27
- printReady(instance);
27
+ if (rest.includes('--json'))
28
+ process.stdout.write(JSON.stringify(instance, null, 2) + '\n');
29
+ else
30
+ printReady(instance);
28
31
  }
29
32
  export function printReady(instance) {
30
33
  process.stdout.write(`World ${instance.name} is ready (${instance.config})
34
+ Instance: ${instance.instance}
35
+ Created: ${instance.createdAt}
31
36
 
32
37
  Services:
33
38
  ${Object.values(instance.services).map((service) => ` ${service.id}: ${service.url ?? '(no listener — a World output)'}`).join('\n')}
@@ -43,11 +48,11 @@ GET <url>/twin; a scripted twin's GET <url>/twin/scenario lists handlers + misse
43
48
  Env:
44
49
  ${instance.envFile}
45
50
 
46
- Instance:
51
+ Record:
47
52
  ${instance.dirs.instance}/instance.json
48
53
 
49
54
  Stop:
50
- volter-world down ${instance.name}
55
+ volter-world down ${instance.name} --if-instance ${instance.instance}
51
56
 
52
57
  For a bounded job, use volter-world run ... -- <command> to tear down automatically on exit.
53
58
  Persistent up does not expire or infer that your sessions are idle.
@@ -1,6 +1,7 @@
1
1
  import { recordWorldLifecycleEvent } from "./lifecycle-record.js";
2
2
  import { upWorld } from "./runtime.js";
3
3
  import { loadWorldConfig } from "./configs.js";
4
+ import { errorMessage } from "./error-message.js";
4
5
  const worldNameOf = (configId, root) => { try {
5
6
  return loadWorldConfig(configId, root).config.id;
6
7
  }
@@ -36,7 +37,7 @@ process.on('message', async (message) => {
36
37
  reply = { instance: await upWorld(request.configId, request.options, abort.signal) };
37
38
  }
38
39
  catch (error) {
39
- reply = { error: error instanceof Error ? error.message : String(error) };
40
+ reply = { error: errorMessage(error) };
40
41
  }
41
42
  if (callerLost && 'instance' in reply) {
42
43
  try {
@@ -1,3 +1,3 @@
1
- import type { ServeHttpOptions } from '@volter/world-core/runtime';
1
+ import { type ServeHttpOptions } from '@volter/world-core/runtime';
2
2
  /** A message relay for the World's TLS fronts. Transport state is connection-local. */
3
3
  export declare function websocketRelay(targetFor: (request: Request) => string | undefined): NonNullable<ServeHttpOptions['upgrade']>;
@@ -1,3 +1,4 @@
1
+ import { connectWebSocket } from '@volter/world-core/runtime';
1
2
  /** A message relay for the World's TLS fronts. Transport state is connection-local. */
2
3
  export function websocketRelay(targetFor) {
3
4
  const sessions = new WeakMap();
@@ -9,21 +10,33 @@ export function websocketRelay(targetFor) {
9
10
  peer.close(1011, 'no twin for request');
10
11
  return;
11
12
  }
12
- const upstream = new WebSocket(target);
13
- upstream.binaryType = 'arraybuffer';
14
- const session = { upstream, pending: [] };
13
+ const headers = Object.fromEntries(request.headers);
14
+ const protocols = (headers['sec-websocket-protocol'] ?? '').split(',').map(p => p.trim()).filter(Boolean);
15
+ headers['x-volter-twin-original-host'] ??= new URL(request.url).host;
16
+ for (const name of ['host', 'connection', 'upgrade', 'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions', 'sec-websocket-protocol', 'proxy-connection'])
17
+ delete headers[name];
18
+ const session = { pending: [] };
15
19
  sessions.set(peer, session);
16
- upstream.onopen = () => { for (const message of session.pending)
17
- upstream.send(message); session.pending.length = 0; };
18
- upstream.onmessage = event => { peer.send(typeof event.data === 'string' ? event.data : new Uint8Array(event.data)); };
19
- upstream.onclose = event => { peer.close(event.code === 1006 ? 1011 : event.code, event.reason); };
20
- upstream.onerror = () => { peer.close(1011, 'twin websocket error'); };
20
+ void connectWebSocket(target, headers, protocols).then(upstream => {
21
+ if (sessions.get(peer) !== session) {
22
+ upstream.onerror = () => { };
23
+ upstream.close();
24
+ return;
25
+ }
26
+ session.upstream = upstream;
27
+ upstream.binaryType = 'arraybuffer';
28
+ upstream.onopen = () => { for (const message of session.pending)
29
+ upstream.send(message); session.pending.length = 0; };
30
+ upstream.onmessage = event => { peer.send(typeof event.data === 'string' ? event.data : new Uint8Array(event.data)); };
31
+ upstream.onclose = event => { peer.close(event.code === 1006 ? 1011 : event.code, event.reason); };
32
+ upstream.onerror = () => { peer.close(1011, 'twin websocket error'); };
33
+ }).catch(() => peer.close(1011, 'twin websocket connection failed'));
21
34
  },
22
35
  message(peer, data) {
23
36
  const session = sessions.get(peer);
24
37
  if (!session)
25
38
  return;
26
- if (session.upstream.readyState === WebSocket.OPEN)
39
+ if (session.upstream?.readyState === WebSocket.OPEN)
27
40
  session.upstream.send(data);
28
41
  else
29
42
  session.pending.push(data);
@@ -33,7 +46,7 @@ export function websocketRelay(targetFor) {
33
46
  sessions.delete(peer);
34
47
  if (session) {
35
48
  session.pending.length = 0;
36
- session.upstream.close();
49
+ session.upstream?.close();
37
50
  }
38
51
  },
39
52
  };
@@ -104,8 +104,8 @@ export async function serveWorldView(name, opts = {}) {
104
104
  base = world.served;
105
105
  worlds.set(world.served, world);
106
106
  const server = await serveHttp({
107
- // a pushed blob (a release video) is one PUT: bodies up to 1 GiB, as `serve` takes them
108
- hostname: host, port: opts.port ?? 0, maxRequestBodySize: 1024 * 1024 * 1024,
107
+ // The front retains its 1 GiB baseline and honors the served twins' declared caps.
108
+ hostname: host, port: opts.port ?? 0, maxRequestBodySize: world.maxRequestBodySize,
109
109
  // this front serves a World and its branches; each World's own twins journal their requests
110
110
  twinRequestJournal: false,
111
111
  async fetch(incoming) {