@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
9
|
+
## 0.42.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 5ef3159: Security fix: on the embedded transport, a `connect()` socket opened with `secureTransport: "starttls"` could send application data in plaintext after `startTls()`. The I/O thread moved everything the app had queued, including writes issued after the upgrade call, into its write buffer and flushed it in the clear before switching the socket to TLS, so a write that raced the upgrade (about 7% of connections in a loopback test) left unencrypted. The host now records the queue position at the upgrade and only flushes the bytes queued before it in plaintext; everything after waits for the TLS engine, which only emits application data once the handshake is done. On the JS side, `startTls()` closes the plaintext socket's writable (its queued writes still go out, new writes are refused, as in workerd), and the new socket's writes are held until the upgrade is queued behind them. The I/O thread also reports a drained send queue right after flushing instead of after the next input, so a write past the high-water mark no longer waits on the peer. The broker tunnel, which already orders the upgrade among the data frames, no longer stays paused on the plain socket's drain when the upgrade happens under backpressure. Regression tests on both transports record the raw bytes at the origin and refuse anything but a TLS handshake record after the plaintext phase. No compatibility date: `connect()` ships from `2026-11-17`, and the old behaviour was never correct.
|
|
14
|
+
|
|
3
15
|
## 0.42.1
|
|
4
16
|
|
|
5
17
|
### 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
|
}
|
|
@@ -14704,8 +14706,18 @@ function __sbTcpRelease(st) {
|
|
|
14704
14706
|
__sbTcpOpen--;
|
|
14705
14707
|
}
|
|
14706
14708
|
|
|
14707
|
-
//
|
|
14708
|
-
function
|
|
14709
|
+
// Queues bytes for the host; a write past the high-water mark settles when the queue drains.
|
|
14710
|
+
function __sbTcpQueue(st, bytes) {
|
|
14711
|
+
if (st.id === 0) throw new TypeError("This WritableStream has been closed.");
|
|
14712
|
+
const queued = Number(__sbWsOp("b", st.id, __sbToBytes(bytes), 0));
|
|
14713
|
+
if (queued > __SB_TCP_HWM) return new Promise((resolve) => (st.drain = resolve));
|
|
14714
|
+
return undefined;
|
|
14715
|
+
}
|
|
14716
|
+
|
|
14717
|
+
// One socket facade over a host connection. `st` is shared with the socket startTls() returns;
|
|
14718
|
+
// that socket gets `upgrade`, whose `pending` promise holds its writes back until the "S" op
|
|
14719
|
+
// is queued behind everything the plaintext socket had already accepted.
|
|
14720
|
+
function __sbTcpFacade(st, mode, upgrade) {
|
|
14709
14721
|
const closed = new Promise((resolve, reject) => {
|
|
14710
14722
|
st.closedResolve = resolve;
|
|
14711
14723
|
st.closedReject = reject;
|
|
@@ -14759,12 +14771,15 @@ function __sbTcpFacade(st, mode) {
|
|
|
14759
14771
|
},
|
|
14760
14772
|
write(chunk) {
|
|
14761
14773
|
const bytes = __sbTcpChunk(chunk);
|
|
14762
|
-
if (
|
|
14763
|
-
|
|
14764
|
-
if (queued > __SB_TCP_HWM) return new Promise((resolve) => (st.drain = resolve));
|
|
14765
|
-
return undefined;
|
|
14774
|
+
if (upgrade !== null && upgrade.pending !== null) return upgrade.pending.then(() => __sbTcpQueue(st, bytes));
|
|
14775
|
+
return __sbTcpQueue(st, bytes);
|
|
14766
14776
|
},
|
|
14767
14777
|
close() {
|
|
14778
|
+
if (st.upgrading) {
|
|
14779
|
+
// startTls() closed this stream: its queued writes are out, the write side stays open for TLS
|
|
14780
|
+
st.upgrading = false;
|
|
14781
|
+
return undefined;
|
|
14782
|
+
}
|
|
14768
14783
|
if (st.id !== 0) __sbWsOp("H", st.id, "", 0);
|
|
14769
14784
|
st.writeClosed = true;
|
|
14770
14785
|
return undefined;
|
|
@@ -14804,10 +14819,36 @@ function __sbTcpFacade(st, mode) {
|
|
|
14804
14819
|
st.upgradedOnce = true;
|
|
14805
14820
|
let sni = st.host;
|
|
14806
14821
|
if (options != null && options.expectedServerHostname != null) sni = String(options.expectedServerHostname);
|
|
14807
|
-
|
|
14822
|
+
// This socket's streams end here, as workerd detaches them. Its writable is closed first:
|
|
14823
|
+
// what a writer had already queued still goes out in the clear, then the "S" op marks the
|
|
14824
|
+
// queue position after which only TLS records leave the host; the new socket's writes wait
|
|
14825
|
+
// on that (upgrade.pending), so no write issued after startTls() can precede the handshake.
|
|
14826
|
+
const upgrade = { pending: null };
|
|
14827
|
+
upgrade.pending = new Promise((resolve) => {
|
|
14828
|
+
const request = () => {
|
|
14829
|
+
st.upgrading = false;
|
|
14830
|
+
if (st.id !== 0) __sbWsOp("S", st.id, sni, 0);
|
|
14831
|
+
upgrade.pending = null;
|
|
14832
|
+
resolve(undefined);
|
|
14833
|
+
};
|
|
14834
|
+
if (st.writeClosed) request();
|
|
14835
|
+
else {
|
|
14836
|
+
st.upgrading = true;
|
|
14837
|
+
writable.close().then(request, request);
|
|
14838
|
+
}
|
|
14839
|
+
});
|
|
14840
|
+
st.pullWake = null;
|
|
14841
|
+
const oldReadable = st.readableController;
|
|
14842
|
+
if (oldReadable !== null) {
|
|
14843
|
+
try {
|
|
14844
|
+
oldReadable.close();
|
|
14845
|
+
} catch {
|
|
14846
|
+
// already closed or errored
|
|
14847
|
+
}
|
|
14848
|
+
}
|
|
14808
14849
|
// the old socket ends once the upgrade is under way (workerd resolves its `closed`)
|
|
14809
14850
|
const oldResolve = st.closedResolve;
|
|
14810
|
-
const next = __sbTcpFacade(st, "on");
|
|
14851
|
+
const next = __sbTcpFacade(st, "on", upgrade);
|
|
14811
14852
|
setTimeout(() => oldResolve(undefined), 0);
|
|
14812
14853
|
return next;
|
|
14813
14854
|
},
|
|
@@ -14832,11 +14873,11 @@ function __sbConnect(address, options) {
|
|
|
14832
14873
|
const st = {
|
|
14833
14874
|
id: 0, host: target.host, port: target.port, inbox: [], eof: false, failed: null, settled: false,
|
|
14834
14875
|
allowHalfOpen, pullWake: null, drain: null, timer: undefined, opening: null, upgradedOnce: false,
|
|
14835
|
-
closedByUser: false, writableController: null, readableController: null, reason: "", writeClosed: false, writeEnd: false,
|
|
14876
|
+
closedByUser: false, writableController: null, readableController: null, reason: "", writeClosed: false, writeEnd: false, upgrading: false,
|
|
14836
14877
|
release: null, closedResolve: null, closedReject: null,
|
|
14837
14878
|
};
|
|
14838
14879
|
st.release = () => __sbTcpRelease(st);
|
|
14839
|
-
const socket = __sbTcpFacade(st, mode);
|
|
14880
|
+
const socket = __sbTcpFacade(st, mode, null);
|
|
14840
14881
|
if (target.port === 25) {
|
|
14841
14882
|
st.failed = new Error("proxy request failed, cannot connect to the specified address");
|
|
14842
14883
|
st.eof = true;
|
|
@@ -15293,8 +15334,9 @@ function __sbInstallWebSockets() {
|
|
|
15293
15334
|
globalThis.WebSocketRequestResponsePair = __SbWebSocketRequestResponsePair;
|
|
15294
15335
|
}
|
|
15295
15336
|
|
|
15296
|
-
async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top, cpuCell) {
|
|
15337
|
+
async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top, cpuCell, logRec) {
|
|
15297
15338
|
let r;
|
|
15339
|
+
let threw = 0;
|
|
15298
15340
|
try {
|
|
15299
15341
|
r = res && __sbIsFn(res.then) ? await res : res;
|
|
15300
15342
|
if (top && __sbHasWebSocket(r)) r = __sbWsHostResponse(r);
|
|
@@ -15309,7 +15351,8 @@ async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top, cpuCell
|
|
|
15309
15351
|
throw error;
|
|
15310
15352
|
}
|
|
15311
15353
|
// #179 — a rejected handler promise must not take the process down with it.
|
|
15312
|
-
|
|
15354
|
+
threw = 1;
|
|
15355
|
+
r = __sbErrorResponse(error, bindingCall, logRec);
|
|
15313
15356
|
}
|
|
15314
15357
|
// From 2026-11-02 an async handler reports its CPU too: the thread time of the slices in which this
|
|
15315
15358
|
// request ran, not its neighbours' (before it, only a handler that answered synchronously did).
|
|
@@ -15320,7 +15363,8 @@ async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top, cpuCell
|
|
|
15320
15363
|
// The response goes out now and ctx.waitUntil work keeps running after it, as in
|
|
15321
15364
|
// workerd; it was awaited first, so a 1.5 s background task delayed the response by
|
|
15322
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.
|
|
15323
|
-
if (
|
|
15366
|
+
if (logRec) __sbLogRequest(logRec, r, threw);
|
|
15367
|
+
if (tasks.length) __sbDrainWaitUntil(tasks, logRec);
|
|
15324
15368
|
return r;
|
|
15325
15369
|
}
|
|
15326
15370
|
|
|
@@ -15328,8 +15372,8 @@ async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top, cpuCell
|
|
|
15328
15372
|
// propagate past __sbEntry and crash the whole server. Logged first, the way a
|
|
15329
15373
|
// Worker logs "Uncaught Error: ...": a swallowed exception left a 500 with
|
|
15330
15374
|
// nothing on stderr, which hid #168 entirely.
|
|
15331
|
-
function __sbErrorResponse(error, bindingCall) {
|
|
15332
|
-
__sbLogUncaught("fetch", error);
|
|
15375
|
+
function __sbErrorResponse(error, bindingCall, logRec) {
|
|
15376
|
+
__sbLogUncaught("fetch", error, logRec);
|
|
15333
15377
|
// Untyped, as workerd sends it: the text/plain default is for handlers' bodies.
|
|
15334
15378
|
const response = new Response("Internal Server Error", { status: 500 });
|
|
15335
15379
|
response.headers.delete("content-type");
|
|
@@ -15399,7 +15443,11 @@ function __sbHeader(headers, name) {
|
|
|
15399
15443
|
return count > 1 ? headers.get(name) : found;
|
|
15400
15444
|
}
|
|
15401
15445
|
|
|
15402
|
-
function __sbLogUncaught(where, error) {
|
|
15446
|
+
function __sbLogUncaught(where, error, logRec) {
|
|
15447
|
+
if (__sbLogJson()) {
|
|
15448
|
+
__sbLogError(where, error, logRec);
|
|
15449
|
+
return;
|
|
15450
|
+
}
|
|
15403
15451
|
try {
|
|
15404
15452
|
let text = "";
|
|
15405
15453
|
if (error && error.stack) text = String(error.stack);
|
|
@@ -15411,6 +15459,113 @@ function __sbLogUncaught(where, error) {
|
|
|
15411
15459
|
}
|
|
15412
15460
|
}
|
|
15413
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
|
+
|
|
15414
15569
|
// A promise rejected and nothing was waiting on it (an un-awaited `db.prepare(...).run()`
|
|
15415
15570
|
// that failed, say). As on Workers the request, the process and every other request go on:
|
|
15416
15571
|
// the rejection is logged, and an "unhandledrejection" event fires on the global scope when
|
|
@@ -15746,6 +15901,8 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
15746
15901
|
// async handlers (see LogEvent in services/edge) — that is this.
|
|
15747
15902
|
const __cpuCell = __sbCpuBegin();
|
|
15748
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;
|
|
15749
15906
|
const __sbTasks = [];
|
|
15750
15907
|
const ctx = {
|
|
15751
15908
|
waitUntil(p) {
|
|
@@ -15768,19 +15925,24 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
15768
15925
|
// #179 — a synchronous throw anywhere in the handler must not take the
|
|
15769
15926
|
// whole process (and every other in-flight request) down with it.
|
|
15770
15927
|
__sbCpuEnd(__cpuCell);
|
|
15771
|
-
|
|
15928
|
+
const __failed = __sbErrorResponse(error, bindingCall, __logRec);
|
|
15929
|
+
if (__logRec) __sbLogRequest(__logRec, __failed, 1);
|
|
15930
|
+
return __failed;
|
|
15772
15931
|
}
|
|
15773
15932
|
// Same promise-identity rule as above applies to `__sbFetchWithDrain`'s
|
|
15774
15933
|
// own return, which is why it's tail-called rather than chained on here.
|
|
15775
|
-
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);
|
|
15776
15935
|
// A WebSocket handshake answer (from 2026-10-30): its client end becomes the host socket.
|
|
15777
15936
|
if (__sbHasWebSocket(__res)) {
|
|
15778
15937
|
__sbCpuEnd(__cpuCell);
|
|
15779
|
-
|
|
15938
|
+
const __ws = __sbWsHostResponse(__res);
|
|
15939
|
+
if (__logRec) __sbLogRequest(__logRec, __ws, 0);
|
|
15940
|
+
return __ws;
|
|
15780
15941
|
}
|
|
15781
15942
|
// The request ran alone from start to here: its own slices, or the process delta where no slot was free.
|
|
15782
15943
|
const __cpu = __cpuCell ? __sbCpuTake(__cpuCell) : __sbCpuMs() - __t0;
|
|
15783
15944
|
__sbCpuEnd(__cpuCell);
|
|
15945
|
+
if (__logRec) __sbLogRequest(__logRec, __res, 0);
|
|
15784
15946
|
return __sbTagCpu(__res, __cpu);
|
|
15785
15947
|
}
|
|
15786
15948
|
if (!__sbTriggerAuthed(request)) return new Response("forbidden", { status: 403 });
|
|
@@ -3325,6 +3325,7 @@ typedef struct sb_wsc {
|
|
|
3325
3325
|
int port, tls;
|
|
3326
3326
|
// Raw TCP (cloudflare:sockets connect()): no WebSocket framing, bytes both ways.
|
|
3327
3327
|
int raw, starttls_req, shutdown_req, want_drain;
|
|
3328
|
+
size_t starttls_at; // outq bytes queued before the "S" op: the last ones allowed out in the clear
|
|
3328
3329
|
size_t unsent, window;
|
|
3329
3330
|
char sni[256];
|
|
3330
3331
|
} sb_wsc;
|
|
@@ -3458,10 +3459,13 @@ static int sb_wsio_raw_write(sb_wsio* io, const unsigned char* buf, size_t len)
|
|
|
3458
3459
|
// connection ends (-1). out/outlen is application data to send first. With may_read false (the
|
|
3459
3460
|
// reader is far behind) it only sends, and waits for the wake pipe.
|
|
3460
3461
|
static long sb_wsio_pump(sb_wsc* c, sb_wsio* io, unsigned char* buf, size_t cap, unsigned char* out, size_t* outlen, int may_read, int ms) {
|
|
3462
|
+
// Returns right after application bytes went out (0, as for a wake) instead of waiting for
|
|
3463
|
+
// input: the caller reports the drain then, which a write past the high-water mark waits on.
|
|
3461
3464
|
if (!io->tls) {
|
|
3462
3465
|
if (*outlen) {
|
|
3463
3466
|
if (sb_wsio_raw_write(io, out, *outlen) != 0) return -1;
|
|
3464
3467
|
*outlen = 0;
|
|
3468
|
+
return 0;
|
|
3465
3469
|
}
|
|
3466
3470
|
for (;;) {
|
|
3467
3471
|
if (may_read) {
|
|
@@ -3478,6 +3482,7 @@ static long sb_wsio_pump(sb_wsc* c, sb_wsio* io, unsigned char* buf, size_t cap,
|
|
|
3478
3482
|
}
|
|
3479
3483
|
}
|
|
3480
3484
|
br_ssl_engine_context* eng = &io->sc->eng;
|
|
3485
|
+
int wrote = 0;
|
|
3481
3486
|
for (;;) {
|
|
3482
3487
|
unsigned st = br_ssl_engine_current_state(eng);
|
|
3483
3488
|
if (st & BR_SSL_CLOSED) return -1;
|
|
@@ -3501,9 +3506,10 @@ static long sb_wsio_pump(sb_wsc* c, sb_wsio* io, unsigned char* buf, size_t cap,
|
|
|
3501
3506
|
br_ssl_engine_sendapp_ack(eng, n);
|
|
3502
3507
|
memmove(out, out + n, *outlen - n);
|
|
3503
3508
|
*outlen -= n;
|
|
3504
|
-
if (*outlen == 0) br_ssl_engine_flush(eng, 0);
|
|
3509
|
+
if (*outlen == 0) { br_ssl_engine_flush(eng, 0); wrote = 1; }
|
|
3505
3510
|
continue;
|
|
3506
3511
|
}
|
|
3512
|
+
if (wrote) return 0;
|
|
3507
3513
|
// Handshaking, or waiting for the peer: read records while the reader may; else only wake.
|
|
3508
3514
|
int reading = (st & BR_SSL_RECVREC) && (may_read || !(st & BR_SSL_SENDAPP));
|
|
3509
3515
|
if (reading) {
|
|
@@ -3812,15 +3818,30 @@ static void* sb_tcp_thread(void* arg) {
|
|
|
3812
3818
|
int st = c->starttls_req; c->starttls_req = 0;
|
|
3813
3819
|
int sh = c->shutdown_req;
|
|
3814
3820
|
char sni[256]; memcpy(sni, c->sni, sizeof(sni));
|
|
3815
|
-
|
|
3816
|
-
|
|
3821
|
+
// A pending startTls() splits the queue: only what the app queued before the "S" op may
|
|
3822
|
+
// leave in the clear. The rest stays here until the TLS engine owns the socket, so a write
|
|
3823
|
+
// that races the upgrade can only ever go out inside a TLS record.
|
|
3824
|
+
size_t take = c->outq_len;
|
|
3825
|
+
if (st && c->starttls_at < take) take = c->starttls_at;
|
|
3826
|
+
if (take) {
|
|
3827
|
+
if (wq_len + take > wq_cap) {
|
|
3817
3828
|
size_t cap = wq_cap ? wq_cap : 4096;
|
|
3818
|
-
while (cap < wq_len +
|
|
3829
|
+
while (cap < wq_len + take) cap *= 2;
|
|
3819
3830
|
unsigned char* grown = (unsigned char*)realloc(wq, cap);
|
|
3820
3831
|
if (grown) { wq = grown; wq_cap = cap; }
|
|
3821
3832
|
}
|
|
3822
|
-
if (wq_len +
|
|
3833
|
+
if (wq_len + take <= wq_cap) {
|
|
3834
|
+
memcpy(wq + wq_len, c->outq, take);
|
|
3835
|
+
wq_len += take;
|
|
3836
|
+
if (take < c->outq_len) memmove(c->outq, c->outq + take, c->outq_len - take);
|
|
3837
|
+
c->outq_len -= take;
|
|
3838
|
+
} else if (st) {
|
|
3839
|
+
// no room to move the plaintext part: retry the upgrade on the next pass
|
|
3840
|
+
c->starttls_req = 1;
|
|
3841
|
+
st = 0;
|
|
3842
|
+
}
|
|
3823
3843
|
}
|
|
3844
|
+
if (st) c->starttls_at = 0;
|
|
3824
3845
|
c->unsent = wq_len;
|
|
3825
3846
|
pthread_mutex_unlock(&c->lock);
|
|
3826
3847
|
if (rel) {
|
|
@@ -3828,7 +3849,14 @@ static void* sb_tcp_thread(void* arg) {
|
|
|
3828
3849
|
goto finish;
|
|
3829
3850
|
}
|
|
3830
3851
|
if (st && !io.tls) {
|
|
3831
|
-
|
|
3852
|
+
// Everything queued before startTls() is written in the clear here (the plain write path
|
|
3853
|
+
// blocks until the kernel took it all); the socket then belongs to the TLS engine, which
|
|
3854
|
+
// only emits application data once the handshake is done (BR_SSL_SENDAPP).
|
|
3855
|
+
if (wq_len && sb_wsio_pump(c, &io, buf, sizeof(buf), wq, &wq_len, 0, 0) < 0) {
|
|
3856
|
+
snprintf(why, sizeof(why), "connection closed before the TLS handshake");
|
|
3857
|
+
sb_wsc_push(c, 3, 0, why, strlen(why));
|
|
3858
|
+
goto finish;
|
|
3859
|
+
}
|
|
3832
3860
|
if (sb_wsio_start_tls(&io, sni[0] ? sni : c->host, why, sizeof(why)) != 0) { sb_wsc_push(c, 3, 0, why, strlen(why)); goto finish; }
|
|
3833
3861
|
tls_waiting = 1;
|
|
3834
3862
|
}
|
|
@@ -4023,6 +4051,7 @@ static long long sb_wsc_op(int op, unsigned id, const char* data, size_t len, si
|
|
|
4023
4051
|
pthread_mutex_lock(&c->lock);
|
|
4024
4052
|
snprintf(c->sni, sizeof(c->sni), "%.*s", (int)(len < sizeof(c->sni) - 1 ? len : sizeof(c->sni) - 1), data ? data : "");
|
|
4025
4053
|
c->starttls_req = 1;
|
|
4054
|
+
c->starttls_at = c->outq_len;
|
|
4026
4055
|
pthread_mutex_unlock(&c->lock);
|
|
4027
4056
|
sb_wsc_wake_thread(c);
|
|
4028
4057
|
return 0;
|
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.
|