@volter/world-runtime 3.0.166 → 3.0.168
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.
- package/dist/known-external-services.json +0 -1
- package/dist/src/actions-runner.js +53 -14
- package/dist/src/attach.d.ts +9 -0
- package/dist/src/attach.js +9 -2
- package/dist/src/cli-commands.js +19 -3
- package/dist/src/console-apart.js +3 -2
- package/dist/src/credential-door.d.ts +8 -0
- package/dist/src/credential-door.js +54 -0
- package/dist/src/fixture-env.js +2 -2
- package/dist/src/forwardable-headers.d.ts +1 -0
- package/dist/src/forwardable-headers.js +19 -0
- package/dist/src/go-mysql-binding.d.ts +2 -0
- package/dist/src/go-mysql-binding.js +7 -1
- package/dist/src/guest-runner.js +8 -4
- package/dist/src/host-ready.d.ts +7 -3
- package/dist/src/host-ready.js +16 -3
- package/dist/src/index.d.ts +6 -1
- package/dist/src/index.js +7 -1
- package/dist/src/infra.js +10 -5
- package/dist/src/init.d.ts +5 -0
- package/dist/src/init.js +157 -23
- package/dist/src/managed-api-needs.d.ts +101 -0
- package/dist/src/managed-api-needs.js +312 -0
- package/dist/src/managed-api-source.d.ts +24 -0
- package/dist/src/managed-api-source.js +59 -0
- package/dist/src/mongodb-backing.js +33 -58
- package/dist/src/mongodb-command.d.ts +21 -0
- package/dist/src/mongodb-command.js +317 -0
- package/dist/src/pglite-host.mjs +2 -2
- package/dist/src/placeholder.d.ts +24 -1
- package/dist/src/placeholder.js +105 -13
- package/dist/src/process-signal.d.mts +2 -0
- package/dist/src/process-signal.mjs +44 -2
- package/dist/src/project-inspect.d.ts +1 -1
- package/dist/src/project-inspect.js +8 -1
- package/dist/src/redirect-proxy.js +4 -19
- package/dist/src/reflect.js +2 -2
- package/dist/src/runtime.d.ts +5 -1
- package/dist/src/runtime.js +311 -59
- package/dist/src/schema.d.ts +10 -0
- package/dist/src/schema.js +76 -3
- package/dist/src/serve.js +24 -4
- package/dist/src/served-world.d.ts +2 -1
- package/dist/src/served-world.js +6 -4
- package/dist/src/service-command.d.ts +7 -3
- package/dist/src/service-command.js +53 -22
- package/dist/src/service-environment.d.ts +4 -0
- package/dist/src/service-environment.js +21 -0
- package/dist/src/service-recorder.js +9 -13
- package/known-external-services.json +0 -1
- package/package.json +5 -5
- package/src/actions-runner.ts +38 -10
- package/src/attach.ts +15 -2
- package/src/cli-commands.ts +18 -3
- package/src/console-apart.ts +3 -2
- package/src/credential-door.ts +54 -0
- package/src/fixture-env.ts +2 -2
- package/src/forwardable-headers.ts +15 -0
- package/src/go-mysql-binding.ts +8 -1
- package/src/guest-runner.ts +8 -4
- package/src/host-ready.ts +12 -3
- package/src/index.ts +8 -1
- package/src/infra.ts +8 -3
- package/src/init.ts +145 -23
- package/src/managed-api-needs.ts +285 -0
- package/src/managed-api-source.ts +67 -0
- package/src/mongodb-backing.ts +32 -48
- package/src/mongodb-command.ts +238 -0
- package/src/pglite-host.mjs +2 -2
- package/src/placeholder.ts +90 -13
- package/src/process-signal.d.mts +2 -0
- package/src/process-signal.mjs +44 -2
- package/src/project-inspect.ts +9 -2
- package/src/redirect-proxy.ts +4 -15
- package/src/reflect.ts +2 -2
- package/src/runtime.ts +272 -45
- package/src/schema.ts +73 -3
- package/src/serve.ts +22 -4
- package/src/served-world.ts +7 -5
- package/src/service-command.ts +41 -19
- package/src/service-environment.ts +21 -0
- 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
|
+
}
|
package/src/pglite-host.mjs
CHANGED
|
@@ -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" (
|
|
691
|
-
*
|
|
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
|
package/src/placeholder.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/process-signal.d.mts
CHANGED
|
@@ -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;
|
package/src/process-signal.mjs
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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; }
|
package/src/project-inspect.ts
CHANGED
|
@@ -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
|
-
|
|
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; }
|
package/src/redirect-proxy.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|