@volter/world-runtime 3.0.166 → 3.0.167

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 (82) hide show
  1. package/dist/known-external-services.json +0 -1
  2. package/dist/src/actions-runner.js +53 -14
  3. package/dist/src/attach.d.ts +9 -0
  4. package/dist/src/attach.js +9 -2
  5. package/dist/src/cli-commands.js +19 -3
  6. package/dist/src/console-apart.js +3 -2
  7. package/dist/src/credential-door.d.ts +8 -0
  8. package/dist/src/credential-door.js +54 -0
  9. package/dist/src/fixture-env.js +2 -2
  10. package/dist/src/forwardable-headers.d.ts +1 -0
  11. package/dist/src/forwardable-headers.js +19 -0
  12. package/dist/src/go-mysql-binding.d.ts +2 -0
  13. package/dist/src/go-mysql-binding.js +7 -1
  14. package/dist/src/guest-runner.js +8 -4
  15. package/dist/src/host-ready.d.ts +7 -3
  16. package/dist/src/host-ready.js +16 -3
  17. package/dist/src/index.d.ts +5 -1
  18. package/dist/src/index.js +4 -1
  19. package/dist/src/infra.js +10 -5
  20. package/dist/src/init.d.ts +5 -0
  21. package/dist/src/init.js +157 -23
  22. package/dist/src/managed-api-needs.d.ts +101 -0
  23. package/dist/src/managed-api-needs.js +312 -0
  24. package/dist/src/managed-api-source.d.ts +24 -0
  25. package/dist/src/managed-api-source.js +59 -0
  26. package/dist/src/mongodb-backing.js +33 -58
  27. package/dist/src/mongodb-command.d.ts +21 -0
  28. package/dist/src/mongodb-command.js +317 -0
  29. package/dist/src/pglite-host.mjs +2 -2
  30. package/dist/src/placeholder.d.ts +24 -1
  31. package/dist/src/placeholder.js +105 -13
  32. package/dist/src/process-signal.d.mts +2 -0
  33. package/dist/src/process-signal.mjs +44 -2
  34. package/dist/src/project-inspect.d.ts +1 -1
  35. package/dist/src/project-inspect.js +8 -1
  36. package/dist/src/redirect-proxy.js +4 -19
  37. package/dist/src/reflect.js +2 -2
  38. package/dist/src/runtime.d.ts +5 -1
  39. package/dist/src/runtime.js +309 -58
  40. package/dist/src/schema.d.ts +10 -0
  41. package/dist/src/schema.js +76 -3
  42. package/dist/src/serve.js +24 -4
  43. package/dist/src/served-world.d.ts +2 -1
  44. package/dist/src/served-world.js +6 -4
  45. package/dist/src/service-command.d.ts +7 -3
  46. package/dist/src/service-command.js +53 -22
  47. package/dist/src/service-environment.d.ts +4 -0
  48. package/dist/src/service-environment.js +21 -0
  49. package/dist/src/service-recorder.js +9 -13
  50. package/known-external-services.json +0 -1
  51. package/package.json +5 -5
  52. package/src/actions-runner.ts +38 -10
  53. package/src/attach.ts +15 -2
  54. package/src/cli-commands.ts +18 -3
  55. package/src/console-apart.ts +3 -2
  56. package/src/credential-door.ts +54 -0
  57. package/src/fixture-env.ts +2 -2
  58. package/src/forwardable-headers.ts +15 -0
  59. package/src/go-mysql-binding.ts +8 -1
  60. package/src/guest-runner.ts +8 -4
  61. package/src/host-ready.ts +12 -3
  62. package/src/index.ts +5 -1
  63. package/src/infra.ts +8 -3
  64. package/src/init.ts +145 -23
  65. package/src/managed-api-needs.ts +285 -0
  66. package/src/managed-api-source.ts +67 -0
  67. package/src/mongodb-backing.ts +32 -48
  68. package/src/mongodb-command.ts +238 -0
  69. package/src/pglite-host.mjs +2 -2
  70. package/src/placeholder.ts +90 -13
  71. package/src/process-signal.d.mts +2 -0
  72. package/src/process-signal.mjs +44 -2
  73. package/src/project-inspect.ts +9 -2
  74. package/src/redirect-proxy.ts +4 -15
  75. package/src/reflect.ts +2 -2
  76. package/src/runtime.ts +270 -44
  77. package/src/schema.ts +73 -3
  78. package/src/serve.ts +22 -4
  79. package/src/served-world.ts +7 -5
  80. package/src/service-command.ts +41 -19
  81. package/src/service-environment.ts +21 -0
  82. package/src/service-recorder.ts +9 -9
@@ -0,0 +1,238 @@
1
+ // MongoDB's questions as the runtime asks them (architecture, "A negative MongoDB availability question"): one command
2
+ // on one loopback connection, framed by the kernel's MongoDB client (`@volter/world-core/mongodb`, mongoClient) with
3
+ // the BSON codec below, never through MongoDB's driver. The driver's first load sat on every MongoDB-served World's
4
+ // readiness path, after the server's port accepted and before its first answer. A question is the command the driver
5
+ // sent for it; the answer is the serving process's own (mongod's, or the twin pack's where a twin serves the kind).
6
+ //
7
+ // The kernel frames and reads nothing inside a document (architecture, "the MongoDB wire"): the codec is its user's,
8
+ // here the runtime's own, for the few commands it asks and every type an answer may hold.
9
+ // source: https://bsonspec.org/spec.html "element ::= ... | signed_byte(5) e_name binary | ... | signed_byte(8) e_name "\x00" | signed_byte(8) e_name "\x01""
10
+ import { connect } from 'node:net';
11
+ import { MongoMsgFlag, mongoClient, type MongoCodec, type MongoDocument } from '@volter/world-core/mongodb';
12
+
13
+ export type { MongoDocument };
14
+
15
+ /** A command the server answered and refused (`ok` not 1), with its own code, as the driver's server error carries it. */
16
+ export class MongoCommandError extends Error {
17
+ constructor(readonly code: number | undefined, readonly codeName: string | undefined, message: string) {
18
+ super(message);
19
+ this.name = 'MongoCommandError';
20
+ }
21
+ }
22
+
23
+ /** MongoDB's own maxMessageSizeBytes. */
24
+ const MAX_MESSAGE = 48_000_000;
25
+ /** The required flag bits (0-15) an answer may carry: checksumPresent alone; moreToCome is an exhaust answer, never
26
+ * asked for here. */
27
+ const KNOWN_REQUIRED_FLAGS = MongoMsgFlag.CHECKSUM_PRESENT;
28
+ const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
29
+
30
+ function cstring(name: string): Buffer {
31
+ if (name.includes('\0')) throw new Error(`BSON key ${JSON.stringify(name)} holds a NUL`);
32
+ return Buffer.from(`${name}\0`, 'utf8');
33
+ }
34
+
35
+ function element(name: string, value: unknown): Buffer[] {
36
+ const key = cstring(name);
37
+ if (typeof value === 'number') {
38
+ if (Number.isInteger(value) && !Object.is(value, -0) && value >= -0x80000000 && value <= 0x7fffffff) {
39
+ const int = Buffer.alloc(4); int.writeInt32LE(value);
40
+ return [Buffer.of(0x10), key, int];
41
+ }
42
+ const double = Buffer.alloc(8); double.writeDoubleLE(value);
43
+ return [Buffer.of(0x01), key, double];
44
+ }
45
+ if (typeof value === 'bigint') {
46
+ const long = Buffer.alloc(8); long.writeBigInt64LE(value);
47
+ return [Buffer.of(0x12), key, long];
48
+ }
49
+ if (typeof value === 'string') {
50
+ const text = Buffer.from(value, 'utf8');
51
+ const length = Buffer.alloc(4); length.writeInt32LE(text.length + 1);
52
+ return [Buffer.of(0x02), key, length, text, Buffer.of(0)];
53
+ }
54
+ if (typeof value === 'boolean') return [Buffer.of(0x08), key, Buffer.of(value ? 1 : 0)];
55
+ if (value === null) return [Buffer.of(0x0a), key];
56
+ // A hole or an undefined element is null, as the driver writes one: an array's indices stay dense.
57
+ if (Array.isArray(value)) return [Buffer.of(0x04), key, encodeBson(Object.fromEntries(Array.from(value, (item, index) => [String(index), item === undefined ? null : item])))];
58
+ if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) return [Buffer.of(0x03), key, encodeBson(value as MongoDocument)];
59
+ throw new Error(`BSON here carries no value of ${name}'s kind`);
60
+ }
61
+
62
+ /** A document as BSON: the values the runtime's own commands hold (numbers, bigints, strings, booleans, null,
63
+ * documents and arrays). An undefined value is left out, as the driver leaves it; -0 is a double, as it is to BSON. */
64
+ export function encodeBson(document: MongoDocument): Buffer {
65
+ const body = Buffer.concat(Object.entries(document).filter(([, value]) => value !== undefined).flatMap(([name, value]) => element(name, value)));
66
+ const size = Buffer.alloc(4); size.writeInt32LE(body.length + 5);
67
+ return Buffer.concat([size, body, Buffer.of(0)]);
68
+ }
69
+
70
+ /** A BSON document read whole from `bytes` at `start`. Every BSON type is read, so an answer's fields the runtime does
71
+ * not use are still walked; a length out of bounds, a boolean other than 0 or 1, text that is not UTF-8, or a document
72
+ * that does not end where its length says is refused. An int64 is a number where it is a safe integer, as the driver
73
+ * promotes it, and a bigint where it is not. */
74
+ export function decodeBson(bytes: Buffer, start = 0, array = false): MongoDocument | unknown[] {
75
+ if (start + 5 > bytes.length) throw new Error('truncated BSON document');
76
+ const size = bytes.readInt32LE(start);
77
+ const end = start + size;
78
+ if (size < 5 || end > bytes.length || bytes[end - 1] !== 0) throw new Error('BSON document length out of bounds');
79
+ const fields: MongoDocument = {};
80
+ const items: unknown[] = [];
81
+ let at = start + 4;
82
+ const need = (count: number): void => { if (count < 0 || at + count > end - 1) throw new Error('truncated BSON value'); };
83
+ const length = (minimum: number): number => {
84
+ need(4);
85
+ const value = bytes.readInt32LE(at);
86
+ if (value < minimum) throw new Error('BSON length out of bounds');
87
+ return value;
88
+ };
89
+ const text = (from: number, to: number): string => utf8.decode(bytes.subarray(from, to));
90
+ const cstr = (): string => {
91
+ const stop = bytes.indexOf(0, at);
92
+ if (stop < 0 || stop >= end - 1) throw new Error('unterminated BSON cstring');
93
+ const value = text(at, stop);
94
+ at = stop + 1;
95
+ return value;
96
+ };
97
+ const str = (): string => {
98
+ const count = length(1);
99
+ need(4 + count);
100
+ if (bytes[at + 4 + count - 1] !== 0) throw new Error('unterminated BSON string');
101
+ const value = text(at + 4, at + 4 + count - 1);
102
+ at += 4 + count;
103
+ return value;
104
+ };
105
+ while (at < end - 1) {
106
+ const type = bytes[at++]!;
107
+ const name = cstr();
108
+ let value: unknown;
109
+ switch (type) {
110
+ case 0x01: need(8); value = bytes.readDoubleLE(at); at += 8; break;
111
+ case 0x02: case 0x0d: case 0x0e: value = str(); break;
112
+ case 0x03: case 0x04: {
113
+ const count = length(5);
114
+ need(count);
115
+ value = decodeBson(bytes, at, type === 0x04);
116
+ at += count;
117
+ break;
118
+ }
119
+ case 0x05: {
120
+ const count = length(0);
121
+ need(5 + count);
122
+ value = new Uint8Array(bytes.subarray(at + 5, at + 5 + count));
123
+ at += 5 + count;
124
+ break;
125
+ }
126
+ case 0x06: value = undefined; break;
127
+ case 0x07: need(12); value = bytes.toString('hex', at, at + 12); at += 12; break;
128
+ case 0x08: {
129
+ need(1);
130
+ const byte = bytes[at]!;
131
+ if (byte > 1) throw new Error(`BSON boolean byte ${byte} is neither 0 nor 1`);
132
+ value = byte === 1; at += 1;
133
+ break;
134
+ }
135
+ case 0x09: need(8); value = new Date(Number(bytes.readBigInt64LE(at))); at += 8; break;
136
+ case 0x0a: value = null; break;
137
+ case 0x0b: { const pattern = cstr(); value = { $regex: pattern, $options: cstr() }; break; }
138
+ case 0x0c: { const ref = str(); need(12); value = { $ref: ref, $id: bytes.toString('hex', at, at + 12) }; at += 12; break; }
139
+ case 0x0f: {
140
+ const from = at;
141
+ const count = length(14);
142
+ need(count);
143
+ at += 4;
144
+ const code = str();
145
+ const scope = length(5);
146
+ need(scope);
147
+ value = { $code: code, $scope: decodeBson(bytes, at) };
148
+ at += scope;
149
+ if (at !== from + count) throw new Error('BSON code-with-scope length out of bounds');
150
+ break;
151
+ }
152
+ case 0x10: need(4); value = bytes.readInt32LE(at); at += 4; break;
153
+ case 0x11: need(8); value = bytes.readBigUInt64LE(at); at += 8; break;
154
+ case 0x12: {
155
+ need(8);
156
+ const long = bytes.readBigInt64LE(at);
157
+ at += 8;
158
+ value = long >= BigInt(Number.MIN_SAFE_INTEGER) && long <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(long) : long;
159
+ break;
160
+ }
161
+ case 0x13: need(16); value = new Uint8Array(bytes.subarray(at, at + 16)); at += 16; break;
162
+ case 0x7f: value = { $maxKey: 1 }; break;
163
+ case 0xff: value = { $minKey: 1 }; break;
164
+ default: throw new Error(`unknown BSON type 0x${type.toString(16)} at ${JSON.stringify(name)}`);
165
+ }
166
+ if (array) items.push(value);
167
+ else Object.defineProperty(fields, name, { value, enumerable: true, writable: true, configurable: true });
168
+ }
169
+ if (at !== end - 1) throw new Error('BSON document does not end where its length says');
170
+ return array ? items : fields;
171
+ }
172
+
173
+ /** The runtime's codec for the kernel's MongoDB client. */
174
+ const codec: MongoCodec = {
175
+ serialize: (document) => encodeBson(document),
176
+ deserialize: (bytes) => decodeBson(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)) as MongoDocument,
177
+ };
178
+
179
+ /** One message sent on one loopback connection, and the one message answered to it, read whole to its length.
180
+ * An answer with a required flag bit the runtime does not know (moreToCome among them) is refused. */
181
+ function loopbackExchange(port: number, timeoutMs: number, asked: string, signal?: AbortSignal): (message: Uint8Array) => Promise<Uint8Array> {
182
+ return (message) => new Promise((resolve, reject) => {
183
+ if (signal?.aborted) { reject(signal.reason instanceof Error ? signal.reason : new Error(`MongoDB question ${asked} aborted`)); return; }
184
+ if (!(timeoutMs > 0)) { reject(new Error(`MongoDB question ${asked} has no budget left`)); return; }
185
+ const socket = connect({ host: '127.0.0.1', port });
186
+ // The answer's chunks, joined once it is whole (not again at each chunk), and its declared length, read once from
187
+ // its first four bytes however they arrived.
188
+ const chunks: Buffer[] = [];
189
+ let receivedBytes = 0;
190
+ let length: number | undefined;
191
+ let settled = false;
192
+ const onAbort = (): void => finish(signal!.reason instanceof Error ? signal!.reason : new Error(`MongoDB question ${asked} aborted`));
193
+ const timer = setTimeout(() => finish(new Error(`MongoDB did not answer ${asked} within ${timeoutMs} ms`)), timeoutMs);
194
+ function finish(error: unknown, answer?: Uint8Array): void {
195
+ if (settled) return;
196
+ settled = true;
197
+ clearTimeout(timer);
198
+ signal?.removeEventListener('abort', onAbort);
199
+ socket.destroy();
200
+ if (error !== undefined) reject(error); else resolve(answer!);
201
+ }
202
+ signal?.addEventListener('abort', onAbort, { once: true });
203
+ socket.setNoDelay(true);
204
+ socket.once('connect', () => { socket.write(message); });
205
+ socket.on('data', (chunk: Buffer) => {
206
+ chunks.push(chunk);
207
+ receivedBytes += chunk.length;
208
+ if (length === undefined) {
209
+ if (receivedBytes < 4) return;
210
+ const head: Buffer = chunks[0]!.length >= 4 ? chunks[0]! : Buffer.concat(chunks, receivedBytes);
211
+ length = head.readInt32LE(0);
212
+ if (length < 21 || length > MAX_MESSAGE) { finish(new Error(`MongoDB's answer to ${asked} has length ${length}, out of bounds`)); return; }
213
+ }
214
+ if (receivedBytes < length) return;
215
+ const received: Buffer = Buffer.concat(chunks, receivedBytes);
216
+ const answer = received.subarray(0, length);
217
+ const required = answer.readUInt32LE(16) & 0xffff;
218
+ if (required & ~KNOWN_REQUIRED_FLAGS) { finish(new Error(`MongoDB answered ${asked} with required flag bits ${required}`)); return; }
219
+ finish(undefined, answer);
220
+ });
221
+ socket.once('error', (error) => finish(error));
222
+ socket.once('close', () => finish(new Error(`MongoDB closed the connection before answering ${asked}`)));
223
+ });
224
+ }
225
+
226
+ /** One admin command on one loopback connection, answered through the kernel's client: refused where the server says
227
+ * it is not ok, where its answer cannot be read (the client checks its request, opcode and length; a document
228
+ * sequence in it is joined as the field it names, as the client joins one), where the connection ends or refuses
229
+ * first, where `timeoutMs` passes, or where `signal` aborts. */
230
+ export async function mongodbCommand(port: number, command: MongoDocument, timeoutMs: number, signal?: AbortSignal): Promise<MongoDocument> {
231
+ const asked = Object.keys(command)[0] ?? 'a command';
232
+ const answer = await mongoClient(loopbackExchange(port, timeoutMs, asked, signal), codec).command('admin', command);
233
+ if (Number(answer.ok) !== 1) {
234
+ throw new MongoCommandError(typeof answer.code === 'number' ? answer.code : undefined, typeof answer.codeName === 'string' ? answer.codeName : undefined,
235
+ typeof answer.errmsg === 'string' ? answer.errmsg : `MongoDB refused ${asked}`);
236
+ }
237
+ return answer;
238
+ }
@@ -687,8 +687,8 @@ const asBuffer = (m) => Buffer.from(m.buffer, m.byteOffset, m.byteLength);
687
687
 
688
688
  /** Prepared statements' protocol names each connection gives, kept apart where they would collide.
689
689
  * Every connection shares the one backend session, and a client names its statements per connection: Prisma's engine
690
- * starts each process at `s0`, so a second process's first Parse met "prepared statement "s0" already exists" (Postiz
691
- * walk postiz-2: sign-up, and the post workflow's first read, which left no timer to publish the post). A name no other
690
+ * starts each process at `s0`, so a second process's first Parse met "prepared statement "s0" already exists" (an
691
+ * application's sign-up, and its workflow's first read in a second process). A name no other
692
692
  * live connection holds goes to the backend as the client gave it, so SQL on it works as on Postgres; one another
693
693
  * live connection holds goes as a short name of the host's own (`volter_s<N>`), and the connection's own simple-Query
694
694
  * DEALLOCATE or EXECUTE of it is rewritten to match (sqlNames). Names are compared on their first 63 bytes, as Postgres compares them
@@ -5,16 +5,22 @@
5
5
  // `diff` reads empty, nothing is pending. `reset` is a pull from the placeholder again — down
6
6
  // with purge, up, seed — so a world returns to its defaults at any time; local work you want to
7
7
  // keep is a fork, taken before.
8
+ import { spawn } from 'node:child_process';
8
9
  import { randomUUID } from 'node:crypto';
10
+ import { createRequire } from 'node:module';
11
+ import { tmpdir } from 'node:os';
9
12
  import { claimEnvOutput } from './env-output.ts';
13
+ import { withCurrentCredentialDoor } from './credential-door.ts';
14
+ import { packFacts } from './pack-facts.ts';
10
15
  import { materializeRoots } from './root.ts';
11
16
  import { loadWorldConfig } from './configs.ts';
12
- import { WorldDoors, servedName } from './served-world.ts';
13
- import { existsSync, readdirSync } from 'node:fs';
17
+ import { TOKEN_HEADER, TWINS_KEY_HEADER, WorldDoors, servedName } from './served-world.ts';
18
+ import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
14
19
  import { dirname, join, resolve } from 'node:path';
15
20
  import { serveHttp, beginPlaceholderPull, endPlaceholderPull, listEvents, PLACEHOLDER_REMOTE, stateDirName, worldStateRoot } from '@volter/world-core/runtime';
16
- import { downWorld, runWithWorldEnv, saveWorldInstance, statusWorld, upWorld } from './runtime.ts';
17
- import type { WorldInstance } from './schema.ts';
21
+ import { downWorld, runWithWorldEnv, saveWorldInstance, statusWorld, upWorld, withoutVaultSignIn } from './runtime.ts';
22
+ import { ISSUE_ENV_PREFIX, type WorldInstance } from './schema.ts';
23
+ import type { WorldManifest } from './attach.ts';
18
24
 
19
25
  export type SeedWorldOptions = { root?: string; entry?: string; cwd?: string; /** Reset writes a new live environment output; never an input file. */ envFile?: string };
20
26
  export type SeedWorldOutcome = { world: string; entry: string; exitCode: number; observed: Record<string, number> };
@@ -46,6 +52,17 @@ function rootedTwins(instance: WorldInstance, root: string): string[] {
46
52
  return config.services.filter((s) => (s as { root?: unknown }).root).map((s) => s.id);
47
53
  }
48
54
 
55
+ /** The command that runs a seed entry: the runtime that is running, Bun, or Node with its TypeScript type stripping
56
+ * (`--experimental-strip-types`, accepted from Node 22.6 and on by default since 23.6). Never `bun` by name: the
57
+ * published runtime supports Node alone, and the tab's engine has no Bun. */
58
+ function seedRunner(entry: string): string[] {
59
+ if (typeof Bun === 'undefined' && /\.[cm]?ts$/.test(entry)) {
60
+ const [major, minor] = process.versions.node.split('.').map(Number) as [number, number];
61
+ if (major < 22 || (major === 22 && minor < 6)) throw new Error(`${entry} is TypeScript: seeding it under Node needs Node 22.6 or newer (type stripping), or Bun; this is Node ${process.versions.node}`);
62
+ }
63
+ return typeof Bun !== 'undefined' ? ['bun', entry] : [process.execPath, '--experimental-strip-types', '--no-warnings', entry];
64
+ }
65
+
49
66
  /** Pull from the placeholder: every twin's window open around the seed entry (a twin with a root excepted). */
50
67
  export async function seedWorld(name: string, options: SeedWorldOptions = {}): Promise<SeedWorldOutcome> {
51
68
  const root = resolve(options.root ?? process.cwd());
@@ -70,15 +87,7 @@ export async function seedWorld(name: string, options: SeedWorldOptions = {}): P
70
87
  return path === `/-/${served}/browsers` || path.startsWith(`/-/${served}/browsers/`)
71
88
  ? doors.handle(request) : Response.json({ error: 'the seed loader serves browser state only' }, { status: 404 });
72
89
  } });
73
- // The runtime that is running runs the seed: Bun, or Node with its TypeScript type stripping
74
- // (`--experimental-strip-types`, accepted from Node 22.6 and on by default since 23.6). Never
75
- // `bun` by name: the published runtime supports Node alone, and the tab's engine has no Bun.
76
- if (typeof Bun === 'undefined' && /\.[cm]?ts$/.test(entry)) {
77
- const [major, minor] = process.versions.node.split('.').map(Number) as [number, number];
78
- if (major < 22 || (major === 22 && minor < 6)) throw new Error(`${entry} is TypeScript: seeding it under Node needs Node 22.6 or newer (type stripping), or Bun; this is Node ${process.versions.node}`);
79
- }
80
- const runner = typeof Bun !== 'undefined' ? ['bun', entry] : [process.execPath, '--experimental-strip-types', '--no-warnings', entry];
81
- exitCode = await runWithWorldEnv(name, runner, root, { cwd: options.cwd ?? dirname(entry), env: { VOLTER_SEED_SKIP: [...rooted].sort().join(','), VOLTER_WORLD_BROWSER_DOOR: `${doorServer.url.origin}/-/${served}/browsers`, VOLTER_WORLD_BROWSER_TOKEN: token } });
90
+ exitCode = await runWithWorldEnv(name, seedRunner(entry), root, { cwd: options.cwd ?? dirname(entry), env: { VOLTER_SEED_SKIP: [...rooted].sort().join(','), VOLTER_WORLD_BROWSER_DOOR: `${doorServer.url.origin}/-/${served}/browsers`, VOLTER_WORLD_BROWSER_TOKEN: token } });
82
91
  } finally {
83
92
  await doorServer?.stop();
84
93
  for (const service of services) endPlaceholderPull(service, join(instance.dirs.data, service));
@@ -115,3 +124,71 @@ export async function resetWorld(name: string, options: SeedWorldOptions = {}):
115
124
  return { ...seeded, instance };
116
125
  } finally { rooted(); }
117
126
  }
127
+
128
+ export type SeedServedOutcome = { world: string; entry: string; exitCode: number; /** the names its twins issued the seed, by twin */ issued: Record<string, string[]>; /** the mark taken once the seed ran, and how many of the seed's changes it made part of the World's start */ mark: { id?: string; passed: number } | null };
129
+
130
+ /** Set up a served World an app's World was linked to, as `up` sets up a fresh local one (docs/contributing/
131
+ * architecture.md, "A World's changes count from its latest mark" and "The way in is the app's folder"): the app's
132
+ * seed entry runs against the served World's own twins, attached as `volter-world attach <url>` attaches a command
133
+ * (its manifest, each twin bridged with the World's key, the injector), with the credentials each twin issues at its
134
+ * credential door there (a twin takes only the credentials it issued) and the config's plain values; then the World
135
+ * is marked, so what the seed made is where its changes count from, not a change of its own. A seed that fails leaves
136
+ * the World unmarked, holding what the seed wrote before it stopped. */
137
+ export async function seedServedWorld(base: string, token: string, options: { root?: string; config: string; entry?: string; cwd?: string }): Promise<SeedServedOutcome> {
138
+ const root = resolve(options.root ?? process.cwd());
139
+ const at = base.replace(/\/+$/, '');
140
+ const entry = resolve(options.entry ?? join(dirname(resolve(root, options.config)), 'seed.ts'));
141
+ if (!existsSync(entry)) throw new Error(`No seed entry at ${entry}`);
142
+ const { fetchRemoteManifest, remoteAttachEnv } = await import('./serve.ts');
143
+ const manifest: WorldManifest = await fetchRemoteManifest(at, { token });
144
+ const { config } = loadWorldConfig(options.config, root);
145
+ // each twin of the app's World, by the vendor the served World names it: its door asked there, its names filled
146
+ const issued: Record<string, string[]> = {};
147
+ const env: Record<string, string> = {};
148
+ for (const [name, value] of Object.entries(config.env ?? {})) if (value !== '' && !value.startsWith('$')) env[name] = value;
149
+ for (const service of config.services) {
150
+ if ((service.type ?? 'twin') !== 'twin') continue;
151
+ const vendor = /^@volter\/twin-(.+)$/.exec(service.package ?? '')?.[1] ?? service.id;
152
+ const place = manifest.vendors[vendor];
153
+ const names = Object.entries(config.env ?? {}).filter(([, value]) => value === `${ISSUE_ENV_PREFIX}${service.id}`).map(([name]) => name);
154
+ if (!place || names.length === 0) continue;
155
+ const door = withCurrentCredentialDoor(service, packFacts()[vendor]?.credentialDoor).issues;
156
+ if (!door) throw new Error(`${vendor}: the app's World issues ${names.join(', ')} from it, and its pack names no credential door`);
157
+ const res = await fetch(`${place.replace(/\/+$/, '')}${door.path}`, { method: 'POST', headers: { [TWINS_KEY_HEADER]: token, 'content-type': 'application/json' }, body: JSON.stringify(door.body), signal: AbortSignal.timeout(30_000) });
158
+ if (!res.ok) throw new Error(`${vendor}'s credential door at ${at} answered ${res.status}: ${(await res.text().catch(() => '')).replace(/\s+/g, ' ').slice(0, 300)}`);
159
+ const answer = (await res.json()) as Record<string, unknown>;
160
+ for (const name of names) {
161
+ const field = door.fill[name] ?? '';
162
+ // a credential the door hands as a file is kept beside a World on its own machine, never by the command
163
+ if (field.startsWith('file:') || field.startsWith('directory:')) continue;
164
+ const v = answer[field];
165
+ if (typeof v === 'string' && v) { env[name] = v; (issued[vendor] ??= []).push(name); }
166
+ }
167
+ }
168
+ let caFile: string | undefined;
169
+ if (manifest.ca !== null) { caFile = join(mkdtempSync(join(tmpdir(), 'volter-seed-')), 'ca.pem'); writeFileSync(caFile, manifest.ca); }
170
+ const required = createRequire(import.meta.url);
171
+ const { bridgeTwins } = required('@volter/world-core/http-bridge') as { bridgeTwins: (manifest: WorldManifest, key: string, ca?: string) => Promise<Array<{ close: () => void }>> };
172
+ const listeners = await bridgeTwins(manifest, manifest.env.VOLTER_TWINS_KEY ?? token, manifest.ca ?? undefined);
173
+ let exitCode = 1;
174
+ try {
175
+ const attached = remoteAttachEnv(manifest, { injectPath: required.resolve('@volter/world-core/inject'), worldRef: at, ...(caFile === undefined ? {} : { caFile }) });
176
+ // the served World's twins are seeded as any of its twins: none of a new World's has a root
177
+ const commandEnv = withoutVaultSignIn({ ...process.env, ...attached, ...env, VOLTER_WORLD: at, VOLTER_SEED_SKIP: '', VOLTER_TWIN_INJECT_QUIET: process.env.VOLTER_TWIN_INJECT_QUIET ?? '1' });
178
+ const command = seedRunner(entry);
179
+ exitCode = await new Promise<number>((done) => {
180
+ const child = spawn(command[0]!, command.slice(1), { windowsHide: true, cwd: resolve(options.cwd ?? root), env: commandEnv, stdio: 'inherit' });
181
+ child.once('error', () => done(1));
182
+ child.once('exit', (code, signal) => done(code ?? (signal ? 1 : 0)));
183
+ });
184
+ } finally {
185
+ for (const listener of listeners) listener.close();
186
+ if (caFile) rmSync(dirname(caFile), { recursive: true, force: true });
187
+ }
188
+ if (exitCode !== 0) return { world: manifest.name, entry, exitCode, issued, mark: null };
189
+ const doors = new URL(at);
190
+ const marked = await fetch(`${doors.origin}/-/${doors.pathname.replace(/^\/+|\/+$/g, '')}/marks`, { method: 'POST', headers: { [TOKEN_HEADER]: token, 'content-type': 'application/json' }, body: JSON.stringify({ note: `seeded from ${entry.slice(root.length + 1) || entry}` }) });
191
+ const body = (await marked.json().catch(() => ({}))) as { mark?: { id?: string }; passed?: number; error?: string };
192
+ if (!marked.ok) throw new Error(`${at} was seeded, and refused its mark (${marked.status}): ${body.error ?? 'no answer'}`);
193
+ return { world: manifest.name, entry, exitCode, issued, mark: { ...(body.mark?.id ? { id: body.mark.id } : {}), passed: body.passed ?? 0 } };
194
+ }
@@ -7,3 +7,5 @@ export function signalOwnedProcess(identity: SpawnIdentity | undefined, signal:
7
7
  export function signalChild(child: ChildProcess, signal: NodeJS.Signals, group?: boolean, by?: string): boolean;
8
8
  export function signalHeldChild(child: ChildProcess, signal: NodeJS.Signals, group?: boolean, birth?: Promise<SpawnIdentity | undefined>): Promise<boolean>;
9
9
  export function childIdentity(child: ChildProcess): Promise<SpawnIdentity | undefined>;
10
+ export const WINDOWS_BIRTH_LOOKUP_MS: number;
11
+ export function windowsStartScript(pids: number[]): string;
@@ -27,7 +27,12 @@ function saySignal(pid, toGroup, signal, by, result) {
27
27
  // running, CPU load 30%), Node v24.18.0, parent e8d4b55f plus this change: `await processBirth(process.pid)`
28
28
  // from this module, timed with performance.now() around the await, took 237-319 ms over 10
29
29
  // sequential runs, at most 298 ms with 4 awaited at once and at most 424 ms with 8.
30
- const BIRTH_LOOKUP_MS = 1000;
30
+ // Windows reads a birth by starting Windows PowerShell, whose own start is most of the probe. Measured 2026-10-27 on
31
+ // the same machine (Node v24.22.0, twin-world 86381aaf with this World's 22 services booting, a livestream running):
32
+ // world.probe events in videogame-ai's World boot log answered in 252-805 ms, and one probe ran past 1000 ms and
33
+ // failed that boot ("Service birth capture failed"). Command: grep world.probe in the World's `up` output.
34
+ // Windows keeps 5000 ms; elsewhere the original 1000 ms ceiling stands.
35
+ const BIRTH_LOOKUP_MS = process.platform === 'win32' ? 5000 : 1000;
31
36
  /** The command that prints a pid's birth, and how its output reads. Windows has no `ps` and no /proc: its birth is the
32
37
  * process's creation time (GetProcessTimes, a FILETIME of 100 ns ticks in UTC, so neither the caller's zone nor its
33
38
  * locale changes it), read through .NET by the Windows PowerShell every Windows 10 and 11 install ships. */
@@ -159,10 +164,43 @@ function saySignalFailure(error, target, signal, child) {
159
164
  process.stderr.write(`[signal] ${error.code} kill(${target}, ${signal}) exitCode=${child?.exitCode} signalCode=${child?.signalCode} group: ${rows.trim() || 'no rows'}\n`);
160
165
  } catch { /* the failure itself is still thrown by the caller */ }
161
166
  }
167
+ /** Windows has no process groups. A service's "group" there is the process tree under its leader (the service
168
+ * recorder): TERM ends the leader's descendants, the service and everything it started (wrangler's workerd, a
169
+ * postmaster's backends, a `docker run`), so the recorder sees its service end, records it and exits on its own;
170
+ * KILL ends the whole tree, leader included. Signalling the leader alone (TerminateProcess) left the service and its
171
+ * children running, holding their ports and files, and the next `up` and a reset's removal failed on them. taskkill
172
+ * /T follows the parent links Windows keeps; /F because a windowless process has no window to answer a close. A
173
+ * child listed under the leader is admitted only when it was created after the leader, so a reused parent pid never
174
+ * reaches an older process. */
175
+ const WINDOWS_TREE_MS = 5000;
176
+ function windowsTool(name) { return join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', name); }
177
+ function windowsChildren(pid) {
178
+ const script = `$p=Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}';if($p){Get-CimInstance Win32_Process -Filter 'ParentProcessId=${pid}'|Where-Object{$_.CreationDate -ge $p.CreationDate}|ForEach-Object{$_.ProcessId}}`;
179
+ const result = spawnSync(windowsTool('WindowsPowerShell\\v1.0\\powershell.exe'), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
180
+ { windowsHide: true, encoding: 'utf8', timeout: WINDOWS_TREE_MS, killSignal: 'SIGKILL' });
181
+ if (result.status !== 0) throw Object.assign(new Error(`Windows process tree of ${pid} unreadable (exit ${result.status ?? result.signal ?? result.error?.code})`), { code: 'ETREE' });
182
+ return result.stdout.split(/\s+/).filter(line => /^\d+$/.test(line)).map(Number).filter(child => child !== pid);
183
+ }
184
+ function signalWindowsTree(pid, signal) {
185
+ if (signal === 0) return process.kill(pid, 0);
186
+ const targets = signal === 'SIGKILL' ? [pid] : windowsChildren(pid);
187
+ if (!targets.length) return process.kill(pid, signal);
188
+ for (const target of targets) {
189
+ const result = spawnSync(windowsTool('taskkill.exe'), ['/PID', String(target), '/T', '/F'],
190
+ { windowsHide: true, encoding: 'utf8', timeout: WINDOWS_TREE_MS, killSignal: 'SIGKILL' });
191
+ // 128: no such process, already gone. Anything else is said and the leader is still asked below.
192
+ if (result.status !== 0 && result.status !== 128) process.stderr.write(`[signal] taskkill /PID ${target} /T /F exited ${result.status ?? result.signal ?? result.error?.code}: ${(result.stderr ?? '').trim()}\n`);
193
+ }
194
+ return true;
195
+ }
162
196
  /** Stored or retained numbers use birth verification before every delivery. */
163
197
  export async function signalOwnedProcess(identity, signal, group = false, by = undefined) {
164
198
  const asker = askedBy(by);
165
199
  await verifySpawnIdentity(identity);
200
+ if (group && process.platform === 'win32') {
201
+ try { const delivered = signalWindowsTree(identity.pid, signal); saySignal(identity.pid, true, signal, asker, 'delivered'); return delivered; }
202
+ catch (error) { saySignal(identity.pid, true, signal, asker, error.code ?? 'failed'); if (error.code === 'ESRCH' || nothingLeftToSignal(identity.pid)) return false; throw error; }
203
+ }
166
204
  const target = group && process.platform !== 'win32' ? -identity.pid : identity.pid;
167
205
  try { const delivered = process.kill(target, signal); saySignal(identity.pid, target < 0, signal, asker, 'delivered'); return delivered; }
168
206
  catch (error) {
@@ -175,7 +213,11 @@ export async function signalOwnedProcess(identity, signal, group = false, by = u
175
213
  export function signalChild(child, signal, group = false, by = undefined) {
176
214
  if (!child.pid || child.exitCode !== null || child.signalCode !== null) return false;
177
215
  const asker = askedBy(by);
178
- if (!group || process.platform === 'win32') { const delivered = child.kill(signal); saySignal(child.pid, false, signal, asker, delivered ? 'delivered' : 'not delivered'); return delivered; }
216
+ if (group && process.platform === 'win32') {
217
+ try { const delivered = signalWindowsTree(child.pid, signal); saySignal(child.pid, true, signal, asker, 'delivered'); return delivered; }
218
+ catch (error) { saySignal(child.pid, true, signal, asker, error.code ?? 'failed'); if (error.code === 'ESRCH' || nothingLeftToSignal(child.pid)) return false; throw error; }
219
+ }
220
+ if (!group) { const delivered = child.kill(signal); saySignal(child.pid, false, signal, asker, delivered ? 'delivered' : 'not delivered'); return delivered; }
179
221
  try { process.kill(child.pid, 0); }
180
222
  catch (error) { if (error.code === 'ESRCH' || nothingLeftToSignal(child.pid)) return false; saySignalFailure(error, child.pid, 0, child); throw error; }
181
223
  try { const delivered = process.kill(-child.pid, signal); saySignal(child.pid, true, signal, asker, 'delivered'); return delivered; }
@@ -659,7 +659,7 @@ export function projectConnections(input = process.cwd(), attributedEnv = new Se
659
659
  * This is the third vendor signal beside dependencies and env names: hand-written REST clients
660
660
  * often declare no SDK, and an unconventional credential name can hide the same vendor from the
661
661
  * env heuristic. Only direct `fetch("https://...")`, `window.fetch`, and `globalThis.fetch` calls
662
- * count. Dynamic destinations remain intentionally unclaimed; guessing from arbitrary URL strings
662
+ * count, with their literal argument or a literal the same file binds to the name they pass. Dynamic destinations remain intentionally unclaimed; guessing from arbitrary URL strings
663
663
  * would turn docs, links, and webhook examples into false external-service dependencies. */
664
664
  /** A migration tool an application carries, by its evidence in the repository (architecture, World wiring). */
665
665
  export interface ProjectMigrationTool {
@@ -704,9 +704,16 @@ export function projectFetchUrls(input = process.cwd()): ProjectFetchUrl[] {
704
704
  const seen = new Set<string>();
705
705
  // A leading identifier/dot would make this a custom method (`client.fetch`), not platform fetch.
706
706
  const literalFetch = /(?<![\w$.])(?:(?:globalThis|window)\.)?fetch\s*\(\s*(['"`])(https?:\/\/[^'"`\s]+)\1/g;
707
+ // A literal bound to a name in the same file and passed to fetch by that name (`const url = \`https://…\`;
708
+ // fetch(url)`, as Dub's and Rallly's Google Fonts calls are) is the same destination; the demand scan traces it too.
709
+ const boundLiteral = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(['"`])(https?:\/\/[^'"`\s]+)\2/g;
707
710
  for (const path of projectSourceFiles(root)) {
708
711
  const text = readFileSync(path, 'utf8');
709
- for (const match of text.matchAll(literalFetch)) {
712
+ const bound = [...text.matchAll(boundLiteral)].filter(m => m.index !== undefined && isCodeAt(text, m.index));
713
+ const viaName = bound.flatMap(m => [...text.matchAll(new RegExp(`(?<![\\w$.])(?:(?:globalThis|window)\\.)?fetch\\s*\\(\\s*${m[1]!.replace(/\$/g, '\\$')}\\s*[,)]`, 'g'))]
714
+ .filter(call => call.index !== undefined && call.index > m.index! && isCodeAt(text, call.index))
715
+ .map(call => Object.assign([call[0], '', m[3]!] as unknown as RegExpExecArray, { index: call.index })));
716
+ for (const match of [...text.matchAll(literalFetch), ...viaName]) {
710
717
  if (match.index === undefined || !isCodeAt(text, match.index)) continue;
711
718
  const url = match[2]!;
712
719
  try { new URL(url); } catch { continue; }
@@ -39,6 +39,7 @@ import { readFileSync } from 'node:fs';
39
39
  import { loadInject } from './inject-map.ts';
40
40
  import { packFacts } from './pack-facts.ts';
41
41
  import { sessionTrustEnv, type CaTrustInputs } from './ca-trust.ts';
42
+ import { forwardableHeaders } from './forwardable-headers.ts';
42
43
 
43
44
  /**
44
45
  * The body served when a host was MITM'd but no twin serves the PATH.
@@ -235,20 +236,6 @@ export function twinUnreachableMessage(vendor: string, origin: string, err: Erro
235
236
  return `twin "${vendor}" at ${origin} is unreachable${world ? ` in World "${world}"` : ''}: ${errorMessage(err)}${cause?.code ? ` (${cause.code})` : cause?.message ? ` (${errorMessage(cause)})` : ''}; check \`volter-world doctor${world ? ` ${world}` : ''}\`\n`;
236
237
  }
237
238
 
238
- /** The headers of one connection, not of the request (RFC 9110 §7.6.1), with `expect`: the hop that received them
239
- * answered them (Node's server sends the 100 Continue itself). Node's fetch refuses `transfer-encoding`,
240
- * `keep-alive`, `upgrade`, `expect` and a `connection` other than close/keep-alive outright, so a `git push` whose
241
- * pack came chunked (larger than `http.postBuffer`) failed `fetch failed (UND_ERR_INVALID_ARG)`; the body streams
242
- * on and fetch frames it afresh. */
243
- const HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'te', 'trailer', 'upgrade', 'expect'];
244
- const TOKEN = /^[!#$%&'*+.^_`|~0-9a-z-]+$/i;
245
- function forwardableHeaders(incoming: Headers): Headers {
246
- const headers = new Headers(incoming);
247
- for (const listed of (incoming.get('connection') ?? '').split(',')) { const name = listed.trim(); if (TOKEN.test(name)) headers.delete(name); }
248
- for (const h of HOP_BY_HOP) headers.delete(h);
249
- return headers;
250
- }
251
-
252
239
  /** Start the ambient redirect proxy. Returns a handle with the listen port + the env to export.
253
240
  * - Plain `http://` proxied requests to a vendor host → forwarded to the twin.
254
241
  * - `CONNECT host:443` to a vendor host → MITM-terminated with a session-signed leaf, decrypted, and
@@ -376,7 +363,9 @@ export async function startRedirectProxy(options: RedirectProxyOptions): Promise
376
363
  const hostServer = await serveHttp({
377
364
  hostname: '127.0.0.1',
378
365
  port: 0,
379
- tls: { key: leaf.key, cert: leaf.cert },
366
+ // the leaf, then the session CA that signed it: a client given no CA file can still pin this World's CA by its key
367
+ // (Chrome's --ignore-certificate-errors-spki-list, the manifest's `browser`), which it matches in the chain served
368
+ tls: { key: leaf.key, cert: `${leaf.cert.trimEnd()}\n${readFileSync(ca.caCert, 'utf8')}` },
380
369
  // Carry a twin's declared transport cap through TLS termination; public grants add no cap.
381
370
  maxRequestBodySize: (packFacts()[proxyTargetFor(vendorHost, currentEnv())?.vendor ?? ''] as { maxRequestBodySize?: number } | undefined)?.maxRequestBodySize,
382
371
  // A twin can think before its first byte (a git pack built from a cold cache took 19-28 s); Bun's 10 s idle
package/src/reflect.ts CHANGED
@@ -36,6 +36,7 @@ import { connect as netConnect, createServer as createNetServer, type Server as
36
36
  import { join } from 'node:path';
37
37
  import { activeVendorMap, ensureCa, leafCertFor, noTwinMessage, proxyTargetFor, twinUnreachableMessage } from './redirect-proxy.ts';
38
38
  import { instanceDir } from './runtime.ts';
39
+ import { forwardableHeaders } from './forwardable-headers.ts';
39
40
 
40
41
  // ---------------------------------------------------------------------------
41
42
  // Routes — attachment-scoped, file-backed, re-read live by the resolver.
@@ -160,9 +161,8 @@ export async function startReflectFront(options: ReflectFrontOptions): Promise<R
160
161
  let origin: URL;
161
162
  try { origin = new URL(target.origin); } catch { return new Response('bad twin origin', { status: 502 }); }
162
163
 
163
- const headers = new Headers(req.headers);
164
+ const headers = forwardableHeaders(req.headers);
164
165
  headers.delete('x-volter-world-browser');
165
- headers.delete('proxy-connection');
166
166
  headers.delete('accept-encoding');
167
167
  // the consumer spoke TLS to the front; a twin that answers with an absolute URL (Slack's Socket Mode link)
168
168
  // gives the scheme the consumer can reach again, as a vendor behind a TLS-terminating edge does