@sproutboat/runtime 0.33.0 → 0.34.1

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,22 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.34.1
4
+
5
+ ### Patch Changes
6
+
7
+ - e62b72d: An unhandled promise rejection no longer ends the process. A D1 call that failed and was never awaited (`db.prepare("INSERT ...").run()` that broke a foreign key) threw its error out of Porffor's promise job loop into the host's "Uncaught ..." exit, taking every in-flight request down even with a try/catch around the call. As on Workers it is now reported: a log line (`Uncaught (in promise) ...`) and an `unhandledrejection` event (`PromiseRejectionEvent` with `promise` and `reason`) on the global scope when `addEventListener("unhandledrejection", ...)` has a listener, and the process and other requests carry on. This applies at every compatibility date, because a crash is a bug rather than a behaviour an app can depend on. The toolchain's `patchUnhandledRejections` calls the runtime's `globalThis.__sbOnUnhandledRejection`; plain Porffor without it keeps the throw. Applies to every binding call that returns a rejecting Promise (D1, KV, R2, Durable Objects, Queues, fetch). An un-awaited `async function` that throws is reported the same way (it used to be dropped silently): the C runtime that rejects it now hands the promise to the same check.
8
+
9
+ ## 0.34.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 85ec3fe: WebSockets on every host, and outbound WebSockets, from compatibility date `2026-11-01`:
14
+
15
+ - **Client sockets on standalone and platform sprouts.** The native server (toolchain) carries the client end of a 101 response: it upgrades the connection, passes text and binary messages both ways, applies the 1 MiB read window, caps messages at 32 MiB (1009), and answers a close with the code and reason. A message the app sent before the 101 goes out right after it, and the app's `Sec-WebSocket-Protocol` is the one the client gets. Before 2026-11-01 these sprouts still answer a 101 with 501 (sproutd, which sets `sb_ws_hook` itself, carries them from 2026-10-30). The toolchain needs `@sproutboat/runtime` 0.33 or later.
16
+ - **Outbound WebSockets.** `new WebSocket(url, protocols)` (CONNECTING, then `open`, as in a browser) and `fetch(url, { headers: { Upgrade: "websocket" } })`, whose Response carries the connected socket as `response.webSocket` (call `accept()`); a server that answers the upgrade with another status is the Response. `ws`, `wss`, `http` and `https` URLs. Returning that Response from a handler relays the client's socket to the upstream. One I/O thread per socket (up to 64) connects through the egress policy, does TLS with BearSSL, and wakes the loop through the async pipe (sproutd) or the server's waker (standalone). On the embedded transport (standalone, sproutd); the broker transport (platform, `sproutboat dev`) answers `fetch` upgrades with a rejection for now.
17
+ - `validateHttpSyncSource(source, compatibilityDate)` accepts `WebSocket(` from 2026-11-01 and keeps refusing it before.
18
+ - **Hibernation, on a host that keeps client sockets open while the worker is gone (sproutd).** A Durable Object's hibernatable socket (`ctx.acceptWebSocket`) is recorded in storage scoped to the object (its id, tags, attachment, and the object's auto-response, kept current by `serializeAttachment` and `setWebSocketAutoResponse`), so the host may stop the worker while the socket is idle. When a message or close arrives, a new worker constructs the object again with `ctx.getWebSockets()` listing its sockets (tags and attachments intact) from the start of the constructor, and the event runs as `webSocketMessage` or `webSocketClose`. Socket ids are random. A host without this (standalone, platform) keeps every socket resident, as before.
19
+
3
20
  ## 0.33.0
4
21
 
5
22
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.33.0",
3
+ "version": "0.34.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,10 +18,7 @@ const load = new Function(
18
18
  // Every Porffor.c`...` block in the prelude only runs inside a function body
19
19
  // (never at load time), so a no-op tag covers all of them: __sbCpuMs and
20
20
  // __sbEnv just see an empty result, same as "native op returned nothing."
21
- const H = load(
22
- { c: () => "" },
23
- new Proxy({}, { get: () => undefined }),
24
- );
21
+ const H = load({ c: () => "" }, new Proxy({}, { get: () => undefined }));
25
22
 
26
23
  const req = (overrides = {}) => ({
27
24
  headers: { get: (k) => (overrides.headers && overrides.headers[k.toLowerCase()]) ?? null },
@@ -61,3 +58,55 @@ test("a throwing fetch handler doesn't stop the next request from being served",
61
58
  const second = await H.__sbEntry(handlers, req({ boom: false }));
62
59
  expect(await second.text()).toBe("ok");
63
60
  });
61
+
62
+ // A promise that rejects with nobody waiting on it (an un-awaited D1 run() that broke a
63
+ // foreign key) must be reported, not end the process: Porffor's promise job loop calls
64
+ // globalThis.__sbOnUnhandledRejection (toolchain patchUnhandledRejections).
65
+ test("an unhandled rejection is logged and fires an unhandledrejection event", () => {
66
+ const logged = [];
67
+ const consoleError = console.error;
68
+ const dispatch = globalThis.dispatchEvent;
69
+ const Ctor = globalThis.PromiseRejectionEvent;
70
+ const events = [];
71
+ console.error = (line) => logged.push(String(line));
72
+ globalThis.PromiseRejectionEvent = class {
73
+ constructor(type, init) {
74
+ this.type = type;
75
+ this.reason = init.reason;
76
+ this.promise = init.promise;
77
+ }
78
+ };
79
+ globalThis.dispatchEvent = (event) => events.push(event);
80
+ try {
81
+ const promise = Promise.reject(new Error("D1_ERROR: FOREIGN KEY constraint failed"));
82
+ promise.catch(() => {});
83
+ globalThis.__sbOnUnhandledRejection(new Error("D1_ERROR: FOREIGN KEY constraint failed"), promise);
84
+ } finally {
85
+ console.error = consoleError;
86
+ globalThis.dispatchEvent = dispatch;
87
+ globalThis.PromiseRejectionEvent = Ctor;
88
+ }
89
+ expect(logged.join("\n")).toContain("Uncaught (in promise)");
90
+ expect(logged.join("\n")).toContain("FOREIGN KEY");
91
+ expect(events).toHaveLength(1);
92
+ expect(events[0].type).toBe("unhandledrejection");
93
+ expect(events[0].reason.message).toContain("FOREIGN KEY");
94
+ });
95
+
96
+ test("a throwing unhandledrejection listener does not escape the hook", () => {
97
+ const consoleError = console.error;
98
+ const dispatch = globalThis.dispatchEvent;
99
+ const Ctor = globalThis.PromiseRejectionEvent;
100
+ console.error = () => {};
101
+ globalThis.PromiseRejectionEvent = class {};
102
+ globalThis.dispatchEvent = () => {
103
+ throw new Error("listener broke");
104
+ };
105
+ try {
106
+ expect(() => globalThis.__sbOnUnhandledRejection(new Error("x"), undefined)).not.toThrow();
107
+ } finally {
108
+ console.error = consoleError;
109
+ globalThis.dispatchEvent = dispatch;
110
+ globalThis.PromiseRejectionEvent = Ctor;
111
+ }
112
+ });