@sproutboat/wire 0.9.1 → 0.9.3

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,19 @@
1
1
  # @sproutboat/wire
2
2
 
3
+ ## 0.9.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 5dba469: Binary data survives KV and outbound `fetch()` bodies (baronunread/sproutboat#233, and the request half of #232).
8
+ - **KV:** `put()` of an `ArrayBuffer` or any typed array stores its bytes, flagged binary, instead of `String(view)` (`"0,1,2,..."`). `get(key, type | { type })` supports `"text"`, `"json"` and `"arrayBuffer"` as in Workers: a binary value read as text decodes as UTF-8, and a text value read as `arrayBuffer` is its UTF-8 encoding. `"stream"` throws a clear error. Existing stores gain the `binary` column on first use.
9
+ - **`fetch()` and service-binding request bodies:** an `ArrayBuffer` or view is sent as its exact bytes in both transports, instead of `"0,1,2,..."`. The standalone HTTP client also stopped truncating bodies at the first NUL byte.
10
+
11
+ ## 0.9.2
12
+
13
+ ### Patch Changes
14
+
15
+ - 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.
16
+
3
17
  ## 0.9.1
4
18
 
5
19
  ### 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.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -39,12 +39,25 @@ test("ping round-trips", async () => {
39
39
  expect(await b.dispatch({ op: "ping", msg: "hi" })).toMatchObject({ ok: true, op: "pong", echo: "hi" });
40
40
  });
41
41
 
42
+ test("#233: a KV value put as bytes is flagged binary and survives an existing store", async () => {
43
+ const b = make({ bindings: { kv: ["CACHE"] } });
44
+ // One char per byte, as the runtime sends a put of an ArrayBuffer/view.
45
+ const bytes = Array.from({ length: 256 }, (_, i) => String.fromCharCode(i)).join("");
46
+ await b.dispatch({ op: "kv.put", ns: "CACHE", key: "bin", value: bytes, binary: true });
47
+ await b.dispatch({ op: "kv.put", ns: "CACHE", key: "txt", value: "café" });
48
+ expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "bin" })).toEqual({ ok: true, found: true, value: bytes, binary: true });
49
+ expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "txt" })).toMatchObject({ value: "café", binary: false });
50
+ // Overwriting a binary value with text clears the flag.
51
+ await b.dispatch({ op: "kv.put", ns: "CACHE", key: "bin", value: "now text" });
52
+ expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "bin" })).toMatchObject({ value: "now text", binary: false });
53
+ });
54
+
42
55
  test("KV put / get / list / delete, scoped to a bound namespace", async () => {
43
56
  const b = make({ bindings: { kv: ["CACHE"] } });
44
57
  expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toEqual({ ok: true, found: false, value: null });
45
58
  await b.dispatch({ op: "kv.put", ns: "CACHE", key: "k", value: "v" });
46
59
  await b.dispatch({ op: "kv.put", ns: "CACHE", key: "k2", value: "v2" });
47
- expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toEqual({ ok: true, found: true, value: "v" });
60
+ expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toEqual({ ok: true, found: true, value: "v", binary: false });
48
61
  expect(await b.dispatch({ op: "kv.list", ns: "CACHE", prefix: "k" })).toEqual({ ok: true, keys: ["k", "k2"] });
49
62
  await b.dispatch({ op: "kv.delete", ns: "CACHE", key: "k" });
50
63
  expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toMatchObject({ found: false });
@@ -154,6 +167,38 @@ test("fetch is gated by the exact-host allowlist", async () => {
154
167
  expect(calls).toEqual(["https://api.example.com/x"]);
155
168
  });
156
169
 
170
+ test("#232: a binary fetch request body is sent as its bytes, a text body as UTF-8", async () => {
171
+ const seen: Uint8Array[] = [];
172
+ const fetchImpl: FetchLike = async (_url, init) => {
173
+ seen.push(new Uint8Array(await new Response(init?.body).arrayBuffer()));
174
+ return new Response("ok");
175
+ };
176
+ const b = make({ bindings: { outbound: ["api.example.com"] }, fetchImpl });
177
+ // The runtime sends a binary body one char per byte, flagged bodyBinary.
178
+ const raw = Array.from({ length: 256 }, (_, i) => String.fromCharCode(i)).join("");
179
+ await b.dispatch({ op: "fetch", url: "https://api.example.com/up", method: "POST", body: raw, bodyBinary: true });
180
+ await b.dispatch({ op: "fetch", url: "https://api.example.com/up", method: "POST", body: "café" });
181
+ expect(Array.from(seen[0])).toEqual(Array.from({ length: 256 }, (_, i) => i));
182
+ expect(Array.from(seen[1])).toEqual([99, 97, 102, 195, 169]);
183
+ });
184
+
185
+ test("#232: a v1 fetch returns the upstream body as raw bytes; v0 keeps decoded text", async () => {
186
+ const binary = Uint8Array.from({ length: 256 }, (_, i) => i);
187
+ const fetchImpl: FetchLike = async (url) =>
188
+ new Response(String(url).endsWith("/bin") ? binary : "café", { status: 200 });
189
+ const b = make({ bindings: { outbound: ["api.example.com"] }, fetchImpl });
190
+
191
+ const raw = decodeV1Frame(
192
+ await b.handleFrame(encodeV1({ v: 1, token: "tok", op: "fetch", url: "https://api.example.com/bin" })),
193
+ );
194
+ expect(raw.json).toMatchObject({ ok: true, status: 200 });
195
+ expect(raw.json.body).toBeUndefined();
196
+ expect(Buffer.from(raw.bytes).equals(Buffer.from(binary))).toBe(true);
197
+
198
+ // Already-deployed sprouts speak v0 and expect the body as text in the JSON.
199
+ expect(await b.dispatch({ op: "fetch", url: "https://api.example.com/t" })).toMatchObject({ body: "café" });
200
+ });
201
+
157
202
  test("D1: query / run / batch on a bound database, isolated per name", async () => {
158
203
  const b = make({ bindings: { d1: ["DB", "OTHER"] } });
159
204
  await b.dispatch({ op: "d1.exec", db: "DB", sql: "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)" });
@@ -688,6 +733,7 @@ test("resource-backed KV persists across brokers and is shared by id", async ()
688
733
  expect(await second.dispatch({ op: "kv.get", ns: "SHORTENER", key: "a" })).toEqual({
689
734
  ok: true,
690
735
  found: true,
736
+ binary: false,
691
737
  value: "1",
692
738
  });
693
739
  second.close();
package/src/broker.ts CHANGED
@@ -269,6 +269,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
269
269
  );
270
270
  const kvColumns = conn.query<{ name: string }, []>("PRAGMA table_info(kv)").all();
271
271
  if (!kvColumns.some((column) => column.name === "expires_at")) conn.exec("ALTER TABLE kv ADD COLUMN expires_at INTEGER");
272
+ // #233 — 1 when the value was put as bytes (stored one char per byte), so get()
273
+ // can hand back exact bytes or decoded text depending on the requested type.
274
+ if (!kvColumns.some((column) => column.name === "binary")) conn.exec("ALTER TABLE kv ADD COLUMN binary INTEGER NOT NULL DEFAULT 0");
272
275
  // #56 — no `body` column: an object's bytes live in their own file under
273
276
  // `r2Dir` (or an in-memory map for `:memory:`), never in the SQLite row.
274
277
  // Doubling every object through SQLite's own page cache on top of the
@@ -481,8 +484,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
481
484
  };
482
485
 
483
486
  type KvStmts = {
484
- get: Statement<{ value: string; expires_at: number | null }, [string, string]>;
485
- put: Statement<unknown, [string, string, string, number | null]>;
487
+ get: Statement<{ value: string; expires_at: number | null; binary: number }, [string, string]>;
488
+ put: Statement<unknown, [string, string, string, number | null, number]>;
486
489
  del: Statement<unknown, [string, string]>;
487
490
  list: Statement<{ key: string }, [string, string, number]>;
488
491
  };
@@ -491,12 +494,12 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
491
494
  let stmts = kvStmtCache.get(store);
492
495
  if (!stmts) {
493
496
  stmts = {
494
- get: store.query<{ value: string; expires_at: number | null }, [string, string]>(
495
- "SELECT value, expires_at FROM kv WHERE ns = ? AND key = ?",
497
+ get: store.query<{ value: string; expires_at: number | null; binary: number }, [string, string]>(
498
+ "SELECT value, expires_at, binary FROM kv WHERE ns = ? AND key = ?",
496
499
  ),
497
500
  put: store.query(
498
- "INSERT INTO kv (ns, key, value, expires_at) VALUES (?1, ?2, ?3, ?4) " +
499
- "ON CONFLICT (ns, key) DO UPDATE SET value = ?3, expires_at = ?4",
501
+ "INSERT INTO kv (ns, key, value, expires_at, binary) VALUES (?1, ?2, ?3, ?4, ?5) " +
502
+ "ON CONFLICT (ns, key) DO UPDATE SET value = ?3, expires_at = ?4, binary = ?5",
500
503
  ),
501
504
  del: store.query("DELETE FROM kv WHERE ns = ? AND key = ?"),
502
505
  // #58 — an expired row is filtered out here; a periodic sweep to hard-delete
@@ -1119,12 +1122,12 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1119
1122
  * end the process. 32 MiB by default, and the same variable the embedded
1120
1123
  * transport reads so both backends agree.
1121
1124
  */
1122
- async function readCapped(response: Response, host: string): Promise<string> {
1125
+ async function readCapped(response: Response, host: string): Promise<Buffer> {
1123
1126
  const cap = Number(process.env.SB_FETCH_MAX_BYTES) || 32 * 1024 * 1024;
1124
1127
  const declared = Number(response.headers.get("content-length") || 0);
1125
1128
  if (declared > cap) throw new Error(`response exceeds SB_FETCH_MAX_BYTES from ${host}`);
1126
1129
  const reader = response.body?.getReader();
1127
- if (!reader) return "";
1130
+ if (!reader) return Buffer.alloc(0);
1128
1131
  const parts: Uint8Array[] = [];
1129
1132
  let total = 0;
1130
1133
  for (;;) {
@@ -1137,10 +1140,27 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1137
1140
  }
1138
1141
  parts.push(value);
1139
1142
  }
1140
- return new TextDecoder().decode(Buffer.concat(parts));
1143
+ return Buffer.concat(parts);
1141
1144
  }
1142
1145
 
1143
- async function proxyFetch(msg: Frame): Promise<Frame> {
1146
+ /**
1147
+ * #232 — an upstream response as metadata plus its raw bytes. The v0 frame
1148
+ * carries the body as UTF-8 text inside the JSON (what already-deployed
1149
+ * sprouts expect), which turns a binary body into U+FFFD soup; the v1 frame
1150
+ * sends the bytes untouched in its binary section instead.
1151
+ */
1152
+ type Upstream = { reply: Frame; bytes: Buffer };
1153
+ const asTextFrame = (u: Upstream) => ({ ...u.reply, body: new TextDecoder().decode(u.bytes) });
1154
+
1155
+ /** #232 — a binary request body arrives one char per byte; send those bytes, not their UTF-8. */
1156
+ const upstreamBody = (msg: Frame): string | Uint8Array<ArrayBuffer> | undefined =>
1157
+ msg.body == null
1158
+ ? undefined
1159
+ : msg.bodyBinary === true
1160
+ ? new Uint8Array(Buffer.from(str(msg.body), "latin1"))
1161
+ : str(msg.body);
1162
+
1163
+ async function proxyFetch(msg: Frame): Promise<Upstream> {
1144
1164
  let url: URL;
1145
1165
  try {
1146
1166
  url = new URL(str(msg.url));
@@ -1160,12 +1180,12 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1160
1180
  const res = await doFetch(url, {
1161
1181
  method,
1162
1182
  headers,
1163
- body: msg.body == null || method === "GET" || method === "HEAD" ? undefined : str(msg.body),
1183
+ body: method === "GET" || method === "HEAD" ? undefined : upstreamBody(msg),
1164
1184
  redirect: "manual",
1165
1185
  });
1166
1186
  const outHeaders: Array<[string, string]> = [];
1167
1187
  res.headers.forEach((v, k) => outHeaders.push([k, v]));
1168
- return { ok: true, status: res.status, headers: outHeaders, body: await readCapped(res, url.host) };
1188
+ return { reply: { ok: true, status: res.status, headers: outHeaders }, bytes: await readCapped(res, url.host) };
1169
1189
  }
1170
1190
 
1171
1191
  /**
@@ -1176,7 +1196,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1176
1196
  * minus the trip through the network (and therefore minus TLS and the
1177
1197
  * default-deny egress rules, which is why this is not `fetch`).
1178
1198
  */
1179
- async function serviceFetch(msg: Frame): Promise<Frame> {
1199
+ async function serviceFetch(msg: Frame): Promise<Upstream> {
1180
1200
  const binding = str(msg.binding);
1181
1201
  const declared = bindings.services.find((entry) => entry.binding === binding);
1182
1202
  if (!declared) throw new Error(`service not bound: ${binding}`);
@@ -1206,12 +1226,12 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1206
1226
  const res = await doFetch(target, {
1207
1227
  method,
1208
1228
  headers,
1209
- body: msg.body == null || method === "GET" || method === "HEAD" ? undefined : str(msg.body),
1229
+ body: method === "GET" || method === "HEAD" ? undefined : upstreamBody(msg),
1210
1230
  redirect: "manual",
1211
1231
  });
1212
1232
  const outHeaders: Array<[string, string]> = [];
1213
1233
  res.headers.forEach((v, k) => outHeaders.push([k, v]));
1214
- return { ok: true, status: res.status, headers: outHeaders, body: await readCapped(res, host) };
1234
+ return { reply: { ok: true, status: res.status, headers: outHeaders }, bytes: await readCapped(res, host) };
1215
1235
  }
1216
1236
 
1217
1237
  async function dispatch(msg: Frame): Promise<Frame> {
@@ -1227,11 +1247,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1227
1247
  stmts.del.run(part, str(msg.key));
1228
1248
  return { ok: true, found: false, value: null };
1229
1249
  }
1230
- return { ok: true, found: true, value: row.value };
1250
+ return { ok: true, found: true, value: row.value, binary: row.binary === 1 };
1231
1251
  }
1232
1252
  case "kv.put": {
1233
1253
  const { store, part } = storeFor("kv", requireKv(msg.ns));
1234
- kvStmts(store).put.run(part, str(msg.key), str(msg.value), kvExpiresAt(msg));
1254
+ kvStmts(store).put.run(part, str(msg.key), str(msg.value), kvExpiresAt(msg), msg.binary === true ? 1 : 0);
1235
1255
  return { ok: true };
1236
1256
  }
1237
1257
  case "kv.delete": {
@@ -1299,10 +1319,10 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1299
1319
  return { ok: true, value: secrets[name] };
1300
1320
  }
1301
1321
  case "fetch":
1302
- return proxyFetch(msg);
1322
+ return asTextFrame(await proxyFetch(msg));
1303
1323
 
1304
1324
  case "service.fetch":
1305
- return serviceFetch(msg);
1325
+ return asTextFrame(await serviceFetch(msg));
1306
1326
 
1307
1327
  case "d1.query": {
1308
1328
  const conn = d1(requireD1(msg.db));
@@ -1695,6 +1715,10 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1695
1715
  };
1696
1716
  }
1697
1717
 
1718
+ // #232 — a v1 fetch gets the upstream body as raw bytes, not decoded text.
1719
+ if (msg.op === "fetch") return proxyFetch(msg);
1720
+ if (msg.op === "service.fetch") return serviceFetch(msg);
1721
+
1698
1722
  if (msg.op === "assets.get") {
1699
1723
  if (!bindings.assets) throw new Error("assets not bound");
1700
1724
  const reqPath = str(msg.path) || "/";