@volter/world-runtime 3.0.163 → 3.0.165

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 +9 -3
  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 +446 -19
  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 +9 -4
  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 +417 -18
  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
@@ -528,11 +528,11 @@ function usesOf(signals) {
528
528
  }
529
529
  /** Detect every vendor (and every unmapped external-service-shaped signal) an application repo
530
530
  * betrays: mapped npm dependencies across the root + all workspace members, vendor-shaped env
531
- * names in committed `.env*` files, the SMTP/raw-protocol signal, and literal vendor URLs passed
531
+ * names in committed `.env*` files or its declared stack, the SMTP/raw-protocol signal, and literal vendor URLs passed
532
532
  * directly to fetch in production source. URL attribution reads the injector's VENDOR_HOSTS table
533
533
  * rather than re-encoding host rules here. Insertion order follows the sorted dependency, env-name,
534
534
  * and source lists, so callers that preserve it are deterministic. */
535
- export function detectRepoVendors(repoPath, services = []) {
535
+ export function detectRepoVendors(repoPath, services = [], declaredEnvironmentNames = []) {
536
536
  // Application aliases are names the selected backing actually injects, never global vendor adoption.
537
537
  const backingEnvVendors = new Map();
538
538
  for (const service of services) {
@@ -548,7 +548,11 @@ export function detectRepoVendors(repoPath, services = []) {
548
548
  // A Volter package a pack names as its vendor's SDK is one (`@volter/identity`, the Volter identity
549
549
  // service's client, is the volteridentity twin's), as any vendor's SDK is.
550
550
  const dependencies = projectDependencies(repo).filter((dep) => Object.hasOwn(SDK_TWINS, dep) || (!dep.startsWith('@volter/') && !dep.startsWith('pypi:volter')));
551
- const envNames = projectEnvNames(repo);
551
+ for (const name of declaredEnvironmentNames) {
552
+ if (typeof name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
553
+ throw new Error('Declared vendor environment name must be an identifier');
554
+ }
555
+ const envNames = [...new Set([...projectEnvNames(repo), ...declaredEnvironmentNames])].sort();
552
556
  const npmRegistries = projectNpmRegistries(repo);
553
557
  // vendor → detection sources
554
558
  const detected = new Map();
@@ -734,7 +738,7 @@ export function coverWorld(world, repoPath, options = {}) {
734
738
  }
735
739
  const repo = resolve(repoPath);
736
740
  const inventory = worldTwinInventory(world, root);
737
- const { detected, unknown: unknownSources, uses, unknownUses } = detectRepoVendors(repo, inventory.services);
741
+ const { detected, unknown: unknownSources, uses, unknownUses } = detectRepoVendors(repo, inventory.services, options.declaredEnvironmentNames);
738
742
  // Does ANY injector vendor key exist for this vendor? Decides whether an UNINTERCEPTABLE
739
743
  // finding is acknowledgeable: a key that exists means the twin COULD be intercepted and the
740
744
  // wiring is merely wrong (fix it, don't acknowledge it); no key at all means the pack is a
@@ -0,0 +1,19 @@
1
+ /** Source-declared Go MySQL connection bindings; architecture, managed infrastructure. */
2
+ export type GoMysqlBinding = {
3
+ dsnFormat: 'go-mysql';
4
+ endpoint: 'explicit-port' | 'default-port';
5
+ dsnPrefix: '' | 'mysql://';
6
+ dsnProtocol: '' | 'tcp';
7
+ };
8
+ /** Only versioned, explicitly typed data selects a driver grammar. */
9
+ export declare function goMysqlBinding(entry: Record<string, unknown>, version: unknown, name: string, declaredPort: number): Partial<GoMysqlBinding>;
10
+ export declare function renderGoMysqlBinding(entry: GoMysqlBinding & {
11
+ name: string;
12
+ containerPort?: number;
13
+ query?: string;
14
+ }, connection: URL, port: number): string;
15
+ /** Match a declared DSN against its owned listener, never infer a grammar. */
16
+ export declare function goMysqlEndpoint(value: string, binding: GoMysqlBinding, declaredPort: number): {
17
+ host: string;
18
+ port: number;
19
+ } | undefined;
@@ -0,0 +1,51 @@
1
+ /** Only versioned, explicitly typed data selects a driver grammar. */
2
+ export function goMysqlBinding(entry, version, name, declaredPort) {
3
+ if (entry.dsnFormat === undefined) {
4
+ if (entry.endpoint !== undefined || entry.dsnPrefix !== undefined || entry.dsnProtocol !== undefined)
5
+ throw new Error(`volter init: ${name} has DSN fields without a declared format`);
6
+ return {};
7
+ }
8
+ if ((version !== 6 && version !== 7) || entry.dsnFormat !== 'go-mysql' || entry.scheme !== 'mysql' || entry.withPort !== undefined
9
+ || (entry.endpoint !== 'explicit-port' && entry.endpoint !== 'default-port') || (entry.dsnPrefix !== '' && entry.dsnPrefix !== 'mysql://')
10
+ || (entry.dsnProtocol !== '' && entry.dsnProtocol !== 'tcp') || !Number.isInteger(entry.containerPort) || Number(entry.containerPort) < 1 || Number(entry.containerPort) > 65535
11
+ || (entry.endpoint === 'default-port' && entry.containerPort !== declaredPort)
12
+ || (entry.dsnPrefix === 'mysql://' && entry.endpoint === 'explicit-port')
13
+ || (entry.query !== undefined && (typeof entry.query !== 'string' || /[\u0000-\u001f\u007f]/u.test(entry.query)))) {
14
+ throw new Error(`volter init: ${name} needs a valid stack-needs version6/7 Go MySQL binding`);
15
+ }
16
+ return { dsnFormat: 'go-mysql', endpoint: entry.endpoint,
17
+ dsnPrefix: entry.dsnPrefix, dsnProtocol: entry.dsnProtocol };
18
+ }
19
+ export function renderGoMysqlBinding(entry, connection, port) {
20
+ if (connection.protocol !== 'mysql:' || entry.containerPort === undefined)
21
+ throw new Error(`volter init: ${entry.name} needs a supplied MySQL endpoint and declared vendor port`);
22
+ if (entry.endpoint === 'default-port' && port !== entry.containerPort)
23
+ 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`);
24
+ const user = decodeURIComponent(connection.username), password = decodeURIComponent(connection.password), database = decodeURIComponent(connection.pathname.slice(1));
25
+ if (!user || !password || !database || /[:\u0000-\u001f\u007f]/u.test(user) || /[\u0000-\u001f\u007f]/u.test(password))
26
+ 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
+ /** Match a declared DSN against its owned listener, never infer a grammar. */
35
+ export function goMysqlEndpoint(value, binding, declaredPort) {
36
+ if (/[\u0000-\u001f\u007f]/u.test(value) || !value.startsWith(binding.dsnPrefix))
37
+ return undefined;
38
+ const body = value.slice(binding.dsnPrefix.length), slash = body.lastIndexOf('/');
39
+ if (slash < 0)
40
+ 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(')'))
44
+ return undefined;
45
+ const endpoint = /^(?:\[([^\]\s]+)\]|([^:\s/?#()]+))(?::([0-9]{1,5}))?$/u.exec(transport.slice(begin.length, -1));
46
+ if (!endpoint || (binding.endpoint === 'default-port' && endpoint[3] !== undefined)
47
+ || (binding.endpoint === 'explicit-port' && endpoint[3] === undefined))
48
+ return undefined;
49
+ const port = endpoint[3] === undefined ? declaredPort : Number(endpoint[3]);
50
+ return port >= 1 && port <= 65535 ? { host: endpoint[1] ?? endpoint[2], port } : undefined;
51
+ }
@@ -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.
@@ -14,6 +14,9 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from '
14
14
  import { dirname, join } from 'node:path';
15
15
  import { startColocatedHost } from "./host.js";
16
16
  import { publishHostReady } from "./host-ready.js";
17
+ // When this entry runs: the host's own modules are loaded; each twin's are loaded by its factory (S2 R38).
18
+ const began = Date.now();
19
+ const startedAt = {};
17
20
  const argv = process.argv.slice(2);
18
21
  const opt = (name, fb = '') => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] ? argv[i + 1] : fb; };
19
22
  const isolation = opt('--isolation', 'shared');
@@ -83,6 +86,8 @@ function recordGaveUp(twin, exits, detail) {
83
86
  const host = await startColocatedHost(specs, {
84
87
  isolation,
85
88
  onEvent: (e) => {
89
+ if (e.kind === 'started')
90
+ startedAt[e.twin] = Date.now();
86
91
  process.stdout.write(`event twin=${e.twin} kind=${e.kind}${e.detail ? ` detail=${e.detail}` : ''}\n`);
87
92
  if (e.kind === 'gaveup')
88
93
  recordGaveUp(e.twin, e.exits ?? 0, e.detail ?? '');
@@ -94,13 +99,15 @@ process.on('SIGINT', shutdown);
94
99
  const readyFile = opt('--ready-file');
95
100
  if (readyFile) {
96
101
  try {
97
- publishHostReady(readyFile, process.env.VOLTER_WORLD_BOOT_ID ?? '', host.ports);
102
+ publishHostReady(readyFile, process.env.VOLTER_WORLD_BOOT_ID ?? '', host.ports, { began, started: startedAt }, host.streams);
98
103
  }
99
104
  catch (error) {
100
105
  await host.stop();
101
106
  throw error;
102
107
  }
103
108
  }
109
+ // the twins' cold first requests, taken in the host's idle time after its receipt rather than by the application's first calls
110
+ void host.warm();
104
111
  for (const [id, port] of Object.entries(host.ports))
105
112
  process.stdout.write(`ready id=${id} port=${port}\n`);
106
113
  process.stdout.write(`host ready (${specs.length} twins, isolation=${isolation})\n`);
@@ -1,10 +1,24 @@
1
+ /** When the host's own start reached each step, in epoch milliseconds: its entry running (its static modules loaded),
2
+ * and each twin's factory returning. */
3
+ export type HostStartTimes = {
4
+ began: number;
5
+ started: Record<string, number>;
6
+ };
7
+ /** What a receipt says of the host's start, for the runtime's progress line, and which of its twins' main ports are byte
8
+ * streams (host.ts servesByteStream), for their admission; absent from a host that predates it. */
9
+ export type HostReadyTimes = {
10
+ at?: number;
11
+ streams?: string[];
12
+ } & Partial<HostStartTimes>;
1
13
  /** Only the host, after all starts have returned and ports were checked, publishes this receipt. */
2
- export declare function publishHostReady(path: string, boot: string, ports: Record<string, number>): void;
14
+ export declare function publishHostReady(path: string, boot: string, ports: Record<string, number>, times?: HostStartTimes, streams?: readonly string[]): void;
3
15
  /**
4
16
  * Receipt admission is cancellation-aware; stale or malformed state is never
5
17
  * readiness. `timeoutMs` bounds a host that stops making progress, not a slow
6
18
  * one: each change in `progress` (the host log's size, which grows with every
7
19
  * twin it starts) begins the bound again, so a host still starting its twins
8
20
  * on a busy machine is waited for, and one silent for `timeoutMs` is not.
21
+ * Resolves with what the receipt says of the host's start: when it was published, its steps before, and which declared
22
+ * twins' main ports are byte streams.
9
23
  */
10
- export declare function waitForHostReady(path: string, boot: string, ports: ReadonlyMap<string, number>, timeoutMs: number, abort: AbortSignal, progress?: () => number): Promise<void>;
24
+ export declare function waitForHostReady(path: string, boot: string, ports: ReadonlyMap<string, number>, timeoutMs: number, abort: AbortSignal, progress?: () => number): Promise<HostReadyTimes>;
@@ -1,12 +1,14 @@
1
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
1
+ import { mkdirSync, readFileSync, renameSync, watch, writeFileSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  /** Only the host, after all starts have returned and ports were checked, publishes this receipt. */
4
- export function publishHostReady(path, boot, ports) {
4
+ export function publishHostReady(path, boot, ports, times, streams) {
5
5
  if (!boot)
6
6
  throw new Error('A co-located host readiness receipt requires its World boot identity.');
7
7
  mkdirSync(dirname(path), { recursive: true });
8
8
  const temporary = `${path}.${process.pid}.tmp`;
9
- writeFileSync(temporary, `${JSON.stringify({ version: 1, boot, ports })}\n`, { mode: 0o600 });
9
+ // `at`, when it was published, lets the runtime say how long its read came after, and `times` where the host's own
10
+ // start went before it (S2 R28, R38).
11
+ writeFileSync(temporary, `${JSON.stringify({ version: 1, boot, ports, at: Date.now(), ...times, ...(streams?.length ? { streams } : {}) })}\n`, { mode: 0o600 });
10
12
  renameSync(temporary, path);
11
13
  }
12
14
  /**
@@ -15,46 +17,71 @@ export function publishHostReady(path, boot, ports) {
15
17
  * one: each change in `progress` (the host log's size, which grows with every
16
18
  * twin it starts) begins the bound again, so a host still starting its twins
17
19
  * on a busy machine is waited for, and one silent for `timeoutMs` is not.
20
+ * Resolves with what the receipt says of the host's start: when it was published, its steps before, and which declared
21
+ * twins' main ports are byte streams.
18
22
  */
19
23
  export async function waitForHostReady(path, boot, ports, timeoutMs, abort, progress = () => 0) {
20
- let started = Date.now();
21
- let seen = progress();
22
- for (;;) {
23
- abort.throwIfAborted();
24
- let bytes;
25
- try {
26
- bytes = readFileSync(path);
27
- }
28
- catch (error) {
29
- if (error.code !== 'ENOENT')
30
- throw error;
31
- }
32
- if (bytes) {
33
- if (bytes.byteLength > 256 * 1024)
34
- throw new Error('The co-located host readiness receipt exceeds its bounds.');
35
- const value = JSON.parse(bytes.toString('utf8'));
36
- if (!value || value.version !== 1 || value.boot !== boot || !value.ports || typeof value.ports !== 'object' || Array.isArray(value.ports)) {
37
- throw new Error('The co-located host readiness receipt does not belong to this boot.');
24
+ // A change in the receipt's directory (the host's rename is one) wakes the wait at once, where the 25 ms timer
25
+ // alone left a published receipt unread for up to a tick (S2 R28). The timer stays: a filesystem that gives no
26
+ // change events is read as before. Any other write in the directory wakes it too, and costs one read.
27
+ let wake;
28
+ let watcher;
29
+ try {
30
+ watcher = watch(dirname(path), { persistent: false }, () => wake?.());
31
+ watcher.on('error', () => { watcher?.close(); watcher = undefined; });
32
+ }
33
+ catch { /* no change events here: the timer alone */ }
34
+ try {
35
+ let started = Date.now();
36
+ let seen = progress();
37
+ for (;;) {
38
+ abort.throwIfAborted();
39
+ let bytes;
40
+ try {
41
+ bytes = readFileSync(path);
38
42
  }
39
- const answered = value.ports;
40
- if (Object.keys(answered).length !== ports.size || [...ports].some(([id, port]) => !Object.hasOwn(answered, id) || answered[id] !== port)) {
41
- throw new Error('The co-located host readiness receipt does not cover its declared ports.');
43
+ catch (error) {
44
+ if (error.code !== 'ENOENT')
45
+ throw error;
42
46
  }
43
- return;
44
- }
45
- const now = progress();
46
- if (now !== seen) {
47
- seen = now;
48
- started = Date.now();
47
+ if (bytes) {
48
+ if (bytes.byteLength > 256 * 1024)
49
+ throw new Error('The co-located host readiness receipt exceeds its bounds.');
50
+ const value = JSON.parse(bytes.toString('utf8'));
51
+ if (!value || value.version !== 1 || value.boot !== boot || !value.ports || typeof value.ports !== 'object' || Array.isArray(value.ports)) {
52
+ throw new Error('The co-located host readiness receipt does not belong to this boot.');
53
+ }
54
+ const answered = value.ports;
55
+ if (Object.keys(answered).length !== ports.size || [...ports].some(([id, port]) => !Object.hasOwn(answered, id) || answered[id] !== port)) {
56
+ throw new Error('The co-located host readiness receipt does not cover its declared ports.');
57
+ }
58
+ const when = (time) => typeof time === 'number' && Number.isFinite(time) ? time : undefined;
59
+ // only the declared twins' times: an id the runtime did not declare never reaches its progress line
60
+ const twinStarts = value.started && typeof value.started === 'object' && !Array.isArray(value.started)
61
+ ? Object.fromEntries(Object.entries(value.started).flatMap(([id, time]) => !ports.has(id) || when(time) === undefined ? [] : [[id, time]]))
62
+ : undefined;
63
+ const streams = Array.isArray(value.streams) ? value.streams.filter((id) => typeof id === 'string' && ports.has(id)) : [];
64
+ return { at: when(value.at), began: when(value.began), ...(twinStarts ? { started: twinStarts } : {}), ...(streams.length ? { streams } : {}) };
65
+ }
66
+ const now = progress();
67
+ if (now !== seen) {
68
+ seen = now;
69
+ started = Date.now();
70
+ }
71
+ if (Date.now() - started >= timeoutMs)
72
+ throw new Error(`The co-located host made no progress for ${timeoutMs} ms before completing every declared start.`);
73
+ await new Promise((resolve, reject) => {
74
+ const settle = () => { clearTimeout(timer); wake = undefined; abort.removeEventListener('abort', cancel); };
75
+ const cancel = () => { settle(); reject(abort.reason); };
76
+ const timer = setTimeout(() => { settle(); resolve(); }, Math.min(25, Math.max(1, timeoutMs - (Date.now() - started))));
77
+ wake = () => { settle(); resolve(); };
78
+ abort.addEventListener('abort', cancel, { once: true });
79
+ if (abort.aborted)
80
+ cancel();
81
+ });
49
82
  }
50
- if (Date.now() - started >= timeoutMs)
51
- throw new Error(`The co-located host made no progress for ${timeoutMs} ms before completing every declared start.`);
52
- await new Promise((resolve, reject) => {
53
- const cancel = () => { clearTimeout(timer); abort.removeEventListener('abort', cancel); reject(abort.reason); };
54
- const timer = setTimeout(() => { abort.removeEventListener('abort', cancel); resolve(); }, Math.min(25, Math.max(1, timeoutMs - (Date.now() - started))));
55
- abort.addEventListener('abort', cancel, { once: true });
56
- if (abort.aborted)
57
- cancel();
58
- });
83
+ }
84
+ finally {
85
+ watcher?.close();
59
86
  }
60
87
  }
@@ -4,6 +4,7 @@ import { getPack, registerPack } 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 { servesByteStream, servesDiscovery, warmInProcess } from "./host.js";
7
8
  import { importModule } from "./import-module.js";
8
9
  const spec = workerData;
9
10
  const mod = await importModule(spec.module);
@@ -17,5 +18,10 @@ if (typeof factory !== 'function') {
17
18
  }
18
19
  // Awaited: an async factory (fly) must post `ready` only once its listener is bound.
19
20
  const server = await factory({ ...(spec.sideOptions ?? {}), port: spec.port, root: spec.root, readOnly: spec.readOnly, ...(spec.database !== undefined ? { database: spec.database } : {}) });
20
- parentPort?.postMessage({ type: 'ready', port: server.port });
21
+ parentPort?.postMessage({ type: 'ready', port: server.port, byteStream: servesByteStream(mod, descriptor), discovery: servesDiscovery(mod, descriptor) });
22
+ // the host's warm-up, taken here where this twin's server and its handler are (host.ts warmInProcess)
23
+ parentPort?.on('message', (msg) => {
24
+ if (msg?.type === 'warm')
25
+ void warmInProcess(server.port).then(() => parentPort?.postMessage({ type: 'warmed' }));
26
+ });
21
27
  // The Bun.serve listener keeps this worker's event loop alive; nothing else to do.
@@ -1,3 +1,4 @@
1
+ import { type TwinPack } from '@volter/world-core/runtime';
1
2
  /** One twin to mount in the host. `module`/`export` name a `({port,root,readOnly}) => {port,stop}`
2
3
  * factory (every pack ships one, e.g. `createStripeTwinServer`). `module` is anything import()
3
4
  * resolves: a package name (`@volter/twin-stripe`) or an absolute file path (tests/fixtures). */
@@ -22,8 +23,33 @@ export type ColocatedHost = {
22
23
  isolation: HostIsolation;
23
24
  /** Resolved listening port per twin id (equals the requested port). */
24
25
  ports: Record<string, number>;
26
+ /** The twins whose main port is a byte stream (servesByteStream). */
27
+ streams: string[];
28
+ /** The twins whose main port answers the discovery door (GET /twin: the manifest declares `discovery`), warmed by it. */
29
+ discoverable: string[];
30
+ /** Warm every discoverable twin (warmInProcess, in its own thread under worker isolation); never needed for readiness. */
31
+ warm: () => Promise<void>;
25
32
  stop: () => Promise<void>;
26
33
  };
34
+ /** Whether a twin's main port is a byte stream: a raw-tcp pack exporting exactly one `create<Name>TwinStream` is taken
35
+ * to serve that stream there (world-core serveStream: an SMTP relay greets, a Redis server answers RESP). Such a port
36
+ * cannot answer an HTTP request, so the runtime admits it by its TCP answer (runtime.ts, startColocatedServices). Read
37
+ * off the module's own exports, as its serve factory is (twin-standard pack-facts.ts serveExport). A pack whose main
38
+ * port serves HTTP or gRPC must not declare raw-tcp with a stream export (create-pack refuses it). */
39
+ export declare function servesByteStream(mod: Record<string, unknown>, descriptor: TwinPack): boolean;
40
+ /** Whether a twin's main port answers the discovery door: its manifest declares `discovery`, and its port is HTTP. */
41
+ export declare function servesDiscovery(mod: Record<string, unknown>, descriptor: TwinPack): boolean;
42
+ /**
43
+ * Each twin's first request pays its pipeline's cold start (decoding, routing, the pack's front, first-run code), which
44
+ * otherwise falls on the application's first call to it. Its size for an HTTP twin is unmeasured (a byte-stream twin's
45
+ * first SMTP greeting took 57 ms against 1.8 ms later, e946aa4c run 3 on the mini, but that twin is skipped here); the
46
+ * R38 timing lap sizes it. The host takes it in its own idle time once its
47
+ * receipt is published: one GET of the twin's discovery door, handed in this process to the handler serving its port
48
+ * (world-core servedHandlerOf), never on the wire and never in its request journal. Bounded, its answer and any failure
49
+ * ignored; a request the application sends first is served as it would have been, and simply is that twin's warm-up.
50
+ * The discovery door reads no stored state, so a twin's first state read stays cold.
51
+ */
52
+ export declare function warmInProcess(port: number): Promise<void>;
27
53
  /**
28
54
  * Start a co-located host. Resolves once every twin is listening. The returned `stop()`
29
55
  * tears the whole host down (all servers / all workers).
package/dist/src/host.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { errorMessage } from "./error-message.js";
2
- import { getPack, registerPack } from '@volter/world-core/runtime';
2
+ import { getPack, registerPack, servedHandlerOf } 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
@@ -26,6 +26,59 @@ import { siblingScript } from "./sibling.js";
26
26
  import { importModule } from "./import-module.js";
27
27
  import { pathToFileURL } from 'node:url';
28
28
  import { parallelStartup } from "./startup.js";
29
+ /** Whether a twin's main port is a byte stream: a raw-tcp pack exporting exactly one `create<Name>TwinStream` is taken
30
+ * to serve that stream there (world-core serveStream: an SMTP relay greets, a Redis server answers RESP). Such a port
31
+ * cannot answer an HTTP request, so the runtime admits it by its TCP answer (runtime.ts, startColocatedServices). Read
32
+ * off the module's own exports, as its serve factory is (twin-standard pack-facts.ts serveExport). A pack whose main
33
+ * port serves HTTP or gRPC must not declare raw-tcp with a stream export (create-pack refuses it). */
34
+ export function servesByteStream(mod, descriptor) {
35
+ return descriptor.transport === 'raw-tcp'
36
+ && Object.keys(mod).filter((key) => /^create\w*TwinStream$/.test(key) && typeof mod[key] === 'function').length === 1;
37
+ }
38
+ /** Whether a twin's main port answers the discovery door: its manifest declares `discovery`, and its port is HTTP. */
39
+ export function servesDiscovery(mod, descriptor) {
40
+ const manifest = mod.manifest;
41
+ return manifest?.discovery !== undefined && !servesByteStream(mod, descriptor);
42
+ }
43
+ /** The most twins warmed at once (S2 R41). */
44
+ const WARM_CONCURRENCY = 2;
45
+ /** A warm-up's bound: one that has not finished by then is left, its twin as it is. */
46
+ const WARM_TIMEOUT_MS = 2000;
47
+ /**
48
+ * Each twin's first request pays its pipeline's cold start (decoding, routing, the pack's front, first-run code), which
49
+ * otherwise falls on the application's first call to it. Its size for an HTTP twin is unmeasured (a byte-stream twin's
50
+ * first SMTP greeting took 57 ms against 1.8 ms later, e946aa4c run 3 on the mini, but that twin is skipped here); the
51
+ * R38 timing lap sizes it. The host takes it in its own idle time once its
52
+ * receipt is published: one GET of the twin's discovery door, handed in this process to the handler serving its port
53
+ * (world-core servedHandlerOf), never on the wire and never in its request journal. Bounded, its answer and any failure
54
+ * ignored; a request the application sends first is served as it would have been, and simply is that twin's warm-up.
55
+ * The discovery door reads no stored state, so a twin's first state read stays cold.
56
+ */
57
+ export async function warmInProcess(port) {
58
+ const handler = servedHandlerOf(port);
59
+ if (!handler)
60
+ return;
61
+ let timer;
62
+ try {
63
+ await Promise.race([
64
+ (async () => { const response = await handler(new Request(`http://127.0.0.1:${port}/twin`)); await response?.arrayBuffer(); })(),
65
+ new Promise((resolve) => { timer = setTimeout(resolve, WARM_TIMEOUT_MS); timer.unref?.(); }),
66
+ ]);
67
+ }
68
+ catch { /* a warm-up that fails leaves its twin as it was */ }
69
+ finally {
70
+ if (timer !== undefined)
71
+ clearTimeout(timer);
72
+ }
73
+ }
74
+ /** Each of `ids` warmed by `warm`, at most WARM_CONCURRENCY at once. */
75
+ async function warmEach(ids, warm) {
76
+ const queue = [...ids];
77
+ await Promise.all(Array.from({ length: Math.min(WARM_CONCURRENCY, queue.length) }, async () => {
78
+ for (let id = queue.shift(); id !== undefined; id = queue.shift())
79
+ await warm(id);
80
+ }));
81
+ }
29
82
  async function loadFactory(spec) {
30
83
  const mod = await importModule(spec.module);
31
84
  const descriptor = mod.pack ?? getPack(spec.id);
@@ -36,7 +89,7 @@ async function loadFactory(spec) {
36
89
  if (typeof factory !== 'function') {
37
90
  throw new Error(`Twin "${spec.id}": ${spec.module} has no factory export "${spec.export}"`);
38
91
  }
39
- return factory;
92
+ return { factory: factory, byteStream: servesByteStream(mod, descriptor), discovery: servesDiscovery(mod, descriptor) };
40
93
  }
41
94
  const workerEntry = () => pathToFileURL(siblingScript(import.meta.url, 'host-worker'));
42
95
  /** Per-twin stop budget inside host.stop(). Must stay comfortably under downWorld's 5s
@@ -57,6 +110,8 @@ export async function startColocatedHost(specs, options = {}) {
57
110
  const ports = {};
58
111
  for (const spec of specs)
59
112
  ports[spec.id] = spec.port;
113
+ const streams = [];
114
+ const discoverable = [];
60
115
  if (isolation === 'shared') {
61
116
  // Keep one twin's stray async rejection / uncaught throw from killing every other twin in
62
117
  // the shared process. (A thrown *request* handler is already contained by Bun.serve → 500.)
@@ -87,7 +142,11 @@ export async function startColocatedHost(specs, options = {}) {
87
142
  };
88
143
  try {
89
144
  await parallelStartup(specs, async (spec) => {
90
- const factory = await loadFactory(spec);
145
+ const { factory, byteStream, discovery } = await loadFactory(spec);
146
+ if (byteStream)
147
+ streams.push(spec.id);
148
+ if (discovery)
149
+ discoverable.push(spec.id);
91
150
  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 } : {}) });
92
151
  // Record the handle before validating it, so rollback also owns a bad factory result.
93
152
  servers.push(server);
@@ -109,6 +168,9 @@ export async function startColocatedHost(specs, options = {}) {
109
168
  return {
110
169
  isolation,
111
170
  ports,
171
+ streams,
172
+ discoverable,
173
+ warm: () => warmEach(discoverable, (id) => warmInProcess(ports[id])),
112
174
  stop: async () => {
113
175
  // Every stop runs, even when a sibling hangs or throws. Host shutdown
114
176
  // still resolves within its budget so the owning runtime can retire it.
@@ -135,7 +197,13 @@ export async function startColocatedHost(specs, options = {}) {
135
197
  return;
136
198
  }
137
199
  ports[spec.id] = msg.port;
200
+ if (msg.byteStream === true)
201
+ streams.push(spec.id);
202
+ if (msg.discovery === true)
203
+ discoverable.push(spec.id);
138
204
  ready = true;
205
+ // the same progress, and the same start time for the receipt (host-cli.ts), as a shared twin's start
206
+ onEvent({ twin: spec.id, kind: 'started', detail: `port ${msg.port} at ${Math.round(performance.now())} ms` });
139
207
  resolveReady();
140
208
  }
141
209
  });
@@ -170,6 +238,28 @@ export async function startColocatedHost(specs, options = {}) {
170
238
  return {
171
239
  isolation,
172
240
  ports,
241
+ streams,
242
+ discoverable,
243
+ // a worker twin is warmed in its own thread, where its server's handler is: asked, and answered once done
244
+ warm: () => warmEach(discoverable, (id) => new Promise((resolve) => {
245
+ const worker = workers.get(id);
246
+ if (!worker) {
247
+ resolve();
248
+ return;
249
+ }
250
+ const done = () => { clearTimeout(timer); worker.off('message', warmed); worker.off('exit', done); resolve(); };
251
+ const warmed = (msg) => { if (msg?.type === 'warmed')
252
+ done(); };
253
+ const timer = setTimeout(done, WARM_TIMEOUT_MS);
254
+ worker.on('message', warmed);
255
+ worker.once('exit', done);
256
+ try {
257
+ worker.postMessage({ type: 'warm' });
258
+ }
259
+ catch {
260
+ done();
261
+ }
262
+ })),
173
263
  stop: async () => {
174
264
  stopping.value = true;
175
265
  await Promise.all([...workers.values()].map((w) => w.terminate()));
@@ -0,0 +1,35 @@
1
+ import { type GoMysqlBinding } from './go-mysql-binding.js';
2
+ import * as managedOriginalInputs from '@volter/world-core/managed-original-inputs';
3
+ import type { NativeMariaDBService } from './native-mariadb-backing.js';
4
+ export interface InfraService {
5
+ kind: string;
6
+ hostPort: number;
7
+ nativeMariaDB?: NativeMariaDBService;
8
+ virtualTcpEndpoint?: {
9
+ version: 1;
10
+ host: string;
11
+ port: number;
12
+ targetPort: number;
13
+ };
14
+ connectionBindings?: Record<string, GoMysqlBinding & {
15
+ containerPort: number;
16
+ }>;
17
+ originalInputs?: managedOriginalInputs.OriginalInputReference;
18
+ sides?: Record<string, {
19
+ hostPort: number;
20
+ containerPort: number;
21
+ }>;
22
+ configuration?: Record<string, unknown>;
23
+ replicaSet?: string;
24
+ postgres?: {
25
+ user: string;
26
+ database: string;
27
+ password?: string;
28
+ };
29
+ }
30
+ /** Read the generated definition's loopback port mappings and backing-independent options.
31
+ * The Compose extension carries a MongoDB replica-set name. Older generated commands remain readable. */
32
+ export declare function parseInfraDefinition(text: string): InfraService[];
33
+ /** Endpoints of the declared infrastructure, published through external.discover. Only URLs
34
+ * whose protocol and loopback port match a declared service belong to this lifecycle. */
35
+ export declare function infraConnections(services: InfraService[], env: Record<string, string | undefined>): Record<string, string>;