@sproutboat/runtime 0.9.5 → 0.10.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,22 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+ - 7287d9b: Version metadata binding (baronunread/sproutboat#126). `sproutboat.jsonc` gains a `version_metadata` key naming a binding (like wrangler's `[version_metadata]`), and `wrapNativeFetchHandler` bakes `env.<binding> = { id, tag, timestamp }` directly into the module at build time — no broker round trip. `id`/`tag`/`timestamp` are supplied by the caller (the CLI derives them from the artifact digest, the project name, and the build time); this package only wires the config field through and does the baking.
9
+
10
+ ## 0.9.6
11
+
12
+ ### Patch Changes
13
+
14
+ - f070d70: Revert native R2 transfer size-gating (#202). It saved ~11KB of `__text`
15
+ (~1.3% of a typical binary) and on macOS didn't even change the shipped
16
+ file size, since `__TEXT` is page-aligned. Not worth the `#ifdef`
17
+ guard-placement bug class it introduced. R2 transfer support is native code
18
+ again, always compiled in with 404 stubs where unused, same as before #202.
19
+
3
20
  ## 0.9.5
4
21
 
5
22
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.9.5",
3
+ "version": "0.10.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1170,8 +1170,11 @@ globalThis.__sbInstallBindings = function (target, bindings) {
1170
1170
  const r = __sbRpc("kv.get", { ns, key: String(key) });
1171
1171
  return r.found ? r.value : null;
1172
1172
  },
1173
- put(key, value) {
1174
- __sbRpc("kv.put", { ns, key: String(key), value: String(value) });
1173
+ put(key, value, options) {
1174
+ const msg = { ns, key: String(key), value: String(value) };
1175
+ if (options && options.expirationTtl != null) msg.expirationTtl = Number(options.expirationTtl);
1176
+ else if (options && options.expiration != null) msg.expiration = Number(options.expiration);
1177
+ __sbRpc("kv.put", msg);
1175
1178
  },
1176
1179
  delete(key) {
1177
1180
  __sbRpc("kv.delete", { ns, key: String(key) });
@@ -578,7 +578,6 @@ static void sb_r2_delete_blob(const char* path, const char* bucket, const char*
578
578
  // path before it allocates a PendingRequest body. The context owns only a file
579
579
  // descriptor and small metadata buffers; object bytes stay in the kernel page
580
580
  // cache until the final fixed-size digest pass.
581
- #ifdef SB_R2_TRANSFER
582
581
  typedef struct sb_r2_transfer_ctx {
583
582
  FILE* file;
584
583
  sqlite3* db;
@@ -806,7 +805,6 @@ size_t sb_r2_transfer_download_read(sb_r2_transfer_ctx* ctx, size_t offset, char
806
805
  return fread(out, 1, wanted, ctx->file);
807
806
  }
808
807
  void sb_r2_transfer_download_close(sb_r2_transfer_ctx* ctx) { if (ctx) { if (ctx->file) fclose(ctx->file); free(ctx); } }
809
- #endif
810
808
 
811
809
  // Run a script: one or more statements separated by semicolons. sqlite3_exec
812
810
  // handles the whole string, which prepare/step does not — it compiles the first
@@ -1781,8 +1779,13 @@ function __sbEnsureSchema() {
1781
1779
  const s = __sbStore();
1782
1780
  __sbSql(
1783
1781
  s,
1784
- "CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (ns, key))",
1782
+ "CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, " +
1783
+ "expires_at INTEGER, PRIMARY KEY (ns, key))",
1785
1784
  );
1785
+ const kvColumns = __sbSql(s, "PRAGMA table_info(kv)", []).rows;
1786
+ let hasKvExpiresAt = false;
1787
+ for (let i = 0; i < kvColumns.length; i++) if (kvColumns[i][1] === "expires_at") hasKvExpiresAt = true;
1788
+ if (!hasKvExpiresAt) __sbSql(s, "ALTER TABLE kv ADD COLUMN expires_at INTEGER", []);
1786
1789
  // #56 — no `body` column: an object's bytes live in their own file under
1787
1790
  // "<data-dir>/r2-blobs/" (sb_r2_put_c/sb_r2_get_c), never in the sqlite row.
1788
1791
  __sbSql(
@@ -1871,16 +1874,29 @@ function __sbEmbeddedDispatch(msg) {
1871
1874
  if (op === "ping") return { ok: true, op: "pong", echo: msg.msg };
1872
1875
 
1873
1876
  if (op === "kv.get") {
1874
- const r = __sbSql(store, "SELECT value FROM kv WHERE ns = ? AND key = ?", [msg.ns, msg.key]);
1875
- return r.rows.length
1876
- ? { ok: true, found: true, value: r.rows[0][0] }
1877
- : { ok: true, found: false, value: null };
1877
+ const r = __sbSql(store, "SELECT value, expires_at FROM kv WHERE ns = ? AND key = ?", [msg.ns, msg.key]);
1878
+ if (!r.rows.length) return { ok: true, found: false, value: null };
1879
+ const expiresAt = r.rows[0][1];
1880
+ if (expiresAt != null && expiresAt <= Date.now()) {
1881
+ __sbSql(store, "DELETE FROM kv WHERE ns = ? AND key = ?", [msg.ns, msg.key]);
1882
+ return { ok: true, found: false, value: null };
1883
+ }
1884
+ return { ok: true, found: true, value: r.rows[0][0] };
1878
1885
  }
1879
1886
  if (op === "kv.put") {
1887
+ // #58 — CF rejects expirationTtl under 60 seconds; matched here.
1888
+ let expiresAt = null;
1889
+ if (msg.expirationTtl != null) {
1890
+ const ttl = Number(msg.expirationTtl);
1891
+ if (!(ttl >= 60)) throw new Error("expirationTtl must be at least 60 seconds");
1892
+ expiresAt = Date.now() + ttl * 1000;
1893
+ } else if (msg.expiration != null) {
1894
+ expiresAt = Number(msg.expiration) * 1000;
1895
+ }
1880
1896
  __sbSql(
1881
1897
  store,
1882
- "INSERT INTO kv (ns, key, value) VALUES (?1,?2,?3) ON CONFLICT (ns, key) DO UPDATE SET value = ?3",
1883
- [msg.ns, msg.key, msg.value],
1898
+ "INSERT INTO kv (ns, key, value, expires_at) VALUES (?1,?2,?3,?4) ON CONFLICT (ns, key) DO UPDATE SET value = ?3, expires_at = ?4",
1899
+ [msg.ns, msg.key, msg.value, expiresAt],
1884
1900
  );
1885
1901
  return { ok: true };
1886
1902
  }
@@ -1889,10 +1905,11 @@ function __sbEmbeddedDispatch(msg) {
1889
1905
  return { ok: true };
1890
1906
  }
1891
1907
  if (op === "kv.list") {
1892
- const r = __sbSql(store, "SELECT key FROM kv WHERE ns = ? AND key LIKE ? || '%' ORDER BY key", [
1893
- msg.ns,
1894
- msg.prefix || "",
1895
- ]);
1908
+ const r = __sbSql(
1909
+ store,
1910
+ "SELECT key FROM kv WHERE ns = ? AND key LIKE ? || '%' AND (expires_at IS NULL OR expires_at > ?) ORDER BY key",
1911
+ [msg.ns, msg.prefix || "", Date.now()],
1912
+ );
1896
1913
  const keys = [];
1897
1914
  for (let i = 0; i < r.rows.length; i++) keys.push(r.rows[i][0]);
1898
1915
  return { ok: true, keys };
package/src/wrap.ts CHANGED
@@ -177,6 +177,13 @@ export function wrapNativeFetchHandler(
177
177
  assets?: { manifest: unknown; files: Record<string, string> },
178
178
  /** The selected transport determines whether the direct-transfer C ABI is real or stubbed. */
179
179
  transport: Transport = "broker",
180
+ /**
181
+ * #126 — `env.<binding> = { id, tag, timestamp }`. Baked at build time from the
182
+ * artifact digest, like `vars`: the control plane's own deployment id is a
183
+ * separate, later-assigned thing (minted at upload), so this is the artifact's
184
+ * own self-describing identity rather than a broker round trip.
185
+ */
186
+ versionMetadata?: { binding: string; id: string; tag: string; timestamp: string },
180
187
  ): string {
181
188
  const neutralised = neutraliseExports(source);
182
189
  if (neutralised === null || !/\bfetch\s*\(/.test(source)) {
@@ -184,6 +191,13 @@ export function wrapNativeFetchHandler(
184
191
  }
185
192
 
186
193
  const env = `const env = ${JSON.stringify(vars)};\nglobalThis.env = env;\n`;
194
+ const versionMetadataLine = versionMetadata
195
+ ? `env[${JSON.stringify(versionMetadata.binding)}] = ${JSON.stringify({
196
+ id: versionMetadata.id,
197
+ tag: versionMetadata.tag,
198
+ timestamp: versionMetadata.timestamp,
199
+ })};\n`
200
+ : "";
187
201
  // Baked, not a binding: the date belongs to the artifact, and a handler must
188
202
  // not be able to change the semantics it was compiled against at runtime.
189
203
  const compat =
@@ -194,14 +208,13 @@ export function wrapNativeFetchHandler(
194
208
  `globalThis.__sbOutbound = ${JSON.stringify(bindings.outbound)};\n` +
195
209
  (assets ? `globalThis.__sbAssets = ${JSON.stringify(assets)};\n` : "");
196
210
  // The native transfer hook runs before JavaScript receives its Request. It
197
- // is only emitted for an R2 build, keeping its ABI out of applications that
198
- // cannot issue an R2 ticket.
211
+ // has a real file-backed implementation only in embedded builds. The broker
212
+ // transport supplies ABI-compatible stubs because its separate transfer
213
+ // listener owns these URLs, while the shared uWebSockets patch is global.
199
214
  const nativeTransferAbi =
200
- bindings.r2.length === 0
201
- ? ""
202
- : transport === "embedded"
203
- ? `Porffor.c\`const char* sb_standalone_app_name(void) { return ${JSON.stringify(appName)}; }\`;\n`
204
- : 'Porffor.c`typedef struct sb_r2_transfer_ctx { int unused; } sb_r2_transfer_ctx; int sb_r2_transfer_open(const char* a, const char* b, size_t c, sb_r2_transfer_ctx** d) { if (d) *d = 0; return 404; } int sb_r2_transfer_write(sb_r2_transfer_ctx* a, const char* b, size_t c) { return 404; } int sb_r2_transfer_finish(sb_r2_transfer_ctx* a) { return 404; } void sb_r2_transfer_abort(sb_r2_transfer_ctx* a) {} int sb_r2_transfer_download_open(const char* a, const char* b, sb_r2_transfer_ctx** c) { if (c) *c = 0; return 404; } size_t sb_r2_transfer_download_size(sb_r2_transfer_ctx* a) { return 0; } const char* sb_r2_transfer_download_etag(sb_r2_transfer_ctx* a) { return ""; } size_t sb_r2_transfer_download_read(sb_r2_transfer_ctx* a, size_t b, char* c, size_t d) { return 0; } void sb_r2_transfer_download_close(sb_r2_transfer_ctx* a) {}`;\n';
215
+ transport === "embedded"
216
+ ? `Porffor.c\`const char* sb_standalone_app_name(void) { return ${JSON.stringify(appName)}; }\`;\n`
217
+ : "Porffor.c`typedef struct sb_r2_transfer_ctx { int unused; } sb_r2_transfer_ctx; int sb_r2_transfer_open(const char* a, const char* b, size_t c, sb_r2_transfer_ctx** d) { if (d) *d = 0; return 404; } int sb_r2_transfer_write(sb_r2_transfer_ctx* a, const char* b, size_t c) { return 404; } int sb_r2_transfer_finish(sb_r2_transfer_ctx* a) { return 404; } void sb_r2_transfer_abort(sb_r2_transfer_ctx* a) {} int sb_r2_transfer_download_open(const char* a, const char* b, sb_r2_transfer_ctx** c) { if (c) *c = 0; return 404; } size_t sb_r2_transfer_download_size(sb_r2_transfer_ctx* a) { return 0; } const char* sb_r2_transfer_download_etag(sb_r2_transfer_ctx* a) { return \"\"; } size_t sb_r2_transfer_download_read(sb_r2_transfer_ctx* a, size_t b, char* c, size_t d) { return 0; } void sb_r2_transfer_download_close(sb_r2_transfer_ctx* a) {}`;\n";
205
218
  const wire = hasBindings(bindings)
206
219
  ? `__sbInstallBindings(env, ${JSON.stringify(bindings)});\n`
207
220
  : "";
@@ -216,7 +229,7 @@ export function wrapNativeFetchHandler(
216
229
  : "";
217
230
 
218
231
  return (
219
- `${prelude}\n${nativeTransferAbi}${compat}${env}${wire}` +
232
+ `${prelude}\n${nativeTransferAbi}${compat}${env}${versionMetadataLine}${wire}` +
220
233
  `${neutralised}\n` +
221
234
  `${registerDO}${triggers}` +
222
235
  `export default {\n port: ${port},\n fetch(request) { return __sbEntry(__sbHandlers, request); }\n};\n`
package/src/wrap.test.ts DELETED
@@ -1,17 +0,0 @@
1
- import { expect, test } from "bun:test";
2
- import { EMPTY_BINDINGS, wrapNativeFetchHandler } from "./wrap";
3
-
4
- const handler = "export default { fetch() { return new Response('ok'); } };";
5
-
6
- test("direct R2 transfer ABI is emitted only for an R2 binding", () => {
7
- const empty = wrapNativeFetchHandler(handler, "", {}, EMPTY_BINDINGS);
8
- expect(empty).not.toContain("sb_r2_transfer_open");
9
-
10
- const r2 = wrapNativeFetchHandler(handler, "", {}, { ...EMPTY_BINDINGS, r2: ["OBJECTS"] });
11
- expect(r2).toContain("sb_r2_transfer_open");
12
- });
13
-
14
- test("scrypt remains available without a capability declaration", () => {
15
- const wrapped = wrapNativeFetchHandler(handler, "sb_scrypt", {}, EMPTY_BINDINGS);
16
- expect(wrapped).toContain("sb_scrypt");
17
- });