@sproutboat/wire 0.8.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 CHANGED
@@ -1,5 +1,18 @@
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
+
3
16
  ## 0.8.0
4
17
 
5
18
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/wire",
3
- "version": "0.8.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.2.0"
14
+ "@sproutboat/assets": "^0.3.0"
15
15
  },
16
16
  "exports": {
17
17
  ".": "./src/index.ts",
@@ -75,6 +75,62 @@ test("an unbound KV namespace is rejected", async () => {
75
75
  await expect(b.dispatch({ op: "kv.put", ns: "OTHER", key: "k", value: "v" })).rejects.toThrow("not bound");
76
76
  });
77
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
+
78
134
  test("secrets resolve only when both bound and present", async () => {
79
135
  const b = make({ bindings: { secrets: ["API_KEY", "MISSING"] }, secrets: { API_KEY: "s3cr3t" } });
80
136
  expect(await b.dispatch({ op: "secret.get", name: "API_KEY" })).toEqual({ ok: true, value: "s3cr3t" });
package/src/broker.ts CHANGED
@@ -332,6 +332,14 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
332
332
  "CREATE TABLE IF NOT EXISTS ratelimit (name TEXT NOT NULL, key TEXT NOT NULL, window_start INTEGER NOT NULL, " +
333
333
  "count INTEGER NOT NULL, PRIMARY KEY (name, key))",
334
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
+ );
335
343
 
336
344
  // #74 — one SQLite file per account-level resource id, opened on first use.
337
345
  const resourceDbs = new Map<string, Database>();
@@ -1240,6 +1248,50 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1240
1248
  .map((r) => r.key),
1241
1249
  };
1242
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
+ }
1243
1295
  case "secret.get": {
1244
1296
  const name = str(msg.name);
1245
1297
  if (!bindings.secrets.includes(name)) throw new Error(`secret not bound: ${name}`);
@@ -1704,6 +1756,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1704
1756
  const REPLAYABLE = new Set([
1705
1757
  "kv.put",
1706
1758
  "kv.delete",
1759
+ "cache.put",
1760
+ "cache.delete",
1707
1761
  "queue.send",
1708
1762
  "queue.send_batch",
1709
1763
  "d1.query",