@sproutboat/wire 0.7.0 → 0.9.0
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 +19 -0
- package/package.json +2 -2
- package/src/broker.test.ts +76 -0
- package/src/broker.ts +94 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @sproutboat/wire
|
|
2
2
|
|
|
3
|
+
## 0.9.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 4a7fe62: Cache API: `caches.default` / `caches.open(name)` (baronunread/sproutboat#59), with `match()`/`put()`/`delete()` backed by both transports (broker's `cache.*` ops and the standalone embedded transport, so behavior matches whichever way the app deploys). Keyed on method+URL for v1 (no `Vary` support yet). `put()` throws for a non-GET request, matching the spec; a 206 status or an explicit `no-store`/`private` `Cache-Control` is a silent no-op rather than an error, matching Workers' own `caches.default`. TTL derives from `s-maxage` (preferred) or `max-age`; entries with neither have no expiry.
|
|
8
|
+
|
|
9
|
+
Deliberately synchronous, not Promise-returning: every other buffered-body method this runtime already ships (`text()`/`json()`/`arrayBuffer()`/`blob()`/`formData()`) is sync too, and this runtime's own coroutine machinery has a known, still-open memory-corruption issue tied specifically to nested `async function` calls accumulating across many requests (baronunread/sproutboat#168) — no reason to add more async surface here. `await caches.default.match(...)` still reads correctly: awaiting a non-promise value resolves to it immediately.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Updated dependencies [6145afe]
|
|
14
|
+
- @sproutboat/assets@0.3.0
|
|
15
|
+
|
|
16
|
+
## 0.8.0
|
|
17
|
+
|
|
18
|
+
### Minor Changes
|
|
19
|
+
|
|
20
|
+
- 7287d9b: KV: native key expiration (baronunread/sproutboat#58). `env.KV.put(key, value, { expirationTtl })` / `{ expiration }` now match the Cloudflare Workers contract: `expirationTtl` is seconds from now (rejected below 60, matching CF), `expiration` is an absolute unix-seconds timestamp. An expired key reads as absent from `get()` and is omitted from `list()`; the row is hard-deleted lazily the next time it's touched by a get. No new transport op — both `expirationTtl` and `expiration` are optional fields on the existing `kv.put` frame, so an older broker/prelude pair that ignores them keeps working exactly as before.
|
|
21
|
+
|
|
3
22
|
## 0.7.0
|
|
4
23
|
|
|
5
24
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sproutboat/wire",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"main": "./src/index.ts",
|
|
12
12
|
"types": "./src/index.ts",
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@sproutboat/assets": "^0.
|
|
14
|
+
"@sproutboat/assets": "^0.3.0"
|
|
15
15
|
},
|
|
16
16
|
"exports": {
|
|
17
17
|
".": "./src/index.ts",
|
package/src/broker.test.ts
CHANGED
|
@@ -50,11 +50,87 @@ test("KV put / get / list / delete, scoped to a bound namespace", async () => {
|
|
|
50
50
|
expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toMatchObject({ found: false });
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
+
test("KV expirationTtl under 60 seconds is rejected, matching CF", async () => {
|
|
54
|
+
const b = make({ bindings: { kv: ["CACHE"] } });
|
|
55
|
+
await expect(b.dispatch({ op: "kv.put", ns: "CACHE", key: "k", value: "v", expirationTtl: 59 })).rejects.toThrow(
|
|
56
|
+
"at least 60 seconds",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a key past its expiration reads as absent and is omitted from list", async () => {
|
|
61
|
+
const b = make({ bindings: { kv: ["CACHE"] } });
|
|
62
|
+
const past = Math.floor((Date.now() - 1000) / 1000);
|
|
63
|
+
await b.dispatch({ op: "kv.put", ns: "CACHE", key: "expired", value: "v", expiration: past });
|
|
64
|
+
await b.dispatch({ op: "kv.put", ns: "CACHE", key: "fresh", value: "v", expirationTtl: 3600 });
|
|
65
|
+
expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "expired" })).toEqual({
|
|
66
|
+
ok: true,
|
|
67
|
+
found: false,
|
|
68
|
+
value: null,
|
|
69
|
+
});
|
|
70
|
+
expect(await b.dispatch({ op: "kv.list", ns: "CACHE", prefix: "" })).toEqual({ ok: true, keys: ["fresh"] });
|
|
71
|
+
});
|
|
72
|
+
|
|
53
73
|
test("an unbound KV namespace is rejected", async () => {
|
|
54
74
|
const b = make({ bindings: { kv: ["CACHE"] } });
|
|
55
75
|
await expect(b.dispatch({ op: "kv.put", ns: "OTHER", key: "k", value: "v" })).rejects.toThrow("not bound");
|
|
56
76
|
});
|
|
57
77
|
|
|
78
|
+
test("cache.match on an empty cache is a plain miss", async () => {
|
|
79
|
+
const b = make();
|
|
80
|
+
expect(await b.dispatch({ op: "cache.match", key: "GET https://example.com/a" })).toEqual({
|
|
81
|
+
ok: true,
|
|
82
|
+
found: false,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("cache: put then match round-trips status/headers/body, deletes work, ambient (no binding needed)", async () => {
|
|
87
|
+
const b = make();
|
|
88
|
+
const key = "GET https://example.com/a";
|
|
89
|
+
expect(
|
|
90
|
+
await b.dispatch({ op: "cache.put", key, status: 200, headers: { "content-type": "text/plain" }, body: "hi" }),
|
|
91
|
+
).toEqual({ ok: true, stored: true });
|
|
92
|
+
expect(await b.dispatch({ op: "cache.match", key })).toEqual({
|
|
93
|
+
ok: true,
|
|
94
|
+
found: true,
|
|
95
|
+
status: 200,
|
|
96
|
+
headers: { "content-type": "text/plain" },
|
|
97
|
+
body: "hi",
|
|
98
|
+
});
|
|
99
|
+
expect(await b.dispatch({ op: "cache.delete", key })).toEqual({ ok: true, deleted: true });
|
|
100
|
+
expect(await b.dispatch({ op: "cache.match", key })).toEqual({ ok: true, found: false });
|
|
101
|
+
expect(await b.dispatch({ op: "cache.delete", key })).toEqual({ ok: true, deleted: false });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("cache.put skips storage (silently) for a 206, no-store, or private response", async () => {
|
|
105
|
+
const b = make();
|
|
106
|
+
await b.dispatch({ op: "cache.put", key: "k1", status: 206, body: "partial" });
|
|
107
|
+
await b.dispatch({ op: "cache.put", key: "k2", status: 200, headers: { "cache-control": "no-store" }, body: "x" });
|
|
108
|
+
await b.dispatch({ op: "cache.put", key: "k3", status: 200, headers: { "cache-control": "private" }, body: "x" });
|
|
109
|
+
expect(await b.dispatch({ op: "cache.match", key: "k1" })).toEqual({ ok: true, found: false });
|
|
110
|
+
expect(await b.dispatch({ op: "cache.match", key: "k2" })).toEqual({ ok: true, found: false });
|
|
111
|
+
expect(await b.dispatch({ op: "cache.match", key: "k3" })).toEqual({ ok: true, found: false });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("cache.put derives TTL from s-maxage over max-age; an expired entry reads as a miss", async () => {
|
|
115
|
+
const b = make();
|
|
116
|
+
await b.dispatch({
|
|
117
|
+
op: "cache.put",
|
|
118
|
+
key: "k",
|
|
119
|
+
status: 200,
|
|
120
|
+
headers: { "cache-control": "max-age=3600, s-maxage=0" },
|
|
121
|
+
body: "x",
|
|
122
|
+
});
|
|
123
|
+
// s-maxage=0 wins over max-age=3600 -> expires immediately.
|
|
124
|
+
expect(await b.dispatch({ op: "cache.match", key: "k" })).toEqual({ ok: true, found: false });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("cache namespaces (caches.open) don't collide on the same key", async () => {
|
|
128
|
+
const b = make();
|
|
129
|
+
await b.dispatch({ op: "cache.put", name: "images", key: "k", status: 200, body: "img" });
|
|
130
|
+
expect(await b.dispatch({ op: "cache.match", name: "images", key: "k" })).toMatchObject({ found: true, body: "img" });
|
|
131
|
+
expect(await b.dispatch({ op: "cache.match", key: "k" })).toEqual({ ok: true, found: false });
|
|
132
|
+
});
|
|
133
|
+
|
|
58
134
|
test("secrets resolve only when both bound and present", async () => {
|
|
59
135
|
const b = make({ bindings: { secrets: ["API_KEY", "MISSING"] }, secrets: { API_KEY: "s3cr3t" } });
|
|
60
136
|
expect(await b.dispatch({ op: "secret.get", name: "API_KEY" })).toEqual({ ok: true, value: "s3cr3t" });
|
package/src/broker.ts
CHANGED
|
@@ -264,8 +264,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
264
264
|
conn.exec("PRAGMA journal_mode = WAL");
|
|
265
265
|
conn.exec("PRAGMA synchronous = NORMAL");
|
|
266
266
|
conn.exec(
|
|
267
|
-
"CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
|
|
267
|
+
"CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, " +
|
|
268
|
+
"expires_at INTEGER, PRIMARY KEY (ns, key))",
|
|
268
269
|
);
|
|
270
|
+
const kvColumns = conn.query<{ name: string }, []>("PRAGMA table_info(kv)").all();
|
|
271
|
+
if (!kvColumns.some((column) => column.name === "expires_at")) conn.exec("ALTER TABLE kv ADD COLUMN expires_at INTEGER");
|
|
269
272
|
// #56 — no `body` column: an object's bytes live in their own file under
|
|
270
273
|
// `r2Dir` (or an in-memory map for `:memory:`), never in the SQLite row.
|
|
271
274
|
// Doubling every object through SQLite's own page cache on top of the
|
|
@@ -329,6 +332,14 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
329
332
|
"CREATE TABLE IF NOT EXISTS ratelimit (name TEXT NOT NULL, key TEXT NOT NULL, window_start INTEGER NOT NULL, " +
|
|
330
333
|
"count INTEGER NOT NULL, PRIMARY KEY (name, key))",
|
|
331
334
|
);
|
|
335
|
+
// #59 — caches.default: ambient, not a resource a project binds (no config
|
|
336
|
+
// key), so it lives in the per-deployment db like do_storage/ae rather than
|
|
337
|
+
// going through the account-level resource registry. Key on method+URL for
|
|
338
|
+
// v1, per the issue's own scoped-down ask; no Vary support yet.
|
|
339
|
+
db.exec(
|
|
340
|
+
"CREATE TABLE IF NOT EXISTS cache (name TEXT NOT NULL, cache_key TEXT NOT NULL, status INTEGER NOT NULL, " +
|
|
341
|
+
"headers_json TEXT NOT NULL, body TEXT NOT NULL, expires_at INTEGER, PRIMARY KEY (name, cache_key))",
|
|
342
|
+
);
|
|
332
343
|
|
|
333
344
|
// #74 — one SQLite file per account-level resource id, opened on first use.
|
|
334
345
|
const resourceDbs = new Map<string, Database>();
|
|
@@ -470,29 +481,48 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
470
481
|
};
|
|
471
482
|
|
|
472
483
|
type KvStmts = {
|
|
473
|
-
get: Statement<{ value: string }, [string, string]>;
|
|
474
|
-
put: Statement<unknown, [string, string, string]>;
|
|
484
|
+
get: Statement<{ value: string; expires_at: number | null }, [string, string]>;
|
|
485
|
+
put: Statement<unknown, [string, string, string, number | null]>;
|
|
475
486
|
del: Statement<unknown, [string, string]>;
|
|
476
|
-
list: Statement<{ key: string }, [string, string]>;
|
|
487
|
+
list: Statement<{ key: string }, [string, string, number]>;
|
|
477
488
|
};
|
|
478
489
|
const kvStmtCache = new Map<Database, KvStmts>();
|
|
479
490
|
const kvStmts = (store: Database): KvStmts => {
|
|
480
491
|
let stmts = kvStmtCache.get(store);
|
|
481
492
|
if (!stmts) {
|
|
482
493
|
stmts = {
|
|
483
|
-
get: store.query<{ value: string }, [string, string]>(
|
|
494
|
+
get: store.query<{ value: string; expires_at: number | null }, [string, string]>(
|
|
495
|
+
"SELECT value, expires_at FROM kv WHERE ns = ? AND key = ?",
|
|
496
|
+
),
|
|
484
497
|
put: store.query(
|
|
485
|
-
"INSERT INTO kv (ns, key, value) VALUES (?1, ?2, ?3
|
|
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",
|
|
486
500
|
),
|
|
487
501
|
del: store.query("DELETE FROM kv WHERE ns = ? AND key = ?"),
|
|
488
|
-
|
|
489
|
-
|
|
502
|
+
// #58 — an expired row is filtered out here; a periodic sweep to hard-delete
|
|
503
|
+
// it is a separate concern (ponytail: rows accumulate until touched by a
|
|
504
|
+
// get/list/put on the same key/prefix, add a cron sweep if that matters).
|
|
505
|
+
list: store.query<{ key: string }, [string, string, number]>(
|
|
506
|
+
"SELECT key FROM kv WHERE ns = ? AND key LIKE ? || '%' AND (expires_at IS NULL OR expires_at > ?) ORDER BY key LIMIT 1000",
|
|
490
507
|
),
|
|
491
508
|
};
|
|
492
509
|
kvStmtCache.set(store, stmts);
|
|
493
510
|
}
|
|
494
511
|
return stmts;
|
|
495
512
|
};
|
|
513
|
+
/** CF's own floor. Cloudflare rejects `expirationTtl < 60`; matched here so a
|
|
514
|
+
* handler ported from Workers gets the same validation error either place. */
|
|
515
|
+
const MIN_KV_TTL_SECONDS = 60;
|
|
516
|
+
/** `{ expirationTtl }` (seconds from now) or `{ expiration }` (absolute unix
|
|
517
|
+
* seconds) -> ms epoch, or `null` for no expiry. */
|
|
518
|
+
const kvExpiresAt = (msg: Frame): number | null => {
|
|
519
|
+
if (isNumber(msg.expirationTtl)) {
|
|
520
|
+
if (msg.expirationTtl < MIN_KV_TTL_SECONDS) throw new Error(`expirationTtl must be at least ${MIN_KV_TTL_SECONDS} seconds`);
|
|
521
|
+
return Date.now() + msg.expirationTtl * 1000;
|
|
522
|
+
}
|
|
523
|
+
if (isNumber(msg.expiration)) return msg.expiration * 1000;
|
|
524
|
+
return null;
|
|
525
|
+
};
|
|
496
526
|
|
|
497
527
|
const bound =
|
|
498
528
|
(list: string[], kind: string) =>
|
|
@@ -1190,12 +1220,18 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1190
1220
|
return { ok: true, op: "pong", echo: msg.msg ?? null, pid: process.pid };
|
|
1191
1221
|
case "kv.get": {
|
|
1192
1222
|
const { store, part } = storeFor("kv", requireKv(msg.ns));
|
|
1193
|
-
const
|
|
1194
|
-
|
|
1223
|
+
const stmts = kvStmts(store);
|
|
1224
|
+
const row = stmts.get.get(part, str(msg.key));
|
|
1225
|
+
if (!row) return { ok: true, found: false, value: null };
|
|
1226
|
+
if (row.expires_at != null && row.expires_at <= Date.now()) {
|
|
1227
|
+
stmts.del.run(part, str(msg.key));
|
|
1228
|
+
return { ok: true, found: false, value: null };
|
|
1229
|
+
}
|
|
1230
|
+
return { ok: true, found: true, value: row.value };
|
|
1195
1231
|
}
|
|
1196
1232
|
case "kv.put": {
|
|
1197
1233
|
const { store, part } = storeFor("kv", requireKv(msg.ns));
|
|
1198
|
-
kvStmts(store).put.run(part, str(msg.key), str(msg.value));
|
|
1234
|
+
kvStmts(store).put.run(part, str(msg.key), str(msg.value), kvExpiresAt(msg));
|
|
1199
1235
|
return { ok: true };
|
|
1200
1236
|
}
|
|
1201
1237
|
case "kv.delete": {
|
|
@@ -1208,10 +1244,54 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1208
1244
|
return {
|
|
1209
1245
|
ok: true,
|
|
1210
1246
|
keys: kvStmts(store)
|
|
1211
|
-
.list.all(part, str(msg.prefix))
|
|
1247
|
+
.list.all(part, str(msg.prefix), Date.now())
|
|
1212
1248
|
.map((r) => r.key),
|
|
1213
1249
|
};
|
|
1214
1250
|
}
|
|
1251
|
+
case "cache.match": {
|
|
1252
|
+
const name = str(msg.name) || "default";
|
|
1253
|
+
const key = str(msg.key);
|
|
1254
|
+
const row = db
|
|
1255
|
+
.query<
|
|
1256
|
+
{ status: number; headers_json: string; body: string; expires_at: number | null },
|
|
1257
|
+
[string, string]
|
|
1258
|
+
>("SELECT status, headers_json, body, expires_at FROM cache WHERE name = ? AND cache_key = ?")
|
|
1259
|
+
.get(name, key);
|
|
1260
|
+
if (!row) return { ok: true, found: false };
|
|
1261
|
+
if (row.expires_at != null && row.expires_at <= Date.now()) {
|
|
1262
|
+
db.query("DELETE FROM cache WHERE name = ? AND cache_key = ?").run(name, key);
|
|
1263
|
+
return { ok: true, found: false };
|
|
1264
|
+
}
|
|
1265
|
+
return { ok: true, found: true, status: row.status, headers: jsonObject(parseJsonValue(row.headers_json)) ?? {}, body: row.body };
|
|
1266
|
+
}
|
|
1267
|
+
case "cache.put": {
|
|
1268
|
+
const name = str(msg.name) || "default";
|
|
1269
|
+
const key = str(msg.key);
|
|
1270
|
+
const status = Number(msg.status) || 200;
|
|
1271
|
+
const headers = jsonObject(msg.headers ?? null) ?? {};
|
|
1272
|
+
const cacheControl = str(headers["cache-control"] ?? headers["Cache-Control"] ?? "").toLowerCase();
|
|
1273
|
+
// Mirrors Workers' own `caches.default.put()`: a 206 or an explicit
|
|
1274
|
+
// no-store/private is a silent no-op, not an error — the handler's
|
|
1275
|
+
// response still goes to the client either way.
|
|
1276
|
+
if (status === 206 || cacheControl.includes("no-store") || cacheControl.includes("private")) {
|
|
1277
|
+
return { ok: true, stored: false };
|
|
1278
|
+
}
|
|
1279
|
+
const sMaxAge = /s-maxage=(\d+)/.exec(cacheControl);
|
|
1280
|
+
const maxAge = /max-age=(\d+)/.exec(cacheControl);
|
|
1281
|
+
const ttlSeconds = sMaxAge ? Number(sMaxAge[1]) : maxAge ? Number(maxAge[1]) : null;
|
|
1282
|
+
const expiresAt = ttlSeconds != null ? Date.now() + ttlSeconds * 1000 : null;
|
|
1283
|
+
db.query(
|
|
1284
|
+
"INSERT INTO cache (name, cache_key, status, headers_json, body, expires_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6) " +
|
|
1285
|
+
"ON CONFLICT (name, cache_key) DO UPDATE SET status = ?3, headers_json = ?4, body = ?5, expires_at = ?6",
|
|
1286
|
+
).run(name, key, status, JSON.stringify(headers), str(msg.body), expiresAt);
|
|
1287
|
+
return { ok: true, stored: true };
|
|
1288
|
+
}
|
|
1289
|
+
case "cache.delete": {
|
|
1290
|
+
const name = str(msg.name) || "default";
|
|
1291
|
+
const key = str(msg.key);
|
|
1292
|
+
const r = db.query("DELETE FROM cache WHERE name = ? AND cache_key = ?").run(name, key);
|
|
1293
|
+
return { ok: true, deleted: r.changes > 0 };
|
|
1294
|
+
}
|
|
1215
1295
|
case "secret.get": {
|
|
1216
1296
|
const name = str(msg.name);
|
|
1217
1297
|
if (!bindings.secrets.includes(name)) throw new Error(`secret not bound: ${name}`);
|
|
@@ -1676,6 +1756,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1676
1756
|
const REPLAYABLE = new Set([
|
|
1677
1757
|
"kv.put",
|
|
1678
1758
|
"kv.delete",
|
|
1759
|
+
"cache.put",
|
|
1760
|
+
"cache.delete",
|
|
1679
1761
|
"queue.send",
|
|
1680
1762
|
"queue.send_batch",
|
|
1681
1763
|
"d1.query",
|