@sproutboat/runtime 0.7.0 → 0.7.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,16 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.7.1
4
+
5
+ ### Patch Changes
6
+
7
+ - b0c5bbd: Fix #177: `new Response(obj.body)` on an `R2Object` UTF-8-re-encoded raw
8
+ bytes, corrupting any binary content (each byte 0x80-0xFF doubled on the
9
+ wire). Added `R2Object.toResponse(init?)`, which serves the bytes
10
+ unmodified via the same `x-sb-raw-body` marker `__sbRawBodyResponse` already
11
+ uses for assets/fetch/service-binding responses. Defaults `content-type`
12
+ from `httpMetadata` and `etag` from `httpEtag` when not set in `init`.
13
+
3
14
  ## 0.7.0
4
15
 
5
16
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1117,6 +1117,21 @@ function __sbR2Object(meta, body) {
1117
1117
  obj.json = function () {
1118
1118
  return JSON.parse(obj.text());
1119
1119
  };
1120
+ // #177 — `new Response(obj.body)` UTF-8-re-encodes the raw bytes (they're
1121
+ // a bytestring, same ambiguity #172/#176 exist to resolve): any byte
1122
+ // 0x80-0xFF doubles into two bytes, corrupting binary content on the way
1123
+ // out. `toResponse()` is the fix already used for assets/fetch/service
1124
+ // bindings (`__sbRawBodyResponse`, below) — same reserved x-sb-raw-body
1125
+ // marker, applied here too.
1126
+ obj.toResponse = function (init) {
1127
+ const opts = init || {};
1128
+ const headers = new Headers(opts.headers || {});
1129
+ headers.set("x-sb-raw-body", "1");
1130
+ if (!headers.has("content-type") && obj.httpMetadata.contentType)
1131
+ headers.set("content-type", obj.httpMetadata.contentType);
1132
+ if (!headers.has("etag")) headers.set("etag", obj.httpEtag);
1133
+ return __sbRawBodyResponse(body, { status: opts.status, headers });
1134
+ };
1120
1135
  }
1121
1136
  return obj;
1122
1137
  }
@@ -0,0 +1,65 @@
1
+ // sproutboat #177 — `new Response(obj.body)` UTF-8-re-encodes an R2 object's
2
+ // raw bytes (they're a bytestring, indistinguishable from Latin-1 text at
3
+ // that layer). `obj.toResponse()` is the fix: same reserved x-sb-raw-body
4
+ // marker __sbRawBodyResponse already uses for assets/fetch/service bindings.
5
+ // The prelude is prepended as text and cannot be imported, so this test lifts
6
+ // the relevant functions out and exercises them directly, against real
7
+ // Response/Headers (both are Bun globals). If any of these move or their
8
+ // boundary markers change, this fails loudly rather than silently testing
9
+ // nothing.
10
+ import { expect, test } from "bun:test";
11
+ import { readFileSync } from "node:fs";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const src = readFileSync(fileURLToPath(new URL("./native-fetch-prelude.js", import.meta.url)), "utf8");
15
+ const utf8Start = src.indexOf("function __sbFromUtf8(");
16
+ const rawStart = src.indexOf("function __sbRawBodyResponse(");
17
+ const utf8End = rawStart;
18
+ const rawEnd = src.indexOf("function __sbEqCt(");
19
+ const r2Start = src.indexOf("function __sbR2Object(");
20
+ const r2End = src.indexOf("// Installed only when the project declares bindings.");
21
+ if (utf8Start === -1 || utf8End === -1) throw new Error("__sbFromUtf8 block not found in prelude");
22
+ if (rawStart === -1 || rawEnd === -1) throw new Error("__sbRawBodyResponse block not found in prelude");
23
+ if (r2Start === -1 || r2End === -1 || r2End < r2Start) throw new Error("__sbR2Object block not found in prelude");
24
+
25
+ // oxlint-disable-next-line no-function-constructor -- lifting pure blocks out of the text-only prelude for test
26
+ const load = new Function(
27
+ `${src.slice(utf8Start, utf8End)}\n${src.slice(rawStart, rawEnd)}\n${src.slice(r2Start, r2End)}\nreturn { __sbR2Object };`,
28
+ );
29
+ const { __sbR2Object } = load();
30
+
31
+ const meta = { key: "f.bin", size: 3, etag: "abc123", uploaded: "2026-09-14T00:00:00.000Z" };
32
+
33
+ // The byte-for-byte round trip itself happens in the compiled runtime's
34
+ // write_response_value (#176's C-side scan, packages/toolchain/src/patch.ts) --
35
+ // a plain-JS Response, Bun's included, re-encodes a bytestring the same way
36
+ // Porffor's does, so this pure-JS test can only prove toResponse() sets the
37
+ // marker that steers that C-side read; the actual byte preservation was
38
+ // verified manually against a real compiled standalone binary (#177).
39
+ test("toResponse() tags the response with the runtime's raw-body marker", () => {
40
+ const bytes = [0x80, 0x81, 0xff].map((b) => String.fromCharCode(b)).join("");
41
+ const obj = __sbR2Object(meta, bytes);
42
+ const fixed = obj.toResponse();
43
+ expect(fixed.headers.get("x-sb-raw-body")).toBe("1");
44
+ });
45
+
46
+ test("toResponse() defaults content-type from httpMetadata and etag from httpEtag", () => {
47
+ const obj = __sbR2Object({ ...meta, httpMetadata: { contentType: "image/png" } }, "x");
48
+ const resp = obj.toResponse();
49
+ expect(resp.headers.get("content-type")).toBe("image/png");
50
+ expect(resp.headers.get("etag")).toBe(obj.httpEtag);
51
+ });
52
+
53
+ test("toResponse() lets caller-supplied headers/status override the defaults", () => {
54
+ const obj = __sbR2Object({ ...meta, httpMetadata: { contentType: "image/png" } }, "x");
55
+ const resp = obj.toResponse({ status: 206, headers: { "content-type": "application/octet-stream" } });
56
+ expect(resp.status).toBe(206);
57
+ expect(resp.headers.get("content-type")).toBe("application/octet-stream");
58
+ });
59
+
60
+ test("toResponse() on an empty body still returns bytes, not the string \"\"", async () => {
61
+ const obj = __sbR2Object(meta, "");
62
+ const resp = obj.toResponse();
63
+ const buf = new Uint8Array(await resp.arrayBuffer());
64
+ expect(buf.length).toBe(0);
65
+ });