@sproutboat/runtime 0.42.1 → 0.43.0
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/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/native-fetch-prelude.js +186 -24
- package/src/transport-embedded.js +35 -6
- package/test/native-base64.ts +12 -26
- package/test/native-bodies.ts +19 -26
- package/test/native-byte-text.ts +3 -19
- package/test/native-compile.ts +163 -0
- package/test/native-cpu-requests.ts +3 -20
- package/test/native-cpu-stamp.ts +3 -20
- package/test/native-crypto.ts +3 -22
- package/test/native-domexception.ts +3 -21
- package/test/native-errors.ts +12 -24
- package/test/native-events.ts +21 -25
- package/test/native-fetch-request.ts +11 -20
- package/test/native-getrandomvalues.ts +3 -21
- package/test/native-headers.ts +3 -21
- package/test/native-http.ts +10 -20
- package/test/native-iterators.ts +24 -27
- package/test/native-json-strings.ts +3 -21
- package/test/native-json.ts +15 -25
- package/test/native-jwt.ts +3 -22
- package/test/native-model-resolver.ts +5 -24
- package/test/native-raw-body.ts +7 -53
- package/test/native-request-log.ts +197 -0
- package/test/native-request-streaming.ts +3 -19
- package/test/native-request.ts +24 -26
- package/test/native-response-defaults.ts +4 -22
- package/test/native-service-bindings.ts +53 -27
- package/test/native-sockets-broker.ts +65 -20
- package/test/native-sockets-starttls.ts +404 -0
- package/test/native-streaming.ts +4 -22
- package/test/native-streams.ts +22 -25
- package/test/native-subtle.ts +3 -21
- package/test/native-textdecoder-encodings.ts +3 -21
- package/test/native-timers.ts +4 -22
- package/test/native-unhandled-rejection.ts +3 -20
- package/test/native-url.ts +15 -25
- package/test/native-utf8.ts +15 -21
- package/test/native-waituntil.ts +7 -23
- package/test/native-web-apis.ts +3 -22
- package/test/native-websockets.ts +3 -19
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { Duplex } from "node:stream";
|
|
7
|
+
import tls from "node:tls";
|
|
8
|
+
|
|
9
|
+
// cloudflare:sockets connect() on the embedded transport (standalone sprouts, sproutd): no
|
|
10
|
+
// application byte written after startTls(), or before `opened` on a secureTransport "on"
|
|
11
|
+
// socket, may ever reach the wire in plaintext. The origins here record every raw byte they
|
|
12
|
+
// receive and refuse anything but a TLS handshake record where the handshake must begin.
|
|
13
|
+
//
|
|
14
|
+
// The deterministic cases hold the sprout's I/O thread in its plaintext flush (a 16 MiB write
|
|
15
|
+
// the origin reads only after a pause) while the handler calls startTls() and writes to the
|
|
16
|
+
// new socket; the I/O thread then sees the upgrade and the new writes in one pass, which is
|
|
17
|
+
// the race the TLS spike observed on 14 of 200 connections. `/burst` repeats the plain
|
|
18
|
+
// immediate-write case many times, as the spike did.
|
|
19
|
+
//
|
|
20
|
+
// Needs SB_CLI, a sproutboat-cli checkout linked to this one (it builds the sprout).
|
|
21
|
+
const BLOB = 16 << 20;
|
|
22
|
+
const handler = `
|
|
23
|
+
import { connect } from "cloudflare:sockets";
|
|
24
|
+
const enc = new TextEncoder();
|
|
25
|
+
const dec = new TextDecoder();
|
|
26
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
27
|
+
const withTimeout = (p, ms, what) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error("TIMEOUT " + what)), ms))]);
|
|
28
|
+
async function readN(reader, n) {
|
|
29
|
+
let out = "";
|
|
30
|
+
while (out.length < n) {
|
|
31
|
+
const r = await withTimeout(reader.read(), 5000, "read");
|
|
32
|
+
if (r.done) return out;
|
|
33
|
+
out += dec.decode(r.value);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
async function readUntil(reader, needle) {
|
|
38
|
+
let out = "";
|
|
39
|
+
while (!out.includes(needle)) {
|
|
40
|
+
const r = await withTimeout(reader.read(), 5000, "read " + needle);
|
|
41
|
+
if (r.done) return out;
|
|
42
|
+
out += dec.decode(r.value);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
let blob = null;
|
|
47
|
+
function theBlob() {
|
|
48
|
+
if (blob === null) {
|
|
49
|
+
blob = new Uint8Array(${BLOB});
|
|
50
|
+
for (let i = 0; i < blob.length; i++) blob[i] = 120;
|
|
51
|
+
}
|
|
52
|
+
return blob;
|
|
53
|
+
}
|
|
54
|
+
// The plaintext phase every startTls route shares: greeting, STARTTLS, go.
|
|
55
|
+
async function plainPhase(port) {
|
|
56
|
+
const s = connect({ hostname: "localhost", port }, { secureTransport: "starttls" });
|
|
57
|
+
const w = s.writable.getWriter();
|
|
58
|
+
const r = s.readable.getReader();
|
|
59
|
+
await readN(r, 11);
|
|
60
|
+
await w.write(enc.encode("STARTTLS\\r\\n"));
|
|
61
|
+
await readN(r, 4);
|
|
62
|
+
return { s, w, r };
|
|
63
|
+
}
|
|
64
|
+
export default {
|
|
65
|
+
async fetch(request) {
|
|
66
|
+
const url = new URL(request.url);
|
|
67
|
+
const q = url.searchParams;
|
|
68
|
+
const port = Number(q.get("port"));
|
|
69
|
+
try {
|
|
70
|
+
if (url.pathname === "/ping") return new Response("pong");
|
|
71
|
+
if (url.pathname === "/immediate" || url.pathname === "/queued") {
|
|
72
|
+
const { s, w, r } = await plainPhase(port);
|
|
73
|
+
// not awaited: the origin reads it only after a pause, so the host is still flushing it
|
|
74
|
+
// in the clear while everything below runs (the yield lets the host start the flush)
|
|
75
|
+
w.write(theBlob());
|
|
76
|
+
await sleep(100);
|
|
77
|
+
w.releaseLock();
|
|
78
|
+
r.releaseLock();
|
|
79
|
+
const secure = s.startTls({ expectedServerHostname: "localhost" });
|
|
80
|
+
const w2 = secure.writable.getWriter();
|
|
81
|
+
const r2 = secure.readable.getReader();
|
|
82
|
+
const n = url.pathname === "/queued" ? 3 : 1;
|
|
83
|
+
for (let i = 1; i <= n; i++) w2.write(enc.encode("secret-" + i));
|
|
84
|
+
const late = s.writable.getWriter().write(enc.encode("late-plaintext")).then(() => "accepted", (e) => e.name);
|
|
85
|
+
await withTimeout(secure.opened, 15000, "tls");
|
|
86
|
+
const echoed = await readUntil(r2, "secret-" + n);
|
|
87
|
+
await secure.close();
|
|
88
|
+
const oldWrite = await withTimeout(late, 1000, "late write").catch(() => "pending");
|
|
89
|
+
return Response.json({ echoed, oldWrite });
|
|
90
|
+
}
|
|
91
|
+
if (url.pathname === "/inflight") {
|
|
92
|
+
const { s, w, r } = await plainPhase(port);
|
|
93
|
+
w.releaseLock();
|
|
94
|
+
r.releaseLock();
|
|
95
|
+
const secure = s.startTls({ expectedServerHostname: "localhost" });
|
|
96
|
+
// the origin holds the ClientHello for a while, so the handshake is still in flight here
|
|
97
|
+
await sleep(100);
|
|
98
|
+
const w2 = secure.writable.getWriter();
|
|
99
|
+
const r2 = secure.readable.getReader();
|
|
100
|
+
w2.write(enc.encode("secret-1"));
|
|
101
|
+
await withTimeout(secure.opened, 15000, "tls");
|
|
102
|
+
const echoed = await readUntil(r2, "secret-1");
|
|
103
|
+
await secure.close();
|
|
104
|
+
return Response.json({ echoed });
|
|
105
|
+
}
|
|
106
|
+
if (url.pathname === "/on") {
|
|
107
|
+
const s = connect({ hostname: "localhost", port }, { secureTransport: "on" });
|
|
108
|
+
const w = s.writable.getWriter();
|
|
109
|
+
const r = s.readable.getReader();
|
|
110
|
+
// written before \`opened\`: it may only leave inside a TLS record
|
|
111
|
+
w.write(enc.encode("secret-1"));
|
|
112
|
+
await withTimeout(s.opened, 15000, "opened");
|
|
113
|
+
const echoed = await readUntil(r, "secret-1");
|
|
114
|
+
await s.close();
|
|
115
|
+
return Response.json({ echoed });
|
|
116
|
+
}
|
|
117
|
+
if (url.pathname === "/burst") {
|
|
118
|
+
const n = Number(q.get("n"));
|
|
119
|
+
let ok = 0;
|
|
120
|
+
const errors = [];
|
|
121
|
+
for (let i = 0; i < n; i++) {
|
|
122
|
+
try {
|
|
123
|
+
const { s, w, r } = await plainPhase(port);
|
|
124
|
+
w.releaseLock();
|
|
125
|
+
r.releaseLock();
|
|
126
|
+
const secure = s.startTls({ expectedServerHostname: "localhost" });
|
|
127
|
+
const w2 = secure.writable.getWriter();
|
|
128
|
+
const r2 = secure.readable.getReader();
|
|
129
|
+
w2.write(enc.encode("secret-" + i + "\\n"));
|
|
130
|
+
const echoed = await readUntil(r2, "secret-" + i + "\\n");
|
|
131
|
+
await secure.close();
|
|
132
|
+
if (echoed.includes("secret-" + i)) ok++;
|
|
133
|
+
else errors.push("bad echo " + i);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
errors.push(String(e && e.message));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return Response.json({ ok, errors: errors.slice(0, 3) });
|
|
139
|
+
}
|
|
140
|
+
return new Response("no route", { status: 404 });
|
|
141
|
+
} catch (e) {
|
|
142
|
+
return Response.json({ error: String(e && e.message) }, { status: 500 });
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
`;
|
|
147
|
+
|
|
148
|
+
const cli = process.env.SB_CLI;
|
|
149
|
+
if (!cli) {
|
|
150
|
+
console.log("skipped: set SB_CLI to a sproutboat-cli checkout linked to this checkout (it builds the sprout)");
|
|
151
|
+
process.exit(0);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function freePort(): number {
|
|
155
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
156
|
+
const port = listener.port;
|
|
157
|
+
listener.stop(true);
|
|
158
|
+
return port;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const workdir = mkdtempSync(join(tmpdir(), "sb-native-sockets-starttls-"));
|
|
162
|
+
const servers: net.Server[] = [];
|
|
163
|
+
|
|
164
|
+
// A self-signed certificate for localhost; the sprout is told to trust it through SB_CA_BUNDLE.
|
|
165
|
+
const made = Bun.spawnSync([
|
|
166
|
+
"openssl",
|
|
167
|
+
"req",
|
|
168
|
+
"-x509",
|
|
169
|
+
"-newkey",
|
|
170
|
+
"rsa:2048",
|
|
171
|
+
"-nodes",
|
|
172
|
+
"-keyout",
|
|
173
|
+
join(workdir, "key.pem"),
|
|
174
|
+
"-out",
|
|
175
|
+
join(workdir, "cert.pem"),
|
|
176
|
+
"-days",
|
|
177
|
+
"2",
|
|
178
|
+
"-subj",
|
|
179
|
+
"/CN=localhost",
|
|
180
|
+
"-addext",
|
|
181
|
+
"subjectAltName=DNS:localhost",
|
|
182
|
+
]);
|
|
183
|
+
assert.equal(made.exitCode, 0, `openssl failed: ${made.stderr}`);
|
|
184
|
+
const cert = readFileSync(join(workdir, "cert.pem"), "utf8");
|
|
185
|
+
const key = readFileSync(join(workdir, "key.pem"), "utf8");
|
|
186
|
+
|
|
187
|
+
interface OriginOptions {
|
|
188
|
+
/** Plaintext bytes the client still owes after "go", swallowed before the TLS handshake. */
|
|
189
|
+
readonly plainBytes?: number;
|
|
190
|
+
/** Stop reading for this long after "go", so the client's send side fills up. */
|
|
191
|
+
readonly stallMs?: number;
|
|
192
|
+
/** Hold the first TLS record this long before the server's TLS side sees it. */
|
|
193
|
+
readonly holdHelloMs?: number;
|
|
194
|
+
/** TLS from the first byte (secureTransport "on") instead of a STARTTLS exchange. */
|
|
195
|
+
readonly tlsFromStart?: boolean;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A recording origin: `records[i]` holds every raw byte connection i sent after the plaintext phase. */
|
|
199
|
+
function origin(options: OriginOptions) {
|
|
200
|
+
const records: Buffer[][] = [];
|
|
201
|
+
const server = net.createServer((s) => {
|
|
202
|
+
const raw: Buffer[] = [];
|
|
203
|
+
records.push(raw);
|
|
204
|
+
s.on("error", () => {});
|
|
205
|
+
// A TLSSocket over a net.Socket reads the handle directly, so the TLS side runs over a tap
|
|
206
|
+
// fed by the data listener that records the bytes.
|
|
207
|
+
const tap = new Duplex({
|
|
208
|
+
read() {},
|
|
209
|
+
write(chunk, _enc, done) {
|
|
210
|
+
s.write(chunk, done);
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
let plainLeft = options.plainBytes ?? 0;
|
|
214
|
+
let handshakeSeen = false;
|
|
215
|
+
const held: Buffer[] = [];
|
|
216
|
+
let holding = options.holdHelloMs !== undefined;
|
|
217
|
+
const feed = (c: Buffer) => {
|
|
218
|
+
if (!handshakeSeen) {
|
|
219
|
+
handshakeSeen = true;
|
|
220
|
+
// anything but a TLS handshake record here is a leak: end the connection at once
|
|
221
|
+
if (c[0] !== 0x16) {
|
|
222
|
+
s.destroy();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (holding) {
|
|
226
|
+
setTimeout(() => {
|
|
227
|
+
holding = false;
|
|
228
|
+
for (const h of held.splice(0)) tap.push(h);
|
|
229
|
+
}, options.holdHelloMs);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (holding) held.push(c);
|
|
233
|
+
else tap.push(c);
|
|
234
|
+
};
|
|
235
|
+
const startTls = () => {
|
|
236
|
+
const secure = new tls.TLSSocket(tap, { isServer: true, key, cert });
|
|
237
|
+
secure.on("data", (c) => secure.write(Buffer.concat([Buffer.from("tls:"), c])));
|
|
238
|
+
secure.on("error", () => {});
|
|
239
|
+
};
|
|
240
|
+
let afterGo = options.tlsFromStart === true;
|
|
241
|
+
s.on("data", (c: Buffer) => {
|
|
242
|
+
if (!afterGo) return;
|
|
243
|
+
raw.push(Buffer.from(c));
|
|
244
|
+
let rest = c;
|
|
245
|
+
if (plainLeft > 0) {
|
|
246
|
+
const k = Math.min(plainLeft, rest.length);
|
|
247
|
+
plainLeft -= k;
|
|
248
|
+
rest = rest.subarray(k);
|
|
249
|
+
}
|
|
250
|
+
if (rest.length > 0) feed(rest);
|
|
251
|
+
});
|
|
252
|
+
if (afterGo) {
|
|
253
|
+
startTls();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
s.write("220 hello\r\n");
|
|
257
|
+
s.once("data", () => {
|
|
258
|
+
afterGo = true;
|
|
259
|
+
s.write("go\r\n");
|
|
260
|
+
if (options.stallMs !== undefined) {
|
|
261
|
+
s.pause();
|
|
262
|
+
setTimeout(() => s.resume(), options.stallMs);
|
|
263
|
+
}
|
|
264
|
+
startTls();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
server.listen(0, "127.0.0.1");
|
|
268
|
+
servers.push(server);
|
|
269
|
+
// SAFETY: a listening TCP server's address() is an AddressInfo.
|
|
270
|
+
return { port: (server.address() as net.AddressInfo).port, records };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const stalled = origin({ plainBytes: BLOB, stallMs: 500 });
|
|
274
|
+
const stalledQueued = origin({ plainBytes: BLOB, stallMs: 500 });
|
|
275
|
+
const slowHello = origin({ holdHelloMs: 300 });
|
|
276
|
+
const burst = origin({});
|
|
277
|
+
const tlsOn = origin({ tlsFromStart: true });
|
|
278
|
+
|
|
279
|
+
const plaintextOnly = (raw: Buffer, plainBytes: number, label: string) => {
|
|
280
|
+
assert.ok(
|
|
281
|
+
raw.length > plainBytes,
|
|
282
|
+
`${label}: the origin saw ${raw.length} bytes, expected the handshake after ${plainBytes}`,
|
|
283
|
+
);
|
|
284
|
+
if (plainBytes > 0)
|
|
285
|
+
assert.ok(
|
|
286
|
+
raw.subarray(0, plainBytes).equals(Buffer.alloc(plainBytes, 0x78)),
|
|
287
|
+
`${label}: the plaintext part is intact`,
|
|
288
|
+
);
|
|
289
|
+
assert.equal(raw[plainBytes], 0x16, `${label}: a TLS handshake record follows the plaintext phase`);
|
|
290
|
+
assert.equal(raw.includes("secret"), false, `${label}: no application bytes reached the origin in plaintext`);
|
|
291
|
+
assert.equal(raw.includes("late-plaintext"), false, `${label}: nothing written to the old socket reached the wire`);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
let sprout: ReturnType<typeof Bun.spawn> | undefined;
|
|
295
|
+
try {
|
|
296
|
+
const slug = "starttls-race";
|
|
297
|
+
const project = join(workdir, slug);
|
|
298
|
+
mkdirSync(join(project, "src"), { recursive: true });
|
|
299
|
+
writeFileSync(join(project, "src/index.js"), handler);
|
|
300
|
+
writeFileSync(
|
|
301
|
+
join(project, "sproutboat.jsonc"),
|
|
302
|
+
JSON.stringify({ name: slug, main: "src/index.js", compatibility_date: "2026-11-17" }),
|
|
303
|
+
);
|
|
304
|
+
const built = Bun.spawnSync(["bun", join(cli, "src/main.ts"), "build", "--standalone", "--target", "host"], {
|
|
305
|
+
cwd: project,
|
|
306
|
+
stdout: "inherit",
|
|
307
|
+
stderr: "inherit",
|
|
308
|
+
});
|
|
309
|
+
assert.equal(built.exitCode, 0, "the CLI could not build the sprout");
|
|
310
|
+
const port = freePort();
|
|
311
|
+
sprout = Bun.spawn([join(project, "dist", slug)], {
|
|
312
|
+
cwd: project,
|
|
313
|
+
env: {
|
|
314
|
+
...process.env,
|
|
315
|
+
PORT: String(port),
|
|
316
|
+
SB_EGRESS_ALLOW: "127.0.0.1",
|
|
317
|
+
SB_DATA_DIR: join(project, "data"),
|
|
318
|
+
SB_CA_BUNDLE: join(workdir, "cert.pem"),
|
|
319
|
+
},
|
|
320
|
+
stdout: "ignore",
|
|
321
|
+
stderr: "inherit",
|
|
322
|
+
});
|
|
323
|
+
const base = `http://127.0.0.1:${port}`;
|
|
324
|
+
let ready = false;
|
|
325
|
+
for (let attempt = 0; attempt < 100 && !ready; attempt++) {
|
|
326
|
+
try {
|
|
327
|
+
ready = (await (await fetch(`${base}/ping`, { signal: AbortSignal.timeout(500) })).text()) === "pong";
|
|
328
|
+
} catch {
|
|
329
|
+
if (sprout.exitCode !== null) break;
|
|
330
|
+
await Bun.sleep(100);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
assert.ok(ready, "the sprout did not start");
|
|
334
|
+
// A leak makes the origin drop the connection, so a route may hang on the unfixed runtime:
|
|
335
|
+
// report it as an error and let the recorded bytes speak first.
|
|
336
|
+
const get = async (path: string) => {
|
|
337
|
+
try {
|
|
338
|
+
const res = await fetch(`${base}${path}`, { signal: AbortSignal.timeout(30_000) });
|
|
339
|
+
const text = await res.text();
|
|
340
|
+
try {
|
|
341
|
+
return JSON.parse(text);
|
|
342
|
+
} catch {
|
|
343
|
+
return text;
|
|
344
|
+
}
|
|
345
|
+
} catch (e) {
|
|
346
|
+
return { error: `no answer from the sprout: ${e instanceof Error ? e.message : String(e)}` };
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const immediate = await get(`/immediate?port=${stalled.port}`);
|
|
351
|
+
assert.equal(stalled.records.length, 1);
|
|
352
|
+
plaintextOnly(Buffer.concat(stalled.records[0]), BLOB, "immediate");
|
|
353
|
+
assert.equal(immediate.error, undefined, `immediate: ${JSON.stringify(immediate)}`);
|
|
354
|
+
assert.ok(String(immediate.echoed).includes("secret-1"), "immediate: the write was echoed over TLS");
|
|
355
|
+
assert.equal(
|
|
356
|
+
immediate.oldWrite,
|
|
357
|
+
"TypeError",
|
|
358
|
+
"immediate: a write on the plaintext socket after startTls() is refused",
|
|
359
|
+
);
|
|
360
|
+
console.log("a write right after startTls(), while the plaintext flush is still running, only leaves inside TLS");
|
|
361
|
+
|
|
362
|
+
const queued = await get(`/queued?port=${stalledQueued.port}`);
|
|
363
|
+
assert.equal(stalledQueued.records.length, 1);
|
|
364
|
+
plaintextOnly(Buffer.concat(stalledQueued.records[0]), BLOB, "queued");
|
|
365
|
+
assert.equal(queued.error, undefined, `queued: ${JSON.stringify(queued)}`);
|
|
366
|
+
for (const part of ["secret-1", "secret-2", "secret-3"])
|
|
367
|
+
assert.ok(String(queued.echoed).includes(part), `queued: ${part} echoed`);
|
|
368
|
+
assert.equal(queued.oldWrite, "TypeError");
|
|
369
|
+
console.log("several writes queued behind startTls() all wait for the handshake");
|
|
370
|
+
|
|
371
|
+
const inflight = await get(`/inflight?port=${slowHello.port}`);
|
|
372
|
+
assert.equal(slowHello.records.length, 1);
|
|
373
|
+
plaintextOnly(Buffer.concat(slowHello.records[0]), 0, "inflight");
|
|
374
|
+
assert.equal(inflight.error, undefined, `inflight: ${JSON.stringify(inflight)}`);
|
|
375
|
+
assert.ok(String(inflight.echoed).includes("secret-1"));
|
|
376
|
+
console.log("a write while the handshake is in flight is held until it completes");
|
|
377
|
+
|
|
378
|
+
const on = await get(`/on?port=${tlsOn.port}`);
|
|
379
|
+
assert.equal(tlsOn.records.length, 1);
|
|
380
|
+
plaintextOnly(Buffer.concat(tlsOn.records[0]), 0, "on");
|
|
381
|
+
assert.equal(on.error, undefined, `on: ${JSON.stringify(on)}`);
|
|
382
|
+
assert.ok(String(on.echoed).includes("secret-1"));
|
|
383
|
+
console.log("secureTransport on: a write before `opened` only leaves inside TLS");
|
|
384
|
+
|
|
385
|
+
const n = 40;
|
|
386
|
+
const many = await get(`/burst?port=${burst.port}&n=${n}`);
|
|
387
|
+
let leaked = 0;
|
|
388
|
+
for (const rec of burst.records) {
|
|
389
|
+
const raw = Buffer.concat(rec);
|
|
390
|
+
if (raw.length === 0 || raw[0] !== 0x16 || raw.includes("secret")) leaked++;
|
|
391
|
+
}
|
|
392
|
+
assert.equal(
|
|
393
|
+
leaked,
|
|
394
|
+
0,
|
|
395
|
+
`burst: ${leaked} of ${burst.records.length} connections sent application bytes in plaintext`,
|
|
396
|
+
);
|
|
397
|
+
assert.equal(many.error, undefined, `burst: ${JSON.stringify(many)}`);
|
|
398
|
+
assert.deepEqual(many, { ok: n, errors: [] });
|
|
399
|
+
console.log(`${n} immediate writes after startTls() in a row, none in plaintext`);
|
|
400
|
+
} finally {
|
|
401
|
+
sprout?.kill();
|
|
402
|
+
for (const server of servers) server.close();
|
|
403
|
+
rmSync(workdir, { recursive: true, force: true });
|
|
404
|
+
}
|
package/test/native-streaming.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { join
|
|
5
|
-
import {
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { compileNative } from "./native-compile";
|
|
6
6
|
import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
7
7
|
|
|
8
8
|
// A ReadableStream response body is streamed (B19): each chunk reaches the client when the
|
|
@@ -36,14 +36,7 @@ function freePort(): number {
|
|
|
36
36
|
const workdir = await mkdtemp(join(tmpdir(), "sb-native-streaming-"));
|
|
37
37
|
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
38
38
|
try {
|
|
39
|
-
const
|
|
40
|
-
const node = Bun.which("node");
|
|
41
|
-
if (!node) throw new Error("native streaming test needs node on PATH");
|
|
42
|
-
|
|
43
|
-
const [core, broker] = await Promise.all([
|
|
44
|
-
readFile(preludePath, "utf8"),
|
|
45
|
-
readFile(transportPath("broker"), "utf8"),
|
|
46
|
-
]);
|
|
39
|
+
const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
|
|
47
40
|
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
48
41
|
const prelude = core.replace(TRANSPORT_MARKER, broker);
|
|
49
42
|
const generated = join(workdir, "streaming.generated.js");
|
|
@@ -51,18 +44,7 @@ try {
|
|
|
51
44
|
await writeFile(generated, wrapNativeFetchHandler(handler, prelude, undefined, undefined, undefined, "2026-10-01"));
|
|
52
45
|
await rm(binary, { force: true });
|
|
53
46
|
|
|
54
|
-
|
|
55
|
-
cwd: workdir,
|
|
56
|
-
env: process.env,
|
|
57
|
-
stdout: "pipe",
|
|
58
|
-
stderr: "pipe",
|
|
59
|
-
});
|
|
60
|
-
const [exitCode, stdout, stderr] = await Promise.all([
|
|
61
|
-
compile.exited,
|
|
62
|
-
new Response(compile.stdout).text(),
|
|
63
|
-
new Response(compile.stderr).text(),
|
|
64
|
-
]);
|
|
65
|
-
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
47
|
+
await compileNative({ generated, binary, dir: workdir });
|
|
66
48
|
|
|
67
49
|
const port = freePort();
|
|
68
50
|
server = Bun.spawn([binary], {
|
package/test/native-streams.ts
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { join
|
|
5
|
-
import {
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { compileNative } from "./native-compile";
|
|
6
6
|
import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
7
7
|
|
|
8
|
-
const expected = {
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
const expected = {
|
|
9
|
+
chunks: ["a", "b"],
|
|
10
|
+
transformed: ["X", "Y"],
|
|
11
|
+
first: { value: "hello", done: false },
|
|
12
|
+
done: true,
|
|
13
|
+
locked: false,
|
|
14
|
+
lockError: true,
|
|
15
|
+
cancelReason: "stop",
|
|
16
|
+
error: "broken",
|
|
17
|
+
tee: [
|
|
18
|
+
[1, 2],
|
|
19
|
+
[1, 2],
|
|
20
|
+
],
|
|
21
|
+
pulled: [3, 4],
|
|
22
|
+
pending: "later",
|
|
23
|
+
bytes: [7, 8],
|
|
24
|
+
byob: [9, 10],
|
|
25
|
+
};
|
|
11
26
|
const handler = `export default { async fetch(request) {
|
|
12
27
|
if (new URL(request.url).pathname === "/null") return new Response(null);
|
|
13
28
|
const stream = new ReadableStream({start(c){c.enqueue("hello");c.close();}});
|
|
@@ -46,14 +61,7 @@ function freePort(): number {
|
|
|
46
61
|
const workdir = await mkdtemp(join(tmpdir(), "sb-native-streams-"));
|
|
47
62
|
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
48
63
|
try {
|
|
49
|
-
const
|
|
50
|
-
const node = Bun.which("node");
|
|
51
|
-
if (!node) throw new Error("native streams test needs node on PATH");
|
|
52
|
-
|
|
53
|
-
const [core, broker] = await Promise.all([
|
|
54
|
-
readFile(preludePath, "utf8"),
|
|
55
|
-
readFile(transportPath("broker"), "utf8"),
|
|
56
|
-
]);
|
|
64
|
+
const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
|
|
57
65
|
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
58
66
|
const prelude = core.replace(TRANSPORT_MARKER, broker);
|
|
59
67
|
const generated = join(workdir, "streams.generated.js");
|
|
@@ -61,18 +69,7 @@ try {
|
|
|
61
69
|
await writeFile(generated, wrapNativeFetchHandler(handler, prelude));
|
|
62
70
|
await rm(binary, { force: true });
|
|
63
71
|
|
|
64
|
-
|
|
65
|
-
cwd: workdir,
|
|
66
|
-
env: process.env,
|
|
67
|
-
stdout: "pipe",
|
|
68
|
-
stderr: "pipe",
|
|
69
|
-
});
|
|
70
|
-
const [exitCode, stdout, stderr] = await Promise.all([
|
|
71
|
-
compile.exited,
|
|
72
|
-
new Response(compile.stdout).text(),
|
|
73
|
-
new Response(compile.stderr).text(),
|
|
74
|
-
]);
|
|
75
|
-
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
72
|
+
await compileNative({ generated, binary, dir: workdir });
|
|
76
73
|
|
|
77
74
|
const port = freePort();
|
|
78
75
|
server = Bun.spawn([binary], {
|
package/test/native-subtle.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { join
|
|
5
|
-
import {
|
|
6
|
-
import { ensurePorffor } from "../../toolchain/src/acquire";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { compileNative } from "./native-compile";
|
|
7
6
|
import { jsonObject, parseJsonValue, type JsonObject, type JsonValue } from "../../wire/src/json";
|
|
8
7
|
import { SUBTLE_DATE } from "../src/subtle-date";
|
|
9
8
|
import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
@@ -109,9 +108,6 @@ async function run(date: string): Promise<JsonObject> {
|
|
|
109
108
|
const workdir = await mkdtemp(join(tmpdir(), "sb-native-subtle-"));
|
|
110
109
|
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
111
110
|
try {
|
|
112
|
-
const porffor = await ensurePorffor();
|
|
113
|
-
const node = Bun.which("node");
|
|
114
|
-
if (!node) throw new Error("native subtle test needs node on PATH");
|
|
115
111
|
const [core, broker] = await Promise.all([
|
|
116
112
|
readFile(preludePath, "utf8"),
|
|
117
113
|
readFile(transportPath("broker"), "utf8"),
|
|
@@ -123,21 +119,7 @@ async function run(date: string): Promise<JsonObject> {
|
|
|
123
119
|
generated,
|
|
124
120
|
wrapNativeFetchHandler(handler, core.replace(TRANSPORT_MARKER, broker), undefined, undefined, undefined, date),
|
|
125
121
|
);
|
|
126
|
-
|
|
127
|
-
[node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
|
|
128
|
-
{
|
|
129
|
-
cwd: workdir,
|
|
130
|
-
env: { ...process.env, ...(await bearsslEnv()) },
|
|
131
|
-
stdout: "pipe",
|
|
132
|
-
stderr: "pipe",
|
|
133
|
-
},
|
|
134
|
-
);
|
|
135
|
-
const [exitCode, stdout, stderr] = await Promise.all([
|
|
136
|
-
compile.exited,
|
|
137
|
-
new Response(compile.stdout).text(),
|
|
138
|
-
new Response(compile.stderr).text(),
|
|
139
|
-
]);
|
|
140
|
-
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
122
|
+
await compileNative({ generated, binary, dir: workdir, bearssl: true });
|
|
141
123
|
const port = freePort();
|
|
142
124
|
server = Bun.spawn([binary], {
|
|
143
125
|
cwd: workdir,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { join
|
|
5
|
-
import {
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { compileNative } from "./native-compile";
|
|
6
6
|
import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
7
7
|
|
|
8
8
|
// TextDecoder encodings (UTF-16LE/BE, windows-1252), Blob.stream and Response.url in a compiled handler, compared with Bun
|
|
@@ -56,10 +56,6 @@ function freePort(): number {
|
|
|
56
56
|
const workdir = await mkdtemp(join(tmpdir(), "sb-native-textdecoder-encodings-"));
|
|
57
57
|
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
58
58
|
try {
|
|
59
|
-
const porffor = await ensurePorffor();
|
|
60
|
-
const node = Bun.which("node");
|
|
61
|
-
if (!node) throw new Error("native streams test needs node on PATH");
|
|
62
|
-
|
|
63
59
|
const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
|
|
64
60
|
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
65
61
|
const prelude = core.replace(TRANSPORT_MARKER, broker);
|
|
@@ -68,21 +64,7 @@ try {
|
|
|
68
64
|
await writeFile(generated, wrapNativeFetchHandler(handler, prelude));
|
|
69
65
|
await rm(binary, { force: true });
|
|
70
66
|
|
|
71
|
-
|
|
72
|
-
[node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
|
|
73
|
-
{
|
|
74
|
-
cwd: workdir,
|
|
75
|
-
env: process.env,
|
|
76
|
-
stdout: "pipe",
|
|
77
|
-
stderr: "pipe",
|
|
78
|
-
},
|
|
79
|
-
);
|
|
80
|
-
const [exitCode, stdout, stderr] = await Promise.all([
|
|
81
|
-
compile.exited,
|
|
82
|
-
new Response(compile.stdout).text(),
|
|
83
|
-
new Response(compile.stderr).text(),
|
|
84
|
-
]);
|
|
85
|
-
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
67
|
+
await compileNative({ generated, binary, dir: workdir });
|
|
86
68
|
|
|
87
69
|
const port = freePort();
|
|
88
70
|
server = Bun.spawn([binary], {
|
package/test/native-timers.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { join
|
|
5
|
-
import {
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { compileNative } from "./native-compile";
|
|
6
6
|
import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
7
7
|
|
|
8
8
|
const expected = { fired: 7, cancelled: false, ticks: 2 };
|
|
@@ -31,14 +31,7 @@ function freePort(): number {
|
|
|
31
31
|
const workdir = await mkdtemp(join(tmpdir(), "sb-native-timers-"));
|
|
32
32
|
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
33
33
|
try {
|
|
34
|
-
const
|
|
35
|
-
const node = Bun.which("node");
|
|
36
|
-
if (!node) throw new Error("native timers test needs node on PATH");
|
|
37
|
-
|
|
38
|
-
const [core, broker] = await Promise.all([
|
|
39
|
-
readFile(preludePath, "utf8"),
|
|
40
|
-
readFile(transportPath("broker"), "utf8"),
|
|
41
|
-
]);
|
|
34
|
+
const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
|
|
42
35
|
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
43
36
|
const prelude = core.replace(TRANSPORT_MARKER, broker);
|
|
44
37
|
const generated = join(workdir, "timers.generated.js");
|
|
@@ -46,18 +39,7 @@ try {
|
|
|
46
39
|
await writeFile(generated, wrapNativeFetchHandler(handler, prelude));
|
|
47
40
|
await rm(binary, { force: true });
|
|
48
41
|
|
|
49
|
-
|
|
50
|
-
cwd: workdir,
|
|
51
|
-
env: process.env,
|
|
52
|
-
stdout: "pipe",
|
|
53
|
-
stderr: "pipe",
|
|
54
|
-
});
|
|
55
|
-
const [exitCode, stdout, stderr] = await Promise.all([
|
|
56
|
-
compile.exited,
|
|
57
|
-
new Response(compile.stdout).text(),
|
|
58
|
-
new Response(compile.stderr).text(),
|
|
59
|
-
]);
|
|
60
|
-
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
42
|
+
await compileNative({ generated, binary, dir: workdir });
|
|
61
43
|
|
|
62
44
|
const port = freePort();
|
|
63
45
|
server = Bun.spawn([binary], {
|