@sproutboat/wire 0.9.1 → 0.9.2

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/wire
2
2
 
3
+ ## 0.9.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 733bd9e: Outbound `fetch()` and service-binding responses keep binary bodies intact (baronunread/sproutboat#232). `arrayBuffer()` and `bytes()` on a fetched response used to go through Porffor's `text()`, which the runtime overrides to decode UTF-8, so binary bodies collapsed; they now read the raw bytes. Through the broker, `fetch` and `service.fetch` sent over a v1 frame now get the upstream body as raw bytes in the frame's binary section instead of `TextDecoder` output. v0 frames, which already-deployed sprouts use, are unchanged, and a new sprout talking to an older broker falls back to the previous text behaviour.
8
+
3
9
  ## 0.9.1
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/wire",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -154,6 +154,23 @@ test("fetch is gated by the exact-host allowlist", async () => {
154
154
  expect(calls).toEqual(["https://api.example.com/x"]);
155
155
  });
156
156
 
157
+ test("#232: a v1 fetch returns the upstream body as raw bytes; v0 keeps decoded text", async () => {
158
+ const binary = Uint8Array.from({ length: 256 }, (_, i) => i);
159
+ const fetchImpl: FetchLike = async (url) =>
160
+ new Response(String(url).endsWith("/bin") ? binary : "café", { status: 200 });
161
+ const b = make({ bindings: { outbound: ["api.example.com"] }, fetchImpl });
162
+
163
+ const raw = decodeV1Frame(
164
+ await b.handleFrame(encodeV1({ v: 1, token: "tok", op: "fetch", url: "https://api.example.com/bin" })),
165
+ );
166
+ expect(raw.json).toMatchObject({ ok: true, status: 200 });
167
+ expect(raw.json.body).toBeUndefined();
168
+ expect(Buffer.from(raw.bytes).equals(Buffer.from(binary))).toBe(true);
169
+
170
+ // Already-deployed sprouts speak v0 and expect the body as text in the JSON.
171
+ expect(await b.dispatch({ op: "fetch", url: "https://api.example.com/t" })).toMatchObject({ body: "café" });
172
+ });
173
+
157
174
  test("D1: query / run / batch on a bound database, isolated per name", async () => {
158
175
  const b = make({ bindings: { d1: ["DB", "OTHER"] } });
159
176
  await b.dispatch({ op: "d1.exec", db: "DB", sql: "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)" });
package/src/broker.ts CHANGED
@@ -1119,12 +1119,12 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1119
1119
  * end the process. 32 MiB by default, and the same variable the embedded
1120
1120
  * transport reads so both backends agree.
1121
1121
  */
1122
- async function readCapped(response: Response, host: string): Promise<string> {
1122
+ async function readCapped(response: Response, host: string): Promise<Buffer> {
1123
1123
  const cap = Number(process.env.SB_FETCH_MAX_BYTES) || 32 * 1024 * 1024;
1124
1124
  const declared = Number(response.headers.get("content-length") || 0);
1125
1125
  if (declared > cap) throw new Error(`response exceeds SB_FETCH_MAX_BYTES from ${host}`);
1126
1126
  const reader = response.body?.getReader();
1127
- if (!reader) return "";
1127
+ if (!reader) return Buffer.alloc(0);
1128
1128
  const parts: Uint8Array[] = [];
1129
1129
  let total = 0;
1130
1130
  for (;;) {
@@ -1137,10 +1137,19 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1137
1137
  }
1138
1138
  parts.push(value);
1139
1139
  }
1140
- return new TextDecoder().decode(Buffer.concat(parts));
1140
+ return Buffer.concat(parts);
1141
1141
  }
1142
1142
 
1143
- async function proxyFetch(msg: Frame): Promise<Frame> {
1143
+ /**
1144
+ * #232 — an upstream response as metadata plus its raw bytes. The v0 frame
1145
+ * carries the body as UTF-8 text inside the JSON (what already-deployed
1146
+ * sprouts expect), which turns a binary body into U+FFFD soup; the v1 frame
1147
+ * sends the bytes untouched in its binary section instead.
1148
+ */
1149
+ type Upstream = { reply: Frame; bytes: Buffer };
1150
+ const asTextFrame = (u: Upstream) => ({ ...u.reply, body: new TextDecoder().decode(u.bytes) });
1151
+
1152
+ async function proxyFetch(msg: Frame): Promise<Upstream> {
1144
1153
  let url: URL;
1145
1154
  try {
1146
1155
  url = new URL(str(msg.url));
@@ -1165,7 +1174,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1165
1174
  });
1166
1175
  const outHeaders: Array<[string, string]> = [];
1167
1176
  res.headers.forEach((v, k) => outHeaders.push([k, v]));
1168
- return { ok: true, status: res.status, headers: outHeaders, body: await readCapped(res, url.host) };
1177
+ return { reply: { ok: true, status: res.status, headers: outHeaders }, bytes: await readCapped(res, url.host) };
1169
1178
  }
1170
1179
 
1171
1180
  /**
@@ -1176,7 +1185,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1176
1185
  * minus the trip through the network (and therefore minus TLS and the
1177
1186
  * default-deny egress rules, which is why this is not `fetch`).
1178
1187
  */
1179
- async function serviceFetch(msg: Frame): Promise<Frame> {
1188
+ async function serviceFetch(msg: Frame): Promise<Upstream> {
1180
1189
  const binding = str(msg.binding);
1181
1190
  const declared = bindings.services.find((entry) => entry.binding === binding);
1182
1191
  if (!declared) throw new Error(`service not bound: ${binding}`);
@@ -1211,7 +1220,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1211
1220
  });
1212
1221
  const outHeaders: Array<[string, string]> = [];
1213
1222
  res.headers.forEach((v, k) => outHeaders.push([k, v]));
1214
- return { ok: true, status: res.status, headers: outHeaders, body: await readCapped(res, host) };
1223
+ return { reply: { ok: true, status: res.status, headers: outHeaders }, bytes: await readCapped(res, host) };
1215
1224
  }
1216
1225
 
1217
1226
  async function dispatch(msg: Frame): Promise<Frame> {
@@ -1299,10 +1308,10 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1299
1308
  return { ok: true, value: secrets[name] };
1300
1309
  }
1301
1310
  case "fetch":
1302
- return proxyFetch(msg);
1311
+ return asTextFrame(await proxyFetch(msg));
1303
1312
 
1304
1313
  case "service.fetch":
1305
- return serviceFetch(msg);
1314
+ return asTextFrame(await serviceFetch(msg));
1306
1315
 
1307
1316
  case "d1.query": {
1308
1317
  const conn = d1(requireD1(msg.db));
@@ -1695,6 +1704,10 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1695
1704
  };
1696
1705
  }
1697
1706
 
1707
+ // #232 — a v1 fetch gets the upstream body as raw bytes, not decoded text.
1708
+ if (msg.op === "fetch") return proxyFetch(msg);
1709
+ if (msg.op === "service.fetch") return serviceFetch(msg);
1710
+
1698
1711
  if (msg.op === "assets.get") {
1699
1712
  if (!bindings.assets) throw new Error("assets not bound");
1700
1713
  const reqPath = str(msg.path) || "/";