@sproutboat/runtime 0.31.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.
@@ -0,0 +1,159 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ // Queues from compatibility date 2026-10-31 (producer checks and content types, the
5
+ // consumer's message and batch, first-call-wins, retry settings), against a fake transport.
6
+ const source = readFileSync(new URL("./native-fetch-prelude.js", import.meta.url), "utf8");
7
+ // oxlint-disable-next-line no-function-constructor -- the prelude is prepended as text in production too
8
+ const load = new Function(
9
+ "Porffor",
10
+ `var __sbCall = (text) => globalThis.__sbTestCall(text);\n${source}\nreturn { install: __sbInstallBindings, run: __sbRunQueueBatch };`,
11
+ );
12
+ const { install, run } = load({ c: () => "" });
13
+
14
+ let sent = [];
15
+ globalThis.__sbTestCall = (text) => {
16
+ const req = JSON.parse(text);
17
+ sent.push(req);
18
+ return JSON.stringify({ ok: true, metrics: { backlogCount: 1, backlogBytes: 7, oldestMessageTimestamp: 1000 } });
19
+ };
20
+ afterEach(() => {
21
+ sent = [];
22
+ });
23
+
24
+ const bindings = {
25
+ queues: ["QUEUE", "DLQ"],
26
+ queueConfig: [
27
+ { binding: "QUEUE", queue: "q", maxRetries: 3, deadLetterQueue: "dlq" },
28
+ { binding: "DLQ", queue: "dlq" },
29
+ ],
30
+ };
31
+ const env = (compat) => {
32
+ globalThis.__sbCompat = compat;
33
+ const target = {};
34
+ install(target, bindings);
35
+ return target;
36
+ };
37
+ const ctx = { waitUntil() {} };
38
+
39
+ test("2026-10-31: send() returns a promise of the backlog and keeps the content type", async () => {
40
+ const { QUEUE } = env("2026-10-31");
41
+ const result = QUEUE.send("123");
42
+ expect(result).toBeInstanceOf(Promise);
43
+ expect(await result).toEqual({
44
+ metadata: { metrics: { backlogCount: 1, backlogBytes: 7, oldestMessageTimestamp: 1000 } },
45
+ });
46
+ expect(sent[0].body).toBe('#sbq1:json:"123"');
47
+ await QUEUE.send("plain", { contentType: "text" });
48
+ await QUEUE.send(new Uint8Array([7, 8]), { contentType: "bytes" });
49
+ await QUEUE.send(new Map([[1, 2]]), { contentType: "v8" });
50
+ expect(sent.map((r) => r.body.split(":")[1])).toEqual(["json", "text", "bytes", "v8"]);
51
+ });
52
+
53
+ test("2026-10-31: send() and sendBatch() reject what workerd rejects", async () => {
54
+ const { QUEUE } = env("2026-10-31");
55
+ const error = (p) =>
56
+ p.then(
57
+ () => "accepted",
58
+ (e) => e.name + ": " + e.message,
59
+ );
60
+ expect(await error(QUEUE.send(undefined))).toBe("TypeError: Message body cannot be undefined");
61
+ expect(await error(QUEUE.send({ b: 1n }))).toBe("TypeError: Do not know how to serialize a BigInt");
62
+ expect(await error(QUEUE.send({}, { contentType: "text" }))).toBe(
63
+ 'TypeError: Content Type "text" requires a value of type string, but received: object',
64
+ );
65
+ expect(await error(QUEUE.send("x", { contentType: "xml" }))).toBe(
66
+ "TypeError: Unsupported queue message content type: xml",
67
+ );
68
+ expect(await error(QUEUE.send("x".repeat(128 * 1024 + 1)))).toStartWith("Error: ");
69
+ expect(await error(QUEUE.sendBatch([]))).toBe("TypeError: sendBatch() requires at least one message");
70
+ expect(await error(QUEUE.sendBatch({ body: 1 }))).toStartWith("TypeError: Failed to execute 'sendBatch'");
71
+ expect(await error(QUEUE.sendBatch(Array.from({ length: 101 }, () => ({ body: 1 }))))).toStartWith("Error: ");
72
+ expect(sent).toEqual([]);
73
+ });
74
+
75
+ test("before 2026-10-31 send() is unchanged", () => {
76
+ const { QUEUE } = env("2026-10-29");
77
+ expect(QUEUE.send("123")).toBeUndefined();
78
+ expect(sent[0].body).toBe("123");
79
+ });
80
+
81
+ const message = (id, body, attempts = 1) => ({ id, body, timestamp: 5000, attempts });
82
+
83
+ test("2026-10-31: message and batch shapes, decoded bodies, and the queue's name", async () => {
84
+ globalThis.__sbCompat = "2026-10-31";
85
+ let seen;
86
+ await run(
87
+ {
88
+ queue(batch) {
89
+ seen = batch;
90
+ },
91
+ },
92
+ {
93
+ queue: "QUEUE",
94
+ messages: [message("a", '#sbq1:text:{"a":1}'), message("b", "#sbq1:bytes:0708")],
95
+ metadata: { metrics: { backlogCount: 2 } },
96
+ },
97
+ ctx,
98
+ );
99
+ expect(seen.queue).toBe("q");
100
+ expect(Object.keys(seen).sort()).toEqual(["messages", "metadata", "queue"]);
101
+ expect(Object.keys(seen.messages[0]).sort()).toEqual(["attempts", "body", "id", "timestamp"]);
102
+ expect(seen.messages[0].timestamp).toEqual(new Date(5000));
103
+ expect(seen.messages[0].body).toBe('{"a":1}');
104
+ expect(new Uint8Array(seen.messages[1].body)).toEqual(new Uint8Array([7, 8]));
105
+ expect(seen.metadata.metrics.backlogCount).toBe(2);
106
+ });
107
+
108
+ test("2026-10-31: the first of ack() and retry() wins, and a message's own call beats the batch's", async () => {
109
+ globalThis.__sbCompat = "2026-10-31";
110
+ const out = await run(
111
+ {
112
+ queue(batch) {
113
+ batch.messages[0].ack();
114
+ batch.messages[0].retry();
115
+ batch.messages[1].retry({ delaySeconds: 7 });
116
+ batch.retryAll();
117
+ throw new Error("after the decisions");
118
+ },
119
+ },
120
+ { queue: "QUEUE", messages: [message("a", "1"), message("b", "2"), message("c", "3")] },
121
+ ctx,
122
+ );
123
+ expect(out.ack).toEqual(["a"]);
124
+ expect(out.retry).toEqual(["b", "c"]);
125
+ expect(out.delays).toEqual({ b: 7, c: 0 });
126
+ });
127
+
128
+ test("max_retries: a retry past it goes to the dead letter queue's binding", async () => {
129
+ globalThis.__sbCompat = "2026-10-31";
130
+ const out = await run(
131
+ {
132
+ queue(batch) {
133
+ batch.retryAll();
134
+ },
135
+ },
136
+ { queue: "QUEUE", messages: [message("a", "1", 3), message("b", "2", 4)] },
137
+ ctx,
138
+ );
139
+ expect(out.retry).toEqual(["a", "b"]);
140
+ expect(out.dead).toEqual(["b"]);
141
+ expect(out.deadLetter).toBe("DLQ");
142
+ });
143
+
144
+ test("before 2026-10-31 without settings, the outcome has only ack and retry", async () => {
145
+ globalThis.__sbCompat = "2026-10-29";
146
+ let legacy;
147
+ const out = await run(
148
+ {
149
+ queue(batch) {
150
+ legacy = [batch.queue, Object.keys(batch.messages[0]).includes("ack"), batch.messages[0].timestamp];
151
+ throw new Error("retry all");
152
+ },
153
+ },
154
+ { queue: "DLQ", messages: [message("a", '{"a":1}')] },
155
+ ctx,
156
+ );
157
+ expect(legacy).toEqual(["dlq", true, 5000]);
158
+ expect(out).toEqual({ ack: [], retry: ["a"] });
159
+ });
@@ -3700,8 +3700,14 @@ function __sbEnsureSchema() {
3700
3700
  __sbSql(s, "CREATE INDEX IF NOT EXISTS mq_due ON mq (queue, dead, visible_at)", []);
3701
3701
  const mqColumns = __sbSql(s, "PRAGMA table_info(mq)", []).rows;
3702
3702
  let mqClaim = false;
3703
- for (let i = 0; i < mqColumns.length; i++) if (mqColumns[i][1] === "claim_token") mqClaim = true;
3703
+ let mqSent = false;
3704
+ for (let i = 0; i < mqColumns.length; i++) {
3705
+ if (mqColumns[i][1] === "claim_token") mqClaim = true;
3706
+ if (mqColumns[i][1] === "sent_at") mqSent = true;
3707
+ }
3704
3708
  if (!mqClaim) __sbSql(s, "ALTER TABLE mq ADD COLUMN claim_token TEXT NOT NULL DEFAULT ''", []);
3709
+ // When the message was sent (message.timestamp); 0 for messages queued before the column.
3710
+ if (!mqSent) __sbSql(s, "ALTER TABLE mq ADD COLUMN sent_at INTEGER NOT NULL DEFAULT 0", []);
3705
3711
  __sbSql(
3706
3712
  s,
3707
3713
  "CREATE TABLE IF NOT EXISTS do_storage (cls TEXT NOT NULL, id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (cls, id, key))",
@@ -4278,22 +4284,25 @@ function __sbEmbeddedDispatch(msg) {
4278
4284
  // (data loss), and delaySeconds was ignored on both calls.
4279
4285
  const items = op === "queue.send" ? [{ body: msg.body, delaySeconds: msg.delaySeconds }] : msg.messages || [];
4280
4286
  let first = -1;
4287
+ const now = Date.now();
4281
4288
  for (let i = 0; i < items.length; i++) {
4282
4289
  const item = items[i] || {};
4283
4290
  const delay = Math.max(0, Number(item.delaySeconds) || 0) * 1000;
4284
- const visible = Date.now() + delay;
4291
+ const visible = now + delay;
4285
4292
  if (first < 0 || visible < first) first = visible;
4286
4293
  __sbSql(
4287
4294
  store,
4288
- "INSERT INTO mq (queue, id, body, visible_at, attempts, dead) VALUES (?1,?2,?3,?4,0,0)",
4289
- [msg.queue, __sbHex(12), String(item.body), visible],
4295
+ "INSERT INTO mq (queue, id, body, visible_at, attempts, dead, sent_at) VALUES (?1,?2,?3,?4,0,0,?5)",
4296
+ [msg.queue, __sbHex(12), String(item.body), visible, now],
4290
4297
  );
4291
4298
  }
4292
4299
  // B22: on sproutd, the host delivers the batch (waking the app if it sleeps by then).
4293
4300
  if (first >= 0 && __sbLocal !== null && __sbLocal.queues.indexOf(msg.queue) !== -1) __sbHostDue(first);
4294
- return { ok: true };
4301
+ return msg.metrics ? { ok: true, metrics: __sbQueueBacklog(store, msg.queue) } : { ok: true };
4295
4302
  }
4296
4303
 
4304
+ if (op === "queue.metrics") return { ok: true, metrics: __sbQueueBacklog(store, msg.queue) };
4305
+
4297
4306
  if (op === "do.storage.get") {
4298
4307
  const r = __sbSql(store, "SELECT value FROM do_storage WHERE cls = ? AND id = ? AND key = ?", [
4299
4308
  msg.cls,
@@ -4739,27 +4748,57 @@ function __sbCronMatches(expr, when) {
4739
4748
 
4740
4749
  var __sbLastCronTick = "";
4741
4750
 
4742
- // One delivery pass over the queues this worker consumes: claims up to 10 due messages per
4743
- // queue, hands them to queue() and applies its acks and retries.
4751
+ // A queue's backlog as Queues reports it (send() results, batch.metadata): its live
4752
+ // messages, their bytes and the oldest one's send time.
4753
+ function __sbQueueBacklog(store, queue) {
4754
+ const r = __sbSql(
4755
+ store,
4756
+ "SELECT COUNT(*), COALESCE(SUM(LENGTH(CAST(body AS BLOB))),0), MIN(NULLIF(sent_at,0)) FROM mq WHERE queue=? AND dead=0",
4757
+ [queue],
4758
+ ).rows[0];
4759
+ return { backlogCount: Number(r[0]) || 0, backlogBytes: Number(r[1]) || 0, oldestMessageTimestamp: Number(r[2]) || 0 };
4760
+ }
4761
+
4762
+ function __sbQueueAttemptCap(settings) {
4763
+ return Math.max(5, (settings.maxRetries !== undefined ? Number(settings.maxRetries) : 3) + 1);
4764
+ }
4765
+
4766
+ // How long a queue's due messages wait to fill a batch (max_batch_timeout), in ms.
4767
+ function __sbQueueWaitMs(binding) {
4768
+ return Math.max(0, Number(__sbQueueSettings(binding).maxBatchTimeout) || 0) * 1000;
4769
+ }
4770
+
4771
+ // One delivery pass over the queues this worker consumes: claims up to max_batch_size (10)
4772
+ // due messages per queue, hands them to queue() and applies its acks and retries. With a
4773
+ // max_batch_timeout, a batch smaller than max_batch_size waits until its oldest message
4774
+ // has been due that long.
4744
4775
  async function __sbQueuePass(handlers, queues) {
4745
4776
  __sbEnsureSchema();
4746
4777
  const store = __sbStore();
4747
4778
  const now = Date.now();
4748
4779
  for (let q = 0; q < queues.length; q++) {
4749
4780
  const name = queues[q];
4781
+ const settings = __sbQueueSettings(name);
4782
+ const size = Math.min(100, Math.max(1, Number(settings.maxBatchSize) || 10));
4783
+ const wait = __sbQueueWaitMs(name);
4750
4784
  const claim = String(now) + ":" + String(Math.random());
4751
4785
  __sbSql(store, "BEGIN IMMEDIATE", []);
4752
4786
  let due;
4753
4787
  try {
4754
- __sbSql(store, "UPDATE mq SET dead=1 WHERE queue=? AND visible_at<=? AND attempts>=5", [
4788
+ // A message whose deliveries never came back (the worker died or timed out each time)
4789
+ // stops after 5 attempts, or max_retries + 1 when that is more.
4790
+ __sbSql(store, "UPDATE mq SET dead=1 WHERE queue=? AND visible_at<=? AND attempts>=?", [
4755
4791
  name,
4756
4792
  now,
4793
+ __sbQueueAttemptCap(settings),
4757
4794
  ]);
4758
4795
  due = __sbSql(
4759
4796
  store,
4760
- "SELECT id,body,attempts FROM mq WHERE queue=? AND dead=0 AND visible_at<=? ORDER BY visible_at LIMIT 10",
4761
- [name, now],
4797
+ "SELECT id,body,attempts,sent_at,visible_at FROM mq WHERE queue=? AND dead=0 AND visible_at<=? ORDER BY visible_at LIMIT ?",
4798
+ [name, now, size],
4762
4799
  );
4800
+ if (wait > 0 && due.rows.length > 0 && due.rows.length < size && Number(due.rows[0][4]) + wait > now)
4801
+ due = { rows: [] };
4763
4802
  for (let i = 0; i < due.rows.length; i++)
4764
4803
  __sbSql(
4765
4804
  store,
@@ -4776,9 +4815,9 @@ async function __sbQueuePass(handlers, queues) {
4776
4815
  const messages = [];
4777
4816
  for (let i = 0; i < due.rows.length; i++) {
4778
4817
  const row = due.rows[i];
4779
-
4780
- messages.push({ id: row[0], body: row[1], timestamp: now, attempts: Number(row[2]) + 1 });
4818
+ messages.push({ id: row[0], body: row[1], timestamp: Number(row[3]) || now, attempts: Number(row[2]) + 1 });
4781
4819
  }
4820
+ const metadata = { metrics: __sbQueueBacklog(store, name) };
4782
4821
  // #171 — same ctx.waitUntil as the broker-delivered path, and the same
4783
4822
  // fix for the handler's own promise: `__sbRunQueueBatch` used to be
4784
4823
  // called fire-and-forget here too, so an async `queue()`'s ack/retry
@@ -4789,24 +4828,55 @@ async function __sbQueuePass(handlers, queues) {
4789
4828
  if (p && __sbIsFn(p.then)) __sbTasks.push(p);
4790
4829
  },
4791
4830
  };
4792
- let result = __sbRunQueueBatch(handlers, { queue: name, messages }, ctx);
4831
+ let result = __sbRunQueueBatch(handlers, { queue: name, messages, metadata }, ctx);
4793
4832
  if (result && __sbIsFn(result.then)) result = await result;
4794
4833
  if (__sbTasks.length) await __sbDrainWaitUntil(__sbTasks);
4795
- for (let i = 0; i < result.ack.length; i++) {
4796
- __sbSql(
4797
- store,
4798
- "DELETE FROM mq WHERE queue=? AND id=? AND claim_token=? AND visible_at>?",
4799
- [name, result.ack[i], claim, Date.now()],
4800
- );
4801
- }
4802
- for (let i = 0; i < result.retry.length; i++) {
4834
+ __sbQueueSettle(store, name, claim, result);
4835
+ }
4836
+ }
4837
+
4838
+ // Applies a batch's outcome to the claimed rows. `dead` (past max_retries) move to the dead
4839
+ // letter queue `deadLetter` as new messages, or are marked dead; `delays` gives each retry's
4840
+ // delay in seconds (5 when the runtime sends none). A row whose claim expired meanwhile was
4841
+ // handed out again and is left alone.
4842
+ function __sbQueueSettle(store, name, claim, result) {
4843
+ const now = Date.now();
4844
+ const dead = result.dead || [];
4845
+ const delays = result.delays || null;
4846
+ for (let i = 0; i < dead.length; i++) {
4847
+ if (result.deadLetter)
4803
4848
  __sbSql(
4804
4849
  store,
4805
- "UPDATE mq SET visible_at=?,claim_token='',dead=CASE WHEN attempts>=5 THEN 1 ELSE 0 END WHERE queue=? AND id=? AND claim_token=? AND visible_at>?",
4806
- [Date.now() + 5000, name, result.retry[i], claim, Date.now()],
4850
+ "UPDATE mq SET queue=?,visible_at=?,attempts=0,claim_token='',dead=0 WHERE queue=? AND id=? AND claim_token=? AND visible_at>?",
4851
+ [String(result.deadLetter), now, name, dead[i], claim, now],
4807
4852
  );
4808
- }
4853
+ else
4854
+ __sbSql(store, "UPDATE mq SET dead=1,claim_token='' WHERE queue=? AND id=? AND claim_token=? AND visible_at>?", [
4855
+ name,
4856
+ dead[i],
4857
+ claim,
4858
+ now,
4859
+ ]);
4809
4860
  }
4861
+ for (let i = 0; i < result.ack.length; i++) {
4862
+ __sbSql(
4863
+ store,
4864
+ "DELETE FROM mq WHERE queue=? AND id=? AND claim_token=? AND visible_at>?",
4865
+ [name, result.ack[i], claim, now],
4866
+ );
4867
+ }
4868
+ for (let i = 0; i < result.retry.length; i++) {
4869
+ const id = result.retry[i];
4870
+ if (dead.indexOf(id) !== -1) continue;
4871
+ const delay = delays !== null && delays[id] != null ? Math.max(0, Number(delays[id]) || 0) * 1000 : 5000;
4872
+ __sbSql(
4873
+ store,
4874
+ "UPDATE mq SET visible_at=?,claim_token='',dead=CASE WHEN attempts>=5 AND ?=0 THEN 1 ELSE 0 END WHERE queue=? AND id=? AND claim_token=? AND visible_at>?",
4875
+ [now + delay, delays !== null ? 1 : 0, name, id, claim, now],
4876
+ );
4877
+ }
4878
+ if (result.deadLetter && dead.length && __sbLocal !== null && __sbLocal.queues.indexOf(String(result.deadLetter)) !== -1)
4879
+ __sbHostDue(now);
4810
4880
  }
4811
4881
 
4812
4882
  // One pass over the Durable Object alarms that are due: claims up to 10 and runs alarm().
@@ -4898,6 +4968,17 @@ function __sbHostDue(at) {
4898
4968
  if (__sbLocal !== null && __sbLocal.consumes) __sbHostNote("due " + String(Math.max(0, Math.trunc(Number(at) || 0))));
4899
4969
  }
4900
4970
 
4971
+ // When a queue next has a batch to deliver: its earliest visible message, plus the batch
4972
+ // timeout unless a whole batch is already there.
4973
+ function __sbQueueNextDue(store, queue) {
4974
+ const r = __sbSql(store, "SELECT MIN(visible_at) FROM mq WHERE queue=? AND dead=0", [queue]);
4975
+ const wait = __sbQueueWaitMs(queue);
4976
+ if (wait === 0 || !r.rows.length || r.rows[0][0] == null) return r;
4977
+ const size = Math.min(100, Math.max(1, Number(__sbQueueSettings(queue).maxBatchSize) || 10));
4978
+ const full = __sbSql(store, "SELECT COUNT(*) FROM mq WHERE queue=? AND dead=0 AND visible_at<=?", [queue, Date.now()]);
4979
+ return Number(full.rows[0][0]) >= size ? r : { rows: [[Number(r.rows[0][0]) + wait]] };
4980
+ }
4981
+
4901
4982
  // The earliest time something this worker consumes falls due: a message becomes visible
4902
4983
  // (or its claim expires), an alarm is set for, or a failed alarm is retried. -1 for none.
4903
4984
  function __sbNextDue() {
@@ -4911,7 +4992,7 @@ function __sbNextDue() {
4911
4992
  };
4912
4993
  const queues = __sbLocal.queues;
4913
4994
  for (let q = 0; q < queues.length; q++)
4914
- take(__sbSql(store, "SELECT MIN(visible_at) FROM mq WHERE queue=? AND dead=0", [queues[q]]));
4995
+ take(__sbQueueNextDue(store, queues[q]));
4915
4996
  if (__sbLocal.alarms) {
4916
4997
  take(__sbSql(store, "SELECT MIN(at) FROM do_alarm", []));
4917
4998
  take(__sbSql(store, "SELECT MIN(expires) FROM do_alarm_delivery WHERE attempts<5", []));
package/src/wrap.ts CHANGED
@@ -58,6 +58,10 @@ export type Bindings = {
58
58
  d1: string[];
59
59
  r2: string[];
60
60
  queues: string[];
61
+ /** Each queue binding's queue name and consumer settings (`queueSettings` in
62
+ * @sproutboat/config). Absent from older artifacts: every queue is then named by its
63
+ * binding, with the default settings. */
64
+ queueConfig?: QueueSettings[];
61
65
  analytics: string[];
62
66
  do: Array<{ binding: string; className: string }>;
63
67
  /** #48 — worker-to-worker: binding name -> the project it calls. The hostname
@@ -71,6 +75,17 @@ export type Bindings = {
71
75
  assets: string;
72
76
  };
73
77
 
78
+ /** One queue binding's name and consumer settings; unset settings are absent. */
79
+ export type QueueSettings = {
80
+ binding: string;
81
+ queue: string;
82
+ maxBatchSize?: number;
83
+ maxBatchTimeout?: number;
84
+ maxRetries?: number;
85
+ retryDelay?: number;
86
+ deadLetterQueue?: string;
87
+ };
88
+
74
89
  export const EMPTY_BINDINGS: Bindings = {
75
90
  kv: [],
76
91
  secrets: [],
@@ -335,7 +350,21 @@ export function readBindingsFromEnv(): Bindings {
335
350
  }
336
351
  }
337
352
  }
338
- return {
353
+ const queueConfig: QueueSettings[] = [];
354
+ if (Array.isArray(parsed.queueConfig)) {
355
+ const num = (v: VarsJson | undefined): v is number => Number(v) === v && Number.isFinite(v) && v >= 0;
356
+ for (const entry of parsed.queueConfig) {
357
+ if (!isVarsObject(entry) || !isVarsString(entry.binding) || !isVarsString(entry.queue)) continue;
358
+ const q: QueueSettings = { binding: entry.binding, queue: entry.queue };
359
+ if (num(entry.maxBatchSize)) q.maxBatchSize = entry.maxBatchSize;
360
+ if (num(entry.maxBatchTimeout)) q.maxBatchTimeout = entry.maxBatchTimeout;
361
+ if (num(entry.maxRetries)) q.maxRetries = entry.maxRetries;
362
+ if (num(entry.retryDelay)) q.retryDelay = entry.retryDelay;
363
+ if (isVarsString(entry.deadLetterQueue)) q.deadLetterQueue = entry.deadLetterQueue;
364
+ queueConfig.push(q);
365
+ }
366
+ }
367
+ const out: Bindings = {
339
368
  kv: strings(parsed.kv),
340
369
  secrets: strings(parsed.secrets),
341
370
  d1: strings(parsed.d1),
@@ -348,4 +377,6 @@ export function readBindingsFromEnv(): Bindings {
348
377
  ratelimiters,
349
378
  assets: isVarsString(parsed.assets) ? parsed.assets : "",
350
379
  };
380
+ if (queueConfig.length) out.queueConfig = queueConfig;
381
+ return out;
351
382
  }
@@ -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
+ }