@volter/world-runtime 3.0.163 → 3.0.164

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 (137) hide show
  1. package/dist/src/ancestry.js +2 -2
  2. package/dist/src/app-url.d.ts +5 -1
  3. package/dist/src/app-url.js +14 -1
  4. package/dist/src/branch.js +28 -8
  5. package/dist/src/browser-files.d.ts +5 -0
  6. package/dist/src/browser-files.js +14 -0
  7. package/dist/src/browsers.d.ts +2 -3
  8. package/dist/src/browsers.js +4 -4
  9. package/dist/src/catalog.d.ts +2 -1
  10. package/dist/src/catalog.js +3 -2
  11. package/dist/src/changeset.js +1 -1
  12. package/dist/src/cli-commands.js +71 -18
  13. package/dist/src/cli-load-clock.d.ts +1 -0
  14. package/dist/src/cli-load-clock.js +2 -0
  15. package/dist/src/cli.js +5 -1
  16. package/dist/src/clickhouse-backing.d.ts +2 -0
  17. package/dist/src/clickhouse-backing.js +145 -0
  18. package/dist/src/command-lifetime.d.mts +1 -1
  19. package/dist/src/command-lifetime.mjs +5 -2
  20. package/dist/src/configs.js +1 -1
  21. package/dist/src/consumers.js +1 -1
  22. package/dist/src/covers.d.ts +4 -2
  23. package/dist/src/covers.js +8 -4
  24. package/dist/src/go-mysql-binding.d.ts +19 -0
  25. package/dist/src/go-mysql-binding.js +51 -0
  26. package/dist/src/guest-processes.d.mts +1 -1
  27. package/dist/src/guest-processes.mjs +22 -14
  28. package/dist/src/host-cli.js +8 -1
  29. package/dist/src/host-ready.d.ts +16 -2
  30. package/dist/src/host-ready.js +66 -39
  31. package/dist/src/host-worker.js +7 -1
  32. package/dist/src/host.d.ts +26 -0
  33. package/dist/src/host.js +93 -3
  34. package/dist/src/infra-definition.d.ts +35 -0
  35. package/dist/src/infra-definition.js +146 -0
  36. package/dist/src/infra.d.ts +3 -0
  37. package/dist/src/infra.js +375 -105
  38. package/dist/src/init.d.ts +46 -0
  39. package/dist/src/init.js +445 -18
  40. package/dist/src/lifecycle-record.d.ts +5 -1
  41. package/dist/src/lifecycle-record.js +27 -4
  42. package/dist/src/local-branches.js +1 -1
  43. package/dist/src/mariadb-bootstrap.d.ts +32 -0
  44. package/dist/src/mariadb-bootstrap.js +90 -0
  45. package/dist/src/mariadb-supplier.d.ts +23 -0
  46. package/dist/src/mariadb-supplier.js +215 -0
  47. package/dist/src/mongodb-backing.d.ts +4 -0
  48. package/dist/src/mongodb-backing.js +71 -1
  49. package/dist/src/native-mariadb-backing.d.ts +13 -0
  50. package/dist/src/native-mariadb-backing.js +512 -0
  51. package/dist/src/native-postgres-backing.d.ts +4 -10
  52. package/dist/src/native-postgres-backing.js +428 -43
  53. package/dist/src/native-server-inputs.d.ts +16 -0
  54. package/dist/src/native-server-inputs.js +204 -0
  55. package/dist/src/origin.js +3 -3
  56. package/dist/src/original-inputs.d.ts +14 -0
  57. package/dist/src/original-inputs.js +84 -0
  58. package/dist/src/pack-facts.d.ts +9 -1
  59. package/dist/src/pglite-backing.d.ts +41 -17
  60. package/dist/src/pglite-backing.js +213 -79
  61. package/dist/src/pglite-host.mjs +482 -10
  62. package/dist/src/process-groups.d.mts +6 -1
  63. package/dist/src/process-groups.d.ts +2 -1
  64. package/dist/src/process-groups.js +1 -1
  65. package/dist/src/process-groups.mjs +82 -15
  66. package/dist/src/process-signal.d.mts +2 -2
  67. package/dist/src/process-signal.mjs +29 -9
  68. package/dist/src/project-inspect.d.ts +12 -0
  69. package/dist/src/project-inspect.js +41 -4
  70. package/dist/src/proxy-env.d.ts +34 -0
  71. package/dist/src/proxy-env.js +114 -0
  72. package/dist/src/redirect-proxy.d.ts +1 -31
  73. package/dist/src/redirect-proxy.js +3 -112
  74. package/dist/src/redis-backing.d.ts +6 -4
  75. package/dist/src/redis-backing.js +20 -168
  76. package/dist/src/runtime.d.ts +1 -0
  77. package/dist/src/runtime.js +503 -218
  78. package/dist/src/schema.d.ts +1 -0
  79. package/dist/src/schema.js +5 -3
  80. package/dist/src/served-world.js +3 -3
  81. package/dist/src/twin-backing.d.ts +47 -0
  82. package/dist/src/twin-backing.js +343 -0
  83. package/dist/src/up-cli.js +56 -3
  84. package/package.json +3 -3
  85. package/src/ancestry.ts +2 -2
  86. package/src/app-url.ts +9 -2
  87. package/src/branch.ts +26 -8
  88. package/src/browser-files.ts +15 -0
  89. package/src/browsers.ts +4 -4
  90. package/src/catalog.ts +4 -3
  91. package/src/changeset.ts +1 -1
  92. package/src/cli-commands.ts +85 -18
  93. package/src/cli-load-clock.ts +2 -0
  94. package/src/cli.ts +5 -1
  95. package/src/clickhouse-backing.ts +70 -0
  96. package/src/command-lifetime.d.mts +1 -1
  97. package/src/command-lifetime.mjs +5 -2
  98. package/src/configs.ts +1 -1
  99. package/src/consumers.ts +1 -1
  100. package/src/covers.ts +9 -4
  101. package/src/go-mysql-binding.ts +49 -0
  102. package/src/guest-processes.d.mts +1 -1
  103. package/src/guest-processes.mjs +22 -14
  104. package/src/host-cli.ts +7 -1
  105. package/src/host-ready.ts +62 -31
  106. package/src/host-worker.ts +6 -2
  107. package/src/host.ts +88 -6
  108. package/src/infra-definition.ts +135 -0
  109. package/src/infra.ts +250 -45
  110. package/src/init.ts +416 -17
  111. package/src/lifecycle-record.ts +18 -4
  112. package/src/local-branches.ts +1 -1
  113. package/src/mariadb-bootstrap.ts +107 -0
  114. package/src/mariadb-supplier.ts +199 -0
  115. package/src/mongodb-backing.ts +59 -1
  116. package/src/native-mariadb-backing.ts +418 -0
  117. package/src/native-postgres-backing.ts +353 -41
  118. package/src/native-server-inputs.ts +169 -0
  119. package/src/origin.ts +3 -3
  120. package/src/original-inputs.ts +72 -0
  121. package/src/pack-facts.ts +1 -1
  122. package/src/pglite-backing.ts +192 -80
  123. package/src/pglite-host.mjs +482 -10
  124. package/src/process-groups.d.mts +6 -1
  125. package/src/process-groups.mjs +82 -15
  126. package/src/process-groups.ts +2 -1
  127. package/src/process-signal.d.mts +2 -2
  128. package/src/process-signal.mjs +29 -9
  129. package/src/project-inspect.ts +37 -4
  130. package/src/proxy-env.ts +128 -0
  131. package/src/redirect-proxy.ts +3 -125
  132. package/src/redis-backing.ts +19 -136
  133. package/src/runtime.ts +342 -107
  134. package/src/schema.ts +5 -4
  135. package/src/served-world.ts +3 -3
  136. package/src/twin-backing.ts +300 -0
  137. package/src/up-cli.ts +52 -3
@@ -0,0 +1,70 @@
1
+ // ClickHouse's managed ownership questions: HTTP ping, this child's kernel boot answer,
2
+ // and a native Hello/Ping. No SQL is executed by readiness.
3
+ // Packet fields: clickhouse-docs/docs/native-protocol/{client,server,basics}.md;
4
+ // revision54429 uses the reached clickhouse-driver0.2.10 layout retained with the pack.
5
+ import { connect } from 'node:net';
6
+ import { WORLD_BOOT_PATH } from '@volter/world-core/runtime';
7
+ import type { ManagedProtocol } from './twin-backing.ts';
8
+ import type { InfraService } from './infra-definition.ts';
9
+
10
+ async function answer(port: number, path: string): Promise<string | undefined> {
11
+ const controller = new AbortController(), timer = setTimeout(() => controller.abort(), 1000);
12
+ try {
13
+ const response = await fetch(`http://127.0.0.1:${port}${path}`, { signal: controller.signal, redirect: 'error' });
14
+ if (!response.ok || !response.body) return undefined;
15
+ // This private proof contains a UUID boot nonce and a JavaScript-safe PID; ping is four bytes.
16
+ const reader = response.body.getReader(); let text = '', bytes = 0;
17
+ try { while (true) { const part = await reader.read(); if (part.done) break; bytes += part.value.length; if (bytes > 256) return undefined; text += new TextDecoder().decode(part.value); } }
18
+ finally { await reader.cancel().catch(() => undefined); }
19
+ return text;
20
+ } catch { return undefined; } finally { clearTimeout(timer); }
21
+ }
22
+ function number(value: number): number[] { const result: number[] = []; do { const byte = value % 128; value = Math.floor(value / 128); result.push(byte | (value ? 128 : 0)); } while (value); return result; }
23
+ function string(value: string): number[] { const bytes = new TextEncoder().encode(value); return [...number(bytes.length), ...bytes]; }
24
+ function nativeAnswers(service: InfraService): Promise<boolean> {
25
+ const port = service.sides?.native?.hostPort; if (!port) return Promise.resolve(false);
26
+ const users = service.configuration?.users as Record<string, string> | undefined;
27
+ const [user, password] = users && Object.hasOwn(users, 'default') ? ['default', users.default!] : Object.entries(users ?? { default: '' })[0] ?? [];
28
+ if (typeof user !== 'string' || typeof password !== 'string') return Promise.resolve(false);
29
+ const hello = Uint8Array.from([0, ...string('World ownership'), 1, 0, ...number(54429), ...string('default'), ...string(user), ...string(password)]);
30
+ return new Promise(resolve => {
31
+ const socket = connect({ host: '127.0.0.1', port, timeout: 1000 }); let bytes = new Uint8Array(), phase = 'hello', settled = false;
32
+ const end = (valid: boolean) => { if (settled) return; settled = true; clearTimeout(timer); socket.destroy(); resolve(valid); };
33
+ // One complete ownership question has one deadline; incoming fragments never extend it.
34
+ const timer = setTimeout(() => end(false), 1000);
35
+ const missing = Symbol('incomplete');
36
+ socket.once('connect', () => socket.write(hello));
37
+ socket.on('data', chunk => {
38
+ // The declaring pack fixes its Hello name, timezone and display name;64KiB is beyond that packet domain.
39
+ if (bytes.length + chunk.length > 65536) { end(false); return; }
40
+ const joined = new Uint8Array(bytes.length + chunk.length); joined.set(bytes); joined.set(chunk, bytes.length); bytes = joined;
41
+ let at = 0;
42
+ const read = (): number => {
43
+ let value = 0n;
44
+ for (let shift = 0; shift < 70; shift += 7) { if (at >= bytes.length) throw missing; const byte = bytes[at++]!; value |= BigInt(byte & 127) << BigInt(shift); if (!(byte & 128)) { if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('Invalid native integer'); return Number(value); } }
45
+ throw new Error('Invalid native integer');
46
+ };
47
+ const text = (): string => { const length = read(); if (length > bytes.length - at) throw missing; const result = new TextDecoder().decode(bytes.subarray(at, at + length)); at += length; return result; };
48
+ try {
49
+ if (phase === 'hello') {
50
+ if (read() !== 0 || text() !== 'ClickHouse') { end(false); return; }
51
+ read(); read(); const revision = read(); if (revision !== 54429) { end(false); return; }
52
+ text(); text(); read(); bytes = bytes.slice(at); phase = 'pong'; socket.write(Uint8Array.of(4));
53
+ if (!bytes.length) return; at = 0;
54
+ }
55
+ end(read() === 4);
56
+ } catch (error) { if (error !== missing) end(false); }
57
+ });
58
+ socket.once('error', () => end(false)); socket.once('timeout', () => end(false)); socket.once('close', () => end(false));
59
+ });
60
+ }
61
+ export const CLICKHOUSE_PROTOCOL: ManagedProtocol = {
62
+ name: 'ClickHouse', bootProof: true,
63
+ async answers(port) { return await answer(port, '/ping') === 'Ok.\n'; },
64
+ async serves(port, pid, service, boot) {
65
+ if (!service || !boot || await answer(port, '/ping') !== 'Ok.\n') return false;
66
+ const body = await answer(port, WORLD_BOOT_PATH); if (!body) return false;
67
+ try { const held = JSON.parse(body); if (held.boot !== boot || held.pid !== pid) return false; } catch { return false; }
68
+ return nativeAnswers(service);
69
+ },
70
+ };
@@ -4,7 +4,7 @@ export const COMMAND_GRACE_MS: number;
4
4
  export const COMMAND_CONFIRM_MS: number;
5
5
  export const commandProcessGroup: boolean;
6
6
  export class CommandRetirementError extends Error {}
7
- export function commandLifetime(child: ChildProcess, abort?: AbortSignal, preserveOnSuccess?: boolean): {
7
+ export function commandLifetime(child: ChildProcess, abort?: AbortSignal, preserveOnSuccess?: boolean, graceMs?: number): {
8
8
  identity: Promise<SpawnIdentity | undefined>;
9
9
  cancel(signal: NodeJS.Signals): void;
10
10
  readonly forwarded: NodeJS.Signals | undefined;
@@ -9,7 +9,10 @@ export class CommandRetirementError extends Error {}
9
9
 
10
10
  /** The child owns a private POSIX group. A bounded retirement requires both closed output and
11
11
  * a vanished group. Unknown retirement keeps the caller's ownership record and reservation. */
12
- export function commandLifetime(child, abort, preserveOnSuccess = false) {
12
+ /** `graceMs` is how long a cancelled child is awaited before SIGKILL. A command gets the default; a STORE (a
13
+ * database, a twin that holds state) is given its lifecycle's budget, since what it does between the signal and
14
+ * its exit is write its state down, and a SIGKILL in the middle of that leaves a store nothing may take. */
15
+ export function commandLifetime(child, abort, preserveOnSuccess = false, graceMs = COMMAND_GRACE_MS) {
13
16
  const identity = child.pid && commandProcessGroup ? childIdentity(child) : Promise.resolve(undefined);
14
17
  let signalFailure;
15
18
  let pendingSignals = 0;
@@ -62,7 +65,7 @@ export function commandLifetime(child, abort, preserveOnSuccess = false) {
62
65
  }
63
66
  };
64
67
  const watch = () => {
65
- if (!deadline) deadline = Date.now() + COMMAND_GRACE_MS;
68
+ if (!deadline) deadline = Date.now() + graceMs;
66
69
  timer ??= setInterval(check, 20);
67
70
  check();
68
71
  };
package/src/configs.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { copyFileSync, constants, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
2
  import { basename, dirname, join, resolve } from 'node:path';
3
- import { getActiveWorldStore, stateDirName } from '@volter/world-core/runtime';
3
+ import { getActiveWorldStore, stateDirName } from '@volter/world-core/runtime-state';
4
4
  import { assertWorldConfig, LEGACY_WORLD_SELECTION, worldConfigDocument, type WorldChangesetRules, type WorldConfig } from './schema.ts';
5
5
 
6
6
  export function resolveConfigPath(config: string, root = process.cwd()): string {
package/src/consumers.ts CHANGED
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { hostname } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { stateDirName, withFileLock } from '@volter/world-core/runtime';
5
+ import { stateDirName, withFileLock } from '@volter/world-core/runtime-state';
6
6
 
7
7
  export type WorldConsumer = {
8
8
  id: string;
package/src/covers.ts CHANGED
@@ -627,6 +627,8 @@ export function worldTwinInventory(world: string, root: string): WorldTwinInvent
627
627
  export type CoverageOptions = {
628
628
  root?: string;
629
629
  allowUnknown?: boolean;
630
+ /** Application environment names already read from its declared stack. */
631
+ declaredEnvironmentNames?: readonly string[];
630
632
  /**
631
633
  * vendor → reason: vendors the operator ACKNOWLEDGES need no twin in this world — e.g. an SDK
632
634
  * used verifier-only (svix's Webhook verifies incoming signatures locally and never calls out).
@@ -685,11 +687,11 @@ function usesOf(signals: Map<string, string[]>): Map<string, Array<{ usage: Worl
685
687
 
686
688
  /** Detect every vendor (and every unmapped external-service-shaped signal) an application repo
687
689
  * betrays: mapped npm dependencies across the root + all workspace members, vendor-shaped env
688
- * names in committed `.env*` files, the SMTP/raw-protocol signal, and literal vendor URLs passed
690
+ * names in committed `.env*` files or its declared stack, the SMTP/raw-protocol signal, and literal vendor URLs passed
689
691
  * directly to fetch in production source. URL attribution reads the injector's VENDOR_HOSTS table
690
692
  * rather than re-encoding host rules here. Insertion order follows the sorted dependency, env-name,
691
693
  * and source lists, so callers that preserve it are deterministic. */
692
- export function detectRepoVendors(repoPath: string, services: Array<WorldServiceConfig | WorldServiceInstance> = []): RepoVendorSignals {
694
+ export function detectRepoVendors(repoPath: string, services: Array<WorldServiceConfig | WorldServiceInstance> = [], declaredEnvironmentNames: readonly string[] = []): RepoVendorSignals {
693
695
  // Application aliases are names the selected backing actually injects, never global vendor adoption.
694
696
  const backingEnvVendors = new Map<string, string>();
695
697
  for (const service of services) {
@@ -703,7 +705,10 @@ export function detectRepoVendors(repoPath: string, services: Array<WorldService
703
705
  // A Volter package a pack names as its vendor's SDK is one (`@volter/identity`, the Volter identity
704
706
  // service's client, is the volteridentity twin's), as any vendor's SDK is.
705
707
  const dependencies = projectDependencies(repo).filter((dep) => Object.hasOwn(SDK_TWINS, dep) || (!dep.startsWith('@volter/') && !dep.startsWith('pypi:volter')));
706
- const envNames = projectEnvNames(repo);
708
+ for (const name of declaredEnvironmentNames) {
709
+ if (typeof name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error('Declared vendor environment name must be an identifier');
710
+ }
711
+ const envNames = [...new Set([...projectEnvNames(repo), ...declaredEnvironmentNames])].sort();
707
712
  const npmRegistries = projectNpmRegistries(repo);
708
713
 
709
714
  // vendor → detection sources
@@ -871,7 +876,7 @@ export function coverWorld(world: string, repoPath: string, options: CoverageOpt
871
876
  const repo = resolve(repoPath);
872
877
  const inventory = worldTwinInventory(world, root);
873
878
 
874
- const { detected, unknown: unknownSources, uses, unknownUses } = detectRepoVendors(repo, inventory.services);
879
+ const { detected, unknown: unknownSources, uses, unknownUses } = detectRepoVendors(repo, inventory.services, options.declaredEnvironmentNames);
875
880
 
876
881
  // Does ANY injector vendor key exist for this vendor? Decides whether an UNINTERCEPTABLE
877
882
  // finding is acknowledgeable: a key that exists means the twin COULD be intercepted and the
@@ -0,0 +1,49 @@
1
+ /** Source-declared Go MySQL connection bindings; architecture, managed infrastructure. */
2
+ export type GoMysqlBinding = { dsnFormat: 'go-mysql'; endpoint: 'explicit-port' | 'default-port'; dsnPrefix: '' | 'mysql://'; dsnProtocol: '' | 'tcp' };
3
+
4
+ /** Only versioned, explicitly typed data selects a driver grammar. */
5
+ export function goMysqlBinding(entry: Record<string, unknown>, version: unknown, name: string, declaredPort: number): Partial<GoMysqlBinding> {
6
+ if (entry.dsnFormat === undefined) {
7
+ if (entry.endpoint !== undefined || entry.dsnPrefix !== undefined || entry.dsnProtocol !== undefined) throw new Error(`volter init: ${name} has DSN fields without a declared format`);
8
+ return {};
9
+ }
10
+ if ((version !== 6 && version !== 7) || entry.dsnFormat !== 'go-mysql' || entry.scheme !== 'mysql' || entry.withPort !== undefined
11
+ || (entry.endpoint !== 'explicit-port' && entry.endpoint !== 'default-port') || (entry.dsnPrefix !== '' && entry.dsnPrefix !== 'mysql://')
12
+ || (entry.dsnProtocol !== '' && entry.dsnProtocol !== 'tcp') || !Number.isInteger(entry.containerPort) || Number(entry.containerPort) < 1 || Number(entry.containerPort) > 65535
13
+ || (entry.endpoint === 'default-port' && entry.containerPort !== declaredPort)
14
+ || (entry.dsnPrefix === 'mysql://' && entry.endpoint === 'explicit-port')
15
+ || (entry.query !== undefined && (typeof entry.query !== 'string' || /[\u0000-\u001f\u007f]/u.test(entry.query)))) {
16
+ throw new Error(`volter init: ${name} needs a valid stack-needs version6/7 Go MySQL binding`);
17
+ }
18
+ return { dsnFormat: 'go-mysql', endpoint: entry.endpoint as GoMysqlBinding['endpoint'],
19
+ dsnPrefix: entry.dsnPrefix as GoMysqlBinding['dsnPrefix'], dsnProtocol: entry.dsnProtocol as GoMysqlBinding['dsnProtocol'] };
20
+ }
21
+
22
+ export function renderGoMysqlBinding(entry: GoMysqlBinding & { name: string; containerPort?: number; query?: string }, connection: URL, port: number): string {
23
+ if (connection.protocol !== 'mysql:' || entry.containerPort === undefined) throw new Error(`volter init: ${entry.name} needs a supplied MySQL endpoint and declared vendor port`);
24
+ if (entry.endpoint === 'default-port' && port !== entry.containerPort) throw new Error(`volter init: ${entry.name} needs a per-service host serving its declared default port; the shared-host high-port allocation cannot supply it`);
25
+ const user = decodeURIComponent(connection.username), password = decodeURIComponent(connection.password), database = decodeURIComponent(connection.pathname.slice(1));
26
+ if (!user || !password || !database || /[:\u0000-\u001f\u007f]/u.test(user) || /[\u0000-\u001f\u007f]/u.test(password)) throw new Error(`volter init: ${entry.name} cannot represent the supplied MySQL credentials in its declared driver grammar`);
27
+ if (entry.dsnPrefix === 'mysql://' && (entry.endpoint === 'explicit-port'
28
+ || !/^[A-Za-z0-9\-._~!$&'()*+,;=:%@]*$/u.test(`${user}:${password}`) || /%(?![A-Fa-f0-9]{2})/u.test(`${user}:${password}`))) {
29
+ throw new Error(`volter init: ${entry.name} cannot preserve its raw credentials/port through the declared mysql:// URL preparse`);
30
+ }
31
+ const address = `${connection.hostname}${entry.endpoint === 'explicit-port' ? `:${port}` : ''}`;
32
+ return `${entry.dsnPrefix}${user}:${password}@${entry.dsnProtocol}(${address})/${encodeURIComponent(database)}${entry.query !== undefined ? `?${entry.query}` : ''}`;
33
+ }
34
+
35
+
36
+ /** Match a declared DSN against its owned listener, never infer a grammar. */
37
+ export function goMysqlEndpoint(value: string, binding: GoMysqlBinding, declaredPort: number): { host: string; port: number } | undefined {
38
+ if (/[\u0000-\u001f\u007f]/u.test(value) || !value.startsWith(binding.dsnPrefix)) return undefined;
39
+ const body = value.slice(binding.dsnPrefix.length), slash = body.lastIndexOf('/');
40
+ if (slash < 0) return undefined;
41
+ const authority = body.slice(0, slash), at = authority.lastIndexOf('@');
42
+ const transport = authority.slice(at + 1), begin = `${binding.dsnProtocol}(`;
43
+ if (!transport.startsWith(begin) || !transport.endsWith(')')) return undefined;
44
+ const endpoint = /^(?:\[([^\]\s]+)\]|([^:\s/?#()]+))(?::([0-9]{1,5}))?$/u.exec(transport.slice(begin.length, -1));
45
+ if (!endpoint || (binding.endpoint === 'default-port' && endpoint[3] !== undefined)
46
+ || (binding.endpoint === 'explicit-port' && endpoint[3] === undefined)) return undefined;
47
+ const port = endpoint[3] === undefined ? declaredPort : Number(endpoint[3]);
48
+ return port >= 1 && port <= 65535 ? { host: endpoint[1] ?? endpoint[2]!, port } : undefined;
49
+ }
@@ -5,5 +5,5 @@ export interface GuestProcessLaunch { path: string; record: Omit<GuestProcessRec
5
5
  export function guestProcessDirectory(instanceDir: string): string;
6
6
  export function beginGuestProcess(directory: string, instance: string, guest: string, kind: GuestProcessRecord['kind']): GuestProcessLaunch;
7
7
  export function retainGuestProcess(receipt: GuestProcessLaunch, identity: SpawnIdentity): void;
8
- export function readGuestProcesses(instanceDir: string, expectedInstance?: string): GuestProcessReceipt[];
8
+ export function readGuestProcesses(instanceDir: string, expectedInstance?: string, onError?: (error: unknown) => void): GuestProcessReceipt[];
9
9
  export function releaseGuestProcesses(receipts: Array<{ path: string }>): void;
@@ -16,7 +16,9 @@ export function retainGuestProcess(receipt, identity) {
16
16
  receipt.record = { ...receipt.record, launching: false, ...identity };
17
17
  publishProcessReceipt(receipt.path, JSON.stringify(receipt.record));
18
18
  }
19
- export function readGuestProcesses(instanceDir, expectedInstance) {
19
+ // Teardown can retain each refusal while joining the other independently valid receipts.
20
+ // Other readers remain strict unless they explicitly collect these errors.
21
+ export function readGuestProcesses(instanceDir, expectedInstance, onError) {
20
22
  const directory = guestProcessDirectory(instanceDir);
21
23
  if (!existsSync(directory)) return [];
22
24
  if (lstatSync(directory).isSymbolicLink()) throw new Error('Guest receipt directory is a symbolic link; ownership retained');
@@ -26,19 +28,25 @@ export function readGuestProcesses(instanceDir, expectedInstance) {
26
28
  try { expectedInstance = JSON.parse(readFileSync(join(instanceDir, 'instance.json'), 'utf8')).instance; }
27
29
  catch { throw new Error('Guest instance identity unreadable; ownership retained'); }
28
30
  }
29
- return names.map(name => {
30
- const path = join(directory, name);
31
- if (!/^[0-9a-f-]+\.json$/.test(name) || !lstatSync(path).isFile() || lstatSync(path).isSymbolicLink()) throw new Error('Unresolved guest receipt publication; ownership retained');
32
- const receipt = readProcessReceipt(path, text => {
33
- const r = JSON.parse(text);
34
- if (r.instance !== expectedInstance || typeof r.guest !== 'string' || !['agent', 'command', 'pty'].includes(r.kind)
35
- || r.launching || !Number.isInteger(r.pid) || r.pid < 1 || (r.start !== undefined && (typeof r.start !== 'string' || !r.start))) {
36
- throw new Error('Guest launch or spawn identity unresolved');
37
- }
38
- return r;
39
- });
40
- if (receipt.error || !receipt.value) throw receipt.error ?? new Error('Guest receipt disappeared during inspection; ownership retained');
41
- return { path, record: receipt.value };
31
+ return names.flatMap(name => {
32
+ try {
33
+ const path = join(directory, name);
34
+ if (!/^[0-9a-f-]+\.json$/.test(name) || !lstatSync(path).isFile() || lstatSync(path).isSymbolicLink()) throw new Error('Unresolved guest receipt publication; ownership retained');
35
+ const receipt = readProcessReceipt(path, text => {
36
+ const r = JSON.parse(text);
37
+ if (r.instance !== expectedInstance || typeof r.guest !== 'string' || !['agent', 'command', 'pty'].includes(r.kind)
38
+ || r.launching || !Number.isInteger(r.pid) || r.pid < 1 || (r.start !== undefined && (typeof r.start !== 'string' || !r.start))) {
39
+ throw new Error('Guest launch or spawn identity unresolved');
40
+ }
41
+ return r;
42
+ });
43
+ if (receipt.error || !receipt.value) throw receipt.error ?? new Error('Guest receipt disappeared during inspection; ownership retained');
44
+ return [{ path, record: receipt.value }];
45
+ } catch (error) {
46
+ if (!onError) throw error;
47
+ onError(error);
48
+ return [];
49
+ }
42
50
  });
43
51
  }
44
52
  // The caller must have confirmed each recorded group absent before releasing these receipts.
package/src/host-cli.ts CHANGED
@@ -15,6 +15,9 @@ import { dirname, join } from 'node:path';
15
15
  import { startColocatedHost, type ColocatedTwinSpec, type HostIsolation } from './host.ts';
16
16
  import { publishHostReady } from './host-ready.ts';
17
17
 
18
+ // When this entry runs: the host's own modules are loaded; each twin's are loaded by its factory (S2 R38).
19
+ const began = Date.now();
20
+ const startedAt: Record<string, number> = {};
18
21
  const argv = process.argv.slice(2);
19
22
  const opt = (name: string, fb = ''): string => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] ? argv[i + 1]! : fb; };
20
23
 
@@ -82,6 +85,7 @@ function recordGaveUp(twin: string, exits: number, detail: string): void {
82
85
  const host = await startColocatedHost(specs, {
83
86
  isolation,
84
87
  onEvent: (e) => {
88
+ if (e.kind === 'started') startedAt[e.twin] = Date.now();
85
89
  process.stdout.write(`event twin=${e.twin} kind=${e.kind}${e.detail ? ` detail=${e.detail}` : ''}\n`);
86
90
  if (e.kind === 'gaveup') recordGaveUp(e.twin, e.exits ?? 0, e.detail ?? '');
87
91
  },
@@ -91,9 +95,11 @@ process.on('SIGTERM', shutdown);
91
95
  process.on('SIGINT', shutdown);
92
96
  const readyFile = opt('--ready-file');
93
97
  if (readyFile) {
94
- try { publishHostReady(readyFile, process.env.VOLTER_WORLD_BOOT_ID ?? '', host.ports); }
98
+ try { publishHostReady(readyFile, process.env.VOLTER_WORLD_BOOT_ID ?? '', host.ports, { began, started: startedAt }, host.streams); }
95
99
  catch (error) { await host.stop(); throw error; }
96
100
  }
101
+ // the twins' cold first requests, taken in the host's idle time after its receipt rather than by the application's first calls
102
+ void host.warm();
97
103
  for (const [id, port] of Object.entries(host.ports)) process.stdout.write(`ready id=${id} port=${port}\n`);
98
104
  process.stdout.write(`host ready (${specs.length} twins, isolation=${isolation})\n`);
99
105
  await keepProcessAlive();
package/src/host-ready.ts CHANGED
@@ -1,12 +1,22 @@
1
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
1
+ import { type FSWatcher, mkdirSync, readFileSync, renameSync, watch, writeFileSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
 
4
+ /** When the host's own start reached each step, in epoch milliseconds: its entry running (its static modules loaded),
5
+ * and each twin's factory returning. */
6
+ export type HostStartTimes = { began: number; started: Record<string, number> };
7
+
8
+ /** What a receipt says of the host's start, for the runtime's progress line, and which of its twins' main ports are byte
9
+ * streams (host.ts servesByteStream), for their admission; absent from a host that predates it. */
10
+ export type HostReadyTimes = { at?: number; streams?: string[] } & Partial<HostStartTimes>;
11
+
4
12
  /** Only the host, after all starts have returned and ports were checked, publishes this receipt. */
5
- export function publishHostReady(path: string, boot: string, ports: Record<string, number>): void {
13
+ export function publishHostReady(path: string, boot: string, ports: Record<string, number>, times?: HostStartTimes, streams?: readonly string[]): void {
6
14
  if (!boot) throw new Error('A co-located host readiness receipt requires its World boot identity.');
7
15
  mkdirSync(dirname(path), { recursive: true });
8
16
  const temporary = `${path}.${process.pid}.tmp`;
9
- writeFileSync(temporary, `${JSON.stringify({ version: 1, boot, ports })}\n`, { mode: 0o600 });
17
+ // `at`, when it was published, lets the runtime say how long its read came after, and `times` where the host's own
18
+ // start went before it (S2 R28, R38).
19
+ writeFileSync(temporary, `${JSON.stringify({ version: 1, boot, ports, at: Date.now(), ...times, ...(streams?.length ? { streams } : {}) })}\n`, { mode: 0o600 });
10
20
  renameSync(temporary, path);
11
21
  }
12
22
 
@@ -16,35 +26,56 @@ export function publishHostReady(path: string, boot: string, ports: Record<strin
16
26
  * one: each change in `progress` (the host log's size, which grows with every
17
27
  * twin it starts) begins the bound again, so a host still starting its twins
18
28
  * on a busy machine is waited for, and one silent for `timeoutMs` is not.
29
+ * Resolves with what the receipt says of the host's start: when it was published, its steps before, and which declared
30
+ * twins' main ports are byte streams.
19
31
  */
20
- export async function waitForHostReady(path: string, boot: string, ports: ReadonlyMap<string, number>, timeoutMs: number, abort: AbortSignal, progress: () => number = () => 0): Promise<void> {
21
- let started = Date.now();
22
- let seen = progress();
23
- for (;;) {
24
- abort.throwIfAborted();
25
- let bytes: Buffer | undefined;
26
- try { bytes = readFileSync(path); }
27
- catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
28
- if (bytes) {
29
- if (bytes.byteLength > 256 * 1024) throw new Error('The co-located host readiness receipt exceeds its bounds.');
30
- const value = JSON.parse(bytes.toString('utf8')) as { version?: unknown; boot?: unknown; ports?: unknown } | null;
31
- if (!value || value.version !== 1 || value.boot !== boot || !value.ports || typeof value.ports !== 'object' || Array.isArray(value.ports)) {
32
- throw new Error('The co-located host readiness receipt does not belong to this boot.');
33
- }
34
- const answered = value.ports as Record<string, unknown>;
35
- if (Object.keys(answered).length !== ports.size || [...ports].some(([id, port]) => !Object.hasOwn(answered, id) || answered[id] !== port)) {
36
- throw new Error('The co-located host readiness receipt does not cover its declared ports.');
32
+ export async function waitForHostReady(path: string, boot: string, ports: ReadonlyMap<string, number>, timeoutMs: number, abort: AbortSignal, progress: () => number = () => 0): Promise<HostReadyTimes> {
33
+ // A change in the receipt's directory (the host's rename is one) wakes the wait at once, where the 25 ms timer
34
+ // alone left a published receipt unread for up to a tick (S2 R28). The timer stays: a filesystem that gives no
35
+ // change events is read as before. Any other write in the directory wakes it too, and costs one read.
36
+ let wake: (() => void) | undefined;
37
+ let watcher: FSWatcher | undefined;
38
+ try {
39
+ watcher = watch(dirname(path), { persistent: false }, () => wake?.());
40
+ watcher.on('error', () => { watcher?.close(); watcher = undefined; });
41
+ } catch { /* no change events here: the timer alone */ }
42
+ try {
43
+ let started = Date.now();
44
+ let seen = progress();
45
+ for (;;) {
46
+ abort.throwIfAborted();
47
+ let bytes: Buffer | undefined;
48
+ try { bytes = readFileSync(path); }
49
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
50
+ if (bytes) {
51
+ if (bytes.byteLength > 256 * 1024) throw new Error('The co-located host readiness receipt exceeds its bounds.');
52
+ const value = JSON.parse(bytes.toString('utf8')) as { version?: unknown; boot?: unknown; ports?: unknown; at?: unknown; began?: unknown; started?: unknown; streams?: unknown } | null;
53
+ if (!value || value.version !== 1 || value.boot !== boot || !value.ports || typeof value.ports !== 'object' || Array.isArray(value.ports)) {
54
+ throw new Error('The co-located host readiness receipt does not belong to this boot.');
55
+ }
56
+ const answered = value.ports as Record<string, unknown>;
57
+ if (Object.keys(answered).length !== ports.size || [...ports].some(([id, port]) => !Object.hasOwn(answered, id) || answered[id] !== port)) {
58
+ throw new Error('The co-located host readiness receipt does not cover its declared ports.');
59
+ }
60
+ const when = (time: unknown): number | undefined => typeof time === 'number' && Number.isFinite(time) ? time : undefined;
61
+ // only the declared twins' times: an id the runtime did not declare never reaches its progress line
62
+ const twinStarts = value.started && typeof value.started === 'object' && !Array.isArray(value.started)
63
+ ? Object.fromEntries(Object.entries(value.started as Record<string, unknown>).flatMap(([id, time]) => !ports.has(id) || when(time) === undefined ? [] : [[id, time as number]]))
64
+ : undefined;
65
+ const streams = Array.isArray(value.streams) ? value.streams.filter((id): id is string => typeof id === 'string' && ports.has(id)) : [];
66
+ return { at: when(value.at), began: when(value.began), ...(twinStarts ? { started: twinStarts } : {}), ...(streams.length ? { streams } : {}) };
37
67
  }
38
- return;
68
+ const now = progress();
69
+ if (now !== seen) { seen = now; started = Date.now(); }
70
+ if (Date.now() - started >= timeoutMs) throw new Error(`The co-located host made no progress for ${timeoutMs} ms before completing every declared start.`);
71
+ await new Promise<void>((resolve, reject) => {
72
+ const settle = (): void => { clearTimeout(timer); wake = undefined; abort.removeEventListener('abort', cancel); };
73
+ const cancel = (): void => { settle(); reject(abort.reason); };
74
+ const timer = setTimeout(() => { settle(); resolve(); }, Math.min(25, Math.max(1, timeoutMs - (Date.now() - started))));
75
+ wake = () => { settle(); resolve(); };
76
+ abort.addEventListener('abort', cancel, { once: true });
77
+ if (abort.aborted) cancel();
78
+ });
39
79
  }
40
- const now = progress();
41
- if (now !== seen) { seen = now; started = Date.now(); }
42
- if (Date.now() - started >= timeoutMs) throw new Error(`The co-located host made no progress for ${timeoutMs} ms before completing every declared start.`);
43
- await new Promise<void>((resolve, reject) => {
44
- const cancel = (): void => { clearTimeout(timer); abort.removeEventListener('abort', cancel); reject(abort.reason); };
45
- const timer = setTimeout(() => { abort.removeEventListener('abort', cancel); resolve(); }, Math.min(25, Math.max(1, timeoutMs - (Date.now() - started))));
46
- abort.addEventListener('abort', cancel, { once: true });
47
- if (abort.aborted) cancel();
48
- });
49
- }
80
+ } finally { watcher?.close(); }
50
81
  }
@@ -4,7 +4,7 @@ import { getPack, registerPack, type TwinPack } from '@volter/world-core/runtime
4
4
  // host does NOT restart it: it gives up on the worker (fires a `gaveup` event → doctor
5
5
  // red) without disturbing sibling twins. Posts {type:'ready'} once listening.
6
6
  import { parentPort, workerData } from 'node:worker_threads';
7
- import type { ColocatedTwinSpec } from './host.ts';
7
+ import { servesByteStream, servesDiscovery, warmInProcess, type ColocatedTwinSpec } from './host.ts';
8
8
  import { importModule } from './import-module.ts';
9
9
 
10
10
  const spec = workerData as ColocatedTwinSpec;
@@ -23,6 +23,10 @@ if (typeof factory !== 'function') {
23
23
 
24
24
  // Awaited: an async factory (fly) must post `ready` only once its listener is bound.
25
25
  const server = await factory({ ...(spec.sideOptions ?? {}), port: spec.port, root: spec.root, readOnly: spec.readOnly, ...(spec.database !== undefined ? { database: spec.database } : {}) });
26
- parentPort?.postMessage({ type: 'ready', port: server.port });
26
+ parentPort?.postMessage({ type: 'ready', port: server.port, byteStream: servesByteStream(mod, descriptor), discovery: servesDiscovery(mod, descriptor) });
27
+ // the host's warm-up, taken here where this twin's server and its handler are (host.ts warmInProcess)
28
+ parentPort?.on('message', (msg: { type?: string }) => {
29
+ if (msg?.type === 'warm') void warmInProcess(server.port).then(() => parentPort?.postMessage({ type: 'warmed' }));
30
+ });
27
31
 
28
32
  // The Bun.serve listener keeps this worker's event loop alive; nothing else to do.
package/src/host.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { errorMessage } from './error-message.ts';
2
- import { getPack, registerPack, type TwinPack } from '@volter/world-core/runtime';
2
+ import { getPack, registerPack, servedHandlerOf, type TwinPack } from '@volter/world-core/runtime';
3
3
  // Co-located twin host — run many twins in ONE process instead of one OS process
4
4
  // per twin. The twin core is a pure, transport-agnostic handler (see a pack's
5
5
  // `handle<Vendor>TwinRequest`), so nothing requires a process per twin; this host
@@ -53,6 +53,12 @@ export type ColocatedHost = {
53
53
  isolation: HostIsolation;
54
54
  /** Resolved listening port per twin id (equals the requested port). */
55
55
  ports: Record<string, number>;
56
+ /** The twins whose main port is a byte stream (servesByteStream). */
57
+ streams: string[];
58
+ /** The twins whose main port answers the discovery door (GET /twin: the manifest declares `discovery`), warmed by it. */
59
+ discoverable: string[];
60
+ /** Warm every discoverable twin (warmInProcess, in its own thread under worker isolation); never needed for readiness. */
61
+ warm: () => Promise<void>;
56
62
  stop: () => Promise<void>;
57
63
  };
58
64
 
@@ -65,7 +71,58 @@ export type ColocatedHost = {
65
71
  type TwinServer = { port: number; stop: () => void | Promise<void> };
66
72
  type TwinServerFactory = (opts: { port?: number; root?: string; readOnly?: boolean; scenarioPath?: string; database?: string; [side: string]: unknown }) => TwinServer | Promise<TwinServer>;
67
73
 
68
- async function loadFactory(spec: ColocatedTwinSpec): Promise<TwinServerFactory> {
74
+ /** Whether a twin's main port is a byte stream: a raw-tcp pack exporting exactly one `create<Name>TwinStream` is taken
75
+ * to serve that stream there (world-core serveStream: an SMTP relay greets, a Redis server answers RESP). Such a port
76
+ * cannot answer an HTTP request, so the runtime admits it by its TCP answer (runtime.ts, startColocatedServices). Read
77
+ * off the module's own exports, as its serve factory is (twin-standard pack-facts.ts serveExport). A pack whose main
78
+ * port serves HTTP or gRPC must not declare raw-tcp with a stream export (create-pack refuses it). */
79
+ export function servesByteStream(mod: Record<string, unknown>, descriptor: TwinPack): boolean {
80
+ return descriptor.transport === 'raw-tcp'
81
+ && Object.keys(mod).filter((key) => /^create\w*TwinStream$/.test(key) && typeof mod[key] === 'function').length === 1;
82
+ }
83
+
84
+ /** Whether a twin's main port answers the discovery door: its manifest declares `discovery`, and its port is HTTP. */
85
+ export function servesDiscovery(mod: Record<string, unknown>, descriptor: TwinPack): boolean {
86
+ const manifest = mod.manifest as { discovery?: unknown } | undefined;
87
+ return manifest?.discovery !== undefined && !servesByteStream(mod, descriptor);
88
+ }
89
+
90
+ /** The most twins warmed at once (S2 R41). */
91
+ const WARM_CONCURRENCY = 2;
92
+ /** A warm-up's bound: one that has not finished by then is left, its twin as it is. */
93
+ const WARM_TIMEOUT_MS = 2000;
94
+
95
+ /**
96
+ * Each twin's first request pays its pipeline's cold start (decoding, routing, the pack's front, first-run code), which
97
+ * otherwise falls on the application's first call to it. Its size for an HTTP twin is unmeasured (a byte-stream twin's
98
+ * first SMTP greeting took 57 ms against 1.8 ms later, e946aa4c run 3 on the mini, but that twin is skipped here); the
99
+ * R38 timing lap sizes it. The host takes it in its own idle time once its
100
+ * receipt is published: one GET of the twin's discovery door, handed in this process to the handler serving its port
101
+ * (world-core servedHandlerOf), never on the wire and never in its request journal. Bounded, its answer and any failure
102
+ * ignored; a request the application sends first is served as it would have been, and simply is that twin's warm-up.
103
+ * The discovery door reads no stored state, so a twin's first state read stays cold.
104
+ */
105
+ export async function warmInProcess(port: number): Promise<void> {
106
+ const handler = servedHandlerOf(port);
107
+ if (!handler) return;
108
+ let timer: ReturnType<typeof setTimeout> | undefined;
109
+ try {
110
+ await Promise.race([
111
+ (async () => { const response = await handler(new Request(`http://127.0.0.1:${port}/twin`)); await response?.arrayBuffer(); })(),
112
+ new Promise<void>((resolve) => { timer = setTimeout(resolve, WARM_TIMEOUT_MS); timer.unref?.(); }),
113
+ ]);
114
+ } catch { /* a warm-up that fails leaves its twin as it was */ } finally { if (timer !== undefined) clearTimeout(timer); }
115
+ }
116
+
117
+ /** Each of `ids` warmed by `warm`, at most WARM_CONCURRENCY at once. */
118
+ async function warmEach(ids: readonly string[], warm: (id: string) => Promise<void>): Promise<void> {
119
+ const queue = [...ids];
120
+ await Promise.all(Array.from({ length: Math.min(WARM_CONCURRENCY, queue.length) }, async () => {
121
+ for (let id = queue.shift(); id !== undefined; id = queue.shift()) await warm(id);
122
+ }));
123
+ }
124
+
125
+ async function loadFactory(spec: ColocatedTwinSpec): Promise<{ factory: TwinServerFactory; byteStream: boolean; discovery: boolean }> {
69
126
  const mod = await importModule(spec.module);
70
127
  const descriptor = mod.pack as TwinPack | undefined ?? getPack(spec.id);
71
128
  if (!descriptor || descriptor.vendor !== spec.id) throw new Error(`Twin ${spec.id}: the loaded module declares no matching pack descriptor`);
@@ -74,7 +131,7 @@ async function loadFactory(spec: ColocatedTwinSpec): Promise<TwinServerFactory>
74
131
  if (typeof factory !== 'function') {
75
132
  throw new Error(`Twin "${spec.id}": ${spec.module} has no factory export "${spec.export}"`);
76
133
  }
77
- return factory as TwinServerFactory;
134
+ return { factory: factory as TwinServerFactory, byteStream: servesByteStream(mod, descriptor), discovery: servesDiscovery(mod, descriptor) };
78
135
  }
79
136
 
80
137
  const workerEntry = (): URL => pathToFileURL(siblingScript(import.meta.url, 'host-worker'));
@@ -100,6 +157,8 @@ export async function startColocatedHost(
100
157
  const onEvent = options.onEvent ?? (() => {});
101
158
  const ports: Record<string, number> = {};
102
159
  for (const spec of specs) ports[spec.id] = spec.port;
160
+ const streams: string[] = [];
161
+ const discoverable: string[] = [];
103
162
 
104
163
  if (isolation === 'shared') {
105
164
  // Keep one twin's stray async rejection / uncaught throw from killing every other twin in
@@ -128,7 +187,9 @@ export async function startColocatedHost(
128
187
  };
129
188
  try {
130
189
  await parallelStartup(specs, async (spec) => {
131
- const factory = await loadFactory(spec);
190
+ const { factory, byteStream, discovery } = await loadFactory(spec);
191
+ if (byteStream) streams.push(spec.id);
192
+ if (discovery) discoverable.push(spec.id);
132
193
  const server = await factory({ ...(spec.sideOptions ?? {}), port: spec.port, root: spec.root, readOnly: spec.readOnly, ...(spec.scenarioPath !== undefined ? { scenarioPath: spec.scenarioPath } : {}), ...(spec.database !== undefined ? { database: spec.database } : {}) });
133
194
  // Record the handle before validating it, so rollback also owns a bad factory result.
134
195
  servers.push(server);
@@ -148,6 +209,9 @@ export async function startColocatedHost(
148
209
  return {
149
210
  isolation,
150
211
  ports,
212
+ streams,
213
+ discoverable,
214
+ warm: () => warmEach(discoverable, (id) => warmInProcess(ports[id]!)),
151
215
  stop: async () => {
152
216
  // Every stop runs, even when a sibling hangs or throws. Host shutdown
153
217
  // still resolves within its budget so the owning runtime can retire it.
@@ -169,13 +233,18 @@ export async function startColocatedHost(
169
233
  // below would emit a 2nd `spawn` for the same twin, which the isolation test asserts against.
170
234
  onEvent({ twin: spec.id, kind: 'spawn', detail: 'worker started' });
171
235
  let ready = false;
172
- worker.on('message', (msg: { type?: string; port?: number }) => {
236
+ worker.on('message', (msg: { type?: string; port?: number; byteStream?: boolean; discovery?: boolean }) => {
173
237
  if (msg?.type === 'ready') {
174
238
  if (typeof msg.port !== 'number' || !Number.isInteger(msg.port) || msg.port < 1 || msg.port > 65535 || (spec.port !== 0 && msg.port !== spec.port)) {
175
239
  rejectReady(new Error(`Twin "${spec.id}" bound port ${msg.port}, requested ${spec.port}.`)); return;
176
240
  }
177
241
  ports[spec.id] = msg.port;
178
- ready = true; resolveReady();
242
+ if (msg.byteStream === true) streams.push(spec.id);
243
+ if (msg.discovery === true) discoverable.push(spec.id);
244
+ ready = true;
245
+ // the same progress, and the same start time for the receipt (host-cli.ts), as a shared twin's start
246
+ onEvent({ twin: spec.id, kind: 'started', detail: `port ${msg.port} at ${Math.round(performance.now())} ms` });
247
+ resolveReady();
179
248
  }
180
249
  });
181
250
  worker.on('error', (err) => {
@@ -205,6 +274,19 @@ export async function startColocatedHost(
205
274
  return {
206
275
  isolation,
207
276
  ports,
277
+ streams,
278
+ discoverable,
279
+ // a worker twin is warmed in its own thread, where its server's handler is: asked, and answered once done
280
+ warm: () => warmEach(discoverable, (id) => new Promise<void>((resolve) => {
281
+ const worker = workers.get(id);
282
+ if (!worker) { resolve(); return; }
283
+ const done = (): void => { clearTimeout(timer); worker.off('message', warmed); worker.off('exit', done); resolve(); };
284
+ const warmed = (msg: { type?: string }): void => { if (msg?.type === 'warmed') done(); };
285
+ const timer = setTimeout(done, WARM_TIMEOUT_MS);
286
+ worker.on('message', warmed);
287
+ worker.once('exit', done);
288
+ try { worker.postMessage({ type: 'warm' }); } catch { done(); }
289
+ })),
208
290
  stop: async () => {
209
291
  stopping.value = true;
210
292
  await Promise.all([...workers.values()].map((w) => w.terminate()));