@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 +13 -0
- package/package.json +1 -1
- package/src/client-ip.test.js +20 -1
- package/src/native-fetch-prelude.js +26 -3
- package/src/transport-embedded.js +30 -11
- package/src/wrap.ts +15 -1
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
package/src/client-ip.test.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
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(
|
|
1891
|
-
|
|
1892
|
-
|
|
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`
|