@sproutboat/runtime 0.42.2 → 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 +6 -0
- package/package.json +1 -1
- package/src/native-fetch-prelude.js +137 -14
- 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 +3 -20
- 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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.43.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 201f8ff: runtime: with `SB_LOG=json` in its environment a binary writes structured records to stderr, one JSON line each (#268). A request writes `{"type":"request","v":1,"id","timestamp","method","path","status","durationMs","outcome"}` once its response is built, and every uncaught error (a handler, `scheduled()`, `queue()`, an alarm, a WebSocket handler, a rejected promise or a `waitUntil` task) writes `{"type":"error","v":1,"id","timestamp","source","name","message","stack"}` in place of the plain `Uncaught (in ...)` text. An error raised while answering a request carries that request's `id`; background work has `id: null`. Bodies, headers, the query string and the client address are never written, and the value of a declared secret that appears in a message or stack is replaced with `[redacted]`. Output is bounded: fields are cut to fixed lengths and at most 200 lines a second are written; the rest are counted and reported by a `{"type":"dropped","count"}` line when the next second starts. Without `SB_LOG=json` nothing changes, so no compatibility date applies.
|
|
8
|
+
|
|
3
9
|
## 0.42.2
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -11561,6 +11561,7 @@ function __sbMakeQueue(binding) {
|
|
|
11561
11561
|
|
|
11562
11562
|
globalThis.__sbInstallBindings = function (target, bindings) {
|
|
11563
11563
|
if (!target) return;
|
|
11564
|
+
__sbLogSecretNames = bindings.secrets || [];
|
|
11564
11565
|
|
|
11565
11566
|
__sbQueueSetup(bindings);
|
|
11566
11567
|
|
|
@@ -13992,7 +13993,7 @@ function __sbTrustedProxyHeader(request, name) {
|
|
|
13992
13993
|
// own.
|
|
13993
13994
|
const __SB_WAITUNTIL_TIMEOUT_MS = 25000;
|
|
13994
13995
|
|
|
13995
|
-
async function __sbDrainWaitUntil(tasks) {
|
|
13996
|
+
async function __sbDrainWaitUntil(tasks, logRec) {
|
|
13996
13997
|
if (!tasks.length) return;
|
|
13997
13998
|
const pending = tasks.splice(0, tasks.length);
|
|
13998
13999
|
// Clear the cap's timer once the tasks finish: a pending timer kept the process
|
|
@@ -14001,16 +14002,17 @@ async function __sbDrainWaitUntil(tasks) {
|
|
|
14001
14002
|
const cap = new Promise((resolve) => {
|
|
14002
14003
|
timer = setTimeout(resolve, __SB_WAITUNTIL_TIMEOUT_MS);
|
|
14003
14004
|
});
|
|
14004
|
-
await Promise.race([__sbDrainAll(pending), cap]);
|
|
14005
|
+
await Promise.race([__sbDrainAll(pending, logRec), cap]);
|
|
14005
14006
|
clearTimeout(timer);
|
|
14006
14007
|
}
|
|
14007
14008
|
|
|
14008
|
-
async function __sbDrainAll(pending) {
|
|
14009
|
+
async function __sbDrainAll(pending, logRec) {
|
|
14009
14010
|
for (let i = 0; i < pending.length; i++) {
|
|
14010
14011
|
try {
|
|
14011
14012
|
await pending[i];
|
|
14012
|
-
} catch {
|
|
14013
|
-
/* a background task's rejection must not fail the response */
|
|
14013
|
+
} catch (error) {
|
|
14014
|
+
/* a background task's rejection must not fail the response; with SB_LOG=json it is recorded (#268) */
|
|
14015
|
+
if (__sbLogJson()) __sbLogError("waitUntil", error, logRec);
|
|
14014
14016
|
}
|
|
14015
14017
|
}
|
|
14016
14018
|
}
|
|
@@ -15332,8 +15334,9 @@ function __sbInstallWebSockets() {
|
|
|
15332
15334
|
globalThis.WebSocketRequestResponsePair = __SbWebSocketRequestResponsePair;
|
|
15333
15335
|
}
|
|
15334
15336
|
|
|
15335
|
-
async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top, cpuCell) {
|
|
15337
|
+
async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top, cpuCell, logRec) {
|
|
15336
15338
|
let r;
|
|
15339
|
+
let threw = 0;
|
|
15337
15340
|
try {
|
|
15338
15341
|
r = res && __sbIsFn(res.then) ? await res : res;
|
|
15339
15342
|
if (top && __sbHasWebSocket(r)) r = __sbWsHostResponse(r);
|
|
@@ -15348,7 +15351,8 @@ async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top, cpuCell
|
|
|
15348
15351
|
throw error;
|
|
15349
15352
|
}
|
|
15350
15353
|
// #179 — a rejected handler promise must not take the process down with it.
|
|
15351
|
-
|
|
15354
|
+
threw = 1;
|
|
15355
|
+
r = __sbErrorResponse(error, bindingCall, logRec);
|
|
15352
15356
|
}
|
|
15353
15357
|
// From 2026-11-02 an async handler reports its CPU too: the thread time of the slices in which this
|
|
15354
15358
|
// request ran, not its neighbours' (before it, only a handler that answered synchronously did).
|
|
@@ -15359,7 +15363,8 @@ async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top, cpuCell
|
|
|
15359
15363
|
// The response goes out now and ctx.waitUntil work keeps running after it, as in
|
|
15360
15364
|
// workerd; it was awaited first, so a 1.5 s background task delayed the response by
|
|
15361
15365
|
// 1.5 s. The 25 s cap still bounds it. Not called for no tasks: an async call is a coroutine and a promise.
|
|
15362
|
-
if (
|
|
15366
|
+
if (logRec) __sbLogRequest(logRec, r, threw);
|
|
15367
|
+
if (tasks.length) __sbDrainWaitUntil(tasks, logRec);
|
|
15363
15368
|
return r;
|
|
15364
15369
|
}
|
|
15365
15370
|
|
|
@@ -15367,8 +15372,8 @@ async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top, cpuCell
|
|
|
15367
15372
|
// propagate past __sbEntry and crash the whole server. Logged first, the way a
|
|
15368
15373
|
// Worker logs "Uncaught Error: ...": a swallowed exception left a 500 with
|
|
15369
15374
|
// nothing on stderr, which hid #168 entirely.
|
|
15370
|
-
function __sbErrorResponse(error, bindingCall) {
|
|
15371
|
-
__sbLogUncaught("fetch", error);
|
|
15375
|
+
function __sbErrorResponse(error, bindingCall, logRec) {
|
|
15376
|
+
__sbLogUncaught("fetch", error, logRec);
|
|
15372
15377
|
// Untyped, as workerd sends it: the text/plain default is for handlers' bodies.
|
|
15373
15378
|
const response = new Response("Internal Server Error", { status: 500 });
|
|
15374
15379
|
response.headers.delete("content-type");
|
|
@@ -15438,7 +15443,11 @@ function __sbHeader(headers, name) {
|
|
|
15438
15443
|
return count > 1 ? headers.get(name) : found;
|
|
15439
15444
|
}
|
|
15440
15445
|
|
|
15441
|
-
function __sbLogUncaught(where, error) {
|
|
15446
|
+
function __sbLogUncaught(where, error, logRec) {
|
|
15447
|
+
if (__sbLogJson()) {
|
|
15448
|
+
__sbLogError(where, error, logRec);
|
|
15449
|
+
return;
|
|
15450
|
+
}
|
|
15442
15451
|
try {
|
|
15443
15452
|
let text = "";
|
|
15444
15453
|
if (error && error.stack) text = String(error.stack);
|
|
@@ -15450,6 +15459,113 @@ function __sbLogUncaught(where, error) {
|
|
|
15450
15459
|
}
|
|
15451
15460
|
}
|
|
15452
15461
|
|
|
15462
|
+
// #268 — structured request and error records for a standalone binary. Off unless SB_LOG=json, so
|
|
15463
|
+
// nothing an app can observe changes and the per-request cost of "off" is one cached boolean. On, every
|
|
15464
|
+
// request writes one JSON line to stderr after its response is built, and every uncaught error (in a
|
|
15465
|
+
// handler, scheduled(), queue(), an alarm, a WebSocket handler, a rejected promise or a waitUntil task)
|
|
15466
|
+
// writes one more, in place of the plain "Uncaught (in ...)" text, so the stream stays parseable.
|
|
15467
|
+
//
|
|
15468
|
+
// What is never written: request or response bodies, headers (cookies and authorization among them), the
|
|
15469
|
+
// query string (it is where tokens end up), the client address, and the value of any declared secret (the
|
|
15470
|
+
// ones the message or stack happens to contain are replaced). Output is bounded: fields are cut to fixed
|
|
15471
|
+
// lengths and at most 200 lines a second go out; the rest are counted and reported by one "dropped" line
|
|
15472
|
+
// when the next second starts. A record's "id" ties a request line to the errors of the same request;
|
|
15473
|
+
// background work has id null.
|
|
15474
|
+
let __sbLogOn;
|
|
15475
|
+
let __sbLogSecretNames = [];
|
|
15476
|
+
let __sbLogSeq = 0;
|
|
15477
|
+
let __sbLogWindow = 0;
|
|
15478
|
+
let __sbLogCount = 0;
|
|
15479
|
+
let __sbLogDropped = 0;
|
|
15480
|
+
const __SB_LOG_PER_SECOND = 200;
|
|
15481
|
+
function __sbLogJson() {
|
|
15482
|
+
if (__sbLogOn === undefined) __sbLogOn = __sbEnv("SB_LOG") === "json";
|
|
15483
|
+
return __sbLogOn;
|
|
15484
|
+
}
|
|
15485
|
+
// Tests turn the records on without an environment, like __sbSetCompat.
|
|
15486
|
+
function __sbSetLog(on) {
|
|
15487
|
+
__sbLogOn = on;
|
|
15488
|
+
}
|
|
15489
|
+
function __sbLogCut(text, max) {
|
|
15490
|
+
const s = String(text);
|
|
15491
|
+
return s.length > max ? s.slice(0, max) + "..." : s;
|
|
15492
|
+
}
|
|
15493
|
+
function __sbLogRedact(text) {
|
|
15494
|
+
let out = text;
|
|
15495
|
+
for (let i = 0; i < __sbLogSecretNames.length; i++) {
|
|
15496
|
+
const raw = __sbEnv(__sbLogSecretNames[i]);
|
|
15497
|
+
if (!raw || raw.length < 4) continue;
|
|
15498
|
+
out = out.split(raw).join("[redacted]");
|
|
15499
|
+
const decoded = __sbFromUtf8(raw);
|
|
15500
|
+
if (decoded !== raw && decoded.length >= 4) out = out.split(decoded).join("[redacted]");
|
|
15501
|
+
}
|
|
15502
|
+
return out;
|
|
15503
|
+
}
|
|
15504
|
+
function __sbLogWrite(record) {
|
|
15505
|
+
const now = Date.now();
|
|
15506
|
+
const second = Math.floor(now / 1000);
|
|
15507
|
+
if (second !== __sbLogWindow) {
|
|
15508
|
+
if (__sbLogDropped > 0) {
|
|
15509
|
+
console.error(JSON.stringify({ type: "dropped", v: 1, timestamp: now, count: __sbLogDropped, reason: "rate" }));
|
|
15510
|
+
__sbLogDropped = 0;
|
|
15511
|
+
}
|
|
15512
|
+
__sbLogWindow = second;
|
|
15513
|
+
__sbLogCount = 0;
|
|
15514
|
+
}
|
|
15515
|
+
if (__sbLogCount >= __SB_LOG_PER_SECOND) {
|
|
15516
|
+
__sbLogDropped++;
|
|
15517
|
+
return;
|
|
15518
|
+
}
|
|
15519
|
+
__sbLogCount++;
|
|
15520
|
+
console.error(JSON.stringify(record));
|
|
15521
|
+
}
|
|
15522
|
+
function __sbLogBegin(request) {
|
|
15523
|
+
__sbLogSeq++;
|
|
15524
|
+
let path = "/";
|
|
15525
|
+
try {
|
|
15526
|
+
path = new URL(request.url).pathname;
|
|
15527
|
+
} catch {
|
|
15528
|
+
/* a request without a parseable URL is logged as "/" */
|
|
15529
|
+
}
|
|
15530
|
+
return { id: String(Math.floor(Date.now() / 1000)) + "-" + __sbLogSeq, t0: Date.now(), method: String(request.method), path: __sbLogCut(path, 256) };
|
|
15531
|
+
}
|
|
15532
|
+
function __sbLogRequest(rec, response, threw) {
|
|
15533
|
+
try {
|
|
15534
|
+
__sbLogWrite({
|
|
15535
|
+
type: "request",
|
|
15536
|
+
v: 1,
|
|
15537
|
+
id: rec.id,
|
|
15538
|
+
timestamp: rec.t0,
|
|
15539
|
+
method: __sbLogCut(rec.method, 16),
|
|
15540
|
+
path: rec.path,
|
|
15541
|
+
status: response && response.status ? response.status : 0,
|
|
15542
|
+
durationMs: Date.now() - rec.t0,
|
|
15543
|
+
outcome: threw === 1 ? "exception" : "ok",
|
|
15544
|
+
});
|
|
15545
|
+
} catch {
|
|
15546
|
+
/* logging must never turn a served request into a crash */
|
|
15547
|
+
}
|
|
15548
|
+
}
|
|
15549
|
+
function __sbLogError(where, error, rec) {
|
|
15550
|
+
try {
|
|
15551
|
+
const name = error && error.name ? String(error.name) : "Error";
|
|
15552
|
+
const message = error && error.message != null ? String(error.message) : String(error);
|
|
15553
|
+
const record = {
|
|
15554
|
+
type: "error",
|
|
15555
|
+
v: 1,
|
|
15556
|
+
id: rec ? rec.id : null,
|
|
15557
|
+
timestamp: Date.now(),
|
|
15558
|
+
source: __sbLogCut(where, 40),
|
|
15559
|
+
name: __sbLogCut(name, 100),
|
|
15560
|
+
message: __sbLogCut(__sbLogRedact(message), 1000),
|
|
15561
|
+
};
|
|
15562
|
+
if (error && error.stack) record.stack = __sbLogCut(__sbLogRedact(String(error.stack)), 2000);
|
|
15563
|
+
__sbLogWrite(record);
|
|
15564
|
+
} catch {
|
|
15565
|
+
/* logging must never turn a handled error into a crash */
|
|
15566
|
+
}
|
|
15567
|
+
}
|
|
15568
|
+
|
|
15453
15569
|
// A promise rejected and nothing was waiting on it (an un-awaited `db.prepare(...).run()`
|
|
15454
15570
|
// that failed, say). As on Workers the request, the process and every other request go on:
|
|
15455
15571
|
// the rejection is logged, and an "unhandledrejection" event fires on the global scope when
|
|
@@ -15785,6 +15901,8 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
15785
15901
|
// async handlers (see LogEvent in services/edge) — that is this.
|
|
15786
15902
|
const __cpuCell = __sbCpuBegin();
|
|
15787
15903
|
const __t0 = __cpuCell ? 0 : __sbCpuMs();
|
|
15904
|
+
// #268 — one record per request when SB_LOG=json; null (and no work at all) otherwise.
|
|
15905
|
+
const __logRec = __sbLogJson() ? __sbLogBegin(request) : null;
|
|
15788
15906
|
const __sbTasks = [];
|
|
15789
15907
|
const ctx = {
|
|
15790
15908
|
waitUntil(p) {
|
|
@@ -15807,19 +15925,24 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
15807
15925
|
// #179 — a synchronous throw anywhere in the handler must not take the
|
|
15808
15926
|
// whole process (and every other in-flight request) down with it.
|
|
15809
15927
|
__sbCpuEnd(__cpuCell);
|
|
15810
|
-
|
|
15928
|
+
const __failed = __sbErrorResponse(error, bindingCall, __logRec);
|
|
15929
|
+
if (__logRec) __sbLogRequest(__logRec, __failed, 1);
|
|
15930
|
+
return __failed;
|
|
15811
15931
|
}
|
|
15812
15932
|
// Same promise-identity rule as above applies to `__sbFetchWithDrain`'s
|
|
15813
15933
|
// own return, which is why it's tail-called rather than chained on here.
|
|
15814
|
-
if (__sbTasks.length || (__res && __sbIsFn(__res.then)) || __sbHasStreamBody(__res)) return __sbFetchWithDrain(__res, __sbTasks, bindingCall, false, true, __cpuCell);
|
|
15934
|
+
if (__sbTasks.length || (__res && __sbIsFn(__res.then)) || __sbHasStreamBody(__res)) return __sbFetchWithDrain(__res, __sbTasks, bindingCall, false, true, __cpuCell, __logRec);
|
|
15815
15935
|
// A WebSocket handshake answer (from 2026-10-30): its client end becomes the host socket.
|
|
15816
15936
|
if (__sbHasWebSocket(__res)) {
|
|
15817
15937
|
__sbCpuEnd(__cpuCell);
|
|
15818
|
-
|
|
15938
|
+
const __ws = __sbWsHostResponse(__res);
|
|
15939
|
+
if (__logRec) __sbLogRequest(__logRec, __ws, 0);
|
|
15940
|
+
return __ws;
|
|
15819
15941
|
}
|
|
15820
15942
|
// The request ran alone from start to here: its own slices, or the process delta where no slot was free.
|
|
15821
15943
|
const __cpu = __cpuCell ? __sbCpuTake(__cpuCell) : __sbCpuMs() - __t0;
|
|
15822
15944
|
__sbCpuEnd(__cpuCell);
|
|
15945
|
+
if (__logRec) __sbLogRequest(__logRec, __res, 0);
|
|
15823
15946
|
return __sbTagCpu(__res, __cpu);
|
|
15824
15947
|
}
|
|
15825
15948
|
if (!__sbTriggerAuthed(request)) return new Response("forbidden", { status: 403 });
|
package/test/native-base64.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
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 = Array.from({length:10}, (_,length) => {
|
|
9
|
-
const bytes=Uint8Array.from({length}, (_,i) => (i*71+251)%256);
|
|
10
|
-
const padded=Buffer.from(bytes).toString("base64");
|
|
11
|
-
return {
|
|
8
|
+
const expected = Array.from({ length: 10 }, (_, length) => {
|
|
9
|
+
const bytes = Uint8Array.from({ length }, (_, i) => (i * 71 + 251) % 256);
|
|
10
|
+
const padded = Buffer.from(bytes).toString("base64");
|
|
11
|
+
return {
|
|
12
|
+
padded,
|
|
13
|
+
url: padded.replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""),
|
|
14
|
+
decoded: Array.from(bytes),
|
|
15
|
+
};
|
|
12
16
|
});
|
|
13
17
|
const handler = `export default {fetch() {
|
|
14
18
|
const rows=[];
|
|
@@ -37,14 +41,7 @@ function freePort(): number {
|
|
|
37
41
|
const workdir = await mkdtemp(join(tmpdir(), "sb-native-base64-"));
|
|
38
42
|
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
39
43
|
try {
|
|
40
|
-
const
|
|
41
|
-
const node = Bun.which("node");
|
|
42
|
-
if (!node) throw new Error("native streams test needs node on PATH");
|
|
43
|
-
|
|
44
|
-
const [core, broker] = await Promise.all([
|
|
45
|
-
readFile(preludePath, "utf8"),
|
|
46
|
-
readFile(transportPath("broker"), "utf8"),
|
|
47
|
-
]);
|
|
44
|
+
const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
|
|
48
45
|
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
49
46
|
const prelude = core.replace(TRANSPORT_MARKER, broker);
|
|
50
47
|
const generated = join(workdir, "base64.generated.js");
|
|
@@ -52,18 +49,7 @@ try {
|
|
|
52
49
|
await writeFile(generated, wrapNativeFetchHandler(handler, prelude));
|
|
53
50
|
await rm(binary, { force: true });
|
|
54
51
|
|
|
55
|
-
|
|
56
|
-
cwd: workdir,
|
|
57
|
-
env: process.env,
|
|
58
|
-
stdout: "pipe",
|
|
59
|
-
stderr: "pipe",
|
|
60
|
-
});
|
|
61
|
-
const [exitCode, stdout, stderr] = await Promise.all([
|
|
62
|
-
compile.exited,
|
|
63
|
-
new Response(compile.stdout).text(),
|
|
64
|
-
new Response(compile.stderr).text(),
|
|
65
|
-
]);
|
|
66
|
-
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
52
|
+
await compileNative({ generated, binary, dir: workdir });
|
|
67
53
|
|
|
68
54
|
const port = freePort();
|
|
69
55
|
server = Bun.spawn([binary], {
|
package/test/native-bodies.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
|
// Bodies on the wire and the Fetch classes, against Bun: byte bodies go out as their bytes
|
|
@@ -80,14 +80,7 @@ function freePort(): number {
|
|
|
80
80
|
const workdir = await mkdtemp(join(tmpdir(), "sb-native-bodies-"));
|
|
81
81
|
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
82
82
|
try {
|
|
83
|
-
const
|
|
84
|
-
const node = Bun.which("node");
|
|
85
|
-
if (!node) throw new Error("native streams test needs node on PATH");
|
|
86
|
-
|
|
87
|
-
const [core, broker] = await Promise.all([
|
|
88
|
-
readFile(preludePath, "utf8"),
|
|
89
|
-
readFile(transportPath("broker"), "utf8"),
|
|
90
|
-
]);
|
|
83
|
+
const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
|
|
91
84
|
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
92
85
|
const prelude = core.replace(TRANSPORT_MARKER, broker);
|
|
93
86
|
const generated = join(workdir, "bodies.generated.js");
|
|
@@ -95,18 +88,7 @@ try {
|
|
|
95
88
|
await writeFile(generated, wrapNativeFetchHandler(handler, prelude, undefined, undefined, undefined, "2026-10-21"));
|
|
96
89
|
await rm(binary, { force: true });
|
|
97
90
|
|
|
98
|
-
|
|
99
|
-
cwd: workdir,
|
|
100
|
-
env: process.env,
|
|
101
|
-
stdout: "pipe",
|
|
102
|
-
stderr: "pipe",
|
|
103
|
-
});
|
|
104
|
-
const [exitCode, stdout, stderr] = await Promise.all([
|
|
105
|
-
compile.exited,
|
|
106
|
-
new Response(compile.stdout).text(),
|
|
107
|
-
new Response(compile.stderr).text(),
|
|
108
|
-
]);
|
|
109
|
-
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
91
|
+
await compileNative({ generated, binary, dir: workdir });
|
|
110
92
|
|
|
111
93
|
const port = freePort();
|
|
112
94
|
server = Bun.spawn([binary], {
|
|
@@ -142,7 +124,9 @@ try {
|
|
|
142
124
|
const form = await fetch(`http://127.0.0.1:${port}/form`);
|
|
143
125
|
const boundary = (form.headers.get("content-type") || "").split("boundary=")[1];
|
|
144
126
|
assert(boundary, "multipart content type");
|
|
145
|
-
const parsed = await new Response(await form.arrayBuffer(), {
|
|
127
|
+
const parsed = await new Response(await form.arrayBuffer(), {
|
|
128
|
+
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
|
|
129
|
+
}).formData();
|
|
146
130
|
assert.equal(parsed.get("a"), "1");
|
|
147
131
|
const file = parsed.get("f");
|
|
148
132
|
assert(file instanceof File, "the file part parses back as a File");
|
|
@@ -153,11 +137,20 @@ try {
|
|
|
153
137
|
const got = JSON.parse(await (await fetch(`http://127.0.0.1:${port}/cases`)).text()) as string[];
|
|
154
138
|
const want: string[] = [];
|
|
155
139
|
// SAFETY: CASE_SOURCES is the handler's own literal list of case functions.
|
|
156
|
-
const reference = new Function(`return (() => { ${CASE_SOURCES} return cases; })()`)() as Array<
|
|
140
|
+
const reference = new Function(`return (() => { ${CASE_SOURCES} return cases; })()`)() as Array<
|
|
141
|
+
() => Promise<string> | string
|
|
142
|
+
>;
|
|
157
143
|
for (const run of reference) {
|
|
158
|
-
try {
|
|
144
|
+
try {
|
|
145
|
+
want.push(String(await run()));
|
|
146
|
+
} catch (e) {
|
|
147
|
+
want.push("THROWS " + (e instanceof Error ? e.name : String(e)));
|
|
148
|
+
}
|
|
159
149
|
}
|
|
160
|
-
assert.deepEqual(
|
|
150
|
+
assert.deepEqual(
|
|
151
|
+
got,
|
|
152
|
+
want.map((w) => (w.startsWith("workerd:") ? "workerd:TypeError" : w)),
|
|
153
|
+
);
|
|
161
154
|
console.log(`Native bodies are sent as bytes and ${want.length} Fetch class cases match Bun`);
|
|
162
155
|
} finally {
|
|
163
156
|
if (server) {
|
package/test/native-byte-text.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
|
// sproutd #22: __sbFromUtf8 and __sbToBytes run in C (__sbFromUtf8Native, __sbToBytesNative).
|
|
@@ -92,28 +92,12 @@ function freePort(): number {
|
|
|
92
92
|
const workdir = await mkdtemp(join(tmpdir(), "sb-native-byte-text-"));
|
|
93
93
|
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
94
94
|
try {
|
|
95
|
-
const porffor = await ensurePorffor();
|
|
96
|
-
const node = Bun.which("node");
|
|
97
|
-
if (!node) throw new Error("native byte-text test needs node on PATH");
|
|
98
95
|
const broker = await readFile(transportPath("broker"), "utf8");
|
|
99
96
|
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
100
97
|
const generated = join(workdir, "bytes.generated.js");
|
|
101
98
|
const binary = join(workdir, "bytes-native");
|
|
102
99
|
await writeFile(generated, wrapNativeFetchHandler(handler, core.replace(TRANSPORT_MARKER, broker)));
|
|
103
|
-
|
|
104
|
-
[node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
|
|
105
|
-
{
|
|
106
|
-
cwd: workdir,
|
|
107
|
-
stdout: "pipe",
|
|
108
|
-
stderr: "pipe",
|
|
109
|
-
},
|
|
110
|
-
);
|
|
111
|
-
const [exitCode, stdout, stderr] = await Promise.all([
|
|
112
|
-
compile.exited,
|
|
113
|
-
new Response(compile.stdout).text(),
|
|
114
|
-
new Response(compile.stderr).text(),
|
|
115
|
-
]);
|
|
116
|
-
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
100
|
+
await compileNative({ generated, binary, dir: workdir });
|
|
117
101
|
|
|
118
102
|
const port = freePort();
|
|
119
103
|
// Peak RSS: /proc's high-water mark on Linux, time(1) on macOS.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { ensurePorffor } from "../../toolchain/src/acquire";
|
|
6
|
+
import { ensurePorfforA21 } from "../../toolchain/src/a21/acquire";
|
|
7
|
+
import {
|
|
8
|
+
composeStandaloneA21,
|
|
9
|
+
SHARED_PRELUDE_FILE,
|
|
10
|
+
STANDALONE_A21_COMPILE_FLAGS,
|
|
11
|
+
} from "../../toolchain/src/a21/standalone";
|
|
12
|
+
import { buildStandaloneA21 } from "../../toolchain/src/a21/standalone-build";
|
|
13
|
+
import { standaloneChannel } from "../../toolchain/src/a21/standalone-channel";
|
|
14
|
+
import { bearsslEnv } from "./bearssl";
|
|
15
|
+
|
|
16
|
+
export type NativeCompileInput = {
|
|
17
|
+
/** The generated module (`wrapNativeFetchHandler` output), already written to disk. */
|
|
18
|
+
generated: string;
|
|
19
|
+
/** Where the compiled binary goes. */
|
|
20
|
+
binary: string;
|
|
21
|
+
/** Working directory of the compile, and where alpha 22 puts its intermediate files. Default: the binary's directory. */
|
|
22
|
+
dir?: string;
|
|
23
|
+
/** Toolchain cache root for the alpha 19 tree (`ensurePorffor({ cacheRoot })`). */
|
|
24
|
+
cacheRoot?: string;
|
|
25
|
+
/** Objects and link flags for the link line (alpha 19: `SB_EXTRA_LINK`). */
|
|
26
|
+
extraLink?: string[];
|
|
27
|
+
/** Flags for every C compile (alpha 19: `SB_EXTRA_CFLAGS`). */
|
|
28
|
+
extraCflags?: string[];
|
|
29
|
+
/** Link BearSSL, as a test that uses crypto.subtle needs. */
|
|
30
|
+
bearssl?: boolean;
|
|
31
|
+
/** `-O` level. Default 0. */
|
|
32
|
+
optimize?: number;
|
|
33
|
+
/** Extra environment for the alpha 19 compiler process (the default is `process.env`). */
|
|
34
|
+
env?: Record<string, string>;
|
|
35
|
+
/** Message prefix of the failure assertion. Default "Porffor native compile failed". */
|
|
36
|
+
failure?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type UwsModule = {
|
|
40
|
+
UWS_COMMIT: string;
|
|
41
|
+
ensureUWebSockets: () => string;
|
|
42
|
+
ensureUSocketsBuilt: (dir: string) => void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
let renderQueue: Promise<void> = Promise.resolve();
|
|
46
|
+
|
|
47
|
+
const channel = () => standaloneChannel();
|
|
48
|
+
|
|
49
|
+
const split = (flags: string | undefined): string[] => (flags ?? "").split(" ").filter(Boolean);
|
|
50
|
+
|
|
51
|
+
async function drain(child: Bun.Subprocess<"ignore", "pipe", "pipe">) {
|
|
52
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
53
|
+
child.exited,
|
|
54
|
+
new Response(child.stdout).text(),
|
|
55
|
+
new Response(child.stderr).text(),
|
|
56
|
+
]);
|
|
57
|
+
return { exitCode, stdout, stderr };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The host uWebSockets checkout with a built uSockets.a, built on first use. */
|
|
61
|
+
async function uwsDirectory(alpha19: string): Promise<string> {
|
|
62
|
+
// SAFETY: the patched alpha 19 tree's compiler/uwebsockets.js exports these (see the toolchain's standaloneShimSource).
|
|
63
|
+
const mod: UwsModule = await import(resolve(alpha19, "compiler/uwebsockets.js"));
|
|
64
|
+
const dir = resolve(homedir(), ".cache/porffor/deps", `uWebSockets-${mod.UWS_COMMIT}`);
|
|
65
|
+
if (!existsSync(join(dir, "uSockets/uSockets.a"))) {
|
|
66
|
+
// SAFETY: the compiler's prefs.js installs `Prefs` on globalThis as a plain flag object.
|
|
67
|
+
const g = globalThis as { Prefs?: { eventLoop?: boolean } };
|
|
68
|
+
const prev = g.Prefs;
|
|
69
|
+
g.Prefs = { ...prev, eventLoop: true };
|
|
70
|
+
try {
|
|
71
|
+
mod.ensureUSocketsBuilt(mod.ensureUWebSockets());
|
|
72
|
+
} finally {
|
|
73
|
+
if (prev === undefined) delete g.Prefs;
|
|
74
|
+
else g.Prefs = prev;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return dir;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Compile a generated module into a native binary on the standalone channel (alpha 22 by default, `SPROUTBOAT_PORFFOR_CHANNEL=alpha-19` for the fallback). */
|
|
81
|
+
export async function compileNative(input: NativeCompileInput): Promise<void> {
|
|
82
|
+
const which = channel();
|
|
83
|
+
const dir = input.dir ?? dirname(input.binary);
|
|
84
|
+
const optimize = input.optimize ?? 0;
|
|
85
|
+
const failure = input.failure ?? "Porffor native compile failed";
|
|
86
|
+
const bearssl = input.bearssl ? await bearsslEnv() : undefined;
|
|
87
|
+
const extraLink = [...(input.extraLink ?? []), ...split(bearssl?.SB_EXTRA_LINK)];
|
|
88
|
+
const extraCflags = [...(input.extraCflags ?? []), ...split(bearssl?.SB_EXTRA_CFLAGS)];
|
|
89
|
+
const alpha19 = await ensurePorffor(input.cacheRoot === undefined ? {} : { cacheRoot: input.cacheRoot });
|
|
90
|
+
|
|
91
|
+
if (which === "alpha-19") {
|
|
92
|
+
const node = Bun.which("node");
|
|
93
|
+
if (!node) throw new Error("native compile needs node on PATH");
|
|
94
|
+
const env = { ...process.env, ...input.env };
|
|
95
|
+
if (extraLink.length > 0) env.SB_EXTRA_LINK = extraLink.join(" ");
|
|
96
|
+
if (extraCflags.length > 0) env.SB_EXTRA_CFLAGS = extraCflags.join(" ");
|
|
97
|
+
const compile = Bun.spawn(
|
|
98
|
+
[
|
|
99
|
+
node,
|
|
100
|
+
resolve(alpha19, "runtime/index.js"),
|
|
101
|
+
"native",
|
|
102
|
+
input.generated,
|
|
103
|
+
"-o",
|
|
104
|
+
input.binary,
|
|
105
|
+
"-s",
|
|
106
|
+
`-O${optimize}`,
|
|
107
|
+
],
|
|
108
|
+
{ cwd: dir, env, stdout: "pipe", stderr: "pipe" },
|
|
109
|
+
);
|
|
110
|
+
const { exitCode, stdout, stderr } = await drain(compile);
|
|
111
|
+
if (exitCode !== 0) throw new Error(`${failure}:\n${stderr}\n${stdout}`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const bun = process.execPath;
|
|
116
|
+
const alpha22 = await ensurePorfforA21("alpha-22");
|
|
117
|
+
const work = join(dir, `a22-${(input.binary.split("/").pop() ?? "bin").replace(/[^\w.-]/g, "_")}`);
|
|
118
|
+
await rm(work, { recursive: true, force: true });
|
|
119
|
+
await mkdir(work, { recursive: true });
|
|
120
|
+
const composed = composeStandaloneA21(await readFile(input.generated, "utf8"), alpha19);
|
|
121
|
+
// Beside the generated module: its relative imports (a handler's sibling helper files) resolve from the
|
|
122
|
+
// directory of the file they were written in, and the shared prelude carries the module's body. The
|
|
123
|
+
// chain recognises the prelude by its file name, so a test that compiles several variants from one
|
|
124
|
+
// directory at once (native-websockets) takes turns: the render is the only step that reads it.
|
|
125
|
+
const source = dirname(resolve(input.generated));
|
|
126
|
+
const entry = join(source, "entry.a22.js");
|
|
127
|
+
const turn = renderQueue;
|
|
128
|
+
let release = () => {};
|
|
129
|
+
renderQueue = new Promise<void>((done) => (release = done));
|
|
130
|
+
await turn;
|
|
131
|
+
let rendered: Awaited<ReturnType<typeof drain>>;
|
|
132
|
+
try {
|
|
133
|
+
await writeFile(join(source, SHARED_PRELUDE_FILE), composed.sharedPrelude);
|
|
134
|
+
await writeFile(entry, composed.entry);
|
|
135
|
+
const unitsDir = join(work, "units");
|
|
136
|
+
const render = Bun.spawn(
|
|
137
|
+
[bun, resolve(alpha22, "runtime/index.js"), "c", entry, "-o", `${unitsDir}/`, ...STANDALONE_A21_COMPILE_FLAGS],
|
|
138
|
+
{ cwd: work, env: { ...process.env, ...input.env }, stdout: "pipe", stderr: "pipe" },
|
|
139
|
+
);
|
|
140
|
+
rendered = await drain(render);
|
|
141
|
+
} finally {
|
|
142
|
+
release();
|
|
143
|
+
}
|
|
144
|
+
if (rendered.exitCode !== 0) throw new Error(`${failure} (alpha 22 render):\n${rendered.stderr}\n${rendered.stdout}`);
|
|
145
|
+
const unitsDir = join(work, "units");
|
|
146
|
+
await rm(input.binary, { force: true });
|
|
147
|
+
await buildStandaloneA21({
|
|
148
|
+
unitsDir,
|
|
149
|
+
alpha19Root: alpha19,
|
|
150
|
+
outPath: input.binary,
|
|
151
|
+
cc: ["cc"],
|
|
152
|
+
cxx: ["c++"],
|
|
153
|
+
uwsDir: await uwsDirectory(alpha19),
|
|
154
|
+
extraLink,
|
|
155
|
+
extraCflags,
|
|
156
|
+
optimize,
|
|
157
|
+
flto: false,
|
|
158
|
+
musl: false,
|
|
159
|
+
strip: true,
|
|
160
|
+
}).catch((error: Error) => {
|
|
161
|
+
throw new Error(`${failure} (alpha 22 build):\n${error.message}`);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
@@ -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
|
// Per-request CPU (design 02): x-sb-cpu-ms counts the thread CPU time of the slices in which one
|
|
@@ -43,9 +43,6 @@ function freePort(): number {
|
|
|
43
43
|
const workdir = await mkdtemp(join(tmpdir(), "sb-native-cpu-requests-"));
|
|
44
44
|
const servers: ReturnType<typeof Bun.spawn>[] = [];
|
|
45
45
|
async function build(date: string): Promise<number> {
|
|
46
|
-
const porffor = await ensurePorffor();
|
|
47
|
-
const node = Bun.which("node");
|
|
48
|
-
if (!node) throw new Error("native cpu test needs node on PATH");
|
|
49
46
|
const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
|
|
50
47
|
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
51
48
|
const generated = join(workdir, `cpu-${date}.generated.js`);
|
|
@@ -54,21 +51,7 @@ async function build(date: string): Promise<number> {
|
|
|
54
51
|
generated,
|
|
55
52
|
wrapNativeFetchHandler(handler, core.replace(TRANSPORT_MARKER, broker), undefined, undefined, undefined, date),
|
|
56
53
|
);
|
|
57
|
-
|
|
58
|
-
[node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
|
|
59
|
-
{
|
|
60
|
-
cwd: workdir,
|
|
61
|
-
env: process.env,
|
|
62
|
-
stdout: "pipe",
|
|
63
|
-
stderr: "pipe",
|
|
64
|
-
},
|
|
65
|
-
);
|
|
66
|
-
const [exitCode, stdout, stderr] = await Promise.all([
|
|
67
|
-
compile.exited,
|
|
68
|
-
new Response(compile.stdout).text(),
|
|
69
|
-
new Response(compile.stderr).text(),
|
|
70
|
-
]);
|
|
71
|
-
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
54
|
+
await compileNative({ generated, binary, dir: workdir });
|
|
72
55
|
const port = freePort();
|
|
73
56
|
const server = Bun.spawn([binary], {
|
|
74
57
|
cwd: workdir,
|