@sproutboat/runtime 0.41.0 → 0.42.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,11 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.42.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2baeae9: `connect()` from `cloudflare:sockets`, from compatibility date 2026-11-17: plain TCP, `secureTransport: "on"` and `"starttls"` with `startTls()`, `readable`/`writable` byte streams with write backpressure, `opened`, `closed`, `close()`, half-close, workerd's error shapes, and port 25 refused. It runs on both transports: the embedded one (standalone and sproutd) opens the connection in a thread per socket and does TLS with BearSSL (extra roots through `SB_CA_BUNDLE`); the broker one tunnels through the broker, which vets every address with the `fetch()` policy (`SB_EGRESS_ALLOW`, private ranges refused), terminates TLS, and hands the tunnel to the egress service where the broker has no network. Limits: 16 sockets per app. From the same date `navigator.userAgent` is `"Cloudflare-Workers"` and a bundled `Buffer`/`process` is accepted, so unmodified `pg`, `postgres` and Redis clients run. The toolchain gains three Porffor fixes that those drivers hit: bindings named like libc macros, call arguments evaluated in order, and a writable `navigator`.
8
+
3
9
  ## 0.41.0
4
10
 
5
11
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -11,6 +11,8 @@ function __sbEnvValue() {
11
11
  // Setters, not bare assignments in the tail: the template and every app must compile the prelude identically.
12
12
  function __sbSetCompat(date) {
13
13
  __sbCompatNow = date;
14
+ // navigator.userAgent as workerd has it: pg picks its Workers socket code on exactly this string.
15
+ if (date >= "2026-11-17") navigator.userAgent = "Cloudflare-Workers";
14
16
  Request._cd = date;
15
17
  if (date >= "2026-11-15" && Error.captureStackTrace === undefined) Error.captureStackTrace = __sbCaptureStackTrace;
16
18
  }
@@ -14162,7 +14164,7 @@ function __sbWsOp(op, id, data, n) {
14162
14164
  const unsigned int __id = id.type == 1 && id.val > 0 ? (unsigned int)id.val : 0;
14163
14165
  const size_t __n = n.type == 1 && n.val > 0 ? (size_t)n.val : 0;
14164
14166
  char __r[64]; __r[0] = 0;
14165
- if (__op == 'u' || __op == 'W' || (__id & 0x80000000u)) {
14167
+ if (__op == 'u' || __op == 'T' || __op == 'W' || (__id & 0x80000000u)) {
14166
14168
  long long __w = -1;
14167
14169
  if (!sb_wsc_hook) snprintf(__r, sizeof(__r), "0");
14168
14170
  else if (__op == 'W') snprintf(__r, sizeof(__r), "%d", sb_wsc_wakeable_hook && sb_wsc_wakeable_hook() ? 1 : 0);
@@ -14181,7 +14183,7 @@ function __sbWsOp(op, id, data, n) {
14181
14183
  const char* __d = 0; size_t __dl = 0;
14182
14184
  if (porf_native_fetch_read_raw_bytes(data, &__d, &__dl) == 0) __w = sb_wsc_hook('b', __id, __d, __dl, 0, 0, 0);
14183
14185
  snprintf(__r, sizeof(__r), "%lld", __w);
14184
- } else if (__op == 't' || __op == 'c' || __op == 'u') {
14186
+ } else if (__op == 't' || __op == 'c' || __op == 'u' || __op == 'T' || __op == 'S') {
14185
14187
  const char* __d; size_t __dl; char* __do = 0;
14186
14188
  porf_native_fetch_read_value(data, &__d, &__dl, &__do);
14187
14189
  __w = sb_wsc_hook(__op, __id, __d, __dl, __n, 0, 0);
@@ -14573,6 +14575,289 @@ function __sbWsPump(ws) {
14573
14575
  }
14574
14576
  }
14575
14577
 
14578
+ // ---------------------------------------------------------------------------
14579
+ // cloudflare:sockets connect(), from compatibility date 2026-11-17. A socket is a host
14580
+ // socket in the outbound WebSocket table (sb_wsc, raw mode); this end speaks the __sbWsOp ops:
14581
+ // "T" open (host, port, secureTransport mode, server name), "b" queue bytes (returns how many
14582
+ // are still unsent), "S" start TLS, "H" shut the write side, "p" "i" take an event, "k" ack,
14583
+ // "w" park a timer, "r" release. Events: 1 data, 3 error, 4 connected, 6 end of input,
14584
+ // 8 send queue drained, 9 TLS handshake done.
14585
+ const __SB_SOCKETS_DATE = "2026-11-17";
14586
+ const __SB_TCP_HWM = 262144;
14587
+ let __sbTcpOpen = 0;
14588
+
14589
+ function __sbTcpBadInt() {
14590
+ return new TypeError("The value cannot be converted because it is not an integer.");
14591
+ }
14592
+ function __sbTcpTarget(address) {
14593
+ let host;
14594
+ let port;
14595
+ if (__sbIsStr(address)) {
14596
+ let rest = address;
14597
+ let cut;
14598
+ if (rest.charAt(0) === "[") {
14599
+ const close = rest.indexOf("]");
14600
+ cut = close >= 0 && rest.charAt(close + 1) === ":" ? close + 1 : -1;
14601
+ } else cut = rest.lastIndexOf(":");
14602
+ if (cut < 0) {
14603
+ if (rest === "") throw new TypeError("Specified address is missing hostname.");
14604
+ throw new TypeError("Specified address is missing port.");
14605
+ }
14606
+ host = rest.slice(0, cut);
14607
+ port = rest.slice(cut + 1);
14608
+ } else if (address != null && __sbIsObj(address)) {
14609
+ host = address.hostname;
14610
+ port = address.port;
14611
+ if (host === undefined || host === "") throw new TypeError("Specified address is missing hostname.");
14612
+ if (port === undefined) throw new TypeError("Specified address is missing port.");
14613
+ } else throw new TypeError("connect() takes an address string or { hostname, port }.");
14614
+ const n = Number(port);
14615
+ if (!(Number.isInteger(n)) || String(port).trim() === "") throw __sbTcpBadInt();
14616
+ if (n < 0 || n > 65535) throw new RangeError("Port out of range.");
14617
+ return { host: String(host), port: n };
14618
+ }
14619
+
14620
+ function __sbTcpChunk(chunk) {
14621
+ if (__sbIsStr(chunk)) return new TextEncoder().encode(chunk);
14622
+ if (chunk instanceof ArrayBuffer) return new Uint8Array(chunk);
14623
+ if (ArrayBuffer.isView(chunk)) return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
14624
+ throw new TypeError("This TransformStream is being used as a byte stream, but received an object of non-ArrayBuffer/ArrayBufferView type on its writable side.");
14625
+ }
14626
+
14627
+ // Takes every event the host has, then parks a timer (or polls, where nothing can wake the loop).
14628
+ function __sbTcpPump(st) {
14629
+ if (st.id === 0) return;
14630
+ for (;;) {
14631
+ const head = __sbWsOp("p", st.id, "", 0);
14632
+ if (head === "") {
14633
+ if (st.id === 0) return;
14634
+ if (__sbWsCanWake()) {
14635
+ st.timer = setTimeout(() => __sbTcpPump(st), 2147483647);
14636
+ __sbWsOp("w", st.id, "", st.timer);
14637
+ } else st.timer = setTimeout(() => __sbTcpPump(st), 10);
14638
+ return;
14639
+ }
14640
+ const parts = head.split(" ");
14641
+ const kind = Number(parts[0]);
14642
+ const length = Number(parts[1]);
14643
+ const bytes = new Uint8Array(length);
14644
+ if (length > 0) __sbWsOp("i", st.id, bytes, 0);
14645
+ else __sbWsOp("i", st.id, new Uint8Array(0), 0);
14646
+ if (kind === 1) {
14647
+ st.inbox.push(bytes);
14648
+ } else if (kind === 4 || kind === 9) {
14649
+ if (st.opening) {
14650
+ const o = st.opening;
14651
+ st.opening = null;
14652
+ o.resolve({ remoteAddress: st.host + ":" + st.port, localAddress: null });
14653
+ }
14654
+ } else if (kind === 6) {
14655
+ st.eof = true;
14656
+ if (!st.allowHalfOpen) st.writeEnd = true;
14657
+ } else if (kind === 8) {
14658
+ const d = st.drain;
14659
+ st.drain = null;
14660
+ if (d) d();
14661
+ } else if (kind === 3) {
14662
+ // workerd words every connect failure the same; the host's reason stays on st.reason
14663
+ st.reason = new TextDecoder().decode(bytes);
14664
+ st.failed = new Error("proxy request failed, cannot connect to the specified address");
14665
+ st.eof = true;
14666
+ }
14667
+ __sbTcpSettle(st);
14668
+ if (st.id === 0) return;
14669
+ }
14670
+ }
14671
+
14672
+ // Applies state changes to the streams and promises.
14673
+ function __sbTcpSettle(st) {
14674
+ if (st.opening && st.failed) {
14675
+ const o = st.opening;
14676
+ st.opening = null;
14677
+ o.reject(st.failed);
14678
+ }
14679
+ const wake = st.pullWake;
14680
+ if (wake && (st.inbox.length > 0 || st.eof)) {
14681
+ st.pullWake = null;
14682
+ wake();
14683
+ }
14684
+ if (st.eof && st.inbox.length === 0 && !st.settled) {
14685
+ if (st.failed) {
14686
+ st.settled = true;
14687
+ st.closedReject(st.failed);
14688
+ st.release();
14689
+ } else if (!st.allowHalfOpen) {
14690
+ st.settled = true;
14691
+ st.closedResolve(undefined);
14692
+ if (st.writableController) st.writableController.error(new TypeError("This WritableStream has been closed."));
14693
+ st.release();
14694
+ }
14695
+ }
14696
+ }
14697
+
14698
+ function __sbTcpRelease(st) {
14699
+ const id = st.id;
14700
+ if (id === 0) return;
14701
+ st.id = 0;
14702
+ if (st.timer !== undefined) clearTimeout(st.timer);
14703
+ __sbWsOp("r", id, "", 0);
14704
+ __sbTcpOpen--;
14705
+ }
14706
+
14707
+ // One socket facade over a host connection. `st` is shared with the socket startTls() returns.
14708
+ function __sbTcpFacade(st, mode) {
14709
+ const closed = new Promise((resolve, reject) => {
14710
+ st.closedResolve = resolve;
14711
+ st.closedReject = reject;
14712
+ });
14713
+ // A rejected `closed` nobody awaits must not surface as an unhandled rejection.
14714
+ closed.catch(() => {});
14715
+ const opened = new Promise((resolve, reject) => {
14716
+ st.opening = { resolve, reject };
14717
+ });
14718
+ opened.catch(() => {});
14719
+ const readable = new ReadableStream(
14720
+ {
14721
+ start(controller) {
14722
+ st.readableController = controller;
14723
+ },
14724
+ pull(controller) {
14725
+ if (st.inbox.length > 0) {
14726
+ const chunk = st.inbox.shift();
14727
+ controller.enqueue(chunk);
14728
+ if (st.id !== 0) __sbWsOp("k", st.id, "", chunk.length);
14729
+ return undefined;
14730
+ }
14731
+ if (st.eof) {
14732
+ if (st.failed && !st.closedByUser) {
14733
+ // a failed connect reads as an empty stream, as in workerd
14734
+ }
14735
+ controller.close();
14736
+ return undefined;
14737
+ }
14738
+ return new Promise((resolve) => {
14739
+ st.pullWake = () => {
14740
+ if (st.inbox.length > 0) {
14741
+ const chunk = st.inbox.shift();
14742
+ controller.enqueue(chunk);
14743
+ if (st.id !== 0) __sbWsOp("k", st.id, "", chunk.length);
14744
+ } else controller.close();
14745
+ resolve();
14746
+ };
14747
+ });
14748
+ },
14749
+ cancel() {
14750
+ return undefined;
14751
+ },
14752
+ },
14753
+ { highWaterMark: 0 },
14754
+ );
14755
+ const writable = new WritableStream(
14756
+ {
14757
+ start(controller) {
14758
+ st.writableController = controller;
14759
+ },
14760
+ write(chunk) {
14761
+ const bytes = __sbTcpChunk(chunk);
14762
+ if (st.id === 0) throw new TypeError("This WritableStream has been closed.");
14763
+ const queued = Number(__sbWsOp("b", st.id, __sbToBytes(bytes), 0));
14764
+ if (queued > __SB_TCP_HWM) return new Promise((resolve) => (st.drain = resolve));
14765
+ return undefined;
14766
+ },
14767
+ close() {
14768
+ if (st.id !== 0) __sbWsOp("H", st.id, "", 0);
14769
+ st.writeClosed = true;
14770
+ return undefined;
14771
+ },
14772
+ abort() {
14773
+ return undefined;
14774
+ },
14775
+ },
14776
+ { highWaterMark: 1 },
14777
+ );
14778
+ const socket = {
14779
+ readable,
14780
+ writable,
14781
+ opened,
14782
+ closed,
14783
+ upgraded: false,
14784
+ secureTransport: mode,
14785
+ close() {
14786
+ st.closedByUser = true;
14787
+ if (st.writableController) st.writableController.error(new TypeError("This WritableStream belongs to an object that is closing."));
14788
+ const wake = st.pullWake;
14789
+ st.pullWake = null;
14790
+ if (!st.settled) {
14791
+ st.settled = true;
14792
+ st.closedResolve(undefined);
14793
+ }
14794
+ st.inbox.length = 0;
14795
+ st.eof = true;
14796
+ if (wake && st.readableController) st.readableController.error(new TypeError("This socket has been closed."));
14797
+ __sbTcpRelease(st);
14798
+ return Promise.resolve();
14799
+ },
14800
+ startTls(options) {
14801
+ if (mode !== "starttls" || st.upgradedOnce)
14802
+ throw new TypeError(st.upgradedOnce ? "The connection was closed before startTls could be started." : "The `secureTransport` socket option must be set to 'starttls' for startTls to be used.");
14803
+ if (readable.locked || writable.locked) throw new TypeError("Cannot startTls while the streams are locked.");
14804
+ st.upgradedOnce = true;
14805
+ let sni = st.host;
14806
+ if (options != null && options.expectedServerHostname != null) sni = String(options.expectedServerHostname);
14807
+ __sbWsOp("S", st.id, sni, 0);
14808
+ // the old socket ends once the upgrade is under way (workerd resolves its `closed`)
14809
+ const oldResolve = st.closedResolve;
14810
+ const next = __sbTcpFacade(st, "on");
14811
+ setTimeout(() => oldResolve(undefined), 0);
14812
+ return next;
14813
+ },
14814
+ };
14815
+ return socket;
14816
+ }
14817
+
14818
+ function __sbConnect(address, options) {
14819
+ if (__sbCompatDate() < __SB_SOCKETS_DATE)
14820
+ throw new Error("connect() needs compatibility_date " + __SB_SOCKETS_DATE + " or later");
14821
+ const target = __sbTcpTarget(address);
14822
+ let mode = "off";
14823
+ let allowHalfOpen = false;
14824
+ if (options != null) {
14825
+ if (options.secureTransport !== undefined) {
14826
+ mode = String(options.secureTransport);
14827
+ if (mode !== "off" && mode !== "on" && mode !== "starttls")
14828
+ throw new TypeError("Unsupported secureTransport value: " + mode);
14829
+ }
14830
+ allowHalfOpen = options.allowHalfOpen === true;
14831
+ }
14832
+ const st = {
14833
+ id: 0, host: target.host, port: target.port, inbox: [], eof: false, failed: null, settled: false,
14834
+ allowHalfOpen, pullWake: null, drain: null, timer: undefined, opening: null, upgradedOnce: false,
14835
+ closedByUser: false, writableController: null, readableController: null, reason: "", writeClosed: false, writeEnd: false,
14836
+ release: null, closedResolve: null, closedReject: null,
14837
+ };
14838
+ st.release = () => __sbTcpRelease(st);
14839
+ const socket = __sbTcpFacade(st, mode);
14840
+ if (target.port === 25) {
14841
+ st.failed = new Error("proxy request failed, cannot connect to the specified address");
14842
+ st.eof = true;
14843
+ setTimeout(() => __sbTcpSettle(st), 0);
14844
+ return socket;
14845
+ }
14846
+ const spec = target.host + "\n" + target.port + "\n" + (mode === "on" ? "1" : mode === "starttls" ? "2" : "0") + "\n" + target.host;
14847
+ const id = __sbTcpOpen >= 16 ? 0 : Number(__sbWsOp("T", 0, spec, 0));
14848
+ if (!(id > 0)) {
14849
+ st.failed = new Error(__sbTcpOpen >= 16 ? "Too many open connections" : "TCP sockets are not available on this host");
14850
+ st.eof = true;
14851
+ setTimeout(() => __sbTcpSettle(st), 0);
14852
+ return socket;
14853
+ }
14854
+ st.id = id;
14855
+ __sbTcpOpen++;
14856
+ __sbTcpPump(st);
14857
+ return socket;
14858
+ }
14859
+ globalThis.__sbSockets = { connect: __sbConnect };
14860
+
14576
14861
  // ---------------------------------------------------------------------------
14577
14862
  // Outbound WebSockets, from compatibility date 2026-11-01: new WebSocket(url, protocols) and
14578
14863
  // fetch(url, { headers: { Upgrade: "websocket" } }), whose Response carries the connected
@@ -0,0 +1,42 @@
1
+ import { expect, test } from "bun:test";
2
+ import { inlineWorkersImports } from "./wrap";
3
+ import { SOCKETS_DATE, validateHttpSyncSource } from "./source";
4
+
5
+ test("cloudflare:sockets imports become the prelude's connect", () => {
6
+ const out = inlineWorkersImports('import { connect } from "cloudflare:sockets";\nconst x = 1;');
7
+ expect(out).toContain("const { connect } = globalThis.__sbSockets;");
8
+ expect(inlineWorkersImports('import { connect as dial } from "cloudflare:sockets";')).toContain(
9
+ "const { connect: dial } = globalThis.__sbSockets;",
10
+ );
11
+ });
12
+
13
+ test("a lazy import of cloudflare:sockets (postgres/cf, pg-cloudflare) resolves to the same object", () => {
14
+ const out = inlineWorkersImports('const { connect } = await import("cloudflare:sockets");');
15
+ expect(out).toBe("const { connect } = await Promise.resolve(globalThis.__sbSockets);");
16
+ });
17
+
18
+ const handler = "export default { fetch() { return new Response(Buffer.from('a').toString() + process.nextTick); } };";
19
+
20
+ test("Buffer and process in a bundle are refused before the sockets date and allowed from it", () => {
21
+ expect(validateHttpSyncSource(handler, "2026-09-14").ok).toBe(false);
22
+ expect(validateHttpSyncSource(handler, SOCKETS_DATE)).toEqual({ ok: true });
23
+ });
24
+
25
+ test("a node: import specifier is still refused from the sockets date", () => {
26
+ const withNode = "export default { fetch() { return new Response('node:fs'.length + \"\"); } };".replace(
27
+ "'node:fs'",
28
+ '"node:fs"',
29
+ );
30
+ expect(validateHttpSyncSource(withNode, SOCKETS_DATE).ok).toBe(false);
31
+ });
32
+
33
+ test("cloudflare:sockets needs the sockets date", () => {
34
+ const app =
35
+ 'import { connect } from "cloudflare:sockets";\nexport default { fetch() { return new Response(String(typeof connect)); } };';
36
+ const before = validateHttpSyncSource(app, "2026-11-16");
37
+ expect(before.ok).toBe(false);
38
+ expect(before.ok ? [] : before.errors).toContain(
39
+ `cloudflare:sockets needs compatibility_date ${SOCKETS_DATE} or later`,
40
+ );
41
+ expect(validateHttpSyncSource(app, SOCKETS_DATE)).toEqual({ ok: true });
42
+ });
package/src/source.ts CHANGED
@@ -67,6 +67,14 @@ function usesNodeGlobalUnguarded(module: string): boolean {
67
67
  /** `new WebSocket(url)` connects out from this compatibility date (earlier, only WebSocketPair makes one). */
68
68
  export const OUTBOUND_WEBSOCKET_DATE = "2026-11-01";
69
69
 
70
+ /**
71
+ * From this date `cloudflare:sockets` connect() exists, and so does the bundled Buffer and process
72
+ * stub a database driver brings: the bundler inlines both, so `Buffer.from` and `process.nextTick`
73
+ * in the output are the bundle's own code, not a host API.
74
+ */
75
+ export const SOCKETS_DATE = "2026-11-17";
76
+ const driverAllowed = (message: string) => message === "Node, Bun, and Deno APIs are not supported";
77
+
70
78
  export function validateHttpSyncSource(module: string, compatibilityDate?: string): SourceValidation {
71
79
  // `cloudflare:workers` is the one import allowed past bundling: the compiler turns it
72
80
  // into globals the same way (see inlineWorkersImports).
@@ -85,8 +93,15 @@ export function validateHttpSyncSource(module: string, compatibilityDate?: strin
85
93
  "handler must default-export an object with fetch(request), scheduled() or queue(), or a WorkerEntrypoint class",
86
94
  );
87
95
  }
96
+ if (
97
+ /["']cloudflare:sockets["']/.test(module) &&
98
+ !(compatibilityDate !== undefined && compatibilityDate >= SOCKETS_DATE)
99
+ )
100
+ errors.push(`cloudflare:sockets needs compatibility_date ${SOCKETS_DATE} or later`);
88
101
  const guardedGlobals = compatibilityDate !== undefined && compatibilityDate >= GUARDED_GLOBALS_DATE;
102
+ const sockets = compatibilityDate !== undefined && compatibilityDate >= SOCKETS_DATE;
89
103
  for (const [pattern, message] of alwaysForbidden) {
104
+ if (sockets && driverAllowed(message) && !/['"`]node:/.test(source)) continue;
90
105
  if (pattern === NODE_GLOBAL_USE && guardedGlobals ? !usesNodeGlobalUnguarded(source) : !pattern.test(source))
91
106
  continue;
92
107
  errors.push(message);