@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 +14 -0
- package/package.json +1 -1
- package/src/broker.test.ts +47 -1
- package/src/broker.ts +43 -19
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
package/src/broker.test.ts
CHANGED
|
@@ -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<
|
|
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
|
|
1143
|
+
return Buffer.concat(parts);
|
|
1141
1144
|
}
|
|
1142
1145
|
|
|
1143
|
-
|
|
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:
|
|
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,
|
|
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<
|
|
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:
|
|
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,
|
|
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) || "/";
|