@sproutboat/runtime 0.9.6 → 0.11.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/runtime
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d0e2c05: `request.cf` gains `httpProtocol`, `tlsVersion`, and `tlsCipher` (baronunread/sproutboat#128). Like `clientIp` (#163), these are only populated when the direct peer is listed in `SB_TRUSTED_PROXIES` and it forwarded the corresponding `x-sb-http-protocol` / `x-sb-tls-version` / `x-sb-tls-cipher` header — otherwise they stay absent rather than a guessed or faked value. `colo`, ASN, and geo fields remain out of scope (no GeoIP database on the box).
8
+
9
+ ## 0.10.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 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.
14
+ - 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.
15
+
3
16
  ## 0.9.6
4
17
 
5
18
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.9.6",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -17,7 +17,7 @@ const load = new Function(
17
17
  "__envMap",
18
18
  `${src.slice(start, end)}
19
19
  const __sbEnv = (k) => __envMap[k];
20
- return { __sbNormalizeIp, __sbIpInCidr, __sbIpTrusted, __sbClientIp, __sbSplitList };`,
20
+ return { __sbNormalizeIp, __sbIpInCidr, __sbIpTrusted, __sbClientIp, __sbSplitList, __sbTrustedProxyHeader };`,
21
21
  );
22
22
  const H = load(new Proxy({}, { get: (_, k) => env[k] }));
23
23
 
@@ -64,3 +64,22 @@ test("__sbClientIp: an untrusted peer cannot use XFF even if it sends one", () =
64
64
  env = { SB_TRUSTED_PROXIES: "10.0.0.0/8" };
65
65
  expect(H.__sbClientIp(req({ "x-sb-remote-addr": "198.51.100.9", "x-forwarded-for": "1.1.1.1" }))).toBe("198.51.100.9");
66
66
  });
67
+
68
+ test("__sbTrustedProxyHeader: no trusted proxies configured means the header is ignored", () => {
69
+ env = {};
70
+ const r = req({ "x-sb-remote-addr": "127.0.0.1", "x-sb-http-protocol": "HTTP/2" });
71
+ expect(H.__sbTrustedProxyHeader(r, "x-sb-http-protocol")).toBeUndefined();
72
+ });
73
+
74
+ test("__sbTrustedProxyHeader: an untrusted direct peer cannot fake it", () => {
75
+ env = { SB_TRUSTED_PROXIES: "10.0.0.0/8" };
76
+ const r = req({ "x-sb-remote-addr": "198.51.100.9", "x-sb-tls-version": "TLSv1.3" });
77
+ expect(H.__sbTrustedProxyHeader(r, "x-sb-tls-version")).toBeUndefined();
78
+ });
79
+
80
+ test("__sbTrustedProxyHeader: a trusted peer's value passes through, absent stays undefined", () => {
81
+ env = { SB_TRUSTED_PROXIES: "127.0.0.0/8" };
82
+ const r = req({ "x-sb-remote-addr": "127.0.0.1", "x-sb-http-protocol": "HTTP/2" });
83
+ expect(H.__sbTrustedProxyHeader(r, "x-sb-http-protocol")).toBe("HTTP/2");
84
+ expect(H.__sbTrustedProxyHeader(r, "x-sb-tls-cipher")).toBeUndefined();
85
+ });
@@ -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) });
@@ -1736,6 +1739,17 @@ function __sbClientIp(request) {
1736
1739
  }
1737
1740
  return peer;
1738
1741
  }
1742
+ // #128 — httpProtocol/tlsVersion/tlsCipher: only a trusted front proxy can set
1743
+ // these (an untrusted direct peer could otherwise fake any value), so this
1744
+ // reuses the same SB_TRUSTED_PROXIES chain #163 already resolves clientIp
1745
+ // against. Undefined when no trusted proxy is configured or it sent nothing —
1746
+ // omitted, not faked, matching how clientIp itself falls back to the raw peer.
1747
+ function __sbTrustedProxyHeader(request, name) {
1748
+ const peer = __sbNormalizeIp(request.headers.get("x-sb-remote-addr") || "");
1749
+ const trusted = __sbSplitList(__sbEnv("SB_TRUSTED_PROXIES"));
1750
+ if (!trusted.length || !__sbIpTrusted(peer, trusted)) return undefined;
1751
+ return request.headers.get(name) || undefined;
1752
+ }
1739
1753
 
1740
1754
  // #57 — ctx.waitUntil: work that must run before the turn completes but must
1741
1755
  // not block the response the handler already built. `waitUntil(p)` just
@@ -1821,9 +1835,18 @@ async function __sbQueueWithDrain(result, tasks) {
1821
1835
  globalThis.__sbEntry = function (handlers, request) {
1822
1836
  const trigger = request.headers.get("x-sb-trigger");
1823
1837
  if (!trigger) {
1824
- // #163 — expose the resolved client IP the Workers way, before the handler runs.
1838
+ // #163/#128 — expose the request metadata the edge already has, the
1839
+ // Workers way, before the handler runs. httpProtocol/tlsVersion/tlsCipher
1840
+ // are only set when a trusted front proxy forwarded them; colo, ASN and
1841
+ // geo fields stay absent everywhere (#128 explicitly rejects faking them).
1825
1842
  const __cf = request.cf || {};
1826
1843
  __cf.clientIp = __sbClientIp(request);
1844
+ const __httpProtocol = __sbTrustedProxyHeader(request, "x-sb-http-protocol");
1845
+ if (__httpProtocol) __cf.httpProtocol = __httpProtocol;
1846
+ const __tlsVersion = __sbTrustedProxyHeader(request, "x-sb-tls-version");
1847
+ if (__tlsVersion) __cf.tlsVersion = __tlsVersion;
1848
+ const __tlsCipher = __sbTrustedProxyHeader(request, "x-sb-tls-cipher");
1849
+ if (__tlsCipher) __cf.tlsCipher = __tlsCipher;
1827
1850
  request.cf = __cf;
1828
1851
  // #28 — per-invocation CPU time. One fetch turn per process (serial), so the
1829
1852
  // process CPU delta across the handler is this invocation's CPU.
@@ -1779,8 +1779,13 @@ function __sbEnsureSchema() {
1779
1779
  const s = __sbStore();
1780
1780
  __sbSql(
1781
1781
  s,
1782
- "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))",
1783
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", []);
1784
1789
  // #56 — no `body` column: an object's bytes live in their own file under
1785
1790
  // "<data-dir>/r2-blobs/" (sb_r2_put_c/sb_r2_get_c), never in the sqlite row.
1786
1791
  __sbSql(
@@ -1869,16 +1874,29 @@ function __sbEmbeddedDispatch(msg) {
1869
1874
  if (op === "ping") return { ok: true, op: "pong", echo: msg.msg };
1870
1875
 
1871
1876
  if (op === "kv.get") {
1872
- const r = __sbSql(store, "SELECT value FROM kv WHERE ns = ? AND key = ?", [msg.ns, msg.key]);
1873
- return r.rows.length
1874
- ? { ok: true, found: true, value: r.rows[0][0] }
1875
- : { 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] };
1876
1885
  }
1877
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
+ }
1878
1896
  __sbSql(
1879
1897
  store,
1880
- "INSERT INTO kv (ns, key, value) VALUES (?1,?2,?3) ON CONFLICT (ns, key) DO UPDATE SET value = ?3",
1881
- [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],
1882
1900
  );
1883
1901
  return { ok: true };
1884
1902
  }
@@ -1887,10 +1905,11 @@ function __sbEmbeddedDispatch(msg) {
1887
1905
  return { ok: true };
1888
1906
  }
1889
1907
  if (op === "kv.list") {
1890
- const r = __sbSql(store, "SELECT key FROM kv WHERE ns = ? AND key LIKE ? || '%' ORDER BY key", [
1891
- msg.ns,
1892
- msg.prefix || "",
1893
- ]);
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
+ );
1894
1913
  const keys = [];
1895
1914
  for (let i = 0; i < r.rows.length; i++) keys.push(r.rows[i][0]);
1896
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 =
@@ -215,7 +229,7 @@ export function wrapNativeFetchHandler(
215
229
  : "";
216
230
 
217
231
  return (
218
- `${prelude}\n${nativeTransferAbi}${compat}${env}${wire}` +
232
+ `${prelude}\n${nativeTransferAbi}${compat}${env}${versionMetadataLine}${wire}` +
219
233
  `${neutralised}\n` +
220
234
  `${registerDO}${triggers}` +
221
235
  `export default {\n port: ${port},\n fetch(request) { return __sbEntry(__sbHandlers, request); }\n};\n`