@volter/world-runtime 3.0.38 → 3.0.40

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
package/src/schema.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { errorMessage } from './error-message.ts';
1
2
  import { normalizeWorldNetwork, type WorldNetwork } from '@volter/world-core/network-policy';
2
3
 
3
4
  export type WorldServiceType = 'twin' | 'process' | 'external';
@@ -188,6 +189,8 @@ export type WorldServiceConfig = {
188
189
  * to loopback services directly while presenting a disguised world to the app/user.
189
190
  */
190
191
  controlPlane?: boolean;
192
+ /** Refuse branching before child compute when this service cannot carry its state to a branch. */
193
+ branchState?: 'unsupported';
191
194
  injectEnv?: string;
192
195
  /**
193
196
  * Additional env vars exported into the generated world env after this service starts.
@@ -259,7 +262,7 @@ export type WorldConfig = {
259
262
  id: string;
260
263
  /** How changesets are made and readied here; omitted: one approval, a summary required. */
261
264
  changesets?: WorldChangesetRules;
262
- /** Real external origins; omitted preserves legacy routing, [] explicitly closes egress. */
265
+ /** HTTPS origin/exact-path/subtree grants with declared phase and explicit build passthrough; [] closes real egress. */
263
266
  network?: WorldNetwork;
264
267
  /** Internal normalized source format and saved discovery selection. */
265
268
  manifestVersion?: 1 | 2;
@@ -297,6 +300,8 @@ export type WorldConfig = {
297
300
  };
298
301
 
299
302
  export type WorldServiceInstance = {
303
+ /** Explicit boot permission survives config edits and missing-field refusals. */
304
+ branchState?: 'supported' | 'unsupported';
300
305
  id: string;
301
306
  type: WorldServiceType;
302
307
  command: string[];
@@ -392,6 +397,8 @@ export type WorldRunRecord = WorldRunOutcome | {
392
397
 
393
398
  export type WorldInstance = {
394
399
  name: string;
400
+ /** Unique boot identity, independent of the World clock; ordinary reading completes older records. */
401
+ instance: string;
395
402
  /** Snapshot of the selection in force when this instance booted. */
396
403
  selection?: WorldSelection;
397
404
  config: string;
@@ -406,6 +413,10 @@ export type WorldInstance = {
406
413
  };
407
414
  services: Record<string, WorldServiceInstance>;
408
415
  env: Record<string, string>;
416
+ /** Always written at boot; optional only so cleanup can read incomplete legacy records. */
417
+ networkCeiling?: WorldNetwork | 'unrestricted';
418
+ bootServices?: string[];
419
+ bootOwner?: string | null;
409
420
  envFile: string;
410
421
  pidsFile: string;
411
422
  actors?: Record<string, unknown>;
@@ -423,6 +434,14 @@ export type WorldInstance = {
423
434
  packFacts?: string[];
424
435
  };
425
436
 
437
+ export function isEnvironmentName(value: unknown): value is string {
438
+ return typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
439
+ }
440
+
441
+ function environmentMap(value: unknown): boolean {
442
+ return record(value) && Object.entries(value).every(([name, entry]) => isEnvironmentName(name) && typeof entry === 'string');
443
+ }
444
+
426
445
  function assertStringArray(value: unknown, what: string, path: string): asserts value is string[] {
427
446
  if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== 'string')) {
428
447
  throw new Error(`${what} must be a non-empty string array in ${path}`);
@@ -433,7 +452,7 @@ function assertValidRegex(pattern: string, what: string, path: string): void {
433
452
  try {
434
453
  new RegExp(pattern);
435
454
  } catch (error) {
436
- throw new Error(`${what} has an invalid regex (${error instanceof Error ? error.message : String(error)}) in ${path}`);
455
+ throw new Error(`${what} has an invalid regex (${errorMessage(error)}) in ${path}`);
437
456
  }
438
457
  }
439
458
 
@@ -530,7 +549,7 @@ function assertLegacyWorldConfig(value: unknown, path: string): WorldConfig {
530
549
  }
531
550
  if (config.network !== undefined) config.network = normalizeWorldNetwork(config.network);
532
551
  if (config.description !== undefined && typeof config.description !== 'string') throw new Error(`World config description must be a string in ${path}`);
533
- if (config.env !== undefined && (!record(config.env) || Object.values(config.env).some((entry) => typeof entry !== 'string'))) throw new Error(`World config env must be a string→string object in ${path}`);
552
+ if (config.env !== undefined && !environmentMap(config.env)) throw new Error(`World config env must be a string→string object in ${path}`);
534
553
  if (config.env?.VOLTER_WORLD_NETWORK_POLICY !== undefined) throw new Error(`World network permission belongs in runtime.network, not environment values in ${path}`);
535
554
  for (const key of ['actors', 'fixtures'] as const) if (config[key] !== undefined && !record(config[key])) throw new Error(`World config ${key} must be an object in ${path}`);
536
555
  if (config.bare !== undefined && (typeof config.bare !== 'object' || typeof (config.bare as { name?: unknown }).name !== 'string' || !/^[a-z0-9_-]+\/[a-z0-9_-]+$/i.test((config.bare as { name: string }).name))) {
@@ -568,7 +587,10 @@ function assertLegacyWorldConfig(value: unknown, path: string): WorldConfig {
568
587
  for (const service of config.services as Partial<WorldServiceConfig>[]) {
569
588
  if (!service || typeof service !== 'object') throw new Error(`Invalid service in ${path}`);
570
589
  if (!service.id || typeof service.id !== 'string') throw new Error(`Service must define string id in ${path}`);
590
+ if (service.branchState !== undefined && service.branchState !== 'unsupported') throw new Error(`Service ${service.id} branchState must be unsupported in ${path}`);
571
591
  const exports = [service.injectEnv, ...Object.keys(service.injectEnvTemplates ?? {}), ...Object.keys(service.cliRedirect ?? {}), ...(Array.isArray(service.external?.discover) ? service.external.discover.map(entry => entry?.as) : [])];
592
+ if (exports.some(name => name !== undefined && !isEnvironmentName(name))) throw new Error(`Service ${service.id} bindings must name environment variables in ${path}`);
593
+ if (service.issues !== undefined && (!record(service.issues) || typeof service.issues.path !== 'string' || !record(service.issues.body) || !environmentMap(service.issues.fill))) throw new Error(`Service ${service.id} issues must define path, body and string→string fill in ${path}`);
572
594
  if (service.env?.VOLTER_WORLD_NETWORK_POLICY !== undefined || exports.includes('VOLTER_WORLD_NETWORK_POLICY')) throw new Error(`Service ${service.id} cannot replace World network permission in ${path}`);
573
595
  if (!/^[a-z0-9][a-z0-9._-]*$/.test(service.id)) throw new Error(`Invalid service id "${service.id}" in ${path}`);
574
596
  if (ids.has(service.id)) throw new Error(`Duplicate service id "${service.id}" in ${path}`);
@@ -638,7 +660,7 @@ function assertLegacyWorldConfig(value: unknown, path: string): WorldConfig {
638
660
  }
639
661
  }
640
662
  if (service.version !== undefined && !(typeof service.version === 'string' && /^\^?\d+\.\d+(\.\d+)?$/.test(service.version))) throw new Error(`Service "${service.id}" version must pin the twin package as "major.minor.patch" or "^major.minor" in ${path}`);
641
- if (service.env !== undefined && (!record(service.env) || Object.values(service.env).some((entry) => typeof entry !== 'string'))) throw new Error(`Service "${service.id}" env must be a string→string object in ${path}`);
663
+ if (service.env !== undefined && !environmentMap(service.env)) throw new Error(`Service "${service.id}" env must be a string→string object in ${path}`);
642
664
  if (service.injectEnv !== undefined && typeof service.injectEnv !== 'string') {
643
665
  throw new Error(`Service "${service.id}" injectEnv must be a string in ${path}`);
644
666
  }
@@ -743,7 +765,7 @@ function serviceV2ToLegacy(value: unknown, path: string): WorldServiceConfig {
743
765
  const bindings = value.bindings === undefined ? {} : value.bindings;
744
766
  if (!record(source) || !record(execution) || !record(endpoint) || !record(bindings)) throw new Error(`Service "${String(value.id ?? '')}" sections must be objects in ${path}`);
745
767
  assertKeys(source, ['package', 'version'], 'service.source', path);
746
- assertKeys(execution, ['process', 'colocate', 'lifecycle', 'cwd', 'environment', 'preload', 'controlPlane'], 'service.execution', path);
768
+ assertKeys(execution, ['process', 'colocate', 'lifecycle', 'cwd', 'environment', 'preload', 'controlPlane', 'branchState'], 'service.execution', path);
747
769
  assertKeys(endpoint, ['port', 'portReason', 'ready'], 'service.endpoint', path);
748
770
  assertKeys(bindings, ['injectEnv', 'injectEnvTemplates', 'cliRedirect', 'discover'], 'service.bindings', path);
749
771
  const process = execution.process === undefined ? {} : execution.process;
@@ -795,6 +817,7 @@ function serviceV2ToLegacy(value: unknown, path: string): WorldServiceConfig {
795
817
  ...(environment.values === undefined ? {} : { env: environment.values as Record<string, string> }),
796
818
  ...(execution.preload === undefined ? {} : { preload: execution.preload as string[] }),
797
819
  ...(execution.controlPlane === undefined ? {} : { controlPlane: execution.controlPlane as boolean }),
820
+ ...(execution.branchState === undefined ? {} : { branchState: execution.branchState as WorldServiceConfig['branchState'] }),
798
821
  ...(endpoint.port === undefined ? {} : { port: endpoint.port as number | 'auto' }),
799
822
  ...(endpoint.portReason === undefined ? {} : { portReason: endpoint.portReason as string }),
800
823
  ...(endpoint.ready === undefined ? {} : { ready: endpoint.ready as WorldServiceConfig['ready'] }),
@@ -829,7 +852,7 @@ function normalizeV2(value: Record<string, unknown>, path: string): WorldConfig
829
852
  const environment = runtime.environment ?? {};
830
853
  if (!record(environment)) throw new Error(`runtime.environment must be an object in ${path}`);
831
854
  assertKeys(environment, ['values', 'strip'], 'runtime.environment', path);
832
- if (environment.values !== undefined && (!record(environment.values) || Object.values(environment.values).some((entry) => typeof entry !== 'string'))) throw new Error(`runtime.environment.values must be a string→string object in ${path}`);
855
+ if (environment.values !== undefined && !environmentMap(environment.values)) throw new Error(`runtime.environment.values must be a string→string object in ${path}`);
833
856
  if (scenario.actors !== undefined && !record(scenario.actors)) throw new Error(`scenario.actors must be an object in ${path}`);
834
857
  if (scenario.fixtures !== undefined && !record(scenario.fixtures)) throw new Error(`scenario.fixtures must be an object in ${path}`);
835
858
  const mode = serving.mode ?? 'app';
@@ -897,6 +920,7 @@ function serviceToV2(service: WorldServiceConfig): Record<string, unknown> {
897
920
  ...(service.env === undefined ? {} : { environment: { values: service.env } }),
898
921
  ...(service.preload === undefined ? {} : { preload: service.preload }),
899
922
  ...(service.controlPlane === undefined ? {} : { controlPlane: service.controlPlane }),
923
+ ...(service.branchState === undefined ? {} : { branchState: service.branchState }),
900
924
  };
901
925
  const endpoint = {
902
926
  ...(service.port === undefined ? {} : { port: service.port }),
@@ -1,3 +1,4 @@
1
+ import { errorMessage } from './error-message.ts';
1
2
  import { boardFrameKey, getActiveWorldStore, withHistoryLocks, captureHistory, historyAtInstant, historyDigest, historyEntries, historyLength, readHistoryView, worldPaths, type HistoryReference } 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
@@ -176,7 +177,7 @@ function configRefFor(worldRoot: string, name: string): string {
176
177
  /** A world mounted by a host. `token`/`readToken` read the CURRENT tokens (rotate changes them);
177
178
  * `twins()` names the world's twins once booted; `rotate()` mints both tokens anew — the old die
178
179
  * with the call — and keeps them beside the world like `boot` does. */
179
- export type MountedWorld = { name: string; served: string; root: string; readonly token: string; readonly readToken: string; twins: () => string[]; rotate: () => { token: string; readToken: string }; handle: (request: Request) => Promise<Response>; /** a hostname this World's twins answer, as a browser reads it (siteOfHost) */ site: (request: Request, host: string) => Promise<Response>; /** WorldDoors.pagesPass */ pagesPass: (scope: 'read' | 'write', land: string, grant?: { who?: string; framer?: string; asker?: string }) => string | null; branchBoard: (frame?: string) => Promise<Response>; boot: (url: string) => Promise<void>; stop: () => Promise<void> };
180
+ export type MountedWorld = { maxRequestBodySize: number; name: string; served: string; root: string; readonly token: string; readonly readToken: string; twins: () => string[]; rotate: () => { token: string; readToken: string }; handle: (request: Request) => Promise<Response>; /** a hostname this World's twins answer, as a browser reads it (siteOfHost) */ site: (request: Request, host: string) => Promise<Response>; /** WorldDoors.pagesPass */ pagesPass: (scope: 'read' | 'write', land: string, grant?: { who?: string; framer?: string; asker?: string }) => string | null; branchBoard: (frame?: string) => Promise<Response>; boot: (url: string) => Promise<void>; stop: () => Promise<void> };
180
181
  /** A browser session a World keeps: its scope, when it ends, and the person a pass named, when one did. */
181
182
  /** What a content-plane pass, and the session it opens, carry: its scope; the control session that asked (the content
182
183
  * session ends with it; none when the World's token asked); the person; the console that frames it; and whether it
@@ -235,6 +236,7 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
235
236
  let stopping: Promise<void> | null = null;
236
237
  const mounted: MountedWorld = {
237
238
  name, served, root: worldRoot,
239
+ maxRequestBodySize: Math.max(1024 * 1024 * 1024, ...loadWorldConfig(configRef, worldRoot).config.services.map((service) => packFacts()[service.package?.replace(/^@volter\/twin-/, '') ?? service.id]?.maxRequestBodySize ?? 0)),
238
240
  get token() { return world.token; }, get readToken() { return world.readToken; },
239
241
  twins: () => world.twins(),
240
242
  rotate: () => { world.retoken(mintToken('tok_'), mintToken('tok_r_')); keep(); return { token: world.token, readToken: world.readToken }; },
@@ -303,9 +305,9 @@ export async function serveWorld(name: string, opts: { root?: string; port?: num
303
305
  // stale server on the port must never be mistaken for this one. Until the world is up the
304
306
  // doors answer 503.
305
307
  let consoleOrigin: string | null = null;
306
- // a pushed blob (a release video) is one PUT: bodies up to 1 GiB, past a vendor's own media limits
308
+ // Keep the 1 GiB front baseline unless a served twin declares a larger transport cap.
307
309
  // a front for the whole World: its twins and links keep their own journals (architecture: a multiplexing front journals nothing)
308
- const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize: 1024 * 1024 * 1024, twinRequestJournal: false, fetch: (request) => consoleRedirect(request, consoleOrigin) ?? mounted.handle(request) });
310
+ const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize: mounted.maxRequestBodySize, twinRequestJournal: false, fetch: (request) => consoleRedirect(request, consoleOrigin) ?? mounted.handle(request) });
309
311
  const url = `http://${host}:${server.port}`;
310
312
  try { await mounted.boot(url); } catch (error) { await server.stop(true); throw error; }
311
313
  // the console, when it is installed beside the World, on an origin of its own (console-apart.ts)
@@ -330,7 +332,10 @@ export async function serveWorld(name: string, opts: { root?: string; port?: num
330
332
  opts.announce?.({ name: mounted.served, base: `${url}/${mounted.served}`, token: mounted.token, readToken: mounted.readToken, console: consoleUrl });
331
333
  } catch (error) {
332
334
  // Boot succeeded: this call owns the World, but cannot return its stop handle.
333
- try { await stopAll(); } finally { await mounted.stop(); }
335
+ const errors: unknown[] = [error];
336
+ try { await stopAll(); } catch (cleanup) { errors.push(cleanup); }
337
+ try { await mounted.stop(); } catch (cleanup) { errors.push(cleanup); }
338
+ if (errors.length > 1) throw new AggregateError(errors, 'World announcement and host cleanup failed');
334
339
  throw error;
335
340
  }
336
341
  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(); } };
@@ -581,6 +586,7 @@ export class WorldDoors {
581
586
  }
582
587
  if (door === 'open.js' && request.method === 'GET') {
583
588
  const script = `(() => {
589
+ const errorMessage = (${errorMessage.toString()});
584
590
  const world = document.body.dataset.world;
585
591
  const carried = new URLSearchParams(location.hash.slice(1));
586
592
  history.replaceState(null, '', location.pathname);
@@ -598,7 +604,7 @@ export class WorldDoors {
598
604
  if (!r.ok) throw new Error(opened.error || 'This World did not open.');
599
605
  location.replace(to);
600
606
  })
601
- .catch((e) => { document.body.textContent = e.message || 'This World did not answer.'; });
607
+ .catch((e) => { document.body.textContent = errorMessage(e) || 'This World did not answer.'; tell({ error: document.body.textContent }); });
602
608
  })();
603
609
  `;
604
610
  return new Response(script, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
@@ -729,7 +735,7 @@ export class WorldDoors {
729
735
  // a session a revoked key opened ends with it
730
736
  if (Object.values(this.sessions()).some((x) => x.key !== undefined && ids.has(x.key))) this.keepSessions(Object.fromEntries(Object.entries(this.sessions()).filter(([, x]) => x.key === undefined || !ids.has(x.key))));
731
737
  // the keys are gone whatever a removal answers; a branch that stays is said, not hidden (its parent key no longer opens this World)
732
- for (const k of [...gone, ...also]) if (k.branch) await this.host.branches?.remove(k.branch).catch((error: unknown) => { console.error(`${this.served}: branch ${k.branch} stays after its key was revoked: ${error instanceof Error ? error.message : String(error)}`); return false; });
738
+ for (const k of [...gone, ...also]) if (k.branch) await this.host.branches?.remove(k.branch).catch((error: unknown) => { console.error(`${this.served}: branch ${k.branch} stays after its key was revoked: ${errorMessage(error)}`); return false; });
733
739
  return [...ids];
734
740
  }
735
741
  private async keysDoor(request: Request, id: string | undefined, scope: 'read' | 'write', via: 'token' | 'key' | 'session' | null, session?: string): Promise<Response> {
@@ -1196,7 +1202,7 @@ export class WorldDoors {
1196
1202
  }
1197
1203
  return Response.json({ error: `not this world: serving /${this.served}/` }, { status: 404 });
1198
1204
  } catch (error) {
1199
- return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
1205
+ return Response.json({ error: errorMessage(error) }, { status: 500 });
1200
1206
  }
1201
1207
  }
1202
1208
 
@@ -1370,7 +1376,7 @@ export class WorldDoors {
1370
1376
  const base = validateRemoteOrigin(origin);
1371
1377
  // opened for this link's origin only: a credential sealed for another destination, or for none, refuses
1372
1378
  let credential: Awaited<ReturnType<typeof openSealedAt>>;
1373
- try { credential = await openSealedAt(credentialPath(this.worldRoot, name), name, destinationOf(base.origin)); } catch (error) { return Response.json({ error: (error as Error).message }, { status: 409 }); }
1379
+ try { credential = await openSealedAt(credentialPath(this.worldRoot, name), name, destinationOf(base.origin)); } catch (error) { return Response.json({ error: errorMessage(error) }, { status: 409 }); }
1374
1380
  if (credential === null) return Response.json({ error: `the ${name} link has no credential sealed` }, { status: 409 });
1375
1381
  const target = new URL(`${base.origin}/${rest}${url.search}`);
1376
1382
  if (target.origin !== base.origin) return Response.json({ error: 'the path left the link origin' }, { status: 400 });
@@ -1730,7 +1736,7 @@ export class WorldDoors {
1730
1736
  if (kind === 'links' && name && request.method === 'PUT') {
1731
1737
  if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name) || this.up().services[name]) return Response.json({ error: 'a link is named [a-z0-9_-] and is not a twin of this World' }, { status: 400 });
1732
1738
  const body = (await request.json()) as { origin?: string };
1733
- try { validateRemoteOrigin(body.origin ?? ''); } catch (error) { return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 }); }
1739
+ try { validateRemoteOrigin(body.origin ?? ''); } catch (error) { return Response.json({ error: errorMessage(error) }, { status: 400 }); }
1734
1740
  getActiveWorldStore().write(this.linksPath(), `${JSON.stringify({ ...this.links(), [name]: { origin: body.origin! } }, null, 2)}\n`);
1735
1741
  return Response.json({ link: name, origin: body.origin });
1736
1742
  }
@@ -1751,7 +1757,7 @@ export class WorldDoors {
1751
1757
  if (source.length > MAX_BODY) return Response.json({ error: 'too large' }, { status: 413 });
1752
1758
  const path = join(dir, `${name}.js`);
1753
1759
  store.write(path, source);
1754
- try { await loadCheck(path, name); } catch (error) { store.remove(path); return Response.json({ error: `not a check: ${error instanceof Error ? error.message : String(error)}` }, { status: 400 }); }
1760
+ try { await loadCheck(path, name); } catch (error) { store.remove(path); return Response.json({ error: `not a check: ${errorMessage(error)}` }, { status: 400 }); }
1755
1761
  return Response.json({ check: name });
1756
1762
  }
1757
1763
  if (name && request.method === 'DELETE') { for (const ext of ['js', 'mjs', 'ts']) store.remove(join(dir, `${name}.${ext}`)); return Response.json({ removed: name }); }
@@ -1821,7 +1827,7 @@ export class WorldDoors {
1821
1827
  if (scope !== 'write') return Response.json({ error: 'the rules are changed with write access' }, { status: 403 });
1822
1828
  if (via === 'key') return Response.json({ error: 'a key changes no rules: a person sets them, or the World\'s token' }, { status: 403 });
1823
1829
  const body = (await request.json().catch(() => null)) as { changesets?: unknown } | null;
1824
- try { assertChangesetRules(body?.changesets, path); } catch (error) { return Response.json({ error: error instanceof Error ? error.message.replace(` in ${path}`, '') : String(error) }, { status: 400 }); }
1830
+ try { assertChangesetRules(body?.changesets, path); } catch (error) { return Response.json({ error: errorMessage(error).replace(` in ${path}`, '') }, { status: 400 }); }
1825
1831
  const { config } = loadWorldConfig(path, this.worldRoot);
1826
1832
  const rules = body!.changesets as WorldChangesetRules;
1827
1833
  const next = { ...config, changesets: { ...(rules.approvals === undefined ? {} : { approvals: rules.approvals }), ...(rules.summary === undefined ? {} : { summary: rules.summary }) } };
@@ -2089,7 +2095,7 @@ export class WorldDoors {
2089
2095
  let known = false;
2090
2096
  try { known = listWorlds(this.worldRoot).some((w) => w.name === target); } catch { /* no local branches */ }
2091
2097
  if (!known) return Response.json({ error: `no branch ${target} in this World` }, { status: 404 });
2092
- try { await this.host.serveBranch(target); } catch (error) { return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 }); }
2098
+ try { await this.host.serveBranch(target); } catch (error) { return Response.json({ error: errorMessage(error) }, { status: 409 }); }
2093
2099
  return Response.json({ world: this.served, checkedOut: target });
2094
2100
  }
2095
2101
  if (rest.length === 0 && request.method === 'POST') {
@@ -2134,7 +2140,7 @@ export class WorldDoors {
2134
2140
  return Response.json({ ...made, ...where }, { status: 201 });
2135
2141
  } catch (error) {
2136
2142
  this.keepKeys(this.keys().filter((k) => k.id !== keyId)); // no branch, no key
2137
- return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 });
2143
+ return Response.json({ error: errorMessage(error) }, { status: 409 });
2138
2144
  }
2139
2145
  }
2140
2146
  if (rest.length === 2 && request.method === 'DELETE') {
@@ -2416,7 +2422,7 @@ export class WorldDoors {
2416
2422
  const kept = ((): Changeset | null => { try { return JSON.parse(getActiveWorldStore().read(join(worldChangesetsDir(this.name, this.worldRoot), `${changeset.name}.json`)) ?? 'null') as Changeset | null; } catch { return null; } })();
2417
2423
  return Response.json({ world: this.served, changeset: changeset.name, receipts, position, snapshots, ...(kept?.owner ? { owner: kept.owner } : {}), ...(kept?.pushedBy ? { pushedBy: kept.pushedBy } : {}) });
2418
2424
  } catch (error) {
2419
- return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 });
2425
+ return Response.json({ error: errorMessage(error) }, { status: 409 });
2420
2426
  }
2421
2427
  }
2422
2428
  }
@@ -2529,7 +2535,7 @@ export async function refreshSchedule(worldRoot: string, layout: WorldLayout, lo
2529
2535
  export async function scheduleRefreshes(name: string, worldRoot: string, instance: WorldInstance, log: (line: string) => void = (l) => process.stderr.write(`${l}\n`)): Promise<ReturnType<typeof setInterval>[]> {
2530
2536
  const timers: ReturnType<typeof setInterval>[] = [];
2531
2537
  for (const { vendor, controlRoot, root, everyMs } of await refreshSchedule(worldRoot, instance, log)) {
2532
- const timer = setInterval(() => { refreshTwin({ worldRoot, vendor, controlRoot, root, force: true }).then((r) => { if (!r.refreshed) log(`${vendor}: not refreshed: ${r.reason}`); }, (e: unknown) => log(`${vendor}: refresh failed: ${e instanceof Error ? e.message : String(e)}`)); }, everyMs);
2538
+ const timer = setInterval(() => { refreshTwin({ worldRoot, vendor, controlRoot, root, force: true }).then((r) => { if (!r.refreshed) log(`${vendor}: not refreshed: ${r.reason}`); }, (e: unknown) => log(`${vendor}: refresh failed: ${errorMessage(e)}`)); }, everyMs);
2533
2539
  timer.unref?.();
2534
2540
  timers.push(timer);
2535
2541
  }
@@ -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.ts';
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';
@@ -73,7 +75,7 @@ const watch = setInterval(() => {
73
75
  const statSize = (): number => { try { return logPath ? statSync(logPath).size : 0; } catch { return 0; } };
74
76
 
75
77
  let forwarded: string | null = null;
76
- for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) process.on(signal, () => { forwarded = forwarded || signal; try { child.kill('SIGTERM'); } catch { /* already gone */ } });
78
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) process.on(signal, () => { forwarded = forwarded || signal; try { if (child.pid) signalChild(child, 'SIGTERM'); } catch (error) { process.stderr.write(`${errorMessage(error)}\n`); } });
77
79
  let ended = false;
78
80
  const done = (status: number, end: Record<string, unknown>): void => {
79
81
  if (ended) return;
@@ -82,7 +84,7 @@ const done = (status: number, end: Record<string, unknown>): void => {
82
84
  record({ event: 'world.service.exited', status, ...end, pid: child.pid, uptimeMs: Date.now() - started, ...(forwarded ? { stopRequested: forwarded } : {}) }, () => process.exit(status));
83
85
  };
84
86
  child.once('error', (error: Error) => {
85
- const message = String(error?.message || error);
87
+ const message = errorMessage(error);
86
88
  process.stderr.write(`${message}\n`);
87
89
  done(127, { code: null, signal: null, error: message });
88
90
  });
@@ -0,0 +1 @@
1
+ export function offSignal(signal: NodeJS.Signals, listener: (...args: never[]) => void): void;
@@ -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
+ }
package/src/signals.ts CHANGED
@@ -1,10 +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: NodeJS.Signals, listener: (...args: never[]) => void): void {
7
- const rest = process.listeners(signal).filter((l) => l !== listener);
8
- process.removeAllListeners(signal);
9
- for (const l of rest) process.on(signal, l as (...args: unknown[]) => void);
10
- }
1
+ export { offSignal } from './signals.mjs';
package/src/tail.ts CHANGED
@@ -30,7 +30,7 @@ type TailRecord = Record<string, unknown> & {
30
30
  subject?: { type?: string; id?: string };
31
31
  };
32
32
 
33
- type TailEntry = { service: string; record: TailRecord; source: 'actions' | 'requests' };
33
+ type TailEntry = { service: string; record: TailRecord; source: 'actions' | 'requests' | 'logs' };
34
34
 
35
35
  export type TailWorldOptions = {
36
36
  root?: string;
@@ -45,6 +45,8 @@ export type TailWorldOptions = {
45
45
  * status}` rows; world-core/src/serve.ts). Reads are otherwise invisible here: an
46
46
  * empty-catalog GET that 404s leaves no trace in an actions-only tail. */
47
47
  requests?: boolean;
48
+ /** Also read existing service process logs verbatim. These are vendor output, not shape-only journals. */
49
+ logs?: boolean;
48
50
  /** Poll interval while following (ledger appends are cross-process, so this polls rather than
49
51
  * fs.watch — watch descriptors can't see a file that doesn't exist yet). */
50
52
  pollMs?: number;
@@ -129,8 +131,9 @@ function entryInstant(entry: TailEntry): string | undefined {
129
131
  }
130
132
 
131
133
  function formatEntry(entry: TailEntry, json: boolean): string {
132
- if (json) return JSON.stringify({ ...entry.record, service: entry.service });
134
+ if (json) return JSON.stringify({ ...entry.record, service: entry.service, ...(entry.source === 'logs' ? { source: 'logs' } : {}) });
133
135
  const { record } = entry;
136
+ if (entry.source === 'logs') return `${entry.service} stdout ${record.message}`;
134
137
  if (entry.source === 'requests') {
135
138
  // a served request, not an action: `HH:MM:SS.mmm <service> <METHOD> <path> <status> in <ms>ms`, and —
136
139
  // when the caller presented one — which credential arrived, named and fingerprinted. The
@@ -204,6 +207,10 @@ export async function tailWorldActions(name: string, options: TailWorldOptions =
204
207
  }
205
208
  }
206
209
  }
210
+ const log = instance.services[service]?.log;
211
+ if (options.logs && log) {
212
+ for (const line of readNewLines(log, offsets)) entries.push({ service, record: { message: line }, source: 'logs' });
213
+ }
207
214
  }
208
215
  return entries.sort(byOccurredAt);
209
216
  };
@@ -215,7 +222,7 @@ export async function tailWorldActions(name: string, options: TailWorldOptions =
215
222
  emit(collect());
216
223
  for (const service of services) {
217
224
  if (links.includes(service) && !instance.services[service]) continue; // a link records requests, never actions
218
- if (serviceLedgers(instance.dirs.data, service).length === 0) {
225
+ if (serviceLedgers(instance.dirs.data, service).length === 0 && !(options.logs && instance.services[service]?.log)) {
219
226
  notice(`World ${name}: service "${service}" has no action ledger yet${follow
220
227
  ? ' — watching; drive the app (or a vendor SDK) through the world to record actions'
221
228
  : '; drive the app (or a vendor SDK) through the world to record actions, then tail again'}`);
package/src/tcp-port.ts CHANGED
@@ -17,19 +17,30 @@ export async function allocatePort(excluded: Set<number> = new Set()): Promise<n
17
17
  throw new Error('Could not allocate a TCP port after 32 attempts');
18
18
  }
19
19
 
20
- function probePort(): Promise<number> {
21
- return new Promise((resolvePort, reject) => {
22
- const server = createServer();
23
- server.once('error', reject);
20
+ async function probePort(): Promise<number> {
21
+ const ipv4 = createServer();
22
+ const ipv6 = createServer();
23
+ try {
24
24
  // Match serveHttp's default scope; a loopback-only probe can miss a wildcard listener.
25
- server.listen(0, '0.0.0.0', () => {
26
- const address = server.address();
27
- if (!address || typeof address === 'string') {
28
- server.close(() => reject(new Error('Could not allocate a TCP port')));
29
- return;
30
- }
31
- const port = address.port;
32
- server.close((error) => error ? reject(error) : resolvePort(port));
25
+ await new Promise<void>((resolve, reject) => {
26
+ ipv4.once('error', reject);
27
+ ipv4.listen(0, '0.0.0.0', resolve);
33
28
  });
34
- });
29
+ const address = ipv4.address();
30
+ if (!address || typeof address === 'string') throw new Error('Could not allocate a TCP port');
31
+ // Hold the IPv4 probe while checking the same port on IPv6, without a dual-stack bind.
32
+ await new Promise<void>((resolve, reject) => {
33
+ ipv6.once('error', (error: NodeJS.ErrnoException) => {
34
+ if (['EAFNOSUPPORT', 'EADDRNOTAVAIL', 'EPROTONOSUPPORT'].includes(error.code ?? '')) resolve();
35
+ else reject(error);
36
+ });
37
+ ipv6.listen({ port: address.port, host: '::', ipv6Only: true }, resolve);
38
+ });
39
+ return address.port;
40
+ } finally {
41
+ await Promise.all([ipv6, ipv4].filter((server) => server.listening).map((server) =>
42
+ new Promise<void>((resolve, reject) => {
43
+ server.close((error) => error ? reject(error) : resolve());
44
+ })));
45
+ }
35
46
  }
@@ -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); });
package/src/up-cli.ts CHANGED
@@ -23,11 +23,14 @@ export async function upCommand(subject: string | undefined, rest: string[], roo
23
23
  // browser tab's image, comes up over its data instead of starting empty, as a fresh `up` does)
24
24
  const keepState = rest.includes('--keep-state');
25
25
  const instance = await superviseWorldUp(subject, { name, root, mode, envFile, forceEnvOutput: rest.includes('--force'), owner: ownerOption(rest), ...(isolation ? { isolation } : {}), ...(keepState ? { keepState: true } : {}) });
26
- printReady(instance);
26
+ if (rest.includes('--json')) process.stdout.write(JSON.stringify(instance, null, 2) + '\n');
27
+ else printReady(instance);
27
28
  }
28
29
 
29
30
  export function printReady(instance: Awaited<ReturnType<typeof upWorld>>): void {
30
31
  process.stdout.write(`World ${instance.name} is ready (${instance.config})
32
+ Instance: ${instance.instance}
33
+ Created: ${instance.createdAt}
31
34
 
32
35
  Services:
33
36
  ${Object.values(instance.services).map((service) => ` ${service.id}: ${service.url ?? '(no listener — a World output)'}`).join('\n')}
@@ -43,11 +46,11 @@ GET <url>/twin; a scripted twin's GET <url>/twin/scenario lists handlers + misse
43
46
  Env:
44
47
  ${instance.envFile}
45
48
 
46
- Instance:
49
+ Record:
47
50
  ${instance.dirs.instance}/instance.json
48
51
 
49
52
  Stop:
50
- volter-world down ${instance.name}
53
+ volter-world down ${instance.name} --if-instance ${instance.instance}
51
54
 
52
55
  For a bounded job, use volter-world run ... -- <command> to tear down automatically on exit.
53
56
  Persistent up does not expire or infer that your sessions are idle.
@@ -1,6 +1,7 @@
1
1
  import { recordWorldLifecycleEvent } from './lifecycle-record.ts';
2
2
  import { upWorld } from './runtime.ts';
3
3
  import { loadWorldConfig } from './configs.ts';
4
+ import { errorMessage } from './error-message.ts';
4
5
 
5
6
  const worldNameOf = (configId: string, root: string): string => { try { return loadWorldConfig(configId, root).config.id; } catch { return configId; } };
6
7
  import type { UpTaskReply, UpTaskRequest } from './up-task.ts';
@@ -25,7 +26,7 @@ process.on('message', async message => {
25
26
  started = true;
26
27
  let reply: UpTaskReply;
27
28
  try { reply = { instance: await upWorld(request.configId, request.options, abort.signal) }; }
28
- catch (error) { reply = { error: error instanceof Error ? error.message : String(error) }; }
29
+ catch (error) { reply = { error: errorMessage(error) }; }
29
30
  if (callerLost && 'instance' in reply) {
30
31
  try { recordWorldLifecycleEvent(reply.instance.root, reply.instance.name, 'boot completed after its caller went away; the World is up'); } catch { /* the World is up regardless */ }
31
32
  }
@@ -1,32 +1,39 @@
1
- import type { ServeHttpOptions } from '@volter/world-core/runtime';
1
+ import { connectWebSocket, type ServeHttpOptions } from '@volter/world-core/runtime';
2
2
 
3
3
  /** A message relay for the World's TLS fronts. Transport state is connection-local. */
4
4
  export function websocketRelay(targetFor: (request: Request) => string | undefined): NonNullable<ServeHttpOptions['upgrade']> {
5
5
  type Peer = Parameters<NonNullable<ServeHttpOptions['upgrade']>['open']>[0];
6
- const sessions = new WeakMap<Peer, { upstream: WebSocket; pending: Array<string | Uint8Array> }>();
6
+ const sessions = new WeakMap<Peer, { upstream?: WebSocket; pending: Array<string | Uint8Array> }>();
7
7
  return {
8
8
  accepts: request => targetFor(request) !== undefined,
9
9
  open(peer, request) {
10
10
  const target = targetFor(request);
11
11
  if (!target) { peer.close(1011, 'no twin for request'); return; }
12
- const upstream = new WebSocket(target);
13
- upstream.binaryType = 'arraybuffer';
14
- const session = { upstream, pending: [] as Array<string | Uint8Array> };
12
+ const headers = Object.fromEntries(request.headers);
13
+ const protocols = (headers['sec-websocket-protocol'] ?? '').split(',').map(p => p.trim()).filter(Boolean);
14
+ headers['x-volter-twin-original-host'] ??= new URL(request.url).host;
15
+ for (const name of ['host', 'connection', 'upgrade', 'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions', 'sec-websocket-protocol', 'proxy-connection']) delete headers[name];
16
+ const session: { upstream?: WebSocket; pending: Array<string | Uint8Array> } = { pending: [] };
15
17
  sessions.set(peer, session);
16
- upstream.onopen = () => { for (const message of session.pending) upstream.send(message); session.pending.length = 0; };
17
- upstream.onmessage = event => { peer.send(typeof event.data === 'string' ? event.data : new Uint8Array(event.data as ArrayBuffer)); };
18
- upstream.onclose = event => { peer.close(event.code === 1006 ? 1011 : event.code, event.reason); };
19
- upstream.onerror = () => { peer.close(1011, 'twin websocket error'); };
18
+ void connectWebSocket(target, headers, protocols).then(upstream => {
19
+ if (sessions.get(peer) !== session) { upstream.onerror = () => {}; upstream.close(); return; }
20
+ session.upstream = upstream;
21
+ upstream.binaryType = 'arraybuffer';
22
+ upstream.onopen = () => { for (const message of session.pending) upstream.send(message); session.pending.length = 0; };
23
+ upstream.onmessage = event => { peer.send(typeof event.data === 'string' ? event.data : new Uint8Array(event.data as ArrayBuffer)); };
24
+ upstream.onclose = event => { peer.close(event.code === 1006 ? 1011 : event.code, event.reason); };
25
+ upstream.onerror = () => { peer.close(1011, 'twin websocket error'); };
26
+ }).catch(() => peer.close(1011, 'twin websocket connection failed'));
20
27
  },
21
28
  message(peer, data) {
22
29
  const session = sessions.get(peer);
23
30
  if (!session) return;
24
- if (session.upstream.readyState === WebSocket.OPEN) session.upstream.send(data); else session.pending.push(data);
31
+ if (session.upstream?.readyState === WebSocket.OPEN) session.upstream.send(data); else session.pending.push(data);
25
32
  },
26
33
  close(peer) {
27
34
  const session = sessions.get(peer);
28
35
  sessions.delete(peer);
29
- if (session) { session.pending.length = 0; session.upstream.close(); }
36
+ if (session) { session.pending.length = 0; session.upstream?.close(); }
30
37
  },
31
38
  };
32
39
  }
package/src/world-view.ts CHANGED
@@ -94,8 +94,8 @@ export async function serveWorldView(name: string, opts: { /** the person signed
94
94
  base = world.served;
95
95
  worlds.set(world.served, world);
96
96
  const server = await serveHttp({
97
- // a pushed blob (a release video) is one PUT: bodies up to 1 GiB, as `serve` takes them
98
- hostname: host, port: opts.port ?? 0, maxRequestBodySize: 1024 * 1024 * 1024,
97
+ // The front retains its 1 GiB baseline and honors the served twins' declared caps.
98
+ hostname: host, port: opts.port ?? 0, maxRequestBodySize: world.maxRequestBodySize,
99
99
  // this front serves a World and its branches; each World's own twins journal their requests
100
100
  twinRequestJournal: false,
101
101
  async fetch(incoming) {