@sproutboat/runtime 0.32.0 → 0.33.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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.33.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 90434e0: WebSockets, from compatibility date `2026-10-30`, as in workerd:
8
+
9
+ - **`WebSocketPair` and `WebSocket`.** `accept()`, `send()` (text, `ArrayBuffer` or a typed array), `close(code, reason)`, `readyState`, `binaryType` (binary messages are `Blob`s unless it is `"arraybuffer"`, workerd's standard binary type), `addEventListener` and `on<type>`. Messages sent before the other end accepts wait for it; each message is delivered in its own turn, so a reply reaches the other end before the next message, as in workerd. A close is answered automatically; invalid close codes throw `InvalidAccessError`, reasons over 123 bytes `SyntaxError`. Checked against workerd 1.20260730 (`test/native-websockets.ts`).
10
+ - **`new Response(null, { status: 101, webSocket })`** and `response.webSocket` (`null` when there is none). Any other status with a `webSocket` throws `RangeError`, as in workerd.
11
+ - **Durable Objects: the hibernation API.** `ctx.acceptWebSocket(ws, tags)`, `getWebSockets(tag)`, `getTags(ws)`, `setWebSocketAutoResponse` / `getWebSocketAutoResponse` with `WebSocketRequestResponsePair`, `serializeAttachment` / `deserializeAttachment`, and the `webSocketMessage` / `webSocketClose` handlers, run through the object's input gate.
12
+ - **Client sockets.** The client end of a 101 response becomes a real socket on a host that carries it (sproutd, which sets the runtime's `sb_ws_hook`). A standalone or platform sprout answers such a response with `501` for now; a pair whose ends both stay in the worker (a Worker and its Durable Object) works everywhere.
13
+
14
+ Before `2026-10-30` nothing is defined and `response.webSocket` stays `undefined`.
15
+
3
16
  ## 0.32.0
4
17
 
5
18
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -4295,6 +4295,9 @@ int porf_native_fetch_read_value(jsval value, const char** out_buf, size_t* out_
4295
4295
  int porf_native_fetch_read_raw_bytes(jsval value, const char** out_buf, size_t* out_len);
4296
4296
  // B22: set only by sproutd's worker host (native-host-bridge.c, at init); null everywhere else.
4297
4297
  void (*sb_host_note_hook)(const char* text, size_t length) = 0;
4298
+ // WebSockets (from 2026-10-30): set only by a host that carries client sockets (sproutd's
4299
+ // native-host-bridge.c, at init); null everywhere else. See __sbWsOp for the ops.
4300
+ long long (*sb_ws_hook)(int op, unsigned int id, const char* data, size_t length, char* out, size_t cap) = 0;
4298
4301
 
4299
4302
  // --- #133: SHA-2 + HMAC ---------------------------------------------------
4300
4303
  // Standalone links BearSSL, but a deployed sprout does not, and this inline C
@@ -6671,6 +6674,7 @@ function __sbUpstream(op, msg, label, signal, always) {
6671
6674
  // prelude must not replace fetch just by being evaluated.
6672
6675
  globalThis.__sbInstallFetch = function () {
6673
6676
  __sbIngressStreamingSetup();
6677
+ __sbInstallWebSockets();
6674
6678
  globalThis.fetch = function (input, init) {
6675
6679
  let parts;
6676
6680
  try {
@@ -7032,7 +7036,11 @@ function __sbGetDOInstance(cls, idText, idObj) {
7032
7036
  if (p && __sbIsFn(p.then)) __sbTasks.push(p);
7033
7037
  },
7034
7038
  };
7039
+ // The hibernation API, from 2026-10-30 (where WebSocketPair exists).
7040
+ const holder = { inst: null, auto: null };
7041
+ if (__sbWsEnabled()) __sbDOWebSocketState(state, holder);
7035
7042
  inst = new Ctor(state, globalThis.env);
7043
+ holder.inst = inst;
7036
7044
  inst.__sbTasks = __sbTasks;
7037
7045
  inst.__sbGate = gate;
7038
7046
  __sbDOInstances[cacheKey] = inst;
@@ -8014,10 +8022,414 @@ function __sbStreamResponse(response) {
8014
8022
  return response;
8015
8023
  }
8016
8024
 
8017
- async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow) {
8025
+ // ---------------------------------------------------------------------------
8026
+ // WebSockets, from compatibility date 2026-10-30, as in workerd: WebSocketPair, accept(),
8027
+ // send(), close(), message and close events, new Response(null, { status: 101, webSocket })
8028
+ // and, for Durable Objects, the hibernation API (__sbDOWebSocketState).
8029
+ //
8030
+ // A pair whose ends both stay in this worker (a Worker and its Durable Object) is plain
8031
+ // JavaScript, so it works on every host. The client end of a 101 response becomes a real
8032
+ // socket only on a host that carries it (sproutd: its bridge sets sb_ws_hook); the host
8033
+ // then stands in for that end: what the app sends to it goes out, and what the remote
8034
+ // client sends arrives as if that end had sent it.
8035
+ //
8036
+ // __sbWsOp(op, id, data, n), on the host socket `id`:
8037
+ // "o" open one (returns its id, "0" without a host), "t" send text data, "b" send the
8038
+ // bytes in data, "c" close with code n and reason data, "p" poll the next event ("" for
8039
+ // none, else "KIND LENGTH CODE": 0 text, 1 binary, 2 close, 3 connection lost), "i" take
8040
+ // that event's payload into the Uint8Array data, "k" acknowledge n bytes dispatched,
8041
+ // "w" fire timer n when an event arrives, "r" release. Returns a short string.
8042
+ // oxlint-disable-next-line no-unused-vars -- read inside the RawC block, not by JS.
8043
+ function __sbWsOp(op, id, data, n) {
8044
+ let out = "";
8045
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
8046
+ Porffor.c`
8047
+ const char* __o; size_t __ol; char* __oo = 0;
8048
+ porf_native_fetch_read_value(op, &__o, &__ol, &__oo);
8049
+ char __op = __ol ? __o[0] : 0;
8050
+ if (__oo) free(__oo);
8051
+ const unsigned int __id = id.type == 1 && id.val > 0 ? (unsigned int)id.val : 0;
8052
+ const size_t __n = n.type == 1 && n.val > 0 ? (size_t)n.val : 0;
8053
+ char __r[64]; __r[0] = 0;
8054
+ if (sb_ws_hook) {
8055
+ long long __w = -1;
8056
+ if (__op == 'p') {
8057
+ sb_ws_hook('p', __id, 0, 0, __r, sizeof(__r));
8058
+ } else if (__op == 'i') {
8059
+ char* __at = 0; size_t __cap = 0;
8060
+ if (data.type == 81) {
8061
+ const u32 __ta = (u32)data.val;
8062
+ const int32_t __len = *(int32_t*)(MEM + __ta);
8063
+ if (__len > 0) { __at = (char*)(MEM + *(u32*)(MEM + __ta + 4) + 4); __cap = (size_t)__len; }
8064
+ }
8065
+ __w = sb_ws_hook('i', __id, 0, 0, __at, __cap);
8066
+ snprintf(__r, sizeof(__r), "%lld", __w);
8067
+ } else if (__op == 'b') {
8068
+ const char* __d = 0; size_t __dl = 0;
8069
+ if (porf_native_fetch_read_raw_bytes(data, &__d, &__dl) == 0) __w = sb_ws_hook('b', __id, __d, __dl, 0, 0);
8070
+ snprintf(__r, sizeof(__r), "%lld", __w);
8071
+ } else if (__op == 't' || __op == 'c') {
8072
+ const char* __d; size_t __dl; char* __do = 0;
8073
+ porf_native_fetch_read_value(data, &__d, &__dl, &__do);
8074
+ __w = sb_ws_hook(__op, __id, __d, __dl, 0, __n);
8075
+ if (__do) free(__do);
8076
+ snprintf(__r, sizeof(__r), "%lld", __w);
8077
+ } else {
8078
+ __w = sb_ws_hook(__op, __id, 0, 0, 0, __n);
8079
+ snprintf(__r, sizeof(__r), "%lld", __w);
8080
+ }
8081
+ } else if (__op == 'o') snprintf(__r, sizeof(__r), "0");
8082
+ out = porf_box((f64)porf_native_fetch_alloc_bytestring(__r, strlen(__r)), 195);
8083
+ `;
8084
+ return out;
8085
+ }
8086
+
8087
+ const __SB_WS_OPEN = 1;
8088
+ const __SB_WS_CLOSING = 2;
8089
+ const __SB_WS_CLOSED = 3;
8090
+ const __sbWsKey = {};
8091
+
8092
+ // One end of a WebSocketPair. Events are plain objects with the fields apps read (type,
8093
+ // data; code, reason, wasClean), dispatched to on<type> (when assigned) and then to the
8094
+ // addEventListener listeners, as workerd does. Binary messages arrive as a Blob unless
8095
+ // binaryType is "arraybuffer" (workerd's standard binary type).
8096
+ class __SbWebSocket {
8097
+ constructor(key) {
8098
+ // Outbound WebSockets (new WebSocket(url)) are not supported; the CLI refuses them too.
8099
+ if (key !== __sbWsKey) throw new TypeError("WebSocket: only WebSocketPair can make a WebSocket here");
8100
+ this._peer = null;
8101
+ this._accepted = false;
8102
+ this._inbox = [];
8103
+ this._flushing = false;
8104
+ this._listeners = {};
8105
+ this._state = __SB_WS_OPEN;
8106
+ this._closeSent = false;
8107
+ this._closeSeen = false;
8108
+ // The host socket this end stands for (the client end of a 101 response), or 0.
8109
+ this._host = 0;
8110
+ // { holder, tags } once a Durable Object took it with ctx.acceptWebSocket().
8111
+ this._hib = null;
8112
+ this._attachment = undefined;
8113
+ this.binaryType = "blob";
8114
+ this.url = null;
8115
+ this.protocol = "";
8116
+ this.extensions = "";
8117
+ }
8118
+ get readyState() {
8119
+ return this._state;
8120
+ }
8121
+ get bufferedAmount() {
8122
+ return 0;
8123
+ }
8124
+ accept() {
8125
+ if (this._host !== 0) throw new TypeError("Can't accept() a WebSocket that was already used in a response.");
8126
+ if (this._hib !== null) throw new TypeError("Can't accept() a WebSocket that was accepted with acceptWebSocket().");
8127
+ if (this._accepted) return;
8128
+ this._accepted = true;
8129
+ __sbWsSchedule(this);
8130
+ }
8131
+ send(message) {
8132
+ if (!this._accepted && this._hib === null)
8133
+ throw new TypeError("You must call one of accept() or state.acceptWebSocket() on this WebSocket before sending messages.");
8134
+ if (this._closeSent) throw new TypeError("Can't call WebSocket send() after close().");
8135
+ if (this._state === __SB_WS_CLOSED || this._peer === null) return;
8136
+ let event;
8137
+ if (__sbIsStr(message)) event = { kind: 0, data: message };
8138
+ else if (message instanceof ArrayBuffer) event = { kind: 1, data: message.slice(0) };
8139
+ else if (ArrayBuffer.isView(message))
8140
+ event = { kind: 1, data: new Uint8Array(message.buffer, message.byteOffset, message.byteLength).slice().buffer };
8141
+ else event = { kind: 0, data: String(message) };
8142
+ __sbWsDeliver(this._peer, event);
8143
+ }
8144
+ close(code, reason) {
8145
+ if (code !== undefined) {
8146
+ code = Number(code);
8147
+ if (!(code === 1000 || (code >= 3000 && code <= 4999))) throw __sbCryptoError("InvalidAccessError", "Invalid WebSocket close code: " + code + ".");
8148
+ }
8149
+ const text = reason === undefined ? "" : String(reason);
8150
+ if (__sbToBytes(text).length > 123)
8151
+ throw __sbCryptoError("SyntaxError", "WebSocket close reason must not be longer than 123 bytes when UTF-8 encoded.");
8152
+ if (this._closeSent) return;
8153
+ this._closeSent = true;
8154
+ this._state = this._closeSeen ? __SB_WS_CLOSED : __SB_WS_CLOSING;
8155
+ if (this._peer !== null) __sbWsDeliver(this._peer, { kind: 2, code: code === undefined ? 1005 : code, reason: text, clean: true });
8156
+ }
8157
+ addEventListener(type, listener) {
8158
+ if (listener == null) return;
8159
+ const list = this._listeners[type] || (this._listeners[type] = []);
8160
+ for (let i = 0; i < list.length; i++) if (list[i] === listener) return;
8161
+ list.push(listener);
8162
+ }
8163
+ removeEventListener(type, listener) {
8164
+ const list = this._listeners[type];
8165
+ if (!list) return;
8166
+ for (let i = 0; i < list.length; i++) {
8167
+ if (list[i] === listener) {
8168
+ list.splice(i, 1);
8169
+ return;
8170
+ }
8171
+ }
8172
+ }
8173
+ dispatchEvent(event) {
8174
+ __sbWsFire(this, event);
8175
+ return true;
8176
+ }
8177
+ serializeAttachment(value) {
8178
+ this._attachment = value === undefined ? undefined : structuredClone(value);
8179
+ }
8180
+ deserializeAttachment() {
8181
+ return this._attachment === undefined ? null : structuredClone(this._attachment);
8182
+ }
8183
+ }
8184
+ __SbWebSocket.CONNECTING = 0;
8185
+ __SbWebSocket.OPEN = __SB_WS_OPEN;
8186
+ __SbWebSocket.CLOSING = __SB_WS_CLOSING;
8187
+ __SbWebSocket.CLOSED = __SB_WS_CLOSED;
8188
+ __SbWebSocket.READY_STATE_CONNECTING = 0;
8189
+ __SbWebSocket.READY_STATE_OPEN = __SB_WS_OPEN;
8190
+ __SbWebSocket.READY_STATE_CLOSING = __SB_WS_CLOSING;
8191
+ __SbWebSocket.READY_STATE_CLOSED = __SB_WS_CLOSED;
8192
+
8193
+ class __SbWebSocketPair {
8194
+ constructor() {
8195
+ const client = new __SbWebSocket(__sbWsKey);
8196
+ const server = new __SbWebSocket(__sbWsKey);
8197
+ client._peer = server;
8198
+ server._peer = client;
8199
+ this[0] = client;
8200
+ this[1] = server;
8201
+ }
8202
+ }
8203
+
8204
+ class __SbWebSocketRequestResponsePair {
8205
+ constructor(request, response) {
8206
+ if (!__sbIsStr(request) || !__sbIsStr(response)) throw new TypeError("WebSocketRequestResponsePair needs two strings");
8207
+ this.request = request;
8208
+ this.response = response;
8209
+ }
8210
+ }
8211
+
8212
+ function __sbWsFire(ws, event) {
8213
+ const handler = ws["on" + event.type];
8214
+ if (__sbIsFn(handler)) {
8215
+ try {
8216
+ handler.call(ws, event);
8217
+ } catch (error) {
8218
+ __sbLogUncaught("websocket", error);
8219
+ }
8220
+ }
8221
+ const list = ws._listeners[event.type];
8222
+ if (!list) return;
8223
+ const copy = list.slice();
8224
+ for (let i = 0; i < copy.length; i++) {
8225
+ const fn = copy[i];
8226
+ try {
8227
+ if (__sbIsFn(fn)) fn.call(ws, event);
8228
+ else if (fn && __sbIsFn(fn.handleEvent)) fn.handleEvent(event);
8229
+ } catch (error) {
8230
+ __sbLogUncaught("websocket", error);
8231
+ }
8232
+ }
8233
+ }
8234
+
8235
+ // An event arrives at `ws`, sent by its peer: to the host when `ws` is a host socket,
8236
+ // else into ws's inbox, dispatched once it is accepted.
8237
+ function __sbWsDeliver(ws, event) {
8238
+ if (ws._host !== 0) {
8239
+ const id = ws._host;
8240
+ if (event.kind === 0) __sbWsOp("t", id, event.data, 0);
8241
+ else if (event.kind === 1) __sbWsOp("b", id, __sbToBytes(new Uint8Array(event.data)), 0);
8242
+ else if (!ws._closeSeen) {
8243
+ ws._closeSeen = true;
8244
+ __sbWsOp("c", id, event.reason, event.code);
8245
+ }
8246
+ return;
8247
+ }
8248
+ ws._inbox.push(event);
8249
+ __sbWsSchedule(ws);
8250
+ }
8251
+
8252
+ // One event per turn, as workerd's receive loop does: a reply the handler sends reaches the
8253
+ // other end before this end's next message.
8254
+ function __sbWsSchedule(ws) {
8255
+ if (ws._flushing || !ws._inbox.length || (!ws._accepted && ws._hib === null)) return;
8256
+ ws._flushing = true;
8257
+ Promise.resolve().then(() => {
8258
+ ws._flushing = false;
8259
+ if (ws._inbox.length && (ws._accepted || ws._hib !== null)) __sbWsDispatch(ws, ws._inbox.shift());
8260
+ __sbWsSchedule(ws);
8261
+ });
8262
+ }
8263
+
8264
+ function __sbWsFlush(ws) {
8265
+ while (ws._inbox.length && (ws._accepted || ws._hib !== null)) __sbWsDispatch(ws, ws._inbox.shift());
8266
+ }
8267
+
8268
+ // Dispatches one event that arrived at ws: to its listeners, or to its Durable Object's
8269
+ // webSocketMessage / webSocketClose. A close is answered with a close (workerd replies to
8270
+ // a close automatically), and ends the socket.
8271
+ function __sbWsDispatch(ws, event) {
8272
+ if (event.kind === 2) {
8273
+ ws._closeSeen = true;
8274
+ if (!ws._closeSent) {
8275
+ ws._closeSent = true;
8276
+ if (ws._peer !== null) __sbWsDeliver(ws._peer, { kind: 2, code: event.code, reason: event.reason, clean: event.clean });
8277
+ }
8278
+ ws._state = __SB_WS_CLOSED;
8279
+ if (ws._hib !== null) return __sbWsHibernated(ws, "webSocketClose", [ws, event.code, event.reason, event.clean]);
8280
+ return __sbWsFire(ws, { type: "close", code: event.code, reason: event.reason, wasClean: event.clean, target: ws });
8281
+ }
8282
+ if (ws._hib !== null) {
8283
+ const auto = ws._hib.holder.auto;
8284
+ if (auto !== null && event.kind === 0 && event.data === auto.request) {
8285
+ ws.send(auto.response);
8286
+ return;
8287
+ }
8288
+ return __sbWsHibernated(ws, "webSocketMessage", [ws, event.data]);
8289
+ }
8290
+ const data = event.kind === 1 && ws.binaryType !== "arraybuffer" ? new Blob([event.data]) : event.data;
8291
+ __sbWsFire(ws, { type: "message", data, target: ws });
8292
+ }
8293
+
8294
+ // A hibernatable socket's event runs the object's handler through its input gate.
8295
+ function __sbWsHibernated(ws, name, args) {
8296
+ const inst = ws._hib.holder.inst;
8297
+ if (inst == null || !__sbIsFn(inst[name])) return;
8298
+ const run = () => {
8299
+ if (name === "webSocketClose") return inst.webSocketClose(args[0], args[1], args[2], args[3]);
8300
+ return inst.webSocketMessage(args[0], args[1]);
8301
+ };
8302
+ const done = (p) => {
8303
+ if (p && __sbIsFn(p.then)) p.then(undefined, (error) => __sbLogUncaught("websocket", error));
8304
+ };
8305
+ try {
8306
+ done(__sbDOGated() ? __sbDOEnter(inst.__sbGate, run) : run());
8307
+ } catch (error) {
8308
+ __sbLogUncaught("websocket", error);
8309
+ }
8310
+ }
8311
+
8312
+ // ctx.acceptWebSocket() and the rest of the hibernation API, added to a Durable Object's
8313
+ // state from 2026-10-30. `holder.inst` is the object once it exists.
8314
+ function __sbDOWebSocketState(state, holder) {
8315
+ const sockets = [];
8316
+ const live = () => {
8317
+ for (let i = sockets.length - 1; i >= 0; i--) if (sockets[i]._state === __SB_WS_CLOSED) sockets.splice(i, 1);
8318
+ return sockets;
8319
+ };
8320
+ state.acceptWebSocket = function (ws, tags) {
8321
+ if (!(ws instanceof __SbWebSocket)) throw new TypeError("acceptWebSocket needs a WebSocket");
8322
+ if (ws._accepted || ws._hib !== null || ws._host !== 0) throw new TypeError("This WebSocket was already accepted.");
8323
+ const list = [];
8324
+ if (tags != null) for (const t of tags) list.push(String(t));
8325
+ if (list.length > 10) throw new TypeError("A WebSocket can have at most 10 tags.");
8326
+ ws._hib = { holder, tags: list };
8327
+ live().push(ws);
8328
+ __sbWsSchedule(ws);
8329
+ };
8330
+ state.getWebSockets = function (tag) {
8331
+ const out = [];
8332
+ const all = live();
8333
+ for (let i = 0; i < all.length; i++) if (tag === undefined || all[i]._hib.tags.indexOf(String(tag)) >= 0) out.push(all[i]);
8334
+ return out;
8335
+ };
8336
+ state.getTags = function (ws) {
8337
+ if (!(ws instanceof __SbWebSocket) || ws._hib === null) throw new TypeError("getTags: not a WebSocket accepted by this object");
8338
+ return ws._hib.tags.slice();
8339
+ };
8340
+ state.setWebSocketAutoResponse = function (pair) {
8341
+ if (pair != null && !(pair instanceof __SbWebSocketRequestResponsePair))
8342
+ throw new TypeError("setWebSocketAutoResponse needs a WebSocketRequestResponsePair");
8343
+ holder.auto = pair == null ? null : pair;
8344
+ };
8345
+ state.getWebSocketAutoResponse = function () {
8346
+ return holder.auto;
8347
+ };
8348
+ state.getWebSocketAutoResponseTimestamp = function () {
8349
+ return null;
8350
+ };
8351
+ state.setHibernatableWebSocketEventTimeout = function () {};
8352
+ state.getHibernatableWebSocketEventTimeout = function () {
8353
+ return null;
8354
+ };
8355
+ }
8356
+
8357
+ function __sbWsEnabled() {
8358
+ return globalThis.WebSocketPair === __SbWebSocketPair;
8359
+ }
8360
+ function __sbHasWebSocket(r) {
8361
+ return r != null && r._webSocket != null && __sbWsEnabled();
8362
+ }
8363
+
8364
+ // The client end of a 101 response becomes a host socket: marked x-sb-websocket for the
8365
+ // host (which upgrades the connection), with what the app already sent going out after it.
8366
+ function __sbWsHostResponse(r) {
8367
+ const ws = r._webSocket;
8368
+ if (!(ws instanceof __SbWebSocket) || ws._host !== 0 || ws._accepted || ws._hib !== null) {
8369
+ return __sbErrorResponse(new TypeError("A 101 response needs the unaccepted client end of a WebSocketPair."), false);
8370
+ }
8371
+ const id = Number(__sbWsOp("o", 0, "", 0));
8372
+ if (!(id > 0)) return new Response("WebSockets are not supported by this host yet", { status: 501 });
8373
+ ws._host = id;
8374
+ r.headers._immutable = false;
8375
+ r.headers.set("x-sb-websocket", String(id));
8376
+ const held = ws._inbox;
8377
+ ws._inbox = [];
8378
+ for (let i = 0; i < held.length; i++) __sbWsDeliver(ws, held[i]);
8379
+ __sbWsPump(ws);
8380
+ return r;
8381
+ }
8382
+
8383
+ // Takes what the host received on ws's socket and dispatches it to the app's end, then
8384
+ // waits (a parked timer the host fires) for more.
8385
+ function __sbWsPump(ws) {
8386
+ const id = ws._host;
8387
+ for (;;) {
8388
+ const head = __sbWsOp("p", id, "", 0);
8389
+ if (head === "") {
8390
+ const timer = setTimeout(() => __sbWsPump(ws), 2147483647);
8391
+ __sbWsOp("w", id, "", timer);
8392
+ return;
8393
+ }
8394
+ const parts = head.split(" ");
8395
+ const kind = Number(parts[0]);
8396
+ const length = Number(parts[1]);
8397
+ const bytes = new Uint8Array(length);
8398
+ __sbWsOp("i", id, bytes, 0);
8399
+ let event;
8400
+ if (kind === 1) event = { kind: 1, data: bytes.buffer };
8401
+ else if (kind === 0 || kind === 2) {
8402
+ let text = length > 0 ? __sbFromUtf8Native(bytes) : "";
8403
+ if (text === "" && length > 0) text = new TextDecoder().decode(bytes);
8404
+ event = kind === 0 ? { kind: 0, data: text } : { kind: 2, code: Number(parts[2]), reason: text, clean: true };
8405
+ } else event = { kind: 2, code: 1006, reason: "WebSocket disconnected without sending Close frame.", clean: false };
8406
+ if (event.kind === 2) ws._closeSeen = true;
8407
+ const peer = ws._peer;
8408
+ if (peer !== null) {
8409
+ peer._inbox.push(event);
8410
+ // Dispatched now, so the acknowledgement below means the handler has run.
8411
+ if (!peer._flushing) __sbWsFlush(peer);
8412
+ }
8413
+ __sbWsOp("k", id, "", length);
8414
+ if (event.kind === 2) {
8415
+ __sbWsOp("r", id, "", 0);
8416
+ return;
8417
+ }
8418
+ }
8419
+ }
8420
+
8421
+ function __sbInstallWebSockets() {
8422
+ if (globalThis.__sbCompat < "2026-10-30") return;
8423
+ globalThis.WebSocketPair = __SbWebSocketPair;
8424
+ globalThis.WebSocket = __SbWebSocket;
8425
+ globalThis.WebSocketRequestResponsePair = __SbWebSocketRequestResponsePair;
8426
+ }
8427
+
8428
+ async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top) {
8018
8429
  let r;
8019
8430
  try {
8020
8431
  r = res && __sbIsFn(res.then) ? await res : res;
8432
+ if (top && __sbHasWebSocket(r)) r = __sbWsHostResponse(r);
8021
8433
  if (__sbHasStreamBody(r)) {
8022
8434
  if (__sbStreamingSupported()) __sbStreamResponse(r);
8023
8435
  else r.body = await __sbCollectBody(__sbRawBody(r));
@@ -8314,7 +8726,9 @@ globalThis.__sbEntry = function (handlers, request) {
8314
8726
  }
8315
8727
  // Same promise-identity rule as above applies to `__sbFetchWithDrain`'s
8316
8728
  // own return, which is why it's tail-called rather than chained on here.
8317
- if (__sbTasks.length || (__res && __sbIsFn(__res.then)) || __sbHasStreamBody(__res)) return __sbFetchWithDrain(__res, __sbTasks, bindingCall);
8729
+ if (__sbTasks.length || (__res && __sbIsFn(__res.then)) || __sbHasStreamBody(__res)) return __sbFetchWithDrain(__res, __sbTasks, bindingCall, false, true);
8730
+ // A WebSocket handshake answer (from 2026-10-30): its client end becomes the host socket.
8731
+ if (__sbHasWebSocket(__res)) return __sbWsHostResponse(__res);
8318
8732
  return __sbTagCpu(__res, __t0);
8319
8733
  }
8320
8734
  if (!__sbTriggerAuthed(request)) return new Response("forbidden", { status: 403 });
@@ -0,0 +1,178 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { ensurePorffor } from "../../toolchain/src/acquire";
6
+ import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
7
+
8
+ // WebSockets from compatibility date 2026-10-30: a WebSocketPair whose ends both stay in
9
+ // the worker is plain JavaScript and works on every host; a 101 response needs a host that
10
+ // carries client sockets (sproutd), so a standalone sprout answers it with a 501. Before the
11
+ // date nothing is defined. The answers below are workerd's (1.20260730.1) for the same code.
12
+ const handler = `export default {async fetch(request) {
13
+ const path = new URL(request.url).pathname;
14
+ const tick = () => new Promise((r) => setTimeout(r, 10));
15
+ if (path === "/shape") return Response.json([typeof WebSocketPair, typeof WebSocket, typeof WebSocketRequestResponsePair, new Response("x").webSocket]);
16
+ if (path === "/pair") {
17
+ const [client, server] = Object.values(new WebSocketPair());
18
+ const log = [];
19
+ server.accept();
20
+ client.accept();
21
+ server.binaryType = "arraybuffer";
22
+ client.binaryType = "arraybuffer";
23
+ server.addEventListener("message", (e) => {
24
+ log.push("s:" + (typeof e.data === "string" ? e.data : "bytes " + new Uint8Array(e.data).join(",")));
25
+ server.send(typeof e.data === "string" ? "echo:" + e.data : e.data);
26
+ });
27
+ client.addEventListener("message", (e) => log.push("c:" + (typeof e.data === "string" ? e.data : "bytes " + new Uint8Array(e.data).join(","))));
28
+ client.addEventListener("close", (e) => log.push("client close " + e.code + " " + e.reason + " " + e.wasClean));
29
+ server.addEventListener("close", (e) => log.push("server close " + e.code + " " + e.reason + " " + e.wasClean));
30
+ client.send("hi");
31
+ client.send(new Uint8Array([1, 2, 3]));
32
+ await tick();
33
+ let badCode = "";
34
+ try { server.close(999); } catch (e) { badCode = e.name; }
35
+ client.close(4001, "bye");
36
+ await tick();
37
+ let after = "";
38
+ try { client.send("x"); } catch (e) { after = e.message; }
39
+ return Response.json({ log, badCode, after, states: [client.readyState, server.readyState] });
40
+ }
41
+ if (path === "/blob") {
42
+ // Binary messages are Blobs unless binaryType says otherwise.
43
+ const [client, server] = Object.values(new WebSocketPair());
44
+ server.accept();
45
+ client.accept();
46
+ const got = [server.binaryType];
47
+ server.addEventListener("message", async (e) => got.push(e.data instanceof Blob, Array.from(new Uint8Array(await e.data.arrayBuffer()))));
48
+ client.send(new Uint8Array([7, 8]));
49
+ await tick();
50
+ let reason = "";
51
+ try { server.close(1000, "x".repeat(124)); } catch (e) { reason = e.name; }
52
+ return Response.json([...got, reason]);
53
+ }
54
+ if (path === "/queued") {
55
+ // Messages sent before the other end accepts wait for it.
56
+ const pair = new WebSocketPair();
57
+ const server = pair[1];
58
+ const client = pair[0];
59
+ client.accept();
60
+ client.send("early");
61
+ await tick();
62
+ const got = [];
63
+ server.addEventListener("message", (e) => got.push(e.data));
64
+ server.accept();
65
+ await tick();
66
+ return Response.json(got);
67
+ }
68
+ if (path === "/unaccepted") {
69
+ const pair = new WebSocketPair();
70
+ try { pair[0].send("x"); return new Response("sent"); } catch (e) { return new Response(e.name + ": " + e.message); }
71
+ }
72
+ if (path === "/upgrade") {
73
+ const [client, server] = Object.values(new WebSocketPair());
74
+ server.accept();
75
+ return new Response(null, { status: 101, webSocket: client });
76
+ }
77
+ if (path === "/bad101") {
78
+ try { new Response(null, { status: 101 }); return new Response("made"); } catch (e) { return new Response(e.name); }
79
+ }
80
+ return new Response("no route " + path, { status: 404 });
81
+ }};`;
82
+
83
+ function freePort(): number {
84
+ const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
85
+ const port = listener.port;
86
+ listener.stop(true);
87
+ return port;
88
+ }
89
+
90
+ const workdir = await mkdtemp(join(tmpdir(), "sb-native-websockets-"));
91
+ const servers: ReturnType<typeof Bun.spawn>[] = [];
92
+ try {
93
+ const porffor = await ensurePorffor();
94
+ const node = Bun.which("node");
95
+ if (!node) throw new Error("native WebSocket test needs node on PATH");
96
+ const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
97
+ assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
98
+ const start = async (date: string) => {
99
+ const generated = join(workdir, `ws-${date}.generated.js`);
100
+ const binary = join(workdir, `ws-${date}`);
101
+ await writeFile(
102
+ generated,
103
+ wrapNativeFetchHandler(handler, core.replace(TRANSPORT_MARKER, broker), {}, undefined, undefined, date),
104
+ );
105
+ const compile = Bun.spawn(
106
+ [node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
107
+ {
108
+ cwd: workdir,
109
+ stdout: "pipe",
110
+ stderr: "pipe",
111
+ },
112
+ );
113
+ const [exitCode, stdout, stderr] = await Promise.all([
114
+ compile.exited,
115
+ new Response(compile.stdout).text(),
116
+ new Response(compile.stderr).text(),
117
+ ]);
118
+ assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
119
+ const port = freePort();
120
+ const server = Bun.spawn([binary], {
121
+ cwd: workdir,
122
+ env: { ...process.env, PORT: String(port) },
123
+ stdout: "ignore",
124
+ stderr: "inherit",
125
+ });
126
+ servers.push(server);
127
+ const base = `http://127.0.0.1:${port}`;
128
+ for (let attempt = 0; attempt < 50; attempt++) {
129
+ try {
130
+ await fetch(`${base}/bad101`, { signal: AbortSignal.timeout(500) });
131
+ return base;
132
+ } catch {
133
+ if (server.exitCode !== null) break;
134
+ await Bun.sleep(100);
135
+ }
136
+ }
137
+ throw new Error("compiled handler did not start");
138
+ };
139
+ const [now, before] = await Promise.all([start("2026-10-30"), start("2026-10-29")]);
140
+
141
+ assert.deepEqual(await (await fetch(`${now}/shape`)).json(), ["function", "function", "function", null]);
142
+ assert.deepEqual(await (await fetch(`${before}/shape`)).json(), ["undefined", "undefined", "undefined", null]);
143
+ console.log("WebSocketPair, WebSocket and WebSocketRequestResponsePair exist from 2026-10-30 only");
144
+
145
+ assert.deepEqual(await (await fetch(`${now}/pair`)).json(), {
146
+ log: [
147
+ "s:hi",
148
+ "c:echo:hi",
149
+ "s:bytes 1,2,3",
150
+ "c:bytes 1,2,3",
151
+ "server close 4001 bye true",
152
+ "client close 4001 bye true",
153
+ ],
154
+ badCode: "InvalidAccessError",
155
+ after: "Can't call WebSocket send() after close().",
156
+ states: [3, 3],
157
+ });
158
+ assert.deepEqual(await (await fetch(`${now}/queued`)).json(), ["early"]);
159
+ assert.deepEqual(await (await fetch(`${now}/blob`)).json(), ["blob", true, [7, 8], "SyntaxError"]);
160
+ assert.equal(
161
+ await (await fetch(`${now}/unaccepted`)).text(),
162
+ "TypeError: You must call one of accept() or state.acceptWebSocket() on this WebSocket before sending messages.",
163
+ );
164
+ console.log("a pair passes text, bytes and close codes, and queues messages until accept()");
165
+
166
+ const upgrade = await fetch(`${now}/upgrade`);
167
+ assert.equal(upgrade.status, 501);
168
+ assert.equal(await upgrade.text(), "WebSockets are not supported by this host yet");
169
+ assert.equal(await (await fetch(`${now}/bad101`)).text(), "RangeError");
170
+ assert.equal(await (await fetch(`${before}/bad101`)).text(), "RangeError");
171
+ console.log("a standalone sprout answers a 101 response with 501; status 101 needs a WebSocket");
172
+ } finally {
173
+ for (const server of servers) {
174
+ server.kill(9);
175
+ await server.exited;
176
+ }
177
+ await rm(workdir, { recursive: true, force: true });
178
+ }