@sproutboat/runtime 0.45.0 → 0.46.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,43 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.46.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ca85623: Durable Objects leave memory when idle and are constructed again by their next event, behind compatibility date 2026-11-21 (design 07 stage 1c, plan 018 phase 2). Before the date nothing changes.
8
+
9
+ - **Eviction.** Every object has a record; an event of any kind (RPC, `stub.fetch`, alarm, hibernatable socket event, socket restore) enters through one function, counts the object busy until the event and its `waitUntil` drain have settled, and runs in an async-context frame that names the object. An object is evicted, in one synchronous turn, when nothing is in flight, nothing waits at its input gate, no `blockConcurrencyWhile` or transaction is open, nothing pins it, and its hibernatable sockets can go back to the host. Its storage, alarm, and SQLite file stay (the file is checkpointed and closed: one clean file). The next event runs the constructor again before the event.
10
+ - **Settings** (environment; sproutd sets them from `limits`): `SB_DO_IDLE_MS` (default 10,000; 0 turns idle eviction off), `SB_DO_MAX` (default 16 per MiB of `SB_MEMORY_LIMIT`, 64 to 4,096; 4,096 with no limit), `SB_DO_WAIT_MS` (5,000), `SB_DO_QUEUE_MAX` (1,024).
11
+ - **The cap and the overloaded error.** At `SB_DO_MAX` objects the first event of a new object evicts the stalest idle one; with none idle it waits up to `SB_DO_WAIT_MS`, then fails alone with an error whose `overloaded` is `true`. More than 1,024 events queued behind one object's gate fail the same way.
12
+ - **Pins.** A pending `setTimeout` or `setInterval`, an unawaited `fetch`, an open plain WebSocket, outbound WebSocket or `connect()` socket, and a streamed response keep the object in memory. The timer globals become wrappers once the first object is built; the prelude's own timers use the runtime's.
13
+ - **Revoked instances.** A leftover closure of an evicted instance throws "This Durable Object instance was evicted from memory." instead of writing as a second instance. `abort()` and a `blockConcurrencyWhile` that rejects or runs 30 s reset the object the same way (events waiting at its gate fail).
14
+ - **Alarms.** The embedded pass runs its claimed batch concurrently and renews the claim; `alarm()` gets the delivery row's attempt count, so `retryCount` survives eviction; an alarm that cannot get a slot is released without counting an attempt.
15
+ - **Host.** Parked sweep timer (op `D`), giving hibernatable sockets back (ops `U`, `g`); a host without them pins objects that hold such sockets, and sweeps when the next event arrives. A standalone binary keeps every object with an open socket in memory.
16
+ - A counters line (`objects_awake`, `evicted_idle`, `wake_us`, ...) goes to the host as a note, or to stderr with `SB_DO_STATS=1`.
17
+
18
+ Not in this release: the memory-pressure eviction trigger (it waits for plan 016's pool).
19
+ - ca85623: SQLite page caches start small and grow on evidence, and one function closes a database handle (design 07 stage 1c, plan 018 phase 1).
20
+
21
+ - Every database opens with a 256 KiB page cache (`PRAGMA cache_size=-256`). Every 64th use, a cache over 90% full with over 10% misses doubles, up to 2 MiB or its share. The share is `T / open databases`, where `T` is `SB_MEMORY_LIMIT / 8` (4 MiB at 32 MiB); when a database opens and the sizes no longer fit under `T`, the stalest go back to their share. `SB_SQLITE_CACHE_MB` keeps its meaning and fixes the size. `sqlite3_soft_heap_limit64(T)` is set when a limit is known. A standalone binary without a limit starts at 256 KiB and grows to 2 MiB.
22
+ - The handle table holds 64 databases (was 16). Object databases (`storage.sql`) take at most `clamp(T / 160 KiB, 8, 48)` of them (25 at 32 MiB; 48 with no limit; was 8), and the slot used longest ago is closed first, but never one inside a transaction or savepoint.
23
+ - `sb_db_close` is the only place a handle is closed (statement cache, a TRUNCATE checkpoint if the connection wrote, `sqlite3_close`, which must return OK; `SQLITE_BUSY` leaves the slot open and is counted). New ops `do.sql.close`, `do.stats` and `do.release`.
24
+ - The storage quota scan runs every second per 1,000 files (at most every 10 s) instead of every second: at 5,000 object files the walk takes 7 ms, at 15,000 about 20 ms. A write can overshoot the quota by up to that interval.
25
+
26
+ Nothing an app can observe changes except lower SQLite memory and the longer quota interval; the ABI hash changes with the prelude.
27
+
28
+ ### Patch Changes
29
+
30
+ - d4bbcd5: `_redirects` matching follows Cloudflare's rules. The whole request path must match, so a trailing slash is significant: `/compare /compare/ 308` redirects `/compare` once, and `/compare/` is then served (or matched by a rule of its own) instead of redirecting to itself forever. Before, a rule's `from` was compared segment by segment with the empty ones dropped, so `/compare` also matched `/compare/` and `/user/:id` matched `/user/42/`.
31
+
32
+ What matches, as in Cloudflare's rules engine:
33
+
34
+ - `:name` matches one or more characters up to the next `/`, may sit inside a segment (`/files/:name.html`), and may appear once per rule;
35
+ - `*` matches anything including `/` and the empty string, is `:splat` in the target, and may sit in the middle of the path (`/a/*/c`); a rule has at most one;
36
+ - a rule with a repeated name or two splats never matches (Cloudflare drops it);
37
+ - a static rule (no `:name`, no `*`) beats a dynamic one whatever the order; among rules of one kind the first in the file wins. `findRedirect(rules, pathname)` is the new entry point; `matchRedirect(rule, pathname)` still answers for one rule.
38
+
39
+ This changes what an existing `_redirects` file does for paths that differ only by a trailing slash, with no compatibility date: the edge serves assets before any app code runs and cannot see the date, and the old matching was what looped. Run `bun run test:native-asset-redirects` to check the compiled matcher against the same cases as the portable one.
40
+
3
41
  ## 0.45.0
4
42
 
5
43
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.45.0",
3
+ "version": "0.46.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,46 +1,69 @@
1
1
  import { expect, test } from "bun:test";
2
2
  import { readFileSync } from "node:fs";
3
- import {
4
- matchRedirect,
5
- matchHeaders,
6
- parseHeaders,
7
- parseRedirects,
8
- } from "../../assets/src/assets.ts";
3
+ import { REDIRECT_CASES as CASES } from "../test/fixtures/asset-redirect-cases";
4
+ import { findRedirect, matchRedirect, matchHeaders, parseHeaders, parseRedirects } from "../../assets/src/assets.ts";
9
5
 
10
6
  const source = readFileSync(new URL("./native-fetch-prelude.js", import.meta.url), "utf8");
11
- const start = source.indexOf("function __sbAssetRedirect(");
7
+ const start = source.indexOf("function __sbRuleAlpha(");
12
8
  const end = source.indexOf("// Installed only when the project declares bindings.", start);
13
9
  if (start < 0 || end < start) throw new Error("asset rule helpers not found");
14
10
  // oxlint-disable-next-line no-function-constructor -- test the text-only native prelude
15
11
  const load = new Function(
16
12
  "__sbRawBodyResponse",
17
13
  source.slice(start, end) +
18
- "\nreturn { redirect: __sbAssetRedirect, matches: __sbAssetHeaderMatches, response: __sbAssetResponse };",
14
+ "\nreturn { redirect: __sbAssetRedirect, find: __sbFindAssetRedirect, matches: __sbAssetHeaderMatches, response: __sbAssetResponse };",
19
15
  );
20
- const { redirect, matches, response } = load((body, init) => new Response(body, init));
16
+ const { redirect, find, matches, response } = load((body, init) => new Response(body, init));
21
17
 
22
18
  test("native redirect matching agrees with shared asset rules", () => {
23
- const rules = parseRedirects(
24
- "/old /new 301\n/blog/:slug /posts/:slug\n/docs/* https://example.test/:splat 307",
25
- );
26
- for (const path of [
27
- "/old",
28
- "/old/",
29
- "/blog/a",
30
- "/blog/a/b",
31
- "/docs",
32
- "/docs/",
33
- "/docs/a/b",
34
- "/missing",
35
- ]) {
19
+ const rules = parseRedirects("/old /new 301\n/blog/:slug /posts/:slug\n/docs/* https://example.test/:splat 307");
20
+ for (const path of ["/old", "/old/", "/blog/a", "/blog/a/b", "/docs", "/docs/", "/docs/a/b", "/missing"]) {
21
+ for (const rule of rules) expect(redirect(rule, path)).toBe(matchRedirect(rule, path));
22
+ }
23
+ });
24
+
25
+ // What both matchers are held to (Cloudflare's rules-engine); the compiled one runs the same file in native-asset-redirects.ts.
26
+ // [_redirects text, request path, [status, location] or null]
27
+ test("redirect matching follows Cloudflare: whole path, trailing slash significant, static before dynamic", () => {
28
+ for (const [text, path, want] of CASES) {
29
+ const rules = parseRedirects(text);
30
+ const portable = findRedirect(rules, path);
31
+ const native = find(rules, path);
32
+ const got = (hit) => (hit === null ? null : [hit.rule?.status ?? hit.status, hit.location]);
33
+ expect(got(portable), `portable ${JSON.stringify(text)} ${path}`).toEqual(want);
34
+ expect(got(native), `native ${JSON.stringify(text)} ${path}`).toEqual(want);
36
35
  for (const rule of rules) expect(redirect(rule, path)).toBe(matchRedirect(rule, path));
37
36
  }
38
37
  });
39
38
 
39
+ test("a trailing-slash rule is one hop: the target is not matched again", () => {
40
+ const rules = parseRedirects("/compare /compare/ 308");
41
+ const first = find(rules, "/compare");
42
+ expect(first).toEqual({ status: 308, location: "/compare/" });
43
+ expect(find(rules, first.location)).toBeNull();
44
+ const served = response("/compare/", {
45
+ found: true,
46
+ status: 200,
47
+ type: "text/html",
48
+ body: "page",
49
+ hash: "abc",
50
+ rules: { redirects: rules },
51
+ });
52
+ expect(served.status).toBe(200);
53
+ expect(
54
+ response("/compare", {
55
+ found: true,
56
+ status: 200,
57
+ type: "text/html",
58
+ body: "page",
59
+ hash: "abc",
60
+ rules: { redirects: rules },
61
+ }).status,
62
+ ).toBe(308);
63
+ });
64
+
40
65
  test("native header matching agrees with shared asset rules", () => {
41
- const rules = parseHeaders(
42
- "/*\n X-Test: yes\n/assets/*\n Cache-Control: public\n/exact\n ! ETag",
43
- );
66
+ const rules = parseHeaders("/*\n X-Test: yes\n/assets/*\n Cache-Control: public\n/exact\n ! ETag");
44
67
  for (const path of ["/", "/assets", "/assets/a/b", "/exact", "/exact/deep", "/missing"]) {
45
68
  expect(rules.filter((rule) => matches(rule.pattern, path))).toEqual(matchHeaders(rules, path));
46
69
  }
@@ -104,11 +127,9 @@ test("SPA/custom-404 assets receive headers, generated missing responses do not"
104
127
  });
105
128
  expect(custom.status).toBe(404);
106
129
  expect(custom.headers.get("x-test")).toBe("asset");
107
- expect(
108
- response("/missing", { status: 404, found: false, body: "Not Found", rules }).headers.has(
109
- "x-test",
110
- ),
111
- ).toBe(false);
130
+ expect(response("/missing", { status: 404, found: false, body: "Not Found", rules }).headers.has("x-test")).toBe(
131
+ false,
132
+ );
112
133
  });
113
134
 
114
135
  test("a set wins over an unset in the same header block, matching edge behavior", () => {