@sproutboat/runtime 0.42.2 → 0.44.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 +16 -0
- package/package.json +1 -1
- package/src/egress.test.ts +76 -0
- package/src/native-fetch-prelude.js +164 -14
- package/src/transport-embedded.js +78 -7
- 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-r2-transfer.ts +101 -0
- 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,21 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.44.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8e08bb2: runtime: service bindings between apps on a sproutd host. `env.PEER.fetch()` and RPC on a callee's `WorkerEntrypoint` (`env.PEER.method(...)`) now work from compatibility date 2026-11-18, where the embedded transport had no `service.fetch` op and a call failed with `unknown op`. The caller sends the call as an HTTP request to the pseudo-host `sbsvc.<binding name in hex>` over its egress socket (`SB_EGRESS_FD`). The host looks the binding up in the services map it holds for the caller, answers `ok TICKET` with a connection to its own listener, and the transport sends the ticket as an `x-sb-svc-ticket` header. The host, which alone mints tickets, then delivers the request to the callee marked as a vouched service-binding call, so the callee's existing ingress (`x-sb-binding-call`, `x-sb-edge-token`, `x-sb-url`, `x-sb-rpc`, `x-sb-uncaught`) works unchanged. A call is bounded at 30 seconds per hop, with no depth cap: a loop unwinds on that timeout. Before 2026-11-18 a call fails as before, with a clearer message. A standalone binary, which has no peer apps, reports that. An older host answers the pseudo-host as an unresolvable name.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- 8e08bb2: runtime: the promise-returning R2 binding (compatibility date 2026-10-26 and later) has `createUploadUrl()` and `createDownloadUrl()` again, with the semantics of the legacy binding (the same `r2.transfer.create` call, now returning promises). They were missing there, so an app that moved past 2026-10-26 lost direct transfer with a "not a function" error. No date gate: the methods did not exist at those dates, so nothing that worked changes.
|
|
12
|
+
|
|
13
|
+
## 0.43.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
3
19
|
## 0.42.2
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/package.json
CHANGED
package/src/egress.test.ts
CHANGED
|
@@ -242,6 +242,82 @@ int main(void) {
|
|
|
242
242
|
}
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
+
test('SB_EGRESS_FD: a service-binding call\'s "ok TICKET" answer leaves the ticket for the request', () => {
|
|
246
|
+
const source = readFileSync(transportFile, "utf8");
|
|
247
|
+
const block = source.slice(source.indexOf("// EGRESS-FD:BEGIN"), source.indexOf("// EGRESS-FD:END"));
|
|
248
|
+
const dir = mkdtempSync(join(tmpdir(), "sb-egress-ticket-"));
|
|
249
|
+
try {
|
|
250
|
+
writeFileSync(
|
|
251
|
+
join(dir, "check.c"),
|
|
252
|
+
`#include <errno.h>
|
|
253
|
+
#include <pthread.h>
|
|
254
|
+
#include <stdio.h>
|
|
255
|
+
#include <stdlib.h>
|
|
256
|
+
#include <string.h>
|
|
257
|
+
#include <unistd.h>
|
|
258
|
+
#include <sys/socket.h>
|
|
259
|
+
#include <netinet/in.h>
|
|
260
|
+
#include <netinet/tcp.h>
|
|
261
|
+
#include <sys/time.h>
|
|
262
|
+
#include <sys/wait.h>
|
|
263
|
+
${block}
|
|
264
|
+
static void answer(int via, const char* text, int fd) {
|
|
265
|
+
union { struct cmsghdr align; char space[CMSG_SPACE(sizeof(int))]; } control;
|
|
266
|
+
struct iovec io = { (void*)text, strlen(text) };
|
|
267
|
+
struct msghdr msg;
|
|
268
|
+
memset(&msg, 0, sizeof(msg));
|
|
269
|
+
msg.msg_iov = &io; msg.msg_iovlen = 1;
|
|
270
|
+
if (fd >= 0) {
|
|
271
|
+
msg.msg_control = control.space; msg.msg_controllen = sizeof(control.space);
|
|
272
|
+
struct cmsghdr* c = CMSG_FIRSTHDR(&msg);
|
|
273
|
+
c->cmsg_level = SOL_SOCKET; c->cmsg_type = SCM_RIGHTS; c->cmsg_len = CMSG_LEN(sizeof(int));
|
|
274
|
+
memcpy(CMSG_DATA(c), &fd, sizeof(fd));
|
|
275
|
+
}
|
|
276
|
+
sendmsg(via, &msg, 0);
|
|
277
|
+
}
|
|
278
|
+
int main(void) {
|
|
279
|
+
int pair[2];
|
|
280
|
+
socketpair(AF_UNIX, SOCK_STREAM, 0, pair);
|
|
281
|
+
if (fork() == 0) {
|
|
282
|
+
close(pair[0]);
|
|
283
|
+
char line[512];
|
|
284
|
+
int remote[2];
|
|
285
|
+
read(pair[1], line, sizeof(line) - 1);
|
|
286
|
+
socketpair(AF_UNIX, SOCK_STREAM, 0, remote);
|
|
287
|
+
answer(pair[1], "ok 0123abcd\\n", remote[0]);
|
|
288
|
+
read(pair[1], line, sizeof(line) - 1);
|
|
289
|
+
socketpair(AF_UNIX, SOCK_STREAM, 0, remote);
|
|
290
|
+
answer(pair[1], "ok\\n", remote[0]);
|
|
291
|
+
_exit(0);
|
|
292
|
+
}
|
|
293
|
+
close(pair[1]);
|
|
294
|
+
char refused[512] = "";
|
|
295
|
+
int fd = sb_egress_fd_connect(pair[0], "sbsvc.70", 80, refused, sizeof(refused));
|
|
296
|
+
printf("service %s ticket=%s\\n", fd >= 0 ? "yes" : "no", sb_svc_ticket);
|
|
297
|
+
fd = sb_egress_fd_connect(pair[0], "up.test", 80, refused, sizeof(refused));
|
|
298
|
+
printf("plain %s ticket=%s\\n", fd >= 0 ? "yes" : "no", sb_svc_ticket);
|
|
299
|
+
wait(0);
|
|
300
|
+
return 0;
|
|
301
|
+
}
|
|
302
|
+
`,
|
|
303
|
+
);
|
|
304
|
+
const cc = Bun.spawnSync([
|
|
305
|
+
"cc",
|
|
306
|
+
"-Wall",
|
|
307
|
+
"-Werror",
|
|
308
|
+
"-Wno-unused-result",
|
|
309
|
+
"-o",
|
|
310
|
+
join(dir, "check"),
|
|
311
|
+
join(dir, "check.c"),
|
|
312
|
+
]);
|
|
313
|
+
expect(cc.stderr.toString()).toBe("");
|
|
314
|
+
const run = Bun.spawnSync([join(dir, "check")]);
|
|
315
|
+
expect(run.stdout.toString().trimEnd().split("\n")).toEqual(["service yes ticket=0123abcd", "plain yes ticket="]);
|
|
316
|
+
} finally {
|
|
317
|
+
rmSync(dir, { recursive: true, force: true });
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
245
321
|
test("sb_tcp_connect sets TCP_NODELAY on the socket it returns", () => {
|
|
246
322
|
// Nagle plus the peer's delayed ACK stalled every fresh HTTPS fetch about 44 ms on Linux (a TLS
|
|
247
323
|
// handshake writes small flights). Timing is flaky on CI and loopback on macOS does not show
|
|
@@ -11174,6 +11174,31 @@ function __sbMakeR2(name) {
|
|
|
11174
11174
|
resumeMultipartUpload(key, uploadId) {
|
|
11175
11175
|
return upload(String(key), String(uploadId));
|
|
11176
11176
|
},
|
|
11177
|
+
// Direct transfer (a sproutboat extension, as on the legacy binding): a URL the client sends the bytes to or
|
|
11178
|
+
// fetches them from without passing through the app. Promises, like the rest of this binding.
|
|
11179
|
+
createUploadUrl(key, options) {
|
|
11180
|
+
return attempt(() => {
|
|
11181
|
+
const o = options || {};
|
|
11182
|
+
const r = __sbRpc("r2.transfer.create", {
|
|
11183
|
+
bucket: name,
|
|
11184
|
+
key: String(key),
|
|
11185
|
+
method: "upload",
|
|
11186
|
+
maxBytes: o.maxBytes,
|
|
11187
|
+
expiresIn: o.expiresIn,
|
|
11188
|
+
sha256: o.sha256,
|
|
11189
|
+
httpMetadata: o.httpMetadata || {},
|
|
11190
|
+
customMetadata: o.customMetadata || {},
|
|
11191
|
+
});
|
|
11192
|
+
return { url: r.url, expiresAt: r.expiresAt };
|
|
11193
|
+
});
|
|
11194
|
+
},
|
|
11195
|
+
createDownloadUrl(key, options) {
|
|
11196
|
+
return attempt(() => {
|
|
11197
|
+
const o = options || {};
|
|
11198
|
+
const r = __sbRpc("r2.transfer.create", { bucket: name, key: String(key), method: "download", expiresIn: o.expiresIn });
|
|
11199
|
+
return { url: r.url, expiresAt: r.expiresAt };
|
|
11200
|
+
});
|
|
11201
|
+
},
|
|
11177
11202
|
};
|
|
11178
11203
|
}
|
|
11179
11204
|
|
|
@@ -11561,6 +11586,7 @@ function __sbMakeQueue(binding) {
|
|
|
11561
11586
|
|
|
11562
11587
|
globalThis.__sbInstallBindings = function (target, bindings) {
|
|
11563
11588
|
if (!target) return;
|
|
11589
|
+
__sbLogSecretNames = bindings.secrets || [];
|
|
11564
11590
|
|
|
11565
11591
|
__sbQueueSetup(bindings);
|
|
11566
11592
|
|
|
@@ -11969,6 +11995,8 @@ async function __sbRpcCall(binding, method, args) {
|
|
|
11969
11995
|
const msg = {
|
|
11970
11996
|
binding,
|
|
11971
11997
|
url: "https://rpc.sproutboat.internal/" + method,
|
|
11998
|
+
// Marks an RPC call for a transport that has to tell it from a fetch that carries an x-sb-rpc header of its own.
|
|
11999
|
+
rpc: true,
|
|
11972
12000
|
method: "POST",
|
|
11973
12001
|
headers: [
|
|
11974
12002
|
["x-sb-rpc", method],
|
|
@@ -13992,7 +14020,7 @@ function __sbTrustedProxyHeader(request, name) {
|
|
|
13992
14020
|
// own.
|
|
13993
14021
|
const __SB_WAITUNTIL_TIMEOUT_MS = 25000;
|
|
13994
14022
|
|
|
13995
|
-
async function __sbDrainWaitUntil(tasks) {
|
|
14023
|
+
async function __sbDrainWaitUntil(tasks, logRec) {
|
|
13996
14024
|
if (!tasks.length) return;
|
|
13997
14025
|
const pending = tasks.splice(0, tasks.length);
|
|
13998
14026
|
// Clear the cap's timer once the tasks finish: a pending timer kept the process
|
|
@@ -14001,16 +14029,17 @@ async function __sbDrainWaitUntil(tasks) {
|
|
|
14001
14029
|
const cap = new Promise((resolve) => {
|
|
14002
14030
|
timer = setTimeout(resolve, __SB_WAITUNTIL_TIMEOUT_MS);
|
|
14003
14031
|
});
|
|
14004
|
-
await Promise.race([__sbDrainAll(pending), cap]);
|
|
14032
|
+
await Promise.race([__sbDrainAll(pending, logRec), cap]);
|
|
14005
14033
|
clearTimeout(timer);
|
|
14006
14034
|
}
|
|
14007
14035
|
|
|
14008
|
-
async function __sbDrainAll(pending) {
|
|
14036
|
+
async function __sbDrainAll(pending, logRec) {
|
|
14009
14037
|
for (let i = 0; i < pending.length; i++) {
|
|
14010
14038
|
try {
|
|
14011
14039
|
await pending[i];
|
|
14012
|
-
} catch {
|
|
14013
|
-
/* a background task's rejection must not fail the response */
|
|
14040
|
+
} catch (error) {
|
|
14041
|
+
/* a background task's rejection must not fail the response; with SB_LOG=json it is recorded (#268) */
|
|
14042
|
+
if (__sbLogJson()) __sbLogError("waitUntil", error, logRec);
|
|
14014
14043
|
}
|
|
14015
14044
|
}
|
|
14016
14045
|
}
|
|
@@ -15332,8 +15361,9 @@ function __sbInstallWebSockets() {
|
|
|
15332
15361
|
globalThis.WebSocketRequestResponsePair = __SbWebSocketRequestResponsePair;
|
|
15333
15362
|
}
|
|
15334
15363
|
|
|
15335
|
-
async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top, cpuCell) {
|
|
15364
|
+
async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top, cpuCell, logRec) {
|
|
15336
15365
|
let r;
|
|
15366
|
+
let threw = 0;
|
|
15337
15367
|
try {
|
|
15338
15368
|
r = res && __sbIsFn(res.then) ? await res : res;
|
|
15339
15369
|
if (top && __sbHasWebSocket(r)) r = __sbWsHostResponse(r);
|
|
@@ -15348,7 +15378,8 @@ async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top, cpuCell
|
|
|
15348
15378
|
throw error;
|
|
15349
15379
|
}
|
|
15350
15380
|
// #179 — a rejected handler promise must not take the process down with it.
|
|
15351
|
-
|
|
15381
|
+
threw = 1;
|
|
15382
|
+
r = __sbErrorResponse(error, bindingCall, logRec);
|
|
15352
15383
|
}
|
|
15353
15384
|
// From 2026-11-02 an async handler reports its CPU too: the thread time of the slices in which this
|
|
15354
15385
|
// request ran, not its neighbours' (before it, only a handler that answered synchronously did).
|
|
@@ -15359,7 +15390,8 @@ async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top, cpuCell
|
|
|
15359
15390
|
// The response goes out now and ctx.waitUntil work keeps running after it, as in
|
|
15360
15391
|
// workerd; it was awaited first, so a 1.5 s background task delayed the response by
|
|
15361
15392
|
// 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 (
|
|
15393
|
+
if (logRec) __sbLogRequest(logRec, r, threw);
|
|
15394
|
+
if (tasks.length) __sbDrainWaitUntil(tasks, logRec);
|
|
15363
15395
|
return r;
|
|
15364
15396
|
}
|
|
15365
15397
|
|
|
@@ -15367,8 +15399,8 @@ async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top, cpuCell
|
|
|
15367
15399
|
// propagate past __sbEntry and crash the whole server. Logged first, the way a
|
|
15368
15400
|
// Worker logs "Uncaught Error: ...": a swallowed exception left a 500 with
|
|
15369
15401
|
// nothing on stderr, which hid #168 entirely.
|
|
15370
|
-
function __sbErrorResponse(error, bindingCall) {
|
|
15371
|
-
__sbLogUncaught("fetch", error);
|
|
15402
|
+
function __sbErrorResponse(error, bindingCall, logRec) {
|
|
15403
|
+
__sbLogUncaught("fetch", error, logRec);
|
|
15372
15404
|
// Untyped, as workerd sends it: the text/plain default is for handlers' bodies.
|
|
15373
15405
|
const response = new Response("Internal Server Error", { status: 500 });
|
|
15374
15406
|
response.headers.delete("content-type");
|
|
@@ -15438,7 +15470,11 @@ function __sbHeader(headers, name) {
|
|
|
15438
15470
|
return count > 1 ? headers.get(name) : found;
|
|
15439
15471
|
}
|
|
15440
15472
|
|
|
15441
|
-
function __sbLogUncaught(where, error) {
|
|
15473
|
+
function __sbLogUncaught(where, error, logRec) {
|
|
15474
|
+
if (__sbLogJson()) {
|
|
15475
|
+
__sbLogError(where, error, logRec);
|
|
15476
|
+
return;
|
|
15477
|
+
}
|
|
15442
15478
|
try {
|
|
15443
15479
|
let text = "";
|
|
15444
15480
|
if (error && error.stack) text = String(error.stack);
|
|
@@ -15450,6 +15486,113 @@ function __sbLogUncaught(where, error) {
|
|
|
15450
15486
|
}
|
|
15451
15487
|
}
|
|
15452
15488
|
|
|
15489
|
+
// #268 — structured request and error records for a standalone binary. Off unless SB_LOG=json, so
|
|
15490
|
+
// nothing an app can observe changes and the per-request cost of "off" is one cached boolean. On, every
|
|
15491
|
+
// request writes one JSON line to stderr after its response is built, and every uncaught error (in a
|
|
15492
|
+
// handler, scheduled(), queue(), an alarm, a WebSocket handler, a rejected promise or a waitUntil task)
|
|
15493
|
+
// writes one more, in place of the plain "Uncaught (in ...)" text, so the stream stays parseable.
|
|
15494
|
+
//
|
|
15495
|
+
// What is never written: request or response bodies, headers (cookies and authorization among them), the
|
|
15496
|
+
// query string (it is where tokens end up), the client address, and the value of any declared secret (the
|
|
15497
|
+
// ones the message or stack happens to contain are replaced). Output is bounded: fields are cut to fixed
|
|
15498
|
+
// lengths and at most 200 lines a second go out; the rest are counted and reported by one "dropped" line
|
|
15499
|
+
// when the next second starts. A record's "id" ties a request line to the errors of the same request;
|
|
15500
|
+
// background work has id null.
|
|
15501
|
+
let __sbLogOn;
|
|
15502
|
+
let __sbLogSecretNames = [];
|
|
15503
|
+
let __sbLogSeq = 0;
|
|
15504
|
+
let __sbLogWindow = 0;
|
|
15505
|
+
let __sbLogCount = 0;
|
|
15506
|
+
let __sbLogDropped = 0;
|
|
15507
|
+
const __SB_LOG_PER_SECOND = 200;
|
|
15508
|
+
function __sbLogJson() {
|
|
15509
|
+
if (__sbLogOn === undefined) __sbLogOn = __sbEnv("SB_LOG") === "json";
|
|
15510
|
+
return __sbLogOn;
|
|
15511
|
+
}
|
|
15512
|
+
// Tests turn the records on without an environment, like __sbSetCompat.
|
|
15513
|
+
function __sbSetLog(on) {
|
|
15514
|
+
__sbLogOn = on;
|
|
15515
|
+
}
|
|
15516
|
+
function __sbLogCut(text, max) {
|
|
15517
|
+
const s = String(text);
|
|
15518
|
+
return s.length > max ? s.slice(0, max) + "..." : s;
|
|
15519
|
+
}
|
|
15520
|
+
function __sbLogRedact(text) {
|
|
15521
|
+
let out = text;
|
|
15522
|
+
for (let i = 0; i < __sbLogSecretNames.length; i++) {
|
|
15523
|
+
const raw = __sbEnv(__sbLogSecretNames[i]);
|
|
15524
|
+
if (!raw || raw.length < 4) continue;
|
|
15525
|
+
out = out.split(raw).join("[redacted]");
|
|
15526
|
+
const decoded = __sbFromUtf8(raw);
|
|
15527
|
+
if (decoded !== raw && decoded.length >= 4) out = out.split(decoded).join("[redacted]");
|
|
15528
|
+
}
|
|
15529
|
+
return out;
|
|
15530
|
+
}
|
|
15531
|
+
function __sbLogWrite(record) {
|
|
15532
|
+
const now = Date.now();
|
|
15533
|
+
const second = Math.floor(now / 1000);
|
|
15534
|
+
if (second !== __sbLogWindow) {
|
|
15535
|
+
if (__sbLogDropped > 0) {
|
|
15536
|
+
console.error(JSON.stringify({ type: "dropped", v: 1, timestamp: now, count: __sbLogDropped, reason: "rate" }));
|
|
15537
|
+
__sbLogDropped = 0;
|
|
15538
|
+
}
|
|
15539
|
+
__sbLogWindow = second;
|
|
15540
|
+
__sbLogCount = 0;
|
|
15541
|
+
}
|
|
15542
|
+
if (__sbLogCount >= __SB_LOG_PER_SECOND) {
|
|
15543
|
+
__sbLogDropped++;
|
|
15544
|
+
return;
|
|
15545
|
+
}
|
|
15546
|
+
__sbLogCount++;
|
|
15547
|
+
console.error(JSON.stringify(record));
|
|
15548
|
+
}
|
|
15549
|
+
function __sbLogBegin(request) {
|
|
15550
|
+
__sbLogSeq++;
|
|
15551
|
+
let path = "/";
|
|
15552
|
+
try {
|
|
15553
|
+
path = new URL(request.url).pathname;
|
|
15554
|
+
} catch {
|
|
15555
|
+
/* a request without a parseable URL is logged as "/" */
|
|
15556
|
+
}
|
|
15557
|
+
return { id: String(Math.floor(Date.now() / 1000)) + "-" + __sbLogSeq, t0: Date.now(), method: String(request.method), path: __sbLogCut(path, 256) };
|
|
15558
|
+
}
|
|
15559
|
+
function __sbLogRequest(rec, response, threw) {
|
|
15560
|
+
try {
|
|
15561
|
+
__sbLogWrite({
|
|
15562
|
+
type: "request",
|
|
15563
|
+
v: 1,
|
|
15564
|
+
id: rec.id,
|
|
15565
|
+
timestamp: rec.t0,
|
|
15566
|
+
method: __sbLogCut(rec.method, 16),
|
|
15567
|
+
path: rec.path,
|
|
15568
|
+
status: response && response.status ? response.status : 0,
|
|
15569
|
+
durationMs: Date.now() - rec.t0,
|
|
15570
|
+
outcome: threw === 1 ? "exception" : "ok",
|
|
15571
|
+
});
|
|
15572
|
+
} catch {
|
|
15573
|
+
/* logging must never turn a served request into a crash */
|
|
15574
|
+
}
|
|
15575
|
+
}
|
|
15576
|
+
function __sbLogError(where, error, rec) {
|
|
15577
|
+
try {
|
|
15578
|
+
const name = error && error.name ? String(error.name) : "Error";
|
|
15579
|
+
const message = error && error.message != null ? String(error.message) : String(error);
|
|
15580
|
+
const record = {
|
|
15581
|
+
type: "error",
|
|
15582
|
+
v: 1,
|
|
15583
|
+
id: rec ? rec.id : null,
|
|
15584
|
+
timestamp: Date.now(),
|
|
15585
|
+
source: __sbLogCut(where, 40),
|
|
15586
|
+
name: __sbLogCut(name, 100),
|
|
15587
|
+
message: __sbLogCut(__sbLogRedact(message), 1000),
|
|
15588
|
+
};
|
|
15589
|
+
if (error && error.stack) record.stack = __sbLogCut(__sbLogRedact(String(error.stack)), 2000);
|
|
15590
|
+
__sbLogWrite(record);
|
|
15591
|
+
} catch {
|
|
15592
|
+
/* logging must never turn a handled error into a crash */
|
|
15593
|
+
}
|
|
15594
|
+
}
|
|
15595
|
+
|
|
15453
15596
|
// A promise rejected and nothing was waiting on it (an un-awaited `db.prepare(...).run()`
|
|
15454
15597
|
// that failed, say). As on Workers the request, the process and every other request go on:
|
|
15455
15598
|
// the rejection is logged, and an "unhandledrejection" event fires on the global scope when
|
|
@@ -15785,6 +15928,8 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
15785
15928
|
// async handlers (see LogEvent in services/edge) — that is this.
|
|
15786
15929
|
const __cpuCell = __sbCpuBegin();
|
|
15787
15930
|
const __t0 = __cpuCell ? 0 : __sbCpuMs();
|
|
15931
|
+
// #268 — one record per request when SB_LOG=json; null (and no work at all) otherwise.
|
|
15932
|
+
const __logRec = __sbLogJson() ? __sbLogBegin(request) : null;
|
|
15788
15933
|
const __sbTasks = [];
|
|
15789
15934
|
const ctx = {
|
|
15790
15935
|
waitUntil(p) {
|
|
@@ -15807,19 +15952,24 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
15807
15952
|
// #179 — a synchronous throw anywhere in the handler must not take the
|
|
15808
15953
|
// whole process (and every other in-flight request) down with it.
|
|
15809
15954
|
__sbCpuEnd(__cpuCell);
|
|
15810
|
-
|
|
15955
|
+
const __failed = __sbErrorResponse(error, bindingCall, __logRec);
|
|
15956
|
+
if (__logRec) __sbLogRequest(__logRec, __failed, 1);
|
|
15957
|
+
return __failed;
|
|
15811
15958
|
}
|
|
15812
15959
|
// Same promise-identity rule as above applies to `__sbFetchWithDrain`'s
|
|
15813
15960
|
// 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);
|
|
15961
|
+
if (__sbTasks.length || (__res && __sbIsFn(__res.then)) || __sbHasStreamBody(__res)) return __sbFetchWithDrain(__res, __sbTasks, bindingCall, false, true, __cpuCell, __logRec);
|
|
15815
15962
|
// A WebSocket handshake answer (from 2026-10-30): its client end becomes the host socket.
|
|
15816
15963
|
if (__sbHasWebSocket(__res)) {
|
|
15817
15964
|
__sbCpuEnd(__cpuCell);
|
|
15818
|
-
|
|
15965
|
+
const __ws = __sbWsHostResponse(__res);
|
|
15966
|
+
if (__logRec) __sbLogRequest(__logRec, __ws, 0);
|
|
15967
|
+
return __ws;
|
|
15819
15968
|
}
|
|
15820
15969
|
// The request ran alone from start to here: its own slices, or the process delta where no slot was free.
|
|
15821
15970
|
const __cpu = __cpuCell ? __sbCpuTake(__cpuCell) : __sbCpuMs() - __t0;
|
|
15822
15971
|
__sbCpuEnd(__cpuCell);
|
|
15972
|
+
if (__logRec) __sbLogRequest(__logRec, __res, 0);
|
|
15823
15973
|
return __sbTagCpu(__res, __cpu);
|
|
15824
15974
|
}
|
|
15825
15975
|
if (!__sbTriggerAuthed(request)) return new Response("forbidden", { status: 403 });
|
|
@@ -2679,6 +2679,8 @@ static void sb_tcp_nodelay(int fd) {
|
|
|
2679
2679
|
// broken exchange closes N for good, so a late answer can never be taken for the
|
|
2680
2680
|
// next one.
|
|
2681
2681
|
static int sb_egress_fd_ask(int via, const char* host, int port, char* refused, size_t refused_len);
|
|
2682
|
+
// The ticket of the last service-binding connect on this thread ("" for any other connect).
|
|
2683
|
+
static __thread char sb_svc_ticket[72];
|
|
2682
2684
|
static int sb_egress_fd_connect(int via, const char* host, int port, char* refused, size_t refused_len) {
|
|
2683
2685
|
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
|
|
2684
2686
|
pthread_mutex_lock(&lock);
|
|
@@ -2714,7 +2716,16 @@ static int sb_egress_fd_ask(int via, const char* host, int port, char* refused,
|
|
|
2714
2716
|
return -1;
|
|
2715
2717
|
}
|
|
2716
2718
|
answer[got - 1] = 0;
|
|
2719
|
+
// "ok TICKET" (a service-binding call, host "sbsvc.HEX"): the host connected us to itself and the
|
|
2720
|
+
// ticket, which only it could mint, tells it who is calling. The request carries it as a header.
|
|
2721
|
+
sb_svc_ticket[0] = 0;
|
|
2722
|
+
if (!strncmp(answer, "ok ", 3) && fd >= 0 && strlen(answer + 3) > 0 && strlen(answer + 3) < sizeof(sb_svc_ticket)) {
|
|
2723
|
+
snprintf(sb_svc_ticket, sizeof(sb_svc_ticket), "%s", answer + 3);
|
|
2724
|
+
answer[2] = 0;
|
|
2725
|
+
}
|
|
2717
2726
|
if (!strcmp(answer, "ok") && fd >= 0) {
|
|
2727
|
+
// A service call is bounded per hop at 30 s, as the callee's own wall limit is.
|
|
2728
|
+
if (sb_svc_ticket[0]) tv.tv_sec = 30;
|
|
2718
2729
|
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
|
2719
2730
|
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
|
|
2720
2731
|
sb_tcp_nodelay(fd);
|
|
@@ -2885,11 +2896,22 @@ static char* sb_error_json(const char* prefix, const char* detail) {
|
|
|
2885
2896
|
static char* sb_http_plain(const char* host, int port, const char* path, const char* method,
|
|
2886
2897
|
const char* headers, const char* body, size_t body_len) {
|
|
2887
2898
|
char refused[512] = "";
|
|
2899
|
+
sb_svc_ticket[0] = 0;
|
|
2888
2900
|
int fd = sb_tcp_connect(host, port, refused, sizeof(refused));
|
|
2889
2901
|
if (fd < 0) return refused[0] ? sb_error_json(refused, 0) : sb_error_json("could not connect to ", host);
|
|
2890
2902
|
|
|
2903
|
+
// A service-binding call: the ticket the host minted for it goes first among the headers.
|
|
2904
|
+
sb_buf tagged = { 0, 0, 0 };
|
|
2905
|
+
if (sb_svc_ticket[0]) {
|
|
2906
|
+
sb_puts(&tagged, "x-sb-svc-ticket: ");
|
|
2907
|
+
sb_puts(&tagged, sb_svc_ticket);
|
|
2908
|
+
sb_puts(&tagged, "\r\n");
|
|
2909
|
+
if (headers && *headers) sb_puts(&tagged, headers);
|
|
2910
|
+
headers = tagged.p;
|
|
2911
|
+
}
|
|
2891
2912
|
sb_buf req = { 0, 0, 0 };
|
|
2892
2913
|
sb_build_request(&req, host, port, 80, path, method, headers, body, body_len);
|
|
2914
|
+
free(tagged.p);
|
|
2893
2915
|
size_t sent = 0;
|
|
2894
2916
|
while (sent < req.len) {
|
|
2895
2917
|
long n = write(fd, req.p + sent, req.len - sent);
|
|
@@ -5154,24 +5176,73 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
5154
5176
|
return { ok: true, found: false, status: 404, body: "Not Found" };
|
|
5155
5177
|
}
|
|
5156
5178
|
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
5179
|
+
// A service-binding call (compatibility date 2026-11-18): the host the app runs on routes it to the
|
|
5180
|
+
// callee. The call is an HTTP request to the pseudo-host "sbsvc.HEX" (the binding's name in hex): the
|
|
5181
|
+
// host sees the name on the egress socket the question came in on, finds the callee in its services
|
|
5182
|
+
// map and answers "ok TICKET" with a connection to its own listener. __sbHttpRaw sends the ticket as
|
|
5183
|
+
// a header (sb_http_plain) and the host's frontend, which alone mints tickets, delivers the request
|
|
5184
|
+
// to the callee marked as a service-binding call. See sproutd's docs/bindings.md, "Service bindings".
|
|
5185
|
+
if (op === "fetch" || op === "service.fetch") {
|
|
5186
|
+
const service = op === "service.fetch";
|
|
5187
|
+
let hostname;
|
|
5188
|
+
let port;
|
|
5189
|
+
let pathname;
|
|
5190
|
+
let tls = false;
|
|
5161
5191
|
let headerText = "";
|
|
5192
|
+
if (service) {
|
|
5193
|
+
if (__sbCompatDate() < "2026-11-18") {
|
|
5194
|
+
throw new Error(
|
|
5195
|
+
"service binding " + msg.binding + " needs compatibility_date 2026-11-18 or later to reach another app on this host",
|
|
5196
|
+
);
|
|
5197
|
+
}
|
|
5198
|
+
if (!__sbEnv("SB_EGRESS_FD")) {
|
|
5199
|
+
throw new Error(
|
|
5200
|
+
"service binding " + msg.binding + " has no app to reach here: a standalone binary has no peer apps, only a platform host (sproutd) has",
|
|
5201
|
+
);
|
|
5202
|
+
}
|
|
5203
|
+
const binding = String(msg.binding);
|
|
5204
|
+
let hex = "";
|
|
5205
|
+
for (let i = 0; i < binding.length; i++) {
|
|
5206
|
+
const c = binding.charCodeAt(i);
|
|
5207
|
+
if (c > 127) throw new Error("service binding names are ASCII: " + binding);
|
|
5208
|
+
hex += (c < 16 ? "0" : "") + c.toString(16);
|
|
5209
|
+
}
|
|
5210
|
+
if (hex.length > 200) throw new Error("service binding name too long: " + binding);
|
|
5211
|
+
hostname = "sbsvc." + hex;
|
|
5212
|
+
port = "80";
|
|
5213
|
+
// The callee sees the URL its caller passed (x-sb-url, vouched for by the host); the request line
|
|
5214
|
+
// carries only its path.
|
|
5215
|
+
try {
|
|
5216
|
+
const parsed = new URL(String(msg.url));
|
|
5217
|
+
pathname = parsed.pathname + parsed.search;
|
|
5218
|
+
headerText += "x-sb-url: " + parsed.href + "\r\n";
|
|
5219
|
+
} catch (e) {
|
|
5220
|
+
const p = String(msg.url || "/");
|
|
5221
|
+
pathname = p.charAt(0) === "/" ? p : "/" + p;
|
|
5222
|
+
}
|
|
5223
|
+
} else {
|
|
5224
|
+
const url = new URL(String(msg.url));
|
|
5225
|
+
tls = url.protocol === "https:";
|
|
5226
|
+
if (!tls && url.protocol !== "http:") throw new Error("unsupported protocol: " + url.protocol);
|
|
5227
|
+
hostname = url.hostname;
|
|
5228
|
+
port = url.port ? url.port : tls ? "443" : "80";
|
|
5229
|
+
pathname = url.pathname + url.search;
|
|
5230
|
+
}
|
|
5162
5231
|
const pairs = msg.headers || [];
|
|
5163
5232
|
for (let i = 0; i < pairs.length; i++) {
|
|
5164
5233
|
const key = String(pairs[i][0]).toLowerCase();
|
|
5165
5234
|
// Host, Connection and Content-Length are ours to set.
|
|
5166
5235
|
if (key === "host" || key === "connection" || key === "content-length") continue;
|
|
5236
|
+
// The host's markers are its own: the caller's x-sb-* headers do not travel. Only an RPC call (the prelude's
|
|
5237
|
+
// __sbRpcCall, msg.rpc) sends x-sb-rpc, which names the method.
|
|
5238
|
+
if (service && key.slice(0, 5) === "x-sb-" && !(msg.rpc === true && key === "x-sb-rpc")) continue;
|
|
5167
5239
|
headerText += pairs[i][0] + ": " + pairs[i][1] + "\r\n";
|
|
5168
5240
|
}
|
|
5169
|
-
const port = url.port ? url.port : tls ? "443" : "80";
|
|
5170
5241
|
const asyncId = msg.asyncId > 0 ? msg.asyncId : 0;
|
|
5171
5242
|
const raw = __sbHttpRaw(
|
|
5172
|
-
|
|
5243
|
+
hostname,
|
|
5173
5244
|
port,
|
|
5174
|
-
|
|
5245
|
+
pathname,
|
|
5175
5246
|
String(msg.method || "GET").toUpperCase(),
|
|
5176
5247
|
headerText,
|
|
5177
5248
|
msg.body == null ? "" : String(msg.body),
|
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], {
|