@sproutboat/wire 0.9.2 → 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,13 @@
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
+
3
11
  ## 0.9.2
4
12
 
5
13
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/wire",
3
- "version": "0.9.2",
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,21 @@ 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
+
157
185
  test("#232: a v1 fetch returns the upstream body as raw bytes; v0 keeps decoded text", async () => {
158
186
  const binary = Uint8Array.from({ length: 256 }, (_, i) => i);
159
187
  const fetchImpl: FetchLike = async (url) =>
@@ -705,6 +733,7 @@ test("resource-backed KV persists across brokers and is shared by id", async ()
705
733
  expect(await second.dispatch({ op: "kv.get", ns: "SHORTENER", key: "a" })).toEqual({
706
734
  ok: true,
707
735
  found: true,
736
+ binary: false,
708
737
  value: "1",
709
738
  });
710
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
@@ -1149,6 +1152,14 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1149
1152
  type Upstream = { reply: Frame; bytes: Buffer };
1150
1153
  const asTextFrame = (u: Upstream) => ({ ...u.reply, body: new TextDecoder().decode(u.bytes) });
1151
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
+
1152
1163
  async function proxyFetch(msg: Frame): Promise<Upstream> {
1153
1164
  let url: URL;
1154
1165
  try {
@@ -1169,7 +1180,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1169
1180
  const res = await doFetch(url, {
1170
1181
  method,
1171
1182
  headers,
1172
- body: msg.body == null || method === "GET" || method === "HEAD" ? undefined : str(msg.body),
1183
+ body: method === "GET" || method === "HEAD" ? undefined : upstreamBody(msg),
1173
1184
  redirect: "manual",
1174
1185
  });
1175
1186
  const outHeaders: Array<[string, string]> = [];
@@ -1215,7 +1226,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1215
1226
  const res = await doFetch(target, {
1216
1227
  method,
1217
1228
  headers,
1218
- body: msg.body == null || method === "GET" || method === "HEAD" ? undefined : str(msg.body),
1229
+ body: method === "GET" || method === "HEAD" ? undefined : upstreamBody(msg),
1219
1230
  redirect: "manual",
1220
1231
  });
1221
1232
  const outHeaders: Array<[string, string]> = [];
@@ -1236,11 +1247,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1236
1247
  stmts.del.run(part, str(msg.key));
1237
1248
  return { ok: true, found: false, value: null };
1238
1249
  }
1239
- return { ok: true, found: true, value: row.value };
1250
+ return { ok: true, found: true, value: row.value, binary: row.binary === 1 };
1240
1251
  }
1241
1252
  case "kv.put": {
1242
1253
  const { store, part } = storeFor("kv", requireKv(msg.ns));
1243
- 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);
1244
1255
  return { ok: true };
1245
1256
  }
1246
1257
  case "kv.delete": {