@sproutboat/runtime 0.12.1 → 0.13.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.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 34083e1: Pass `(request, env, ctx)` to handlers built with compatibility dates from 2026-09-28. Older builds retain `(request, ctx)`. Add `Response.redirect()` for native fetch and decode request text as UTF-8 while preserving raw body bytes. The matching compiler Unicode fix shipped in `@sproutboat/toolchain@0.4.14`.
8
+
3
9
  ## 0.12.1
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,41 @@
1
+ import { expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ const src = readFileSync(new URL("./native-fetch-prelude.js", import.meta.url), "utf8");
4
+ const decoder = src.slice(src.indexOf("function __sbFromUtf8("), src.indexOf("// sproutboat #181"));
5
+ const ingress = src.slice(
6
+ src.indexOf("function __sbDecodeIngress("),
7
+ src.indexOf("globalThis.__sbEntry ="),
8
+ );
9
+ if (!decoder || !ingress) throw new Error("Ingress decoder source not found");
10
+ // oxlint-disable-next-line no-function-constructor -- Exercise the text-only runtime prelude.
11
+ const load = new Function(`${decoder}\n${ingress}\nreturn __sbDecodeIngress;`);
12
+ const decode = load();
13
+ const raw = (text) =>
14
+ Array.from(new TextEncoder().encode(text), (byte) => String.fromCharCode(byte)).join("");
15
+
16
+ test("native ingress text and JSON decode Unicode without changing raw bytes", async () => {
17
+ const body = raw('{"id":"caffè-🚤"}');
18
+ const request = { body };
19
+ decode(request);
20
+ expect(await request.text()).toBe('{"id":"caffè-🚤"}');
21
+ expect(await request.json()).toEqual({ id: "caffè-🚤" });
22
+ expect(await request.text()).toBe('{"id":"caffè-🚤"}');
23
+ expect(request.body).toBe(body);
24
+ });
25
+
26
+ test("native ingress retains empty bodies and JSON parse errors", () => {
27
+ const empty = { body: "" };
28
+ decode(empty);
29
+ expect(empty.text()).toBe("");
30
+ expect(() => empty.json()).toThrow();
31
+ const invalid = { body: "{" };
32
+ decode(invalid);
33
+ expect(() => invalid.json()).toThrow();
34
+ });
35
+
36
+ test("a bodyless request keeps its existing methods", () => {
37
+ const text = () => "";
38
+ const request = { body: null, text };
39
+ decode(request);
40
+ expect(request.text).toBe(text);
41
+ });
@@ -1,7 +1,7 @@
1
1
  // Prepended to every handler by tools/compile.ts, before Porffor's native-fetch
2
- // bundled handler. Porffor's runtime/fetch-globals.js (checked through alpha-4)
3
- // gives URL (href/origin/pathname/search only) and Response without a static
4
- // json(). This adds, additively, the rest of the WHATWG surface Worker code
2
+ // bundled handler. Porffor's runtime/fetch-globals.js (checked through alpha-10)
3
+ // gives URL (href/origin/pathname/search only) and Response without static
4
+ // json() or redirect(). This adds, additively, the rest of the WHATWG surface Worker code
5
5
  // expects: URLSearchParams (read + write), URL.prototype.searchParams and the
6
6
  // protocol/host/hostname/port/hash accessors, static Response.json,
7
7
  // crypto.randomUUID / crypto.getRandomValues, crypto.subtle (digest + HMAC
@@ -18,7 +18,7 @@
18
18
 
19
19
  // Duck-typing helpers, `typeof`-free (the repo's anti-slop lint bans `typeof`;
20
20
  // these express the same spec-mandated checks and are verified under Porffor
21
- // alpha-4 by examples/kitchen-sink/harness.ts).
21
+ // alpha-10 by examples/kitchen-sink/harness.ts).
22
22
  function __sbIsStr(v) {
23
23
  return Object(v) !== v && v === String(v);
24
24
  }
@@ -68,7 +68,7 @@ function __sbCpuMs() {
68
68
  return res === "" ? 0 : parseFloat(res);
69
69
  }
70
70
 
71
- // #28 — stamp `x-sb-cpu-ms` onto a handler Response. Porffor alpha-4's
71
+ // #28 — stamp `x-sb-cpu-ms` onto a handler Response. Porffor alpha-10's
72
72
  // native-fetch serializer only reads headers from the plain object passed to
73
73
  // `new Response(body, { headers })` — a later `.set()` or a `Headers` instance
74
74
  // is ignored on the wire — so the metric is carried by rebuilding the Response
@@ -204,6 +204,16 @@ if (Response.json == null) {
204
204
  };
205
205
  }
206
206
 
207
+ // Porffor alpha-10 has no static Response.redirect().
208
+ if (Response.redirect == null) {
209
+ Response.redirect = function (url, status = 302) {
210
+ if (status !== 301 && status !== 302 && status !== 303 && status !== 307 && status !== 308) {
211
+ throw new RangeError("invalid redirect status");
212
+ }
213
+ return new Response("", { status, headers: { location: String(url) } });
214
+ };
215
+ }
216
+
207
217
  // #59 — caches.default / caches.open(name), backed by the broker's cache.*
208
218
  // ops (a sibling SQLite table, key on method+URL for v1 — no Vary yet).
209
219
  // Deliberately NOT async: every other buffered-body method on this runtime
@@ -2015,7 +2025,26 @@ async function __sbQueueWithDrain(result, tasks) {
2015
2025
  return new Response(JSON.stringify(r), { headers: { "content-type": "application/json" } });
2016
2026
  }
2017
2027
 
2028
+ // Native HTTP ingress stores the body as one character per raw byte. Keep
2029
+ // that representation for binary consumers, but decode text lazily before
2030
+ // handing the request to user code. Request.text() in Porffor otherwise treats
2031
+ // the raw bytes as already-decoded characters and corrupts Unicode/HMAC input.
2032
+ function __sbDecodeIngress(request) {
2033
+ const raw = request.body;
2034
+ if (raw == null) return;
2035
+ let decoded = null;
2036
+ request.text = function () {
2037
+ if (decoded === null) decoded = __sbFromUtf8(raw);
2038
+ return decoded;
2039
+ };
2040
+ request.json = function () {
2041
+ if (decoded === null) decoded = __sbFromUtf8(raw);
2042
+ return JSON.parse(decoded);
2043
+ };
2044
+ }
2045
+
2018
2046
  globalThis.__sbEntry = function (handlers, request) {
2047
+ __sbDecodeIngress(request);
2019
2048
  const trigger = request.headers.get("x-sb-trigger");
2020
2049
  if (!trigger) {
2021
2050
  // #163/#128 — expose the request metadata the edge already has, the
@@ -2037,7 +2066,7 @@ globalThis.__sbEntry = function (handlers, request) {
2037
2066
  // concurrent in-process requests.
2038
2067
  //
2039
2068
  // Sync handlers only. An async handler's promise is handed straight back:
2040
- // Porffor alpha-4's native-fetch server resolves the promise the handler
2069
+ // Porffor alpha-10's native-fetch server resolves the promise the handler
2041
2070
  // itself returned, but never one derived from `.then()`, so chaining the
2042
2071
  // tag on hangs the request forever. cpuMs is documented as absent for
2043
2072
  // async handlers (see LogEvent in services/edge) — that is this.
@@ -2050,7 +2079,9 @@ globalThis.__sbEntry = function (handlers, request) {
2050
2079
  };
2051
2080
  let __res;
2052
2081
  try {
2053
- __res = handlers.fetch(request, ctx);
2082
+ __res = globalThis.__sbCompat >= "2026-09-28"
2083
+ ? handlers.fetch(request, globalThis.env, ctx)
2084
+ : handlers.fetch(request, ctx);
2054
2085
  } catch {
2055
2086
  // #179 — a synchronous throw anywhere in the handler must not take the
2056
2087
  // whole process (and every other in-flight request) down with it.
@@ -2077,10 +2108,10 @@ globalThis.__sbEntry = function (handlers, request) {
2077
2108
  };
2078
2109
  let __sres;
2079
2110
  try {
2080
- __sres = handlers.scheduled(
2081
- { cron: body.cron || "", scheduledTime: body.scheduledTime || Date.now(), noRetry() {} },
2082
- ctx,
2083
- );
2111
+ const event = { cron: body.cron || "", scheduledTime: body.scheduledTime || Date.now(), noRetry() {} };
2112
+ __sres = globalThis.__sbCompat >= "2026-09-28"
2113
+ ? handlers.scheduled(event, globalThis.env, ctx)
2114
+ : handlers.scheduled(event, ctx);
2084
2115
  } catch {
2085
2116
  // #179 — same rule as fetch: don't let a throw here crash the process.
2086
2117
  return new Response("", { status: 204 });
@@ -2167,7 +2198,9 @@ async function __sbRunQueueBatch(handlers, body, ctx) {
2167
2198
  },
2168
2199
  };
2169
2200
  try {
2170
- const __qres = handlers.queue(batch, ctx);
2201
+ const __qres = globalThis.__sbCompat >= "2026-09-28"
2202
+ ? handlers.queue(batch, globalThis.env, ctx)
2203
+ : handlers.queue(batch, ctx);
2171
2204
  // Was fire-and-forget: an async `queue()` had this default-ack pass run (and
2172
2205
  // the response go out) before its own `await`s did, so any ack()/retry()
2173
2206
  // past the first one never counted.
package/src/source.ts CHANGED
@@ -25,7 +25,7 @@ const alwaysForbidden: Array<[RegExp, string]> = [
25
25
  // match would also flag it appearing in an ordinary string a dependency
26
26
  // happens to construct (a doc link, a log message).
27
27
  [/['"`]node:/, "Node, Bun, and Deno APIs are not supported"],
28
- // Porffor alpha-4 compiles `new Proxy(...)` and then ignores the handler: a
28
+ // The Porffor pin compiles `new Proxy(...)` and then ignores the handler: a
29
29
  // trapped property reads back as `undefined`, with no throw. Rejecting it
30
30
  // here is the difference between a build error and a 502 nobody can explain.
31
31
  // It is why itty-router and other Proxy-based routers do not work yet.
@@ -0,0 +1,59 @@
1
+ import { expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ const source = readFileSync(new URL("./native-fetch-prelude.js", import.meta.url), "utf8");
5
+ // oxlint-disable-next-line no-function-constructor -- the prelude is prepended as text in production too
6
+ const load = new Function(
7
+ "Porffor",
8
+ `${source}\n__sbTriggerAuthed = () => true; return { entry: __sbEntry, queue: __sbRunQueueBatch };`,
9
+ );
10
+ const { entry, queue } = load({ c: () => "" });
11
+ const request = () => ({ headers: { get: () => null }, body: null });
12
+
13
+ test("the Workers compatibility date passes env and ctx in their standard positions", async () => {
14
+ globalThis.__sbCompat = "2026-09-28";
15
+ globalThis.env = { GREETING: "hello" };
16
+ const response = entry({ fetch(_request, env, ctx) {
17
+ return new Response(env.GREETING + ":" + (ctx.waitUntil instanceof Function));
18
+ } }, request());
19
+ expect(await response.text()).toBe("hello:true");
20
+ });
21
+
22
+ test("earlier compatibility dates retain ctx as the second argument", async () => {
23
+ globalThis.__sbCompat = "2026-09-07";
24
+ const response = entry({ fetch(_request, ctx) {
25
+ return new Response(String(ctx.waitUntil instanceof Function));
26
+ } }, request());
27
+ expect(await response.text()).toBe("true");
28
+ });
29
+
30
+ test("Response.redirect produces a Location header and empty body", async () => {
31
+ const response = Response.redirect("https://example.com/next", 301);
32
+ expect(response.status).toBe(301);
33
+ expect(response.headers.get("location")).toBe("https://example.com/next");
34
+ expect(await response.text()).toBe("");
35
+ });
36
+
37
+ test("queue handlers receive env and ctx with the Workers compatibility date", async () => {
38
+ globalThis.__sbCompat = "2026-09-28";
39
+ globalThis.env = { GREETING: "hello" };
40
+ let seen = "";
41
+ await queue({ queue(_batch, env, ctx) {
42
+ seen = env.GREETING + ":" + (ctx.waitUntil instanceof Function);
43
+ } }, { messages: [] }, { waitUntil() {} });
44
+ expect(seen).toBe("hello:true");
45
+ });
46
+
47
+ test("scheduled handlers receive env and ctx with the Workers compatibility date", () => {
48
+ globalThis.__sbCompat = "2026-09-28";
49
+ globalThis.env = { GREETING: "hello" };
50
+ let seen = "";
51
+ const response = entry({ scheduled(_event, env, ctx) {
52
+ seen = env.GREETING + ":" + (ctx.waitUntil instanceof Function);
53
+ } }, {
54
+ headers: { get: (name) => name === "x-sb-trigger" ? "scheduled" : null },
55
+ body: "{}",
56
+ });
57
+ expect(response.status).toBe(204);
58
+ expect(seen).toBe("hello:true");
59
+ });