@sproutboat/runtime 0.34.3 → 0.34.4

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.34.4
4
+
5
+ ### Patch Changes
6
+
7
+ - 5bd4a25: Cheaper JWT verification. `atob` decodes in C for the usual input (no whitespace, valid characters, correct padding) and falls back to the existing JavaScript for everything else, so what throws and what it throws is unchanged. `TextEncoder.encode` sizes and fills the Uint8Array in two C passes instead of two `charCodeAt` loops (a lone surrogate is still U+FFFD). The ArrayBuffer returned by `crypto.subtle.sign` and `digest` is copied in with one `memcpy`, and the constant-time compare behind `verify` runs in C. A typical `authenticate()` (three base64url decodes, two `JSON.parse`, one `TextEncoder.encode`, one HMAC verify) goes from 21.5 to 7 us on an M-series host. New native test `test:native-jwt`: RFC 4231 HMAC vectors, the RFC 7515 and jwt.io tokens, wrong key, tampered header, payload and signature, short, long and missing signatures, bad base64url, typed-array windows and ArrayBuffers as verify input, and seeded random `atob`, `TextEncoder` and HMAC (SHA-256, 384, 512) inputs compared with Bun and node:crypto.
8
+
3
9
  ## 0.34.3
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.34.3",
3
+ "version": "0.34.4",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -3645,7 +3645,12 @@ if (globalThis.crypto.getRandomValues == null) {
3645
3645
  for (let i = 0; i < 128; i++) sextet.push(255);
3646
3646
  for (let i = 0; i < alphabet.length; i++) sextet[alphabet.charCodeAt(i)] = i;
3647
3647
  globalThis.atob = function (data) {
3648
- let text = String(data).replace(/[\t\n\f\r ]/g, "");
3648
+ // One C pass for the usual input (no whitespace, valid characters); the JS below
3649
+ // takes everything else, so it alone decides what throws.
3650
+ const input = String(data);
3651
+ const fast = __sbAtobNative(input);
3652
+ if (fast !== "") return fast;
3653
+ let text = input.replace(/[\t\n\f\r ]/g, "");
3649
3654
  if (text.length % 4 === 0) text = text.replace(/==?$/, "");
3650
3655
  if (text.length % 4 === 1) throw invalidCharacter("atob: the string is not valid base64");
3651
3656
  const parts = [];
@@ -4058,6 +4063,7 @@ function __sbToBytes(input) {
4058
4063
  }
4059
4064
  function __sbBufFrom(latin1) {
4060
4065
  const b = new Uint8Array(latin1.length);
4066
+ if (__sbFillBytes(latin1, b)) return b.buffer;
4061
4067
  for (let i = 0; i < latin1.length; i++) b[i] = latin1.charCodeAt(i) & 0xff;
4062
4068
  return b.buffer;
4063
4069
  }
@@ -4190,6 +4196,8 @@ function __sbRawBodyResponse(body, init) {
4190
4196
  // Constant-time compare of two latin1 byte strings.
4191
4197
  function __sbEqCt(a, b) {
4192
4198
  if (a.length !== b.length) return false;
4199
+ const native = __sbEqCtNative(a, b);
4200
+ if (native >= 0) return native === 1;
4193
4201
  let diff = 0;
4194
4202
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
4195
4203
  return diff === 0;
@@ -4805,6 +4813,21 @@ static size_t sb_utf8_encode_units(const uint16_t* s, size_t len, unsigned char*
4805
4813
  return k;
4806
4814
  }
4807
4815
 
4816
+ // Unit I of a string held as UTF-16 (WIDE) or one unit per byte.
4817
+ static unsigned sb_unit(const unsigned char* s, int wide, size_t i) {
4818
+ return wide ? (unsigned)((const uint16_t*)s)[i] : (unsigned)s[i];
4819
+ }
4820
+
4821
+ // Base64 sextet of a character, or -1.
4822
+ static int sb_b64_val(unsigned c) {
4823
+ if (c >= 'A' && c <= 'Z') return (int)(c - 'A');
4824
+ if (c >= 'a' && c <= 'z') return (int)(c - 'a') + 26;
4825
+ if (c >= '0' && c <= '9') return (int)(c - '0') + 52;
4826
+ if (c == '+') return 62;
4827
+ if (c == '/') return 63;
4828
+ return -1;
4829
+ }
4830
+
4808
4831
  `;
4809
4832
 
4810
4833
  // sproutd #22: __sbFromUtf8 for a string of bytes (one byte per unit) or a Uint8Array, in C.
@@ -4905,6 +4928,91 @@ function __sbToBytesNative(input) {
4905
4928
  return out;
4906
4929
  }
4907
4930
 
4931
+ // Constant-time equality of two equal-length bytestrings in C: 1 equal, 0 different, -1 when
4932
+ // either is not a bytestring (the caller then compares in JS).
4933
+ // oxlint-disable-next-line no-unused-vars -- `a` and `b` are read inside the RawC block below, not by JS.
4934
+ function __sbEqCtNative(a, b) {
4935
+ let result = -1;
4936
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
4937
+ Porffor.c`
4938
+ if (a.type == 195 && b.type == 195) {
4939
+ const size_t __n = (size_t)*(u32*)(MEM + (u32)a.val);
4940
+ if (__n == (size_t)*(u32*)(MEM + (u32)b.val)) {
4941
+ const unsigned char* __x = (const unsigned char*)(MEM + (u32)a.val + 4);
4942
+ const unsigned char* __y = (const unsigned char*)(MEM + (u32)b.val + 4);
4943
+ unsigned char __diff = 0;
4944
+ for (size_t __i = 0; __i < __n; __i++) __diff |= __x[__i] ^ __y[__i];
4945
+ result = (f64)(__diff == 0 ? 1 : 0);
4946
+ }
4947
+ }
4948
+ `;
4949
+ return result;
4950
+ }
4951
+
4952
+ // Copies a bytestring (one unit per byte) into the front of a Uint8Array.
4953
+ // Returns false (writing nothing) when it cannot.
4954
+ // oxlint-disable-next-line no-unused-vars -- `bytes` and `view` are read inside the RawC block below, not by JS.
4955
+ function __sbFillBytes(bytes, view) {
4956
+ let ok = 0;
4957
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
4958
+ Porffor.c`
4959
+ if (bytes.type == 195 && view.type == 81 && *(int32_t*)(MEM + (u32)view.val) >= 0) {
4960
+ const u32 __p = (u32)bytes.val;
4961
+ const u32 __v = (u32)view.val;
4962
+ const size_t __n = (size_t)*(u32*)(MEM + __p);
4963
+ if (__n <= (size_t)*(int32_t*)(MEM + __v)) {
4964
+ memcpy(MEM + *(u32*)(MEM + __v + 4) + 4, MEM + __p + 4, __n);
4965
+ ok = (f64)1;
4966
+ }
4967
+ }
4968
+ `;
4969
+ return ok === 1;
4970
+ }
4971
+
4972
+ // atob for the common case: no whitespace, only base64 characters, `=` padding
4973
+ // only where the spec allows it. Returns the decoded bytestring, or "" for
4974
+ // anything else (including an empty result), so the caller's JS path decides
4975
+ // and throws.
4976
+ // oxlint-disable-next-line no-unused-vars -- `text` is read inside the RawC block below, not by JS.
4977
+ function __sbAtobNative(text) {
4978
+ let out = "";
4979
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
4980
+ Porffor.c`
4981
+ if (text.type == 195 || text.type == 67) {
4982
+ const u32 __p = (u32)text.val;
4983
+ const int __wide = text.type == 67;
4984
+ size_t __n = (size_t)*(u32*)(MEM + __p);
4985
+ const unsigned char* __s = (const unsigned char*)(MEM + __p + 4);
4986
+ if (__n % 4 == 0) {
4987
+ if (__n > 0 && sb_unit(__s, __wide, __n - 1) == '=') __n--;
4988
+ if (__n > 0 && sb_unit(__s, __wide, __n - 1) == '=' && __n % 4 != 0) __n--;
4989
+ }
4990
+ if (__n > 0 && __n % 4 != 1) {
4991
+ const size_t __cap = __n / 4 * 3 + 3;
4992
+ unsigned char __stack[768];
4993
+ unsigned char* __o = __cap <= sizeof(__stack) ? __stack : (unsigned char*)malloc(__cap);
4994
+ if (__o) {
4995
+ size_t __k = 0, __i = 0;
4996
+ int __bad = 0;
4997
+ uint32_t __acc = 0;
4998
+ int __bits = 0;
4999
+ for (; __i < __n; __i++) {
5000
+ const unsigned __c = sb_unit(__s, __wide, __i);
5001
+ const int __v = __c < 128 ? sb_b64_val(__c) : -1;
5002
+ if (__v < 0) { __bad = 1; break; }
5003
+ __acc = (__acc << 6) | (uint32_t)__v;
5004
+ __bits += 6;
5005
+ if (__bits >= 8) { __bits -= 8; __o[__k++] = (unsigned char)((__acc >> __bits) & 0xff); }
5006
+ }
5007
+ if (!__bad) out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__o, __k), 195);
5008
+ if (__o != __stack) free(__o);
5009
+ }
5010
+ }
5011
+ }
5012
+ `;
5013
+ return out;
5014
+ }
5015
+
4908
5016
  // TRANSPORT: wrap.ts splices one of transport-broker.js / transport-embedded.js here.
4909
5017
 
4910
5018
  // `nStr` is the decimal byte count as a string (same string-param pattern as
@@ -31,7 +31,7 @@ const load = new Function(
31
31
  "__sbRpc",
32
32
  "__sbR2MultipartPut",
33
33
  // __sbIsStr and __sbToBytes (for uploadPart's values), with the C fast paths stubbed out.
34
- `function __sbFromUtf8Native() { return ""; }\nfunction __sbToBytesNative() { return ""; }\n${src.slice(isStrStart, isStrEnd)}\n${src.slice(bytesStart, utf8Start)}\n${src.slice(utf8Start, utf8End)}\n${src.slice(rawStart, rawEnd)}\n${src.slice(r2Start, r2End)}\nreturn { __sbR2Object, __sbR2Multipart };`,
34
+ `function __sbFromUtf8Native() { return ""; }\nfunction __sbToBytesNative() { return ""; }\nfunction __sbFillBytes() { return false; }\n${src.slice(isStrStart, isStrEnd)}\n${src.slice(bytesStart, utf8Start)}\n${src.slice(utf8Start, utf8End)}\n${src.slice(rawStart, rawEnd)}\n${src.slice(r2Start, r2End)}\nreturn { __sbR2Object, __sbR2Multipart };`,
35
35
  );
36
36
  const calls = [];
37
37
  const rpc = (op, value) => {
@@ -0,0 +1,280 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { connect } from "node:net";
5
+ import { tmpdir } from "node:os";
6
+ import { join, resolve } from "node:path";
7
+ import { ensurePorffor } from "../../toolchain/src/acquire";
8
+ import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
9
+
10
+ // The native HTTP server's wire behaviour with raw sockets: keep-alive, pipelining (responses in
11
+ // order, also when a request arrives in pieces), a response bigger than the socket buffer to a slow
12
+ // reader (partial writes), a large request body, and many connections at once. Run once with a
13
+ // collection after every request (SB_GC_EVERY=1) and once with the default interval, so the
14
+ // collection schedule is shown not to disturb any of it.
15
+ const handler = `export default {async fetch(request) {
16
+ const url = new URL(request.url);
17
+ const path = url.pathname;
18
+ if (path.startsWith("/n/")) return new Response("n=" + path.slice(3), { headers: { "x-n": path.slice(3) } });
19
+ if (path === "/echo") { const b = await request.arrayBuffer(); return new Response("len=" + b.byteLength + " first=" + (b.byteLength ? new Uint8Array(b)[0] : -1) + " last=" + (b.byteLength ? new Uint8Array(b)[b.byteLength - 1] : -1)); }
20
+ if (path === "/big") {
21
+ const size = Number(url.searchParams.get("size"));
22
+ const out = new Uint8Array(size);
23
+ for (let i = 0; i < size; i++) out[i] = (i * 31 + 7) & 255;
24
+ return new Response(out);
25
+ }
26
+ if (path === "/garbage") { const rows = []; for (let i = 0; i < 2000; i++) rows.push({ i, s: "x".repeat(i % 50) }); return new Response(JSON.stringify(rows).length + ""); }
27
+ return new Response("not found", { status: 404 });
28
+ }};`;
29
+
30
+ function freePort(): number {
31
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
32
+ const port = listener.port;
33
+ listener.stop(true);
34
+ return port;
35
+ }
36
+
37
+ type Parsed = { status: number; headers: Map<string, string>; body: Buffer };
38
+
39
+ // Parses as many whole Content-Length responses as `buffer` holds; returns them and the rest.
40
+ type ParsedBatch = { done: Parsed[]; rest: Buffer };
41
+ function parseResponses(buffer: Buffer): ParsedBatch {
42
+ const done: Parsed[] = [];
43
+ let rest = buffer;
44
+ for (;;) {
45
+ const end = rest.indexOf("\r\n\r\n");
46
+ if (end < 0) break;
47
+ const head = rest.subarray(0, end).toString("latin1").split("\r\n");
48
+ const status = Number(head[0].split(" ")[1]);
49
+ const headers = new Map<string, string>();
50
+ for (const line of head.slice(1)) {
51
+ const at = line.indexOf(":");
52
+ headers.set(line.slice(0, at).toLowerCase(), line.slice(at + 1).trim());
53
+ }
54
+ const length = Number(headers.get("content-length") ?? 0);
55
+ if (rest.length < end + 4 + length) break;
56
+ done.push({ status, headers, body: rest.subarray(end + 4, end + 4 + length) });
57
+ rest = rest.subarray(end + 4 + length);
58
+ }
59
+ return { done, rest };
60
+ }
61
+
62
+ // Writes `chunks` (with `gapMs` between them), then reads until `want` responses arrived.
63
+ // `readDelayMs` holds the socket paused first, so the server's send buffer fills.
64
+ async function exchange(
65
+ port: number,
66
+ chunks: Buffer[],
67
+ want: number,
68
+ opts: { gapMs?: number; readDelayMs?: number; timeoutMs?: number } = {},
69
+ ): Promise<Parsed[]> {
70
+ return new Promise((resolveExchange, reject) => {
71
+ const socket = connect(port, "127.0.0.1");
72
+ let pending = Buffer.alloc(0);
73
+ const responses: Parsed[] = [];
74
+ const timer = setTimeout(() => {
75
+ socket.destroy();
76
+ reject(new Error(`timed out with ${responses.length} of ${want} responses`));
77
+ }, opts.timeoutMs ?? 30_000);
78
+ socket.on("error", reject);
79
+ socket.on("data", (data) => {
80
+ pending = Buffer.concat([pending, data]);
81
+ const { done, rest } = parseResponses(pending);
82
+ pending = rest;
83
+ responses.push(...done);
84
+ if (responses.length >= want) {
85
+ clearTimeout(timer);
86
+ socket.destroy();
87
+ resolveExchange(responses);
88
+ }
89
+ });
90
+ socket.on("connect", async () => {
91
+ if (opts.readDelayMs) {
92
+ socket.pause();
93
+ setTimeout(() => socket.resume(), opts.readDelayMs);
94
+ }
95
+ for (const chunk of chunks) {
96
+ socket.write(chunk);
97
+ if (opts.gapMs) await Bun.sleep(opts.gapMs);
98
+ }
99
+ });
100
+ });
101
+ }
102
+
103
+ const get = (path: string, close = false) =>
104
+ Buffer.from(`GET ${path} HTTP/1.1\r\nHost: t\r\n${close ? "Connection: close\r\n" : ""}\r\n`);
105
+ const post = (path: string, body: Buffer) =>
106
+ Buffer.concat([Buffer.from(`POST ${path} HTTP/1.1\r\nHost: t\r\nContent-Length: ${body.length}\r\n\r\n`), body]);
107
+
108
+ async function suite(port: number, label: string) {
109
+ // Keep-alive: sequential requests on one connection, each answered before the next is sent.
110
+ {
111
+ const socket = connect(port, "127.0.0.1");
112
+ await new Promise<void>((r) => socket.once("connect", () => r()));
113
+ let pending = Buffer.alloc(0);
114
+ for (let i = 0; i < 5; i++) {
115
+ socket.write(get(`/n/${i}`));
116
+ let got: Parsed | undefined;
117
+ while (!got) {
118
+ const data = await new Promise<Buffer>((r) => socket.once("data", r));
119
+ pending = Buffer.concat([pending, data]);
120
+ const parsed = parseResponses(pending);
121
+ pending = parsed.rest;
122
+ got = parsed.done[0];
123
+ }
124
+ assert.equal(got.body.toString(), `n=${i}`, `${label}: keep-alive response ${i}`);
125
+ }
126
+ socket.destroy();
127
+ }
128
+
129
+ // Pipelining: 60 requests in one write come back complete and in order.
130
+ {
131
+ const all = Buffer.concat(Array.from({ length: 60 }, (_, i) => get(`/n/${i}`)));
132
+ const got = await exchange(port, [all], 60);
133
+ assert.deepEqual(
134
+ got.map((r) => r.body.toString()),
135
+ Array.from({ length: 60 }, (_, i) => `n=${i}`),
136
+ `${label}: pipelined order`,
137
+ );
138
+ }
139
+
140
+ // Pipelining with requests cut at every awkward place: one byte at a time for the first two.
141
+ {
142
+ const bytes = Buffer.concat([get("/n/a"), post("/echo", Buffer.from("hello")), get("/n/b")]);
143
+ const chunks: Buffer[] = [];
144
+ for (let i = 0; i < 40; i++) chunks.push(bytes.subarray(i, i + 1));
145
+ chunks.push(bytes.subarray(40));
146
+ const got = await exchange(port, chunks, 3, { gapMs: 2 });
147
+ assert.deepEqual(
148
+ got.map((r) => r.body.toString()),
149
+ ["n=a", "len=5 first=104 last=111", "n=b"],
150
+ `${label}: pipelined requests split across packets`,
151
+ );
152
+ }
153
+
154
+ // A POST whose body arrives in pieces, followed by a pipelined GET.
155
+ {
156
+ const body = Buffer.alloc(300_000, 0x61);
157
+ body[0] = 0x62;
158
+ const request = Buffer.concat([post("/echo", body), get("/n/after")]);
159
+ const chunks: Buffer[] = [];
160
+ for (let at = 0; at < request.length; at += 40_000) chunks.push(request.subarray(at, at + 40_000));
161
+ const got = await exchange(port, chunks, 2, { gapMs: 3 });
162
+ assert.equal(got[0].body.toString(), "len=300000 first=98 last=97", `${label}: large request body`);
163
+ assert.equal(got[1].body.toString(), "n=after", `${label}: request after a large body`);
164
+ }
165
+
166
+ // Partial writes: 12 MB to a reader that does not read for 400 ms, then a pipelined small response
167
+ // that must still arrive after it, in order.
168
+ {
169
+ const size = 12 * 1024 * 1024;
170
+ const got = await exchange(port, [get(`/big?size=${size}`), get("/n/last")], 2, {
171
+ readDelayMs: 400,
172
+ timeoutMs: 60_000,
173
+ });
174
+ assert.equal(got[0].body.length, size, `${label}: big body length`);
175
+ const expected = Buffer.alloc(size);
176
+ for (let i = 0; i < size; i++) expected[i] = (i * 31 + 7) & 255;
177
+ assert.equal(
178
+ createHash("sha256").update(got[0].body).digest("hex"),
179
+ createHash("sha256").update(expected).digest("hex"),
180
+ `${label}: big body bytes`,
181
+ );
182
+ assert.equal(got[1].body.toString(), "n=last", `${label}: response after a partial write`);
183
+ }
184
+
185
+ // Connection: close ends the connection after the response.
186
+ {
187
+ const got = await exchange(port, [get("/n/close", true)], 1);
188
+ assert.equal(got[0].body.toString(), "n=close");
189
+ }
190
+
191
+ // 150 connections at once, 4 pipelined requests each, with garbage-heavy requests mixed in.
192
+ {
193
+ const results = await Promise.all(
194
+ Array.from({ length: 150 }, async (_, c) => {
195
+ const chunks = [get(`/n/${c}-0`), get("/garbage"), get(`/n/${c}-2`), get(`/n/${c}-3`)];
196
+ return exchange(port, [Buffer.concat(chunks)], 4);
197
+ }),
198
+ );
199
+ results.forEach((got, c) => {
200
+ assert.deepEqual(
201
+ [got[0].body.toString(), got[2].body.toString(), got[3].body.toString()],
202
+ [`n=${c}-0`, `n=${c}-2`, `n=${c}-3`],
203
+ `${label}: connection ${c}`,
204
+ );
205
+ assert.equal(got[1].status, 200);
206
+ });
207
+ }
208
+ }
209
+
210
+ const workdir = await mkdtemp(join(tmpdir(), "sb-native-http-"));
211
+ const servers: Array<ReturnType<typeof Bun.spawn>> = [];
212
+ try {
213
+ const porffor = await ensurePorffor();
214
+ const node = Bun.which("node");
215
+ if (!node) throw new Error("native http test needs node on PATH");
216
+ const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
217
+ assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
218
+ const generated = join(workdir, "http.generated.js");
219
+ const binary = join(workdir, "http-native");
220
+ await writeFile(
221
+ generated,
222
+ wrapNativeFetchHandler(
223
+ handler,
224
+ core.replace(TRANSPORT_MARKER, broker),
225
+ undefined,
226
+ undefined,
227
+ undefined,
228
+ "2026-10-21",
229
+ ),
230
+ );
231
+ const compile = Bun.spawn(
232
+ [node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
233
+ {
234
+ cwd: workdir,
235
+ env: process.env,
236
+ stdout: "pipe",
237
+ stderr: "pipe",
238
+ },
239
+ );
240
+ const [exitCode, stdout, stderr] = await Promise.all([
241
+ compile.exited,
242
+ new Response(compile.stdout).text(),
243
+ new Response(compile.stderr).text(),
244
+ ]);
245
+ assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
246
+
247
+ for (const every of ["1", "8"]) {
248
+ const port = freePort();
249
+ const server = Bun.spawn([binary], {
250
+ cwd: workdir,
251
+ env: { ...process.env, PORT: String(port), SB_GC_EVERY: every },
252
+ stdout: "ignore",
253
+ stderr: "inherit",
254
+ });
255
+ servers.push(server);
256
+ let ready = false;
257
+ for (let attempt = 0; attempt < 50 && !ready; attempt++) {
258
+ try {
259
+ await fetch(`http://127.0.0.1:${port}/n/0`, { signal: AbortSignal.timeout(500) });
260
+ ready = true;
261
+ } catch {
262
+ if (server.exitCode !== null) break;
263
+ await Bun.sleep(100);
264
+ }
265
+ }
266
+ assert(ready, "compiled handler did not start");
267
+ await suite(port, `SB_GC_EVERY=${every}`);
268
+ // The server is still answering after all of it.
269
+ assert.equal(await (await fetch(`http://127.0.0.1:${port}/n/z`)).text(), "n=z");
270
+ }
271
+ console.log(
272
+ "Native HTTP: keep-alive, pipelining, split requests, large bodies, partial writes and 150 connections hold at SB_GC_EVERY=1 and 8",
273
+ );
274
+ } finally {
275
+ for (const server of servers) {
276
+ server.kill(9);
277
+ await server.exited;
278
+ }
279
+ await rm(workdir, { recursive: true, force: true });
280
+ }
@@ -0,0 +1,333 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHmac } from "node:crypto";
3
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { ensurePorffor } from "../../toolchain/src/acquire";
7
+ import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
8
+
9
+ // The pieces of a JWT check (base64url decode, TextEncoder, HMAC verify) against values that do
10
+ // not come from the runtime under test: RFC 4231 and RFC 7515 vectors, the jwt.io example, and
11
+ // Bun / node:crypto on seeded random inputs.
12
+
13
+ let seed = 0x2545f491;
14
+ const rand = (n: number): number => {
15
+ seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
16
+ return seed % n;
17
+ };
18
+ const randomBytes = (n: number): Uint8Array => Uint8Array.from({ length: n }, () => rand(256));
19
+ const toB64Url = (b: Uint8Array): string => Buffer.from(b).toString("base64url");
20
+
21
+ // atob: valid, padded and unpadded, with whitespace, and the inputs it must reject.
22
+ const atobInputs: string[] = [];
23
+ for (let n = 0; n <= 40; n++) {
24
+ const padded = Buffer.from(randomBytes(n)).toString("base64");
25
+ atobInputs.push(padded, padded.replace(/=+$/, ""), padded.replace(/(.{3})/, "$1 \n"), ` ${padded}\t`);
26
+ }
27
+ atobInputs.push(
28
+ Buffer.from(randomBytes(5000)).toString("base64"),
29
+ Buffer.from(randomBytes(769)).toString("base64"),
30
+ "a",
31
+ "abcde",
32
+ "ab$c",
33
+ "=abc",
34
+ "ab=c",
35
+ "a===",
36
+ "====",
37
+ "==",
38
+ "abc==",
39
+ "YQ=",
40
+ "YQ===",
41
+ "YQ==YQ==",
42
+ " ",
43
+ "\n",
44
+ "",
45
+ "-_",
46
+ "abéd",
47
+ "abŁd",
48
+ "QUJDé",
49
+ "Ł",
50
+ "YWJj\0",
51
+ );
52
+
53
+ // TextEncoder: each length, every kind of unit, lone surrogates in every position.
54
+ const pieces = ["a", "Z", "é", "ÿ", "Ā", "߿", "ࠀ", "东", "🚤", "\ud800", "\udc00", "\udbff", "\udfff", "�", "\0"];
55
+ const encodeInputs: string[] = [];
56
+ for (let n = 0; n <= 60; n++) encodeInputs.push(Array.from({ length: n }, () => pieces[rand(pieces.length)]).join(""));
57
+ encodeInputs.push(
58
+ "\ud83d",
59
+ "\ud83dx",
60
+ "x\udea4",
61
+ "\udea4\ud83d",
62
+ "\ud83d🚤",
63
+ "🚤\udea4",
64
+ "é".repeat(3000),
65
+ "🚤".repeat(5000),
66
+ "x".repeat(70000),
67
+ );
68
+
69
+ // HMAC: every key and message length around the SHA-256 and SHA-512 block sizes, high bytes included.
70
+ const hmacCases: { key: number[]; data: number[]; hash: string }[] = [];
71
+ for (const [klen, dlen] of [
72
+ [0, 0],
73
+ [1, 1],
74
+ [20, 8],
75
+ [32, 55],
76
+ [64, 56],
77
+ [64, 64],
78
+ [65, 119],
79
+ [131, 54],
80
+ [200, 300],
81
+ [16, 1000],
82
+ ]) {
83
+ for (const hash of ["SHA-256", "SHA-384", "SHA-512"]) {
84
+ hmacCases.push({ key: Array.from(randomBytes(klen)), data: Array.from(randomBytes(dlen)), hash });
85
+ }
86
+ }
87
+ const nodeHash = (h: string): string => h.replace("-", "").toLowerCase();
88
+ const hexOf = (b: Uint8Array | Buffer): string => Buffer.from(b).toString("hex");
89
+
90
+ // Published vectors.
91
+ const rfc4231 = [
92
+ {
93
+ key: "0b".repeat(20),
94
+ data: Buffer.from("Hi There").toString("hex"),
95
+ mac: "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7",
96
+ },
97
+ {
98
+ key: Buffer.from("Jefe").toString("hex"),
99
+ data: Buffer.from("what do ya want for nothing?").toString("hex"),
100
+ mac: "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843",
101
+ },
102
+ {
103
+ key: "aa".repeat(131),
104
+ data: Buffer.from("Test Using Larger Than Block-Size Key - Hash Key First").toString("hex"),
105
+ mac: "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54",
106
+ },
107
+ ];
108
+ const rfc7515Key = "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow";
109
+ const rfc7515 =
110
+ "eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
111
+ const jwtIo =
112
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
113
+ const jwtIoKey = "your-256-bit-secret";
114
+
115
+ const handler = `
116
+ const codes = (s) => Array.from(s, (c) => c.charCodeAt(0));
117
+ const hex = (buffer) => {
118
+ const bytes = new Uint8Array(buffer);
119
+ let out = "";
120
+ for (let i = 0; i < bytes.length; i++) out += (bytes[i] + 0x100).toString(16).slice(1);
121
+ return out;
122
+ };
123
+ const fromHex = (h) => {
124
+ const out = new Uint8Array(h.length / 2);
125
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
126
+ return out;
127
+ };
128
+ const b64urlBytes = (s) => {
129
+ let b = s.replace(/-/g, "+").replace(/_/g, "/");
130
+ while (b.length % 4) b += "=";
131
+ const bin = atob(b);
132
+ const out = new Uint8Array(bin.length);
133
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
134
+ return out;
135
+ };
136
+ const hmacKey = (bytes, hash, usages) => crypto.subtle.importKey("raw", bytes, { name: "HMAC", hash }, false, usages || ["sign", "verify"]);
137
+ // The check an API server writes: split, decode the signature, verify over the signing input.
138
+ const verifyJwt = async (token, keyBytes) => {
139
+ const dot = token.lastIndexOf(".");
140
+ const key = await hmacKey(keyBytes, "SHA-256", ["verify"]);
141
+ let sig;
142
+ try { sig = b64urlBytes(token.slice(dot + 1)); } catch (e) { return "bad-base64:" + e.name; }
143
+ return crypto.subtle.verify("HMAC", key, sig, new TextEncoder().encode(token.slice(0, dot)));
144
+ };
145
+ export default {
146
+ async fetch() {
147
+ const input = JSON.parse(${JSON.stringify(JSON.stringify({ atobInputs, encodeInputs, hmacCases, rfc4231, rfc7515Key, rfc7515, jwtIo, jwtIoKey }))});
148
+ const out = {};
149
+ out.atob = input.atobInputs.map((s) => { try { return codes(atob(s)); } catch (e) { return e.name; } });
150
+ // The same strings as UTF-16 storage: built from a JSON escape, so not literals.
151
+ out.atobWide = input.atobInputs.slice(0, 12).map((s) => { const w = JSON.parse(JSON.stringify(s + "\\u0141")); try { return codes(atob(w.slice(0, -1))); } catch (e) { return e.name; } });
152
+ out.encode = input.encodeInputs.map((s) => Array.from(new TextEncoder().encode(s)));
153
+ out.encodeWide = input.encodeInputs.slice(0, 12).map((s) => Array.from(new TextEncoder().encode(JSON.parse(JSON.stringify(s)))));
154
+ out.hmac = [];
155
+ for (const c of input.hmacCases) {
156
+ const key = await hmacKey(Uint8Array.from(c.key), c.hash);
157
+ const mac = await crypto.subtle.sign("HMAC", key, Uint8Array.from(c.data));
158
+ const ok = await crypto.subtle.verify("HMAC", key, mac, Uint8Array.from(c.data));
159
+ const bad = await crypto.subtle.verify("HMAC", key, mac, Uint8Array.from(c.data.concat([0])));
160
+ out.hmac.push({ mac: hex(mac), ok, bad });
161
+ }
162
+ out.rfc4231 = [];
163
+ for (const v of input.rfc4231) {
164
+ const key = await hmacKey(fromHex(v.key), "SHA-256");
165
+ out.rfc4231.push(hex(await crypto.subtle.sign("HMAC", key, fromHex(v.data))));
166
+ }
167
+ const sigOf = (token) => token.slice(token.lastIndexOf(".") + 1);
168
+ const flip = (token, at) => token.slice(0, at) + (token[at] === "A" ? "B" : "A") + token.slice(at + 1);
169
+ const k1 = b64urlBytes(input.rfc7515Key);
170
+ const k2 = new TextEncoder().encode(input.jwtIoKey);
171
+ out.jwt = {
172
+ rfc7515: await verifyJwt(input.rfc7515, k1),
173
+ jwtIo: await verifyJwt(input.jwtIo, k2),
174
+ wrongKey: await verifyJwt(input.jwtIo, new TextEncoder().encode("your-256-bit-secreT")),
175
+ emptyKey: await verifyJwt(input.jwtIo, new Uint8Array(0)),
176
+ tamperedSignature: await verifyJwt(flip(input.jwtIo, input.jwtIo.length - 3), k2),
177
+ tamperedPayload: await verifyJwt(flip(input.jwtIo, 40), k2),
178
+ tamperedHeader: await verifyJwt(flip(input.jwtIo, 5), k2),
179
+ shortSignature: await verifyJwt(input.jwtIo.slice(0, -4), k2),
180
+ longSignature: await verifyJwt(input.jwtIo + "AAAA", k2),
181
+ noSignature: await verifyJwt(input.jwtIo.slice(0, input.jwtIo.lastIndexOf(".") + 1), k2),
182
+ badBase64url: await verifyJwt(input.jwtIo.slice(0, -2) + "\\u0021\\u0021", k2),
183
+ badBase64urlLength: await verifyJwt(input.jwtIo.slice(0, -3), k2),
184
+ };
185
+ // Typed-array windows and ArrayBuffers as verify input, not only whole Uint8Arrays.
186
+ const key = await hmacKey(k2, "SHA-256", ["verify"]);
187
+ const signingInput = new TextEncoder().encode(input.jwtIo.slice(0, input.jwtIo.lastIndexOf(".")));
188
+ const sig = b64urlBytes(sigOf(input.jwtIo));
189
+ const padded = new Uint8Array(signingInput.length + 7);
190
+ padded.set(signingInput, 3);
191
+ const sigPadded = new Uint8Array(sig.length + 5);
192
+ sigPadded.set(sig, 2);
193
+ out.views = {
194
+ arrayBuffer: await crypto.subtle.verify("HMAC", key, sig.buffer, signingInput.buffer),
195
+ window: await crypto.subtle.verify("HMAC", key, sigPadded.subarray(2, 34), padded.subarray(3, 3 + signingInput.length)),
196
+ wrongWindow: await crypto.subtle.verify("HMAC", key, sigPadded.subarray(1, 33), padded.subarray(3, 3 + signingInput.length)),
197
+ string: await crypto.subtle.verify("HMAC", key, sig, input.jwtIo.slice(0, input.jwtIo.lastIndexOf("."))),
198
+ };
199
+ out.keyInfo = { type: key.type, extractable: key.extractable, usages: key.usages, algorithm: key.algorithm };
200
+ return Response.json(out);
201
+ },
202
+ };`;
203
+
204
+ function freePort(): number {
205
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
206
+ const port = listener.port;
207
+ listener.stop(true);
208
+ return port;
209
+ }
210
+
211
+ const codes = (s: string): number[] => Array.from(s, (c) => c.charCodeAt(0));
212
+ const expectedAtob = (s: string): number[] | string => {
213
+ try {
214
+ return codes(atob(s));
215
+ } catch (e) {
216
+ return e instanceof Error ? e.name : "unknown";
217
+ }
218
+ };
219
+ const expected = {
220
+ atob: atobInputs.map(expectedAtob),
221
+ atobWide: atobInputs.slice(0, 12).map((s) => expectedAtob(s)),
222
+ encode: encodeInputs.map((s) => Array.from(new TextEncoder().encode(s))),
223
+ encodeWide: encodeInputs.slice(0, 12).map((s) => Array.from(new TextEncoder().encode(s))),
224
+ hmac: hmacCases.map((c) => ({
225
+ mac: hexOf(createHmac(nodeHash(c.hash), Buffer.from(c.key)).update(Buffer.from(c.data)).digest()),
226
+ ok: true,
227
+ bad: false,
228
+ })),
229
+ rfc4231: rfc4231.map((v) => v.mac),
230
+ jwt: {
231
+ rfc7515: true,
232
+ jwtIo: true,
233
+ wrongKey: false,
234
+ emptyKey: false,
235
+ tamperedSignature: false,
236
+ tamperedPayload: false,
237
+ tamperedHeader: false,
238
+ shortSignature: false,
239
+ longSignature: false,
240
+ noSignature: false,
241
+ badBase64url: "bad-base64:InvalidCharacterError",
242
+ badBase64urlLength: false,
243
+ },
244
+ views: { arrayBuffer: true, window: true, wrongWindow: false, string: true },
245
+ keyInfo: {
246
+ type: "secret",
247
+ extractable: false,
248
+ usages: ["verify"],
249
+ algorithm: { name: "HMAC", hash: { name: "SHA-256" }, length: 152 },
250
+ },
251
+ };
252
+ // The published vectors must hold in Bun too, or the test itself is wrong.
253
+ for (const v of rfc4231)
254
+ assert.equal(
255
+ hexOf(createHmac("sha256", Buffer.from(v.key, "hex")).update(Buffer.from(v.data, "hex")).digest()),
256
+ v.mac,
257
+ );
258
+ assert.equal(
259
+ toB64Url(
260
+ createHmac("sha256", Buffer.from(rfc7515Key, "base64url"))
261
+ .update(rfc7515.slice(0, rfc7515.lastIndexOf(".")))
262
+ .digest(),
263
+ ),
264
+ rfc7515.slice(rfc7515.lastIndexOf(".") + 1),
265
+ );
266
+ assert.equal(
267
+ toB64Url(
268
+ createHmac("sha256", jwtIoKey)
269
+ .update(jwtIo.slice(0, jwtIo.lastIndexOf(".")))
270
+ .digest(),
271
+ ),
272
+ jwtIo.slice(jwtIo.lastIndexOf(".") + 1),
273
+ );
274
+
275
+ const workdir = await mkdtemp(join(tmpdir(), "sb-native-jwt-"));
276
+ let server: ReturnType<typeof Bun.spawn> | undefined;
277
+ try {
278
+ const porffor = await ensurePorffor();
279
+ const node = Bun.which("node");
280
+ if (!node) throw new Error("native jwt test needs node on PATH");
281
+
282
+ const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
283
+ assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
284
+ const prelude = core.replace(TRANSPORT_MARKER, broker);
285
+ const generated = join(workdir, "jwt.generated.js");
286
+ const binary = join(workdir, "jwt-native");
287
+ await writeFile(generated, wrapNativeFetchHandler(handler, prelude));
288
+
289
+ const compile = Bun.spawn(
290
+ [node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
291
+ {
292
+ cwd: workdir,
293
+ env: process.env,
294
+ stdout: "pipe",
295
+ stderr: "pipe",
296
+ },
297
+ );
298
+ const [exitCode, stdout, stderr] = await Promise.all([
299
+ compile.exited,
300
+ new Response(compile.stdout).text(),
301
+ new Response(compile.stderr).text(),
302
+ ]);
303
+ assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
304
+
305
+ const port = freePort();
306
+ server = Bun.spawn([binary], {
307
+ cwd: workdir,
308
+ env: { ...process.env, PORT: String(port) },
309
+ stdout: "ignore",
310
+ stderr: "inherit",
311
+ });
312
+ let response: Response | undefined;
313
+ for (let attempt = 0; attempt < 100; attempt++) {
314
+ try {
315
+ response = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(20000) });
316
+ break;
317
+ } catch {
318
+ if (server.exitCode !== null) break;
319
+ await Bun.sleep(100);
320
+ }
321
+ }
322
+ assert(response, "compiled jwt handler did not start");
323
+ assert.equal(response.status, 200);
324
+ const actual: typeof expected = await response.json();
325
+ assert.deepEqual(actual, expected);
326
+ console.log("Native JWT pieces (atob, TextEncoder, HMAC verify) match RFC 4231, RFC 7515, jwt.io and Bun");
327
+ } finally {
328
+ if (server) {
329
+ server.kill(9);
330
+ await server.exited;
331
+ }
332
+ await rm(workdir, { recursive: true, force: true });
333
+ }