sproutboat 0.43.0 → 0.45.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,47 @@ maintained going forward by the `release` skill.
10
10
 
11
11
  ## Unreleased
12
12
 
13
+ ## [0.45.0] - 2026-10-29
14
+
15
+ ### Added
16
+
17
+ - **`sproutboat tail` is a live tail.** It streams the app's request events, `console.*` output and uncaught
18
+ exceptions from the control plane (`GET /api/projects/<name>/tail`, Server-Sent Events) in the shape of a
19
+ Workers tail event. Pretty colored lines by default, one JSON event per line with `--format json`. Server-side
20
+ filters: `--status ok|error|<code>|5xx`, `--method GET,POST`, `--sampling-rate 0.1`, `--search <text>`,
21
+ `--outcome <name>`. It reconnects when the server's ten minute cap closes the stream, reports dropped events on
22
+ stderr, and exits 0 on Ctrl-C. Console lines are separate events, not attached to a request (the runtime does not
23
+ tag them with a request id).
24
+
25
+ ### Changed
26
+
27
+ - **`sproutboat tail` with no flags no longer prints and exits.** The old dump of the last 100 request records is
28
+ `sproutboat tail --recent`; `--sprout` is unchanged. Live tail needs a control plane newer than v0.12.0; against
29
+ an older one the command says so and points at `--recent`.
30
+
31
+ ## [0.44.0] - 2026-10-29
32
+
33
+ Built on `@sproutboat/runtime` 0.36.0, `@sproutboat/toolchain` 0.21.0 and
34
+ `@sproutboat/wire` 0.20.4.
35
+
36
+ ### Added
37
+
38
+ - **`AsyncLocalStorage`** from `node:async_hooks`, as Workers has it: `run`, `exit`, `getStore` and the static
39
+ `snapshot`. `enterWith` and `disable` throw, as there. The store follows a request across awaits,
40
+ microtasks, timers, intervals, async `fetch`, WebSocket callbacks and `ctx.waitUntil`, and requests
41
+ interleaved in one process never see each other's. The static `bind` is not provided: Porffor compiles a
42
+ `.bind(` call on a function value to `Function.prototype.bind`, so use `snapshot()`. The import used to
43
+ build and then throw "not a constructor" when the app ran, so there is no compatibility date for it. Cost: a
44
+ promise reaction is 56 bytes instead of 40.
45
+
46
+ ### Changed
47
+
48
+ - **`x-sb-cpu-ms` is per request when requests interleave in one process.** A request is charged the thread
49
+ CPU time of the slices in which it ran, not the process delta across its handler, so a request is no longer
50
+ charged for its neighbours' work. From compatibility date `2026-11-02` an async handler is stamped too
51
+ (logs show it as `cpuMs`); earlier dates still send no stamp for an async handler. A synchronous
52
+ handler's stamp is thread time, which leaves out the egress threads.
53
+
13
54
  ## [0.43.0] - 2026-10-29
14
55
 
15
56
  Built on `@sproutboat/runtime` 0.35.0, `@sproutboat/toolchain` 0.20.1 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.43.0
6
+ **Package:** `sproutboat` 0.45.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.43.0",
3
+ "version": "0.45.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.35.0",
68
- "@sproutboat/toolchain": "^0.20.1",
69
- "@sproutboat/wire": "^0.20.3"
67
+ "@sproutboat/runtime": "^0.36.0",
68
+ "@sproutboat/toolchain": "^0.21.0",
69
+ "@sproutboat/wire": "^0.20.4"
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.43.0",
82
- "@sproutboat/cli-darwin-x64": "0.43.0",
83
- "@sproutboat/cli-linux-arm64": "0.43.0",
84
- "@sproutboat/cli-linux-x64": "0.43.0"
81
+ "@sproutboat/cli-darwin-arm64": "0.45.0",
82
+ "@sproutboat/cli-darwin-x64": "0.45.0",
83
+ "@sproutboat/cli-linux-arm64": "0.45.0",
84
+ "@sproutboat/cli-linux-x64": "0.45.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/node-shims.ts CHANGED
@@ -371,7 +371,17 @@ export { NodeURL as URL, NodeURLSearchParams as URLSearchParams, urlFormat as fo
371
371
  export default { URL: NodeURL, URLSearchParams: NodeURLSearchParams, format: urlFormat, parse: urlParse, resolve: urlResolve };
372
372
  `;
373
373
 
374
+ // AsyncLocalStorage comes from the runtime (sproutboat-packages: the prelude defines it on globalThis,
375
+ // over an async context kept by the patched Porffor). Bun's own node:async_hooks polyfill builds but
376
+ // its AsyncLocalStorage is not a constructor in a compiled handler. Aliased, like the node:url shim.
377
+ const NODE_ASYNC_HOOKS_SHIM = `
378
+ const SbAsyncLocalStorage = globalThis.AsyncLocalStorage;
379
+ export { SbAsyncLocalStorage as AsyncLocalStorage };
380
+ export default { AsyncLocalStorage: SbAsyncLocalStorage };
381
+ `;
382
+
374
383
  export const NODE_SHIMS = {
384
+ "node:async_hooks": NODE_ASYNC_HOOKS_SHIM,
375
385
  "node:path": NODE_PATH_SHIM,
376
386
  "node:querystring": NODE_QUERYSTRING_SHIM,
377
387
  "node:url": NODE_URL_SHIM,
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
+ }