sproutboat 0.52.1 → 0.53.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/src/run.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * `sproutboat run`: launch a standalone binary with the settings it takes at
3
+ * launch (#275), without baking any of them into it.
4
+ *
5
+ * A standalone binary reads its port, data directory and secrets from the
6
+ * environment and nothing from argv. This command builds that environment from
7
+ * three layers and starts the binary with it. Highest precedence first:
8
+ *
9
+ * 1. explicit launch options: `--port` (PORT) and `--data-dir` (SB_DATA_DIR);
10
+ * 2. the files given with `--env-file` (dotenv syntax, see env-file.ts; repeat
11
+ * the flag and a later file overrides an earlier one);
12
+ * 3. the environment this command inherited, which is how a service manager
13
+ * hands a production binary its settings.
14
+ *
15
+ * Unlike `dev`, which warns, a launch fails when a declared secret (the
16
+ * config's `secrets` plus any `--require`) is unset or empty, as
17
+ * `wrangler deploy` does for a missing required secret. Nothing starts. The same binary therefore moves between a
18
+ * laptop and a server with a different env file, port and data directory.
19
+ * Nothing is written to disk and no value is ever printed: the summary names
20
+ * secrets and the layer each came from.
21
+ */
22
+ import { existsSync } from "node:fs";
23
+ import { readFile } from "node:fs/promises";
24
+ import { resolve } from "node:path";
25
+ import { parseConfig } from "./config";
26
+ import {
27
+ mergeEntries,
28
+ MissingSecretsError,
29
+ missingSecrets,
30
+ readEnvFile,
31
+ resolveEnvironment,
32
+ type EnvEntry,
33
+ type EnvSource,
34
+ } from "./env-file";
35
+
36
+ export const RUN_USAGE =
37
+ "run [project-dir] [--binary <file>] [--env-file <file>]... [--port <n>] [--data-dir <dir>] [--require <NAME,...>] [--check]";
38
+
39
+ const VALUE_FLAGS = ["--binary", "--env-file", "--port", "--data-dir", "--require"] as const;
40
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
41
+
42
+ export type RunArgs = {
43
+ dir: string | undefined;
44
+ binary: string | undefined;
45
+ /** Every `--env-file`, in the order given. */
46
+ envFiles: string[];
47
+ port: number | undefined;
48
+ dataDir: string | undefined;
49
+ require: string[];
50
+ check: boolean;
51
+ };
52
+
53
+ /** Parse `run` arguments; `error` is a usage error message. */
54
+ export function parseRunArgs(args: string[]): { args: RunArgs } | { error: string } {
55
+ const parsed: RunArgs = {
56
+ dir: undefined,
57
+ binary: undefined,
58
+ envFiles: [],
59
+ port: undefined,
60
+ dataDir: undefined,
61
+ require: [],
62
+ check: false,
63
+ };
64
+ for (let index = 0; index < args.length; index += 1) {
65
+ const arg = args[index];
66
+ if (arg === "--check") parsed.check = true;
67
+ else if (arg.startsWith("--")) {
68
+ const eq = arg.indexOf("=");
69
+ const flag = eq < 0 ? arg : arg.slice(0, eq);
70
+ if (!VALUE_FLAGS.some((known) => known === flag)) return { error: `run: unknown flag "${flag}"` };
71
+ const value = eq < 0 ? args[++index] : arg.slice(eq + 1);
72
+ if (value === undefined || (eq < 0 && value.startsWith("--"))) return { error: `run: ${flag} needs a value` };
73
+ if (flag === "--binary") parsed.binary = value;
74
+ else if (flag === "--env-file") parsed.envFiles.push(value);
75
+ else if (flag === "--data-dir") parsed.dataDir = value;
76
+ else if (flag === "--port") {
77
+ const port = Number(value);
78
+ if (!Number.isInteger(port) || port < 1 || port > 65_535)
79
+ return { error: `run: --port must be a whole number from 1 to 65535 (got "${value}")` };
80
+ parsed.port = port;
81
+ } else {
82
+ for (const name of value.split(",").map((part) => part.trim())) {
83
+ if (name === "") continue;
84
+ if (!NAME.test(name)) return { error: `run: --require takes variable names (letters, digits, underscore)` };
85
+ parsed.require.push(name);
86
+ }
87
+ }
88
+ } else if (parsed.dir === undefined) parsed.dir = arg;
89
+ else return { error: `run: unexpected argument "${arg}"` };
90
+ }
91
+ return { args: parsed };
92
+ }
93
+
94
+ /** A launch that cannot proceed for a reason the user can fix; the message is safe to print. */
95
+ export class RunError extends Error {}
96
+
97
+ export type RunPlan = {
98
+ binary: string;
99
+ /** The complete environment the binary starts with. Contains secret values: never print it. */
100
+ env: Record<string, string>;
101
+ declared: string[];
102
+ /** Lines naming what was resolved and from where. No values of secrets. */
103
+ summary: string[];
104
+ /** Env-file lines that were skipped, as `file:line: reason`. Never a value. */
105
+ warnings: string[];
106
+ };
107
+
108
+ /** Resolve what to launch and with which environment, or throw. Starts nothing. */
109
+ export async function planRun(
110
+ args: RunArgs,
111
+ io: { cwd: string; inherited: Record<string, string | undefined> },
112
+ ): Promise<RunPlan> {
113
+ const directory = resolve(io.cwd, args.dir ?? ".");
114
+ const configPath = resolve(directory, "sproutboat.jsonc");
115
+ let name: string | undefined;
116
+ const declared: string[] = [];
117
+ if (existsSync(configPath)) {
118
+ const parsed = parseConfig(await readFile(configPath, "utf8"));
119
+ if (!parsed.ok) throw new RunError(parsed.errors.join("\n"));
120
+ name = parsed.value.name;
121
+ declared.push(...(parsed.value.secrets ?? []));
122
+ } else if (args.binary === undefined) {
123
+ throw new RunError(
124
+ `no sproutboat.jsonc found in ${directory}; pass --binary <file> to run a binary without a project`,
125
+ );
126
+ }
127
+ for (const required of args.require) if (!declared.includes(required)) declared.push(required);
128
+
129
+ const binary = args.binary !== undefined ? resolve(io.cwd, args.binary) : resolve(directory, "dist", name ?? "");
130
+ if (!existsSync(binary)) {
131
+ throw new RunError(
132
+ `no binary at ${binary}; build it first with: sproutboat build --standalone${args.dir ? ` ${args.dir}` : ""}`,
133
+ );
134
+ }
135
+
136
+ const layers: EnvEntry[][] = [];
137
+ const warnings: string[] = [];
138
+ for (const path of args.envFiles) {
139
+ const parsed = await readEnvFile(resolve(io.cwd, path));
140
+ layers.push(parsed.entries);
141
+ warnings.push(...parsed.warnings);
142
+ }
143
+ const file = mergeEntries(layers);
144
+ const options: Record<string, string> = {};
145
+ if (args.port !== undefined) options.PORT = String(args.port);
146
+ if (args.dataDir !== undefined) options.SB_DATA_DIR = resolve(io.cwd, args.dataDir);
147
+ const { env, sources } = resolveEnvironment({ inherited: io.inherited, file, options });
148
+
149
+ const missing = missingSecrets(declared, env);
150
+ if (missing.length > 0) {
151
+ throw new MissingSecretsError(
152
+ missing,
153
+ args.envFiles.length > 0 ? args.envFiles.join(" or ") : "an env file (--env-file)",
154
+ );
155
+ }
156
+
157
+ const origin = (key: string): EnvSource | undefined => sources.get(key);
158
+ const summary: string[] = [];
159
+ summary.push(
160
+ declared.length === 0
161
+ ? "secrets none declared"
162
+ : `secrets ${declared.map((secret) => `${secret} (${origin(secret) ?? "unset"})`).join(", ")}`,
163
+ );
164
+ if (env.PORT) summary.push(`port ${env.PORT} (${origin("PORT")})`);
165
+ const dataKey = env.SB_DATA_DIR ? "SB_DATA_DIR" : env.SPROUTBOAT_DATA ? "SPROUTBOAT_DATA" : undefined;
166
+ if (dataKey) summary.push(`data ${env[dataKey]} (${origin(dataKey)})`);
167
+ return { binary, env, declared, summary, warnings };
168
+ }
package/src/surface.ts CHANGED
@@ -89,9 +89,19 @@ export const COMMANDS: readonly Command[] = [
89
89
  name: "dev",
90
90
  group: "Develop",
91
91
  emoji: "⚡",
92
- args: "[project-dir] [--port <n>] [--no-watch]",
92
+ args: "[project-dir] [--port <n>] [--no-watch] [--env <name>] [--env-file <file>]...",
93
93
  brief: "[project-dir] [--port <n>]",
94
- summary: "Run the project on this machine against a real broker, rebuilding on save.",
94
+ summary:
95
+ "Run the project on this machine against a real broker, rebuilding on save. Secrets come from .dev.vars or the .env files (never both), as in Wrangler; a declared secret with no value is a warning.",
96
+ },
97
+ {
98
+ name: "run",
99
+ group: "Develop",
100
+ emoji: "🚀",
101
+ args: "[project-dir] [--binary <file>] [--env-file <file>]... [--port <n>] [--data-dir <dir>] [--require <NAME,...>] [--check]",
102
+ brief: "[--env-file <file>] [--port <n>]",
103
+ summary:
104
+ "Launch a standalone binary with its port, data directory and declared secrets supplied at launch, never baked in. Precedence: --port/--data-dir, then the --env-file files (later overrides earlier), then the environment. A missing declared secret fails the launch, like wrangler deploy; --check validates without starting.",
95
105
  },
96
106
  {
97
107
  name: "build",
@@ -141,10 +151,10 @@ export const COMMANDS: readonly Command[] = [
141
151
  name: "tail",
142
152
  group: "Ship",
143
153
  emoji: "📡",
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]",
154
+ args: "[project-dir] [--format pretty|json] [--status ok|error|<code>|5xx] [--method GET,POST] [--sampling-rate <0..1>] [--search <text>] [--outcome <name>] [--recent] [--sprout] [--file <log|-> [--lines <n>] [--no-follow]]",
145
155
  brief: "[--status 5xx] [--search <text>]",
146
156
  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.",
157
+ "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. --file follows the JSON records a standalone binary writes with SB_LOG=json (no login needed).",
148
158
  },
149
159
 
150
160
  ...storageCommands,
@@ -0,0 +1,471 @@
1
+ /**
2
+ * `sproutboat tail --file <log>`: follow the structured records a standalone
3
+ * binary writes when it runs with `SB_LOG=json` (#268). No control plane and
4
+ * no login are involved; the log is whatever the operator already keeps:
5
+ *
6
+ * SB_LOG=json ./app 2>> /var/log/app.jsonl
7
+ * sproutboat tail --file /var/log/app.jsonl
8
+ * journalctl -u app -f -o cat | sproutboat tail --file -
9
+ *
10
+ * Records are `request`, `error` and `dropped` lines of version 1. Anything
11
+ * else in the file (the app's own console output, a startup banner, a record
12
+ * of a newer version) is skipped. Output stays bounded and safe to pipe:
13
+ * only known fields are kept and cut to fixed lengths, whatever the file
14
+ * holds, so a hostile or oversized line cannot grow memory, inject terminal
15
+ * escapes or add fields to `--format json` output.
16
+ *
17
+ * Reconnect behavior while following a file: it is polled, and a missing file
18
+ * is waited for; a file replaced by rotation (a new inode) or truncated in
19
+ * place is reopened from its start. Following ends on SIGINT or SIGTERM
20
+ * (exit 0). With `-` (stdin) it ends when the input does.
21
+ */
22
+ import { open, stat, type FileHandle } from "node:fs/promises";
23
+ import { isSafeInteger, isString, jsonObject, parseJsonValue, type JsonObject, type JsonValue } from "./json";
24
+ import { amber, bold, dim, leaf, rose } from "./style";
25
+
26
+ export type LogRequest = {
27
+ type: "request";
28
+ id: string;
29
+ timestamp: number;
30
+ method: string;
31
+ path: string;
32
+ status: number;
33
+ durationMs: number;
34
+ outcome: string;
35
+ };
36
+ export type LogError = {
37
+ type: "error";
38
+ id: string | null;
39
+ timestamp: number;
40
+ source: string;
41
+ name: string;
42
+ message: string;
43
+ stack?: string;
44
+ };
45
+ export type LogDropped = { type: "dropped"; timestamp: number; count: number };
46
+ export type LogRecord = LogRequest | LogError | LogDropped;
47
+
48
+ const MAX_ID = 64;
49
+ const MAX_METHOD = 16;
50
+ const MAX_PATH = 256;
51
+ const MAX_SOURCE = 40;
52
+ const MAX_NAME = 100;
53
+ const MAX_MESSAGE = 1000;
54
+ const MAX_STACK = 2000;
55
+ /** The longest line read; a longer one is skipped, never held in memory. */
56
+ export const DEFAULT_MAX_LINE_BYTES = 64 * 1024;
57
+ /** How much of the end of a file is looked at to find the last lines. */
58
+ const SNAPSHOT_WINDOW_BYTES = 256 * 1024;
59
+ const READ_CHUNK_BYTES = 256 * 1024;
60
+ export const MAX_SNAPSHOT_LINES = 100_000;
61
+
62
+ const cut = (text: string, max: number): string => (text.length > max ? text.slice(0, max) : text);
63
+ const count = (value: JsonValue | undefined): number | undefined =>
64
+ isSafeInteger(value) && value >= 0 ? value : undefined;
65
+
66
+ /** One log line as a record, or undefined when it is not a version 1 record. */
67
+ export function parseLogLine(line: string): LogRecord | undefined {
68
+ const trimmed = line.trim();
69
+ if (!trimmed.startsWith("{")) return undefined;
70
+ let record: JsonObject | undefined;
71
+ try {
72
+ record = jsonObject(parseJsonValue(trimmed));
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ if (!record || record.v !== 1) return undefined;
77
+ const timestamp = count(record.timestamp);
78
+ if (timestamp === undefined) return undefined;
79
+ if (record.type === "request") {
80
+ const status = count(record.status);
81
+ if (!isString(record.id) || !isString(record.method) || !isString(record.path) || status === undefined)
82
+ return undefined;
83
+ return {
84
+ type: "request",
85
+ id: cut(record.id, MAX_ID),
86
+ timestamp,
87
+ method: cut(record.method, MAX_METHOD),
88
+ path: cut(record.path, MAX_PATH),
89
+ status,
90
+ durationMs: count(record.durationMs) ?? 0,
91
+ outcome: isString(record.outcome) ? cut(record.outcome, MAX_NAME) : "ok",
92
+ };
93
+ }
94
+ if (record.type === "error") {
95
+ if (!isString(record.source) || !isString(record.name) || !isString(record.message)) return undefined;
96
+ const error: LogError = {
97
+ type: "error",
98
+ id: isString(record.id) ? cut(record.id, MAX_ID) : null,
99
+ timestamp,
100
+ source: cut(record.source, MAX_SOURCE),
101
+ name: cut(record.name, MAX_NAME),
102
+ message: cut(record.message, MAX_MESSAGE),
103
+ };
104
+ if (isString(record.stack)) error.stack = cut(record.stack, MAX_STACK);
105
+ return error;
106
+ }
107
+ if (record.type === "dropped") return { type: "dropped", timestamp, count: count(record.count) ?? 0 };
108
+ return undefined;
109
+ }
110
+
111
+ type StatusMatcher =
112
+ | { kind: "ok" }
113
+ | { kind: "error" }
114
+ | { kind: "class"; digit: number }
115
+ | { kind: "code"; code: number };
116
+
117
+ const MAX_PENDING_ERRORS = 200;
118
+
119
+ export type LogFilterInput = { status: string[]; method: string[]; search: string | undefined };
120
+
121
+ /**
122
+ * `--status`, `--method` and `--search` for a log. Status and method describe a
123
+ * request, so they select request records; an error follows the request it was
124
+ * raised in (shown when that request was), and an error with no request (an
125
+ * alarm, a queue, a rejected promise) is always shown. The meanings match the
126
+ * live tail: `ok` is a success below 400, `error` is a 5xx or a thrown handler.
127
+ */
128
+ export class LogFilter {
129
+ private readonly status: StatusMatcher[];
130
+ private readonly method: string[];
131
+ private readonly search: string | undefined;
132
+ private readonly shown = new Map<string, boolean>();
133
+ /** Errors whose request record has not arrived yet: the runtime writes an error before its request's record. */
134
+ private readonly pending = new Map<string, LogError>();
135
+
136
+ constructor(input: LogFilterInput) {
137
+ this.status = input.status.map((value): StatusMatcher => {
138
+ const lower = value.toLowerCase();
139
+ if (lower === "ok" || lower === "error") return { kind: lower };
140
+ if (/^[1-5]xx$/.test(lower)) return { kind: "class", digit: Number(lower[0]) };
141
+ return { kind: "code", code: Number(lower) };
142
+ });
143
+ this.method = input.method.map((value) => value.toUpperCase());
144
+ this.search = input.search?.toLowerCase();
145
+ }
146
+
147
+ /**
148
+ * The records to print for `record`, in order. An error raised while answering a request is written
149
+ * before that request's record, so under a status or method filter it waits for the request (up to
150
+ * 200 at a time, oldest dropped) and is printed just before it, or discarded with it.
151
+ */
152
+ accept(record: LogRecord): LogRecord[] {
153
+ if (record.type === "error" && record.id !== null && !this.shown.has(record.id)) {
154
+ const filtered = this.status.length > 0 || this.method.length > 0;
155
+ if (filtered) {
156
+ if (this.search !== undefined && !this.haystack(record).includes(this.search)) return [];
157
+ if (this.pending.size >= MAX_PENDING_ERRORS) {
158
+ const oldest = this.pending.keys().next();
159
+ if (!oldest.done) this.pending.delete(oldest.value);
160
+ }
161
+ this.pending.set(record.id, record);
162
+ return [];
163
+ }
164
+ }
165
+ const passes = this.matches(record);
166
+ if (record.type !== "request") return passes ? [record] : [];
167
+ const waiting = this.pending.get(record.id);
168
+ this.pending.delete(record.id);
169
+ return passes ? (waiting ? [waiting, record] : [record]) : [];
170
+ }
171
+
172
+ matches(record: LogRecord): boolean {
173
+ if (record.type === "dropped") return true;
174
+ const searched = this.search === undefined || this.haystack(record).includes(this.search);
175
+ if (record.type === "request") {
176
+ const passes = searched && this.requestMatches(record);
177
+ this.remember(record.id, passes);
178
+ return passes;
179
+ }
180
+ if (!searched) return false;
181
+ if (record.id === null) return true;
182
+ // An error whose request is unknown (before the window, or filtered by search only) follows the filters it can.
183
+ return this.shown.get(record.id) ?? (this.status.length === 0 && this.method.length === 0);
184
+ }
185
+
186
+ private requestMatches(record: LogRequest): boolean {
187
+ if (this.method.length > 0 && !this.method.includes(record.method.toUpperCase())) return false;
188
+ if (this.status.length === 0) return true;
189
+ return this.status.some((matcher) => {
190
+ switch (matcher.kind) {
191
+ case "ok":
192
+ return record.outcome === "ok" && record.status < 400;
193
+ case "error":
194
+ return record.status >= 500 || record.outcome !== "ok";
195
+ case "code":
196
+ return record.status === matcher.code;
197
+ case "class":
198
+ return Math.floor(record.status / 100) === matcher.digit;
199
+ }
200
+ });
201
+ }
202
+
203
+ private haystack(record: LogRequest | LogError): string {
204
+ return (
205
+ record.type === "request"
206
+ ? `${record.method} ${record.path}`
207
+ : `${record.source} ${record.name} ${record.message}`
208
+ ).toLowerCase();
209
+ }
210
+
211
+ private remember(id: string, shown: boolean): void {
212
+ // Bounded: only the most recent requests are remembered.
213
+ if (this.shown.size >= 2000) {
214
+ const oldest = this.shown.keys().next();
215
+ if (!oldest.done) this.shown.delete(oldest.value);
216
+ }
217
+ this.shown.set(id, shown);
218
+ }
219
+ }
220
+
221
+ /** Newlines and tabs become spaces and other control characters "?", so a path or message cannot move the cursor or recolor the terminal. */
222
+ const printable = (text: string): string => text.replace(/[\n\t]/g, " ").replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
223
+
224
+ const clock = (ms: number): string => {
225
+ const date = new Date(ms);
226
+ const pad = (n: number, width = 2) => String(n).padStart(width, "0");
227
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`;
228
+ };
229
+
230
+ function statusColor(status: number, text: string): string {
231
+ if (status >= 500) return rose(text);
232
+ if (status >= 400) return amber(text);
233
+ if (status >= 300) return dim(text);
234
+ return leaf(text);
235
+ }
236
+
237
+ /** One pretty line for a record. */
238
+ export function formatLogRecord(record: LogRecord): string {
239
+ const time = dim(clock(record.timestamp));
240
+ if (record.type === "dropped") {
241
+ return `${time} ${amber(`! ${record.count} records dropped (the app was writing over its rate limit)`)}`;
242
+ }
243
+ if (record.type === "request") {
244
+ const outcome = record.outcome === "ok" ? dim("ok") : rose(printable(record.outcome));
245
+ return `${time} ${bold(printable(record.method).padEnd(6))} ${printable(record.path)} ${statusColor(record.status, String(record.status))} ${outcome} ${dim(`${record.durationMs}ms ${printable(record.id)}`)}`;
246
+ }
247
+ const message = printable(record.message);
248
+ const id = record.id === null ? "" : ` ${dim(printable(record.id))}`;
249
+ return `${time} ${rose(`✗ [${printable(record.source)}] ${printable(record.name)}: ${message}`)}${id}`;
250
+ }
251
+
252
+ export type LogSinkOptions = {
253
+ format: "pretty" | "json";
254
+ filter: LogFilter;
255
+ signal: AbortSignal;
256
+ stdout: (text: string) => void;
257
+ stderr: (text: string) => void;
258
+ };
259
+
260
+ export type LogTailOptions = LogSinkOptions & {
261
+ path: string;
262
+ /** How many of the file's last matching lines to print first. */
263
+ lines: number;
264
+ /** Keep following after the snapshot. */
265
+ follow: boolean;
266
+ /** Poll interval of a followed file. */
267
+ pollMs?: number;
268
+ maxLineBytes?: number;
269
+ /** Called once the file is open and its snapshot is printed: anything appended after this is followed. */
270
+ onReady?: () => void;
271
+ };
272
+
273
+ /** Turns lines into output. One per run, so a skipped-line count covers the whole run. */
274
+ class LogPrinter {
275
+ private skipped = 0;
276
+ private skippedLimit = DEFAULT_MAX_LINE_BYTES;
277
+ constructor(private readonly options: LogSinkOptions) {}
278
+
279
+ line(text: string): void {
280
+ const record = parseLogLine(text);
281
+ if (!record) return;
282
+ for (const out of this.options.filter.accept(record)) this.emit(out);
283
+ }
284
+
285
+ emit(record: LogRecord): void {
286
+ // The record version rides along in JSON output, as it does in the log.
287
+ const { type, ...fields } = record;
288
+ const json = JSON.stringify({ type, v: 1, ...fields });
289
+ this.options.stdout(`${this.options.format === "json" ? json : formatLogRecord(record)}\n`);
290
+ }
291
+
292
+ skipLine(limit: number): void {
293
+ this.skipped += 1;
294
+ this.skippedLimit = limit;
295
+ this.options.stderr(
296
+ `${amber(`! ${this.skipped} line${this.skipped === 1 ? "" : "s"} over ${limit} bytes skipped`)}\n`,
297
+ );
298
+ }
299
+ }
300
+
301
+ /** Splits bytes into lines of at most `maxBytes`; a longer line is skipped to its end. */
302
+ class LineSplitter {
303
+ private buffer: Buffer = Buffer.alloc(0);
304
+ private skipping = false;
305
+ constructor(
306
+ private readonly maxBytes: number,
307
+ private readonly onLine: (line: string) => void,
308
+ private readonly onSkip: () => void,
309
+ ) {}
310
+
311
+ push(chunk: Uint8Array): void {
312
+ let data: Buffer = this.buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.buffer, chunk]);
313
+ this.buffer = Buffer.alloc(0);
314
+ for (;;) {
315
+ const newline = data.indexOf(10);
316
+ if (newline < 0) break;
317
+ const line = data.subarray(0, newline);
318
+ data = data.subarray(newline + 1);
319
+ if (this.skipping) this.skipping = false;
320
+ else if (line.length > this.maxBytes) this.onSkip();
321
+ else this.onLine(line.toString("utf8"));
322
+ }
323
+ if (this.skipping) return;
324
+ if (data.length > this.maxBytes) {
325
+ this.skipping = true;
326
+ this.onSkip();
327
+ return;
328
+ }
329
+ this.buffer = Buffer.from(data);
330
+ }
331
+
332
+ /** Input ended without a final newline: a complete record is still a record. */
333
+ flush(): void {
334
+ if (!this.skipping && this.buffer.length > 0) this.onLine(this.buffer.toString("utf8"));
335
+ this.buffer = Buffer.alloc(0);
336
+ }
337
+ }
338
+
339
+ /** Records from a stream (stdin) until it ends or the signal aborts. */
340
+ export async function readLogStream(body: ReadableStream<Uint8Array>, options: LogSinkOptions): Promise<void> {
341
+ const printer = new LogPrinter(options);
342
+ const splitter = new LineSplitter(
343
+ DEFAULT_MAX_LINE_BYTES,
344
+ (line) => printer.line(line),
345
+ () => printer.skipLine(DEFAULT_MAX_LINE_BYTES),
346
+ );
347
+ const reader = body.getReader();
348
+ const onAbort = () => void reader.cancel().catch(() => {});
349
+ options.signal.addEventListener("abort", onAbort, { once: true });
350
+ try {
351
+ for (;;) {
352
+ const next = await reader.read();
353
+ if (next.done) break;
354
+ splitter.push(next.value);
355
+ }
356
+ if (!options.signal.aborted) splitter.flush();
357
+ } catch (error) {
358
+ if (!options.signal.aborted) throw error;
359
+ } finally {
360
+ options.signal.removeEventListener("abort", onAbort);
361
+ }
362
+ }
363
+
364
+ const sleep = (ms: number, signal: AbortSignal) =>
365
+ new Promise<void>((resolve) => {
366
+ const timer = setTimeout(resolve, ms);
367
+ signal.addEventListener(
368
+ "abort",
369
+ () => {
370
+ clearTimeout(timer);
371
+ resolve();
372
+ },
373
+ { once: true },
374
+ );
375
+ });
376
+
377
+ const isMissing = (error: Error): boolean => "code" in error && error.code === "ENOENT";
378
+
379
+ /** The last `lines` matching records among the final complete lines of the file's last 256 KiB. */
380
+ async function snapshot(handle: FileHandle, size: number, printer: LogPrinter, options: LogTailOptions): Promise<void> {
381
+ if (options.lines === 0 || size === 0) return;
382
+ const start = Math.max(0, size - SNAPSHOT_WINDOW_BYTES);
383
+ const buffer = Buffer.alloc(size - start);
384
+ await handle.read(buffer, 0, buffer.length, start);
385
+ let text = buffer.toString("utf8");
386
+ // The window can begin mid-line: drop that first fragment.
387
+ if (start > 0) text = text.slice(text.indexOf("\n") + 1);
388
+ const picked: LogRecord[] = [];
389
+ for (const line of text.split("\n")) {
390
+ if (Buffer.byteLength(line) > (options.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES)) continue;
391
+ const record = parseLogLine(line);
392
+ if (record) picked.push(...options.filter.accept(record));
393
+ }
394
+ for (const record of picked.slice(-options.lines)) printer.emit(record);
395
+ }
396
+
397
+ /**
398
+ * Print the file's last lines, then (unless `follow` is false) new records as
399
+ * they are appended, until the signal aborts.
400
+ */
401
+ export async function followLog(options: LogTailOptions): Promise<void> {
402
+ const printer = new LogPrinter(options);
403
+ const pollMs = options.pollMs ?? 250;
404
+ const maxLine = options.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES;
405
+ let announcedWait = false;
406
+ let first = true;
407
+
408
+ while (!options.signal.aborted) {
409
+ let handle: FileHandle;
410
+ try {
411
+ handle = await open(options.path, "r");
412
+ } catch (error) {
413
+ if (!(error instanceof Error) || !isMissing(error)) throw error;
414
+ if (!options.follow) throw new Error(`no such log file: ${options.path}`);
415
+ if (!announcedWait) options.stderr(`${dim(`waiting for ${options.path}`)}\n`);
416
+ announcedWait = true;
417
+ await sleep(pollMs, options.signal);
418
+ continue;
419
+ }
420
+ try {
421
+ const opened = await handle.stat();
422
+ let offset = 0;
423
+ const splitter = new LineSplitter(
424
+ maxLine,
425
+ (line) => printer.line(line),
426
+ () => printer.skipLine(maxLine),
427
+ );
428
+ // A file that was not there at the start is read from its first byte: everything in it is new.
429
+ if (first && !announcedWait) {
430
+ await snapshot(handle, opened.size, printer, options);
431
+ offset = opened.size;
432
+ }
433
+ first = false;
434
+ options.onReady?.();
435
+ if (!options.follow) return;
436
+ // On a reopened file (rotation, truncation, a late start) everything in it is new.
437
+ for (;;) {
438
+ const chunk = Buffer.alloc(READ_CHUNK_BYTES);
439
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, offset);
440
+ if (bytesRead > 0) {
441
+ offset += bytesRead;
442
+ splitter.push(chunk.subarray(0, bytesRead));
443
+ continue;
444
+ }
445
+ if (options.signal.aborted) return;
446
+ await sleep(pollMs, options.signal);
447
+ if (options.signal.aborted) return;
448
+ let current;
449
+ try {
450
+ current = await stat(options.path);
451
+ } catch {
452
+ // Removed while open: wait for the next one.
453
+ options.stderr(`${dim(`${options.path} went away, waiting for it`)}\n`);
454
+ break;
455
+ }
456
+ if (current.ino !== opened.ino || current.dev !== opened.dev) {
457
+ // Drain the old file's last bytes first (the loop above ran to EOF just now), then switch.
458
+ options.stderr(`${dim("log rotated, following the new file")}\n`);
459
+ break;
460
+ }
461
+ if (current.size < offset) {
462
+ options.stderr(`${dim("log truncated, reading from its start")}\n`);
463
+ offset = 0;
464
+ splitter.flush();
465
+ }
466
+ }
467
+ } finally {
468
+ await handle.close();
469
+ }
470
+ }
471
+ }