sproutboat 0.44.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
@@ -10,6 +10,53 @@ maintained going forward by the `release` skill.
10
10
 
11
11
  ## Unreleased
12
12
 
13
+ ## [0.46.0] - 2026-10-30
14
+
15
+ Built on `@sproutboat/runtime` 0.37.1, `@sproutboat/toolchain` 0.21.1 and
16
+ `@sproutboat/wire` 0.21.0. Each change below that an existing app could observe starts at a new
17
+ compatibility date; earlier dates answer as before.
18
+
19
+ ### Added
20
+
21
+ - **D1 like workerd from `2026-11-03`**: a PRAGMA outside D1's allow-list fails with `not authorized`,
22
+ and `exec()` errors and empty statements follow workerd.
23
+ - **Durable Objects like workerd from `2026-11-04`**: `alarm(alarmInfo)` with `{ retryCount, isRetry }`,
24
+ multi-statement `sql.exec()`, `ctx.storage.kv`, `ctx.abort()`, `ctx.props`, transactions on one object
25
+ that run one after another, a transaction that refuses use after `rollback()` or commit, structured
26
+ clone that keeps shared and circular references, and the argument checks of `get`, `put`, `list` and
27
+ `setAlarm`.
28
+ - **Queues like workerd from `2026-11-05`**: the v8 content type keeps shared and circular references, holes
29
+ and invalid dates, message fields are read-only, `send()` and `sendBatch()` check their arguments, and a
30
+ delivery with non-ASCII text reaches the consumer as UTF-8 instead of mojibake.
31
+ - **`DOMException` is a constructible global**, and abort and timeout reasons are instances of it.
32
+ - **`TextDecoder` decodes UTF-16LE, UTF-16BE and windows-1252**; `Blob.prototype.stream()` exists; a
33
+ constructed `Response` has `url` "" and `redirected` false.
34
+
35
+ ### Fixed
36
+
37
+ - **`crypto.getRandomValues` fills every byte** of a wider view (a `Uint32Array` came back below 256).
38
+ - **A collector crash (SIGBUS)**: a minor collection scanned the entries block an array left behind when it
39
+ grew, and marked blocks that had been freed and reused. A request that rejects more promises than
40
+ Promise's pending list has room for could end the process some hundreds of requests later.
41
+
42
+ ## [0.45.0] - 2026-10-29
43
+
44
+ ### Added
45
+
46
+ - **`sproutboat tail` is a live tail.** It streams the app's request events, `console.*` output and uncaught
47
+ exceptions from the control plane (`GET /api/projects/<name>/tail`, Server-Sent Events) in the shape of a
48
+ Workers tail event. Pretty colored lines by default, one JSON event per line with `--format json`. Server-side
49
+ filters: `--status ok|error|<code>|5xx`, `--method GET,POST`, `--sampling-rate 0.1`, `--search <text>`,
50
+ `--outcome <name>`. It reconnects when the server's ten minute cap closes the stream, reports dropped events on
51
+ stderr, and exits 0 on Ctrl-C. Console lines are separate events, not attached to a request (the runtime does not
52
+ tag them with a request id).
53
+
54
+ ### Changed
55
+
56
+ - **`sproutboat tail` with no flags no longer prints and exits.** The old dump of the last 100 request records is
57
+ `sproutboat tail --recent`; `--sprout` is unchanged. Live tail needs a control plane newer than v0.12.0; against
58
+ an older one the command says so and points at `--recent`.
59
+
13
60
  ## [0.44.0] - 2026-10-29
14
61
 
15
62
  Built on `@sproutboat/runtime` 0.36.0, `@sproutboat/toolchain` 0.21.0 and
package/README.md CHANGED
@@ -70,7 +70,7 @@ The everyday loop:
70
70
  | `init [name]` | Scaffold `sproutboat.jsonc` + `src/index.js` |
71
71
  | `dev [--port <n>]` | Run it here against a real broker, rebuilding on save |
72
72
  | `deploy` | Build, provision bindings, upload, wait until the URL serves |
73
- | `tail [--sprout]` | Recent request logs, or the running sprout's output |
73
+ | `tail [filters]` | Live request events, console output and exceptions, like `wrangler tail` |
74
74
  | `rollback <id>` | Re-activate a previous version |
75
75
 
76
76
  Then `check`, `build`, `versions`, `delete`, `login` / `logout` / `whoami`, and
@@ -78,6 +78,45 @@ one command per storage product — `kv`, `d1`, `r2`, `queues`, `secrets`,
78
78
  `domains` — each with the same five verbs.
79
79
 
80
80
  Run `sproutboat` with no arguments for the grouped list.
81
+
82
+ ### Live tail
83
+
84
+ `sproutboat tail` streams one app's events as they happen. Each request prints
85
+ its time, method, URL, status, outcome and CPU and wall milliseconds; the app's
86
+ `console.*` output and uncaught exceptions print as their own lines.
87
+
88
+ ```sh
89
+ sproutboat tail # pretty, colored
90
+ sproutboat tail --status 5xx --search checkout # server-side filters
91
+ sproutboat tail --format json | jq 'select(.outcome != "ok")'
92
+ sproutboat tail --recent # the last 100 request records (the old behaviour)
93
+ sproutboat tail --sprout # the sprout's raw stdout/stderr
94
+ ```
95
+
96
+ | Flag | Meaning |
97
+ | --- | --- |
98
+ | `--format pretty\|json` | Colored lines (default), or one JSON event per line |
99
+ | `--status` | `ok` (below 400), `error` (5xx or a platform failure), a code like `404`, or a class like `5xx`; repeat or comma-separate |
100
+ | `--method` | `GET,POST`; repeat or comma-separate. Console-only events have no method, so this hides them |
101
+ | `--sampling-rate` | Keep this fraction of events, 0 to 1 |
102
+ | `--search` | Case-insensitive substring over the URL, console messages and exception text |
103
+ | `--outcome` | `ok`, `exception`, `exceededWallTime`, `canceled`, `unavailable`, `noRoute`, `proxyError`, ... |
104
+
105
+ Things to know:
106
+
107
+ - **Console lines are not tied to a request.** Concurrent requests share one
108
+ process and the runtime does not tag a line with a request id, so console
109
+ output is printed on its own, flush left, and never indented under a request.
110
+ Lines carry the time the control plane saw them (about one second of
111
+ resolution). The runtime sends `console.log`, `warn` and `error` to the same
112
+ stream without a level, so they all show as `log`; uncaught exceptions are
113
+ recognised and shown as errors with their stack.
114
+ - The server caps the stream at ten minutes and the CLI reconnects; events in
115
+ the gap are not replayed. It also caps events per second and reports the
116
+ count it dropped on stderr, which keeps stdout clean for `--format json`.
117
+ - Needs a control plane with `GET /api/projects/<name>/tail` (Sproutboat
118
+ platform after v0.12.0). Against an older one, use `--recent`.
119
+ - Ctrl-C stops the stream and exits 0.
81
120
  [`SURFACE.md`](SURFACE.md) is the generated inventory: every command, every
82
121
  argument, every env var, kept honest by a drift test.
83
122
 
package/SURFACE.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > Generated by `src/surface.test.ts` from `src/surface.ts` + the pinned
4
4
  > toolchain constants. Do not edit by hand — run `UPDATE_SURFACE=1 bun test`.
5
5
 
6
- **Package:** `sproutboat` 0.44.0
6
+ **Package:** `sproutboat` 0.46.0
7
7
 
8
8
  ## Commands
9
9
 
@@ -18,7 +18,7 @@
18
18
  | `deploy` | `[project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision]` | Build (unless --artifact), auto-provision id-less storage bindings and pin their ids into sproutboat.jsonc, print the report, upload, wait until the URL serves. The control plane skips an upload that matches the live artifact byte-for-byte. --dry-run stops before upload; --no-wait skips the health check; --no-provision leaves id-less bindings as ephemeral deploy-scoped stores. |
19
19
  | `versions` | `<list | view <version-id>> [project-dir]` | List the project's deployed versions, or show one version's artifact and bindings. |
20
20
  | `rollback` | `<version-id> [project-dir]` | Re-activate a previous version. |
21
- | `tail` | `[project-dir] [--sprout]` | Print recent request logs; --sprout prints the running sprout + broker stdout/stderr instead. |
21
+ | `tail` | `[project-dir] [--format pretty|json] [--status ok|error|<code>|5xx] [--method GET,POST] [--sampling-rate <0..1>] [--search <text>] [--outcome <name>] [--recent] [--sprout]` | Stream live request events, console output and exceptions (like wrangler tail); --recent prints the last 100 request records, --sprout the running sprout + broker stdout/stderr. |
22
22
  | `kv` | `<list | create <name> | info <name> | rename <name> <new> | delete <name>> | <key | bulk | export> ...` | KV namespaces. `create` prints the id to bind from sproutboat.jsonc; key and bulk operations manage contents, and export writes a restorable JSON dump. |
23
23
  | `d1` | `<list | create <name> | info <name> | rename <name> <new> | delete <name>>` | D1 databases. `create` prints the id to bind from sproutboat.jsonc. |
24
24
  | `r2` | `<list | create <name> | info <name> | rename <name> <new> | delete <name>>` | R2 buckets. `create` prints the id to bind from sproutboat.jsonc. |
@@ -32,7 +32,7 @@
32
32
  | `complete` | `[bash | zsh | fish]` | Print a shell completion script for the top-level commands (defaults to $SHELL). |
33
33
 
34
34
  ```
35
- usage: sproutboat <init [name] | check [project-dir] | types [project-dir] | dev [project-dir] [--port <n>] [--no-watch] | build [project-dir] [--target host] [--standalone] [--with-restore] [--emit-only] | toolchain doctor [--json] | deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision] | versions <list | view <version-id>> [project-dir] | rollback <version-id> [project-dir] | tail [project-dir] [--sprout] | kv <list | create <name> | info <name> | rename <name> <new> | delete <name>> | <key | bulk | export> ... | d1 <list | create <name> | info <name> | rename <name> <new> | delete <name>> | r2 <list | create <name> | info <name> | rename <name> <new> | delete <name>> | queues <list | create <name> | info <name> | rename <name> <new> | delete <name>> | domains <list | add <host> | verify <host> | delete <host>> [project-dir] | secrets <list | put <NAME> [--value <value>] | delete <NAME>> [project-dir] | delete [project-dir] [--name <project>] --yes | login [--api-url <url>] [--token <token>] | logout [--api-url <url>] | whoami | complete [bash | zsh | fish]>
35
+ usage: sproutboat <init [name] | check [project-dir] | types [project-dir] | dev [project-dir] [--port <n>] [--no-watch] | build [project-dir] [--target host] [--standalone] [--with-restore] [--emit-only] | toolchain doctor [--json] | deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision] | versions <list | view <version-id>> [project-dir] | rollback <version-id> [project-dir] | tail [project-dir] [--format pretty|json] [--status ok|error|<code>|5xx] [--method GET,POST] [--sampling-rate <0..1>] [--search <text>] [--outcome <name>] [--recent] [--sprout] | kv <list | create <name> | info <name> | rename <name> <new> | delete <name>> | <key | bulk | export> ... | d1 <list | create <name> | info <name> | rename <name> <new> | delete <name>> | r2 <list | create <name> | info <name> | rename <name> <new> | delete <name>> | queues <list | create <name> | info <name> | rename <name> <new> | delete <name>> | domains <list | add <host> | verify <host> | delete <host>> [project-dir] | secrets <list | put <NAME> [--value <value>] | delete <NAME>> [project-dir] | delete [project-dir] [--name <project>] --yes | login [--api-url <url>] [--token <token>] | logout [--api-url <url>] | whoami | complete [bash | zsh | fish]>
36
36
  ```
37
37
 
38
38
  ## Environment variables
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutboat",
3
- "version": "0.44.0",
3
+ "version": "0.46.0",
4
4
  "description": "Wrangler-shaped CLI for Sproutboat. Deploys workers to any control plane via --api-url / SPROUTBOAT_API_URL.",
5
5
  "keywords": [
6
6
  "cli",
@@ -64,9 +64,9 @@
64
64
  "@sproutboat/artifact": "^0.3.0",
65
65
  "@sproutboat/assets": "^0.3.0",
66
66
  "@sproutboat/config": "^0.6.0",
67
- "@sproutboat/runtime": "^0.36.0",
68
- "@sproutboat/toolchain": "^0.21.0",
69
- "@sproutboat/wire": "^0.20.4"
67
+ "@sproutboat/runtime": "^0.37.1",
68
+ "@sproutboat/toolchain": "^0.21.1",
69
+ "@sproutboat/wire": "^0.21.0"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@oxlint/plugins": "1.85.0",
@@ -78,10 +78,10 @@
78
78
  "typescript": "7.0.2"
79
79
  },
80
80
  "optionalDependencies": {
81
- "@sproutboat/cli-darwin-arm64": "0.44.0",
82
- "@sproutboat/cli-darwin-x64": "0.44.0",
83
- "@sproutboat/cli-linux-arm64": "0.44.0",
84
- "@sproutboat/cli-linux-x64": "0.44.0"
81
+ "@sproutboat/cli-darwin-arm64": "0.46.0",
82
+ "@sproutboat/cli-darwin-x64": "0.46.0",
83
+ "@sproutboat/cli-linux-arm64": "0.46.0",
84
+ "@sproutboat/cli-linux-x64": "0.46.0"
85
85
  },
86
86
  "engines": {
87
87
  "node": ">=18"
package/src/main.ts CHANGED
@@ -20,6 +20,7 @@ import { controlVersionWarning } from "./api-version";
20
20
  import { amber, bold, dim, leaf, ok, rose } from "./style";
21
21
  import { pathToFileURL } from "node:url";
22
22
  import { inspectToolchain } from "./toolchain";
23
+ import { parseTailArgs, runTail, TAIL_USAGE, tailQuery } from "./tail";
23
24
 
24
25
  // A compiled Bun executable is not a general-purpose `bun` command. Porffor
25
26
  // is acquired after installation, so run its external ESM launcher through an
@@ -607,7 +608,7 @@ async function deploy(args: string[]) {
607
608
  if (!args.includes("--no-wait") && !(await waitForHealthy(deployed.url, 90_000))) {
608
609
  console.warn(
609
610
  amber(
610
- " ! not serving after 90s — Caddy may still be issuing the cert, or the sprout is crashing (`sproutboat tail`)",
611
+ " ! not serving after 90s — Caddy may still be issuing the cert, or the sprout is crashing (`sproutboat tail`, `tail --sprout`)",
611
612
  ),
612
613
  );
613
614
  }
@@ -781,14 +782,33 @@ async function rollback(args: string[]) {
781
782
  }
782
783
 
783
784
  async function tail(args: string[]) {
784
- const sproutLog = args.includes("--sprout");
785
- const dir = args.find((arg) => !arg.startsWith("-"));
786
- const [project, { apiUrl, token }] = await Promise.all([readProjectConfig(dir), apiCredentials()]);
787
- const path = sproutLog ? "logs/sprout" : "logs/recent";
788
- const response = await fetch(`${apiUrl}/api/projects/${project.config.name}/${path}`, {
789
- headers: { "x-api-key": token },
790
- });
791
- process.stdout.write(await responseText(response, "could not read logs"));
785
+ const result = parseTailArgs(args);
786
+ if ("error" in result) usageError(result.error, TAIL_USAGE);
787
+ const parsed = result.args;
788
+ const [project, { apiUrl, token }] = await Promise.all([readProjectConfig(parsed.dir), apiCredentials()]);
789
+ const base = `${apiUrl}/api/projects/${project.config.name}`;
790
+ if (parsed.recent || parsed.sprout) {
791
+ const response = await fetch(`${base}/logs/${parsed.sprout ? "sprout" : "recent"}`, {
792
+ headers: { "x-api-key": token },
793
+ });
794
+ process.stdout.write(await responseText(response, "could not read logs"));
795
+ return;
796
+ }
797
+ // Ctrl-C (or a terminated pipe reader) ends the stream and exits 0.
798
+ const controller = new AbortController();
799
+ for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, () => controller.abort());
800
+ process.stdout.on("error", () => controller.abort());
801
+ const query = tailQuery(parsed);
802
+ try {
803
+ await runTail({
804
+ url: `${base}/tail${query ? `?${query}` : ""}`,
805
+ headers: { "x-api-key": token },
806
+ format: parsed.format,
807
+ signal: controller.signal,
808
+ });
809
+ } catch (error) {
810
+ fail(error instanceof Error ? error.message : String(error));
811
+ }
792
812
  }
793
813
 
794
814
  type DomainView = {
package/src/surface.ts CHANGED
@@ -141,8 +141,10 @@ export const COMMANDS: readonly Command[] = [
141
141
  name: "tail",
142
142
  group: "Ship",
143
143
  emoji: "📡",
144
- args: "[project-dir] [--sprout]",
145
- summary: "Print recent request logs; --sprout prints the running sprout + broker stdout/stderr instead.",
144
+ args: "[project-dir] [--format pretty|json] [--status ok|error|<code>|5xx] [--method GET,POST] [--sampling-rate <0..1>] [--search <text>] [--outcome <name>] [--recent] [--sprout]",
145
+ brief: "[--status 5xx] [--search <text>]",
146
+ summary:
147
+ "Stream live request events, console output and exceptions (like wrangler tail); --recent prints the last 100 request records, --sprout the running sprout + broker stdout/stderr.",
146
148
  },
147
149
 
148
150
  ...storageCommands,
package/src/tail.ts ADDED
@@ -0,0 +1,409 @@
1
+ /**
2
+ * `sproutboat tail`: stream one app's live events from the control plane
3
+ * (`GET /api/projects/<name>/tail`, Server-Sent Events) the way `wrangler tail`
4
+ * does. Request events come from the edge log; console output and uncaught
5
+ * exceptions come from the sprout's own output and arrive as their own events,
6
+ * never attached to a request (the runtime does not tag a console line with a
7
+ * request id). The server enforces the filters, a per-second event cap and a
8
+ * ten minute connection cap; this side reconnects when that cap closes the stream.
9
+ */
10
+ import { isString, jsonObject, parseJsonValue, type JsonObject, type JsonValue } from "./json";
11
+ import { amber, bold, dim, leaf, rose } from "./style";
12
+
13
+ export type TailFormat = "pretty" | "json";
14
+
15
+ export type TailArgs = {
16
+ dir: string | undefined;
17
+ format: TailFormat;
18
+ recent: boolean;
19
+ sprout: boolean;
20
+ status: string[];
21
+ method: string[];
22
+ samplingRate: string | undefined;
23
+ search: string | undefined;
24
+ outcome: string[];
25
+ };
26
+
27
+ export type TailLog = { level: string; message: JsonValue[]; timestamp: number };
28
+ export type TailException = { name: string; message: string; timestamp: number; stack?: string };
29
+ export type TailEvent = {
30
+ scriptName: string;
31
+ outcome: string;
32
+ eventTimestamp: number;
33
+ event: {
34
+ request: { url: string; method: string; headers?: Record<string, string> };
35
+ response: { status: number };
36
+ } | null;
37
+ cpuTime: number | null;
38
+ wallTime: number;
39
+ logs: TailLog[];
40
+ exceptions: TailException[];
41
+ };
42
+
43
+ export type TailFrame =
44
+ | { type: "ready" }
45
+ | { type: "event"; event: TailEvent }
46
+ | { type: "dropped"; count: number; bytes?: number; reason: string }
47
+ | { type: "closed"; reason: string };
48
+
49
+ export const TAIL_USAGE =
50
+ "tail [project-dir] [--format pretty|json] [--status ok|error|<code>|5xx] [--method GET,POST] [--sampling-rate 0.1] [--search <text>] [--outcome <name>] | tail --recent | tail --sprout";
51
+
52
+ const VALUE_FLAGS = ["--format", "--status", "--method", "--sampling-rate", "--search", "--outcome"] as const;
53
+
54
+ /** Parse `tail` arguments; `error` is a usage error message. */
55
+ export function parseTailArgs(args: string[]): { args: TailArgs } | { error: string } {
56
+ const parsed: TailArgs = {
57
+ dir: undefined,
58
+ format: "pretty",
59
+ recent: false,
60
+ sprout: false,
61
+ status: [],
62
+ method: [],
63
+ samplingRate: undefined,
64
+ search: undefined,
65
+ outcome: [],
66
+ };
67
+ const split = (value: string) =>
68
+ value
69
+ .split(",")
70
+ .map((part) => part.trim())
71
+ .filter(Boolean);
72
+ for (let index = 0; index < args.length; index += 1) {
73
+ const arg = args[index];
74
+ if (arg === "--recent") parsed.recent = true;
75
+ else if (arg === "--sprout") parsed.sprout = true;
76
+ else if (arg.startsWith("--")) {
77
+ const eq = arg.indexOf("=");
78
+ const flag = eq < 0 ? arg : arg.slice(0, eq);
79
+ if (!VALUE_FLAGS.some((known) => known === flag)) return { error: `tail: unknown flag "${flag}"` };
80
+ const value = eq < 0 ? args[++index] : arg.slice(eq + 1);
81
+ if (value === undefined || (eq < 0 && value.startsWith("--"))) return { error: `tail: ${flag} needs a value` };
82
+ if (flag === "--format") {
83
+ if (value !== "pretty" && value !== "json")
84
+ return { error: `tail: --format must be pretty or json (got "${value}")` };
85
+ parsed.format = value;
86
+ } else if (flag === "--status") parsed.status.push(...split(value));
87
+ else if (flag === "--method") parsed.method.push(...split(value).map((method) => method.toUpperCase()));
88
+ else if (flag === "--outcome") parsed.outcome.push(...split(value));
89
+ else if (flag === "--sampling-rate") parsed.samplingRate = value;
90
+ else parsed.search = value;
91
+ } else if (parsed.dir === undefined) parsed.dir = arg;
92
+ else return { error: `tail: unexpected argument "${arg}"` };
93
+ }
94
+ const filtered =
95
+ parsed.status.length || parsed.method.length || parsed.outcome.length || parsed.samplingRate || parsed.search;
96
+ if (parsed.recent && parsed.sprout) return { error: "tail: --recent and --sprout are alternatives" };
97
+ if ((parsed.recent || parsed.sprout) && filtered)
98
+ return { error: "tail: filters apply to the live stream, not --recent or --sprout" };
99
+ for (const status of parsed.status) {
100
+ if (!/^(ok|error|[1-5]xx|[1-5]\d\d)$/i.test(status))
101
+ return {
102
+ error: `tail: --status must be ok, error, a code such as 404, or a class such as 5xx (got "${status}")`,
103
+ };
104
+ }
105
+ if (parsed.samplingRate !== undefined) {
106
+ const rate = Number(parsed.samplingRate);
107
+ if (parsed.samplingRate.trim() === "" || !Number.isFinite(rate) || rate < 0 || rate > 1)
108
+ return { error: "tail: --sampling-rate must be a number from 0 to 1" };
109
+ }
110
+ return { args: parsed };
111
+ }
112
+
113
+ /** Query string for the live endpoint. Repeatable filters repeat the parameter. */
114
+ export function tailQuery(args: TailArgs): string {
115
+ const params = new URLSearchParams();
116
+ for (const status of args.status) params.append("status", status);
117
+ for (const method of args.method) params.append("method", method);
118
+ for (const outcome of args.outcome) params.append("outcome", outcome);
119
+ if (args.samplingRate !== undefined) params.set("sampling-rate", args.samplingRate);
120
+ if (args.search) params.set("search", args.search);
121
+ return params.toString();
122
+ }
123
+
124
+ /** Decode an SSE body into frames; comments (keepalives) and malformed data are skipped. */
125
+ export async function* readFrames(body: ReadableStream<Uint8Array>): AsyncGenerator<TailFrame> {
126
+ const reader = body.getReader();
127
+ const decoder = new TextDecoder();
128
+ let buffer = "";
129
+ try {
130
+ for (;;) {
131
+ const next = await reader.read();
132
+ if (next.done) break;
133
+ buffer += decoder.decode(next.value, { stream: true });
134
+ for (let cut = buffer.indexOf("\n\n"); cut >= 0; cut = buffer.indexOf("\n\n")) {
135
+ const block = buffer.slice(0, cut);
136
+ buffer = buffer.slice(cut + 2);
137
+ const data = block
138
+ .split("\n")
139
+ .filter((line) => line.startsWith("data:"))
140
+ .map((line) => line.slice(5).replace(/^ /, ""))
141
+ .join("\n");
142
+ if (!data) continue;
143
+ const frame = toFrame(data);
144
+ if (frame) yield frame;
145
+ }
146
+ }
147
+ } finally {
148
+ reader.releaseLock();
149
+ }
150
+ }
151
+
152
+ function toFrame(data: string): TailFrame | undefined {
153
+ let value: JsonObject | undefined;
154
+ try {
155
+ value = jsonObject(parseJsonValue(data));
156
+ } catch {
157
+ return undefined;
158
+ }
159
+ if (!value) return undefined;
160
+ switch (value.type) {
161
+ case "ready":
162
+ return { type: "ready" };
163
+ case "closed":
164
+ return { type: "closed", reason: isString(value.reason) ? value.reason : "" };
165
+ case "dropped":
166
+ return {
167
+ type: "dropped",
168
+ count: num(value.count) ?? 0,
169
+ bytes: num(value.bytes),
170
+ reason: isString(value.reason) ? value.reason : "",
171
+ };
172
+ case "event": {
173
+ const event = toEvent(value.event);
174
+ return event ? { type: "event", event } : undefined;
175
+ }
176
+ default:
177
+ return undefined;
178
+ }
179
+ }
180
+
181
+ function num(value: JsonValue | undefined): number | undefined {
182
+ return value !== undefined && Object(value) !== value && value === Number(value) && Number.isFinite(value)
183
+ ? value
184
+ : undefined;
185
+ }
186
+
187
+ function objects(value: JsonValue | undefined): JsonObject[] {
188
+ const found: JsonObject[] = [];
189
+ for (const entry of Array.isArray(value) ? value : []) {
190
+ const object = jsonObject(entry);
191
+ if (object) found.push(object);
192
+ }
193
+ return found;
194
+ }
195
+
196
+ function toEvent(value: JsonValue | undefined): TailEvent | undefined {
197
+ const event = value === undefined ? undefined : jsonObject(value);
198
+ if (!event || !isString(event.outcome)) return undefined;
199
+ const eventTimestamp = num(event.eventTimestamp);
200
+ if (eventTimestamp === undefined) return undefined;
201
+ const inner = jsonObject(event.event ?? null);
202
+ const request = jsonObject(inner?.request ?? null);
203
+ const response = jsonObject(inner?.response ?? null);
204
+ return {
205
+ scriptName: isString(event.scriptName) ? event.scriptName : "",
206
+ outcome: event.outcome,
207
+ eventTimestamp,
208
+ event:
209
+ request && response
210
+ ? {
211
+ request: {
212
+ url: isString(request.url) ? request.url : "",
213
+ method: isString(request.method) ? request.method : "",
214
+ },
215
+ response: { status: num(response.status) ?? 0 },
216
+ }
217
+ : null,
218
+ cpuTime: num(event.cpuTime) ?? null,
219
+ wallTime: num(event.wallTime) ?? 0,
220
+ logs: objects(event.logs).map((log) => ({
221
+ level: isString(log.level) ? log.level : "log",
222
+ message: Array.isArray(log.message) ? log.message : [],
223
+ timestamp: num(log.timestamp) ?? 0,
224
+ })),
225
+ exceptions: objects(event.exceptions).map((exception) => ({
226
+ name: isString(exception.name) ? exception.name : "Error",
227
+ message: isString(exception.message) ? exception.message : "",
228
+ timestamp: num(exception.timestamp) ?? 0,
229
+ stack: isString(exception.stack) ? exception.stack : undefined,
230
+ })),
231
+ };
232
+ }
233
+
234
+ const CROSS = "\u2717";
235
+
236
+ const clock = (ms: number): string => {
237
+ const date = new Date(ms);
238
+ const pad = (n: number, width = 2) => String(n).padStart(width, "0");
239
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`;
240
+ };
241
+
242
+ function statusColor(status: number, text: string): string {
243
+ if (status >= 500) return rose(text);
244
+ if (status >= 400) return amber(text);
245
+ if (status >= 300) return dim(text);
246
+ return leaf(text);
247
+ }
248
+
249
+ const message = (parts: JsonValue[]): string =>
250
+ parts.map((part) => (isString(part) ? part : JSON.stringify(part))).join(" ");
251
+
252
+ /**
253
+ * Pretty lines for one event: the request line (time, method, url, status,
254
+ * outcome, cpu and wall ms), then its console lines and exceptions indented. A
255
+ * console-only event (the usual case, see the module comment) prints flush left
256
+ * so it does not read as belonging to the request printed above it.
257
+ */
258
+ export function formatEvent(event: TailEvent): string[] {
259
+ const lines: string[] = [];
260
+ const time = dim(clock(event.eventTimestamp));
261
+ const indent = event.event ? " " : "";
262
+ if (event.event) {
263
+ const { request, response } = event.event;
264
+ const cpu = event.cpuTime === null ? "cpu -" : `cpu ${Number(event.cpuTime.toFixed(2))}ms`;
265
+ const outcome = event.outcome === "ok" ? dim("ok") : rose(event.outcome);
266
+ lines.push(
267
+ `${time} ${bold(request.method.padEnd(6))} ${request.url} ${statusColor(response.status, String(response.status))} ${outcome} ${dim(`${cpu}, wall ${event.wallTime}ms`)}`,
268
+ );
269
+ }
270
+ for (const log of event.logs) {
271
+ const level = log.level === "error" ? rose(log.level) : log.level === "warn" ? amber(log.level) : dim(log.level);
272
+ for (const [index, text] of message(log.message).split("\n").entries()) {
273
+ lines.push(event.event || index > 0 ? `${indent}${dim("|")} ${text}` : `${time} ${level} ${text}`);
274
+ }
275
+ }
276
+ for (const exception of event.exceptions) {
277
+ lines.push(`${event.event ? indent : `${time} `}${rose(`${CROSS} ${exception.name}: ${exception.message}`)}`);
278
+ for (const frame of (exception.stack ?? "").split("\n").slice(1)) lines.push(`${indent}${dim(frame.trimEnd())}`);
279
+ }
280
+ return lines;
281
+ }
282
+
283
+ export type RunTailOptions = {
284
+ url: string;
285
+ headers: Record<string, string>;
286
+ format: TailFormat;
287
+ signal: AbortSignal;
288
+ stdout?: (text: string) => void;
289
+ stderr?: (text: string) => void;
290
+ /** First delay after a failed connection; doubles up to 8 seconds. */
291
+ retryMs?: number;
292
+ /** Give up after this many failed connections in a row. */
293
+ maxFailures?: number;
294
+ /** Reconnect if the server sends nothing (not even a keepalive) for this long. */
295
+ idleMs?: number;
296
+ fetchFn?: typeof fetch;
297
+ };
298
+
299
+ /** How a stream attempt ended, so the caller can decide to retry or stop. */
300
+ export class TailError extends Error {
301
+ constructor(
302
+ message: string,
303
+ readonly status?: number,
304
+ ) {
305
+ super(message);
306
+ }
307
+ }
308
+
309
+ const sleep = (ms: number, signal: AbortSignal) =>
310
+ new Promise<void>((resolve) => {
311
+ const timer = setTimeout(resolve, ms);
312
+ signal.addEventListener(
313
+ "abort",
314
+ () => {
315
+ clearTimeout(timer);
316
+ resolve();
317
+ },
318
+ { once: true },
319
+ );
320
+ });
321
+
322
+ /**
323
+ * Stream until `signal` aborts. Reconnects when the server closes the stream
324
+ * (its time cap) or the link drops; a 4xx answer (bad filter, no auth, no such
325
+ * project) is final.
326
+ */
327
+ export async function runTail(options: RunTailOptions): Promise<void> {
328
+ const out = options.stdout ?? ((text: string) => void process.stdout.write(text));
329
+ const err = options.stderr ?? ((text: string) => void process.stderr.write(text));
330
+ const fetchFn = options.fetchFn ?? fetch;
331
+ const idleMs = options.idleMs ?? 45_000;
332
+ const maxFailures = options.maxFailures ?? 5;
333
+ let delay = options.retryMs ?? 500;
334
+ let failures = 0;
335
+ let announced = false;
336
+
337
+ while (!options.signal.aborted) {
338
+ const attempt = new AbortController();
339
+ const stop = () => attempt.abort();
340
+ options.signal.addEventListener("abort", stop, { once: true });
341
+ let idle: ReturnType<typeof setTimeout> | undefined;
342
+ const arm = () => {
343
+ clearTimeout(idle);
344
+ idle = setTimeout(() => attempt.abort(), idleMs);
345
+ };
346
+ try {
347
+ arm();
348
+ const response = await fetchFn(options.url, {
349
+ headers: { ...options.headers, accept: "text/event-stream" },
350
+ signal: attempt.signal,
351
+ });
352
+ if (!response.ok || !response.body) {
353
+ const detail = await response.text().catch(() => "");
354
+ if (response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429)
355
+ throw new TailError(
356
+ response.status === 404
357
+ ? "live tail is not available here: the project was not found, or this control plane predates `tail` streaming (use `sproutboat tail --recent`)"
358
+ : `could not start the tail (${response.status}): ${errorText(detail)}`,
359
+ response.status,
360
+ );
361
+ throw new Error(`control plane answered ${response.status}`);
362
+ }
363
+ let sawFrame = false;
364
+ for await (const frame of readFrames(response.body)) {
365
+ arm();
366
+ sawFrame = true;
367
+ failures = 0;
368
+ delay = options.retryMs ?? 500;
369
+ if (frame.type === "ready") {
370
+ if (options.format === "pretty" && !announced) err(`${dim("tailing live, Ctrl-C to stop")}\n`);
371
+ announced = true;
372
+ } else if (frame.type === "event") {
373
+ if (options.format === "json") out(`${JSON.stringify(frame.event)}\n`);
374
+ else for (const line of formatEvent(frame.event)) out(`${line}\n`);
375
+ } else if (frame.type === "dropped") {
376
+ const what = frame.count > 0 ? `${frame.count} events` : `${frame.bytes ?? 0} bytes of output`;
377
+ err(`${amber(`! ${what} dropped (${frame.reason === "rate" ? "event rate limit" : "output backlog"})`)}\n`);
378
+ }
379
+ // "closed" is the server's time cap: fall out of the loop and reconnect.
380
+ }
381
+ if (!sawFrame) throw new Error("stream ended before it started");
382
+ } catch (error) {
383
+ if (options.signal.aborted) break;
384
+ if (error instanceof TailError) throw error;
385
+ failures += 1;
386
+ if (failures >= maxFailures)
387
+ throw new TailError(
388
+ `lost the connection ${failures} times in a row: ${error instanceof Error ? error.message : String(error)}`,
389
+ );
390
+ if (options.format === "pretty") err(`${amber("! connection lost, retrying")}\n`);
391
+ await sleep(delay, options.signal);
392
+ delay = Math.min(delay * 2, 8000);
393
+ } finally {
394
+ clearTimeout(idle);
395
+ options.signal.removeEventListener("abort", stop);
396
+ attempt.abort();
397
+ }
398
+ }
399
+ }
400
+
401
+ function errorText(body: string): string {
402
+ try {
403
+ const error = jsonObject(parseJsonValue(body))?.error;
404
+ if (error !== undefined && isString(error)) return error;
405
+ } catch {
406
+ /* plain text */
407
+ }
408
+ return body.trim() || "no detail";
409
+ }