sproutboat 0.44.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 +18 -0
- package/README.md +40 -1
- package/SURFACE.md +3 -3
- package/package.json +5 -5
- package/src/main.ts +29 -9
- package/src/surface.ts +4 -2
- package/src/tail.ts +409 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,24 @@ 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
|
+
|
|
13
31
|
## [0.44.0] - 2026-10-29
|
|
14
32
|
|
|
15
33
|
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 [
|
|
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.
|
|
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]` |
|
|
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.
|
|
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",
|
|
@@ -78,10 +78,10 @@
|
|
|
78
78
|
"typescript": "7.0.2"
|
|
79
79
|
},
|
|
80
80
|
"optionalDependencies": {
|
|
81
|
-
"@sproutboat/cli-darwin-arm64": "0.
|
|
82
|
-
"@sproutboat/cli-darwin-x64": "0.
|
|
83
|
-
"@sproutboat/cli-linux-arm64": "0.
|
|
84
|
-
"@sproutboat/cli-linux-x64": "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
|
|
785
|
-
|
|
786
|
-
const
|
|
787
|
-
const
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
-
|
|
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
|
+
}
|