@sproutboat/runtime 0.18.0 → 0.19.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,17 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.19.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 6676ead: `env.BUCKET.put(key, request.body)`, `env.KV.put(key, request.body)` and `fetch(url, { body: request.body })` store and send the request body again. Since 0.19.0, `body` is a `ReadableStream`, and these took its string form. Incoming request bodies are also read as the bytes they are, so `arrayBuffer()`, `blob()`, `body` and KV `put` no longer UTF-8 encode a non-ASCII upload twice. R2 `put` accepts bytes as well.
8
+
9
+ ## 0.19.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 4274bef: `Response.body` and `Request.body` are `ReadableStream`s for every body type, as in the Fetch spec and workerd. Before, they held the value given (a string, bytes or a `Blob`). `res.body.getReader()`, `res.body.pipeThrough(...)` and `new Response(other.body)` now work on any response, including the incoming request. The value as given stays internal, so responses are still written with a fixed length unless the body really is a stream. A body whose stream has been locked can no longer be read with `text()` and the other readers, which throw a `TypeError`.
14
+
3
15
  ## 0.18.0
4
16
 
5
17
  ### Minor Changes
package/WEB_STREAMS.md CHANGED
@@ -20,8 +20,8 @@ runtime links no zlib. Compression writes one fixed-Huffman block per chunk as i
20
20
  (close to zlib on text: 252 bytes against 221 for 22.5 KB); decompression inflates all
21
21
  block types once the input ends and checks the CRC-32 or Adler-32. Response and Request
22
22
  bodies that are ReadableStreams are read by text(), json(), arrayBuffer() and blob(), and
23
- their chunks must be bytes, as in workerd. A body given as a string, bytes or a Blob is
24
- still exposed as that value, not as a ReadableStream.
23
+ their chunks must be bytes, as in workerd. `body` is a ReadableStream for every body type,
24
+ as in Fetch: a stream body itself, otherwise a stream made once over the bytes.
25
25
  BYOB buffer detachment requires transfer support from the underlying engine;
26
26
  the polyfill cannot supply that engine primitive. Full Web Platform Tests
27
27
  conformance has not been established for Porffor.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -6,9 +6,10 @@ const ingress = src.slice(
6
6
  src.indexOf("function __sbDecodeIngress("),
7
7
  src.indexOf("globalThis.__sbEntry ="),
8
8
  );
9
- if (!decoder || !ingress) throw new Error("Ingress decoder source not found");
9
+ const rawBody = src.slice(src.indexOf("function __sbRawBody("), src.indexOf("function __sbHasStreamBody("));
10
+ if (!decoder || !ingress || !rawBody) throw new Error("Ingress decoder source not found");
10
11
  // 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 load = new Function(`${decoder}\n${rawBody}\n${ingress}\nreturn __sbDecodeIngress;`);
12
13
  const decode = load();
13
14
  const raw = (text) =>
14
15
  Array.from(new TextEncoder().encode(text), (byte) => String.fromCharCode(byte)).join("");
@@ -3092,7 +3092,7 @@ function __sbCpuMs() {
3092
3092
  function __sbTagCpu(res, t0) {
3093
3093
  const cpu = __sbCpuMs() - t0;
3094
3094
  try {
3095
- const body = res && res.body;
3095
+ const body = __sbRawBody(res);
3096
3096
  if (__sbIsStr(body) && !res.headers.has("set-cookie")) {
3097
3097
  const headers = {};
3098
3098
  res.headers.forEach(function (value, name) {
@@ -4045,7 +4045,14 @@ function __sbFromUtf8(bytes) {
4045
4045
  // #232 — a fetch() request body for the wire: text as-is, and an ArrayBuffer or
4046
4046
  // any view as its bytes (one char per byte) flagged bodyBinary, instead of
4047
4047
  // String(view), which sent "0,1,2,...".
4048
+ // A body stream the runtime made over a value (Response/Request body): that value. Bindings
4049
+ // that take a body at once (R2 and KV put, outbound fetch) read it without draining the
4050
+ // stream, so put(key, request.body) works as in Workers. Other values pass through.
4051
+ function __sbUnwrapBody(value) {
4052
+ return value != null && value._sbBody !== undefined ? value._sbBody : value;
4053
+ }
4048
4054
  function __sbRequestBody(body) {
4055
+ body = __sbUnwrapBody(body);
4049
4056
  if (body == null) return { body: null };
4050
4057
  if (!__sbIsStr(body) && body.byteLength !== undefined) {
4051
4058
  const bytes = body.buffer !== undefined ? new Uint8Array(body.buffer, body.byteOffset, body.byteLength) : body;
@@ -4966,6 +4973,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
4966
4973
  return type === "json" ? JSON.parse(text) : text;
4967
4974
  },
4968
4975
  put(key, value, options) {
4976
+ value = __sbUnwrapBody(value);
4969
4977
  const msg = { ns, key: String(key), value: "" };
4970
4978
  if (value != null && !__sbIsStr(value) && value.byteLength !== undefined) {
4971
4979
  // ArrayBuffer or any ArrayBufferView: its bytes, not String(view) ("0,1,2,...").
@@ -5011,12 +5019,15 @@ globalThis.__sbInstallBindings = function (target, bindings) {
5011
5019
  const name = bindings.r2[i];
5012
5020
  target[name] = {
5013
5021
  put(key, value, options) {
5022
+ value = __sbUnwrapBody(value);
5014
5023
  const o = options || {};
5015
5024
  // #56 — the body goes out of band where the transport allows it.
5025
+ // Bytes go in as bytes: a byte view or the bytes of a body stream (request.body).
5026
+ const raw = value == null ? "" : !__sbIsStr(value) && value.byteLength !== undefined ? __sbToBytes(value.buffer !== undefined ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : value) : String(value);
5016
5027
  return __sbR2Put(
5017
5028
  name,
5018
5029
  String(key),
5019
- value == null ? "" : String(value),
5030
+ raw,
5020
5031
  o.httpMetadata || {},
5021
5032
  o.customMetadata || {},
5022
5033
  ).object;
@@ -5189,7 +5200,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
5189
5200
  url,
5190
5201
  method: opts.method || "GET",
5191
5202
  headers,
5192
- ...__sbRequestBody(opts.body),
5203
+ ...__sbRequestBody(opts instanceof Request ? __sbRawBody(opts) : opts.body),
5193
5204
  });
5194
5205
  const respHeaders = new Headers();
5195
5206
  for (let j = 0; j < (r.headers || []).length; j++) respHeaders.set(r.headers[j][0], r.headers[j][1]);
@@ -5221,7 +5232,7 @@ globalThis.__sbInstallFetch = function () {
5221
5232
  if (__sbIsFn(source.forEach)) source.forEach((v, k) => headers.push([k, v]));
5222
5233
  else for (const k in source) headers.push([k, source[k]]);
5223
5234
  }
5224
- const body = opts.body !== undefined ? opts.body : req && req.body !== "" ? req.body : null;
5235
+ const body = opts.body !== undefined && !(opts instanceof Request) ? opts.body : req && __sbRawBody(req) !== "" ? __sbRawBody(req) : null;
5225
5236
  const method = opts.method || (req && req.method) || "GET";
5226
5237
  const msg = { url, method, headers, ...__sbRequestBody(body) };
5227
5238
  const respond = (r) => {
@@ -5647,8 +5658,14 @@ async function __sbCollectBody(stream) {
5647
5658
  }
5648
5659
  return out;
5649
5660
  }
5661
+ // The body as given (a string, bytes or a ReadableStream), not the stream the Fetch-spec
5662
+ // body getter makes over it; plain objects keep their body as is.
5663
+ function __sbRawBody(message) {
5664
+ if (message == null) return null;
5665
+ return message._bodyInit !== undefined ? message._bodyInit : message.body;
5666
+ }
5650
5667
  function __sbHasStreamBody(res) {
5651
- return res != null && res.body instanceof ReadableStream;
5668
+ return res != null && __sbRawBody(res) instanceof ReadableStream;
5652
5669
  }
5653
5670
 
5654
5671
  // B19: on a host that streams (a standalone sprout's native server defines sb_stream_*), a
@@ -5697,7 +5714,7 @@ function __sbStreamingSupported() {
5697
5714
  return __sbStreaming === 1;
5698
5715
  }
5699
5716
  function __sbStreamResponse(response) {
5700
- const stream = response.body;
5717
+ const stream = __sbRawBody(response);
5701
5718
  const id = __sbStreamOp("o", "0", "");
5702
5719
  response.headers.set("x-sb-stream", id);
5703
5720
  response.body = "";
@@ -5733,7 +5750,7 @@ async function __sbFetchWithDrain(res, tasks) {
5733
5750
  r = res && __sbIsFn(res.then) ? await res : res;
5734
5751
  if (__sbHasStreamBody(r)) {
5735
5752
  if (__sbStreamingSupported()) __sbStreamResponse(r);
5736
- else r.body = await __sbCollectBody(r.body);
5753
+ else r.body = await __sbCollectBody(__sbRawBody(r));
5737
5754
  }
5738
5755
  } catch (error) {
5739
5756
  // #179 — a rejected handler promise must not take the process down with it.
@@ -5794,8 +5811,10 @@ async function __sbQueueWithDrain(result, tasks) {
5794
5811
  // handing the request to user code. Request.text() in Porffor otherwise treats
5795
5812
  // the raw bytes as already-decoded characters and corrupts Unicode/HMAC input.
5796
5813
  function __sbDecodeIngress(request) {
5797
- const raw = request.body;
5814
+ const raw = __sbRawBody(request);
5798
5815
  if (raw == null) return;
5816
+ // Raw bytes, one character each: arrayBuffer(), blob() and body read them as bytes.
5817
+ request._bodyLatin1 = true;
5799
5818
  let decoded = null;
5800
5819
  request.text = function () {
5801
5820
  if (decoded === null) decoded = __sbFromUtf8(raw);
@@ -5986,7 +6005,8 @@ async function __sbRunQueueBatch(handlers, body, ctx) {
5986
6005
 
5987
6006
  function __sbReadJson(request) {
5988
6007
  try {
5989
- return JSON.parse(request.body == null ? "{}" : String(request.body));
6008
+ const body = __sbRawBody(request);
6009
+ return JSON.parse(body == null ? "{}" : String(body));
5990
6010
  } catch {
5991
6011
  return {};
5992
6012
  }
@@ -18,6 +18,13 @@ const handler = `export default {async fetch(request) {
18
18
  if (path === "/stream-strings") return new Response(new ReadableStream({ start(c) { c.enqueue("text"); c.close(); } }));
19
19
  if (path === "/form") { const f = new FormData(); f.append("a", "1"); f.append("f", new Blob(["xy"]), "n.txt"); return new Response(f); }
20
20
  const cases = [
21
+ // body is a ReadableStream for every body type (Fetch), and readers still work.
22
+ () => [new Response("hi").body instanceof ReadableStream, new Response(new Uint8Array([1])).body instanceof ReadableStream, new Response(null).body, new Request("http://a.test", { method: "POST", body: "q" }).body instanceof ReadableStream].join(","),
23
+ async () => new Response(new Response("abc").body).text(),
24
+ async () => { const r = new Response("x"); const b = r.body; return (b === r.body) + ":" + (await r.text()); },
25
+ async () => { const r = new Response("x"); r.body.getReader(); try { await r.text(); return "no throw"; } catch (e) { return e.name; } },
26
+ async () => { const reader = new Response("chunk").body.getReader(); const { value, done } = await reader.read(); return new TextDecoder().decode(value) + ":" + done + ":" + (await reader.read()).done; },
27
+ async () => { const r = new Response("cloned"); return (await r.clone().text()) + "|" + (await r.text()); },
21
28
  // HTMLRewriter, against Bun's (lol-html, as in workerd): the W2 cases and more.
22
29
  () => typeof HTMLRewriter,
23
30
  () => new HTMLRewriter().on("h1", { element(e) { e.setInnerContent("x"); } }).transform(new Response("<h1>a</h1><p>b</p>")).text(),
@@ -18,7 +18,9 @@ const CASES = [
18
18
  `new Request(new URL("http://a.test/u")).url`,
19
19
  `new Request("http://a.test/x", { method: "post" }).method`,
20
20
  ];
21
- const handler = `export default {async fetch() {
21
+ const handler = `export default {async fetch(request) {
22
+ // The incoming request: its body is a ReadableStream, and text() still reads it.
23
+ if (request.method === "POST") { const viaStream = await new Response(request.body).text(); return new Response((request.body instanceof ReadableStream) + ":" + viaStream); }
22
24
  const cases = [
23
25
  ${CASES.map((c) => ` () => (${c}),`).join("\n")}
24
26
  ];
@@ -91,7 +93,9 @@ try {
91
93
  const got = JSON.parse(await (await fetch(`http://127.0.0.1:${port}/`)).text()) as string[];
92
94
  const diffs = CASES.flatMap((c, i) => (got[i] === want[i] ? [] : [`${c}\n want ${JSON.stringify(want[i])}\n got ${JSON.stringify(got[i])}`]));
93
95
  assert.deepEqual(diffs, [], `Request copies differ from Bun in ${diffs.length} of ${CASES.length} cases:\n${diffs.join("\n")}`);
94
- console.log(`Native Request copies match Bun in all ${CASES.length} cases`);
96
+ const posted = await (await fetch(`http://127.0.0.1:${port}/`, { method: "POST", body: "posted é" })).text();
97
+ assert.equal(posted, "true:posted é", "the incoming request body is a stream and text() reads it");
98
+ console.log(`Native Request copies match Bun in all ${CASES.length} cases; the incoming body is a stream`);
95
99
  } finally {
96
100
  if (server) {
97
101
  server.kill(9);
@@ -17,7 +17,14 @@ const text = "/café/日本/😀/x";
17
17
  const handler = `
18
18
  const URL = "shadowed";
19
19
  export default {
20
- async fetch() {
20
+ async fetch(request) {
21
+ // put(key, request.body) and fetch(url, { body: request.body }) are the Workers idiom:
22
+ // body is a ReadableStream, and the bindings take the value it was made from.
23
+ if (request.method === "POST") {
24
+ env.UPLOADS.put("upload", request.body);
25
+ await env.KV.put("upload", request.body);
26
+ return Response.json({ r2: env.UPLOADS.get("upload").text(), kv: await env.KV.get("upload") });
27
+ }
21
28
  await env.KV.put("k", ${JSON.stringify(text)});
22
29
  const kv = await env.KV.get("k");
23
30
  await env.DB.prepare("CREATE TABLE t (v TEXT)").run();
@@ -38,7 +45,7 @@ function freePort(): number {
38
45
  }
39
46
 
40
47
  const workdir = await mkdtemp(join(tmpdir(), "sb-native-utf8-"));
41
- const bindings = { ...EMPTY_BINDINGS, kv: ["KV"], d1: ["DB"] };
48
+ const bindings = { ...EMPTY_BINDINGS, kv: ["KV"], d1: ["DB"], r2: ["UPLOADS"] };
42
49
  const broker = createBroker({
43
50
  db: ":memory:",
44
51
  token: "native-utf8",
@@ -113,7 +120,9 @@ try {
113
120
  );
114
121
  assert.match(body.error, /tabellà/);
115
122
  assert.equal(body.url, "shadowed");
116
- console.log("Non-ASCII KV, D1 and error text round-trip through the broker; a handler-level URL stays scoped");
123
+ const stored = await (await fetch(`http://127.0.0.1:${port}/`, { method: "POST", body: "uploaded é" })).json();
124
+ assert.deepEqual(stored, { r2: "uploaded é", kv: "uploaded é" }, "put(key, request.body) stores the request body");
125
+ console.log("Non-ASCII KV, D1 and error text round-trip through the broker; a handler-level URL stays scoped; put(key, request.body) stores the body");
117
126
  } finally {
118
127
  if (server) {
119
128
  server.kill(9);