@sayknow-cli/utils 0.2.2
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/dist/types/abortable.d.ts +27 -0
- package/dist/types/async.d.ts +6 -0
- package/dist/types/cli.d.ts +118 -0
- package/dist/types/color.d.ts +82 -0
- package/dist/types/dirs.d.ts +163 -0
- package/dist/types/env.d.ts +68 -0
- package/dist/types/fetch-retry.d.ts +80 -0
- package/dist/types/format.d.ts +37 -0
- package/dist/types/frontmatter.d.ts +25 -0
- package/dist/types/fs-error.d.ts +31 -0
- package/dist/types/glob.d.ts +28 -0
- package/dist/types/hook-fetch.d.ts +16 -0
- package/dist/types/index.d.ts +30 -0
- package/dist/types/json.d.ts +4 -0
- package/dist/types/logger.d.ts +66 -0
- package/dist/types/mermaid-ascii.d.ts +11 -0
- package/dist/types/mime.d.ts +29 -0
- package/dist/types/peek-file.d.ts +9 -0
- package/dist/types/postmortem.d.ts +29 -0
- package/dist/types/procmgr.d.ts +35 -0
- package/dist/types/prompt.d.ts +18 -0
- package/dist/types/ptree.d.ts +108 -0
- package/dist/types/ring.d.ts +93 -0
- package/dist/types/safe-stderr.d.ts +1 -0
- package/dist/types/sanitize-text.d.ts +14 -0
- package/dist/types/snowflake.d.ts +25 -0
- package/dist/types/spawn-env.d.ts +4 -0
- package/dist/types/stream.d.ts +68 -0
- package/dist/types/tab-spacing.d.ts +9 -0
- package/dist/types/temp.d.ts +14 -0
- package/dist/types/type-guards.d.ts +3 -0
- package/dist/types/which.d.ts +37 -0
- package/package.json +61 -0
- package/src/abortable.ts +73 -0
- package/src/async.ts +50 -0
- package/src/cli.ts +439 -0
- package/src/color.ts +204 -0
- package/src/dirs.ts +539 -0
- package/src/env.ts +278 -0
- package/src/fetch-retry.ts +298 -0
- package/src/format.ts +112 -0
- package/src/frontmatter.ts +154 -0
- package/src/fs-error.ts +56 -0
- package/src/glob.ts +189 -0
- package/src/hook-fetch.ts +30 -0
- package/src/index.ts +50 -0
- package/src/json.ts +10 -0
- package/src/logger.ts +392 -0
- package/src/mermaid-ascii.ts +31 -0
- package/src/mime.ts +159 -0
- package/src/peek-file.ts +114 -0
- package/src/postmortem.ts +197 -0
- package/src/procmgr.ts +209 -0
- package/src/prompt.ts +471 -0
- package/src/ptree.ts +390 -0
- package/src/ring.ts +169 -0
- package/src/safe-stderr.ts +15 -0
- package/src/sanitize-text.ts +38 -0
- package/src/snowflake.ts +136 -0
- package/src/spawn-env.ts +23 -0
- package/src/stream.ts +403 -0
- package/src/tab-spacing.ts +312 -0
- package/src/temp.ts +77 -0
- package/src/type-guards.ts +11 -0
- package/src/which.ts +232 -0
package/src/logger.ts
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized logger for sayknow-cli.
|
|
3
|
+
*
|
|
4
|
+
* Default: rotating `~/.skc/logs/skc.<DATE>.log`, no console output (writing
|
|
5
|
+
* to stdout/stderr would corrupt the TUI). Long-running headless services
|
|
6
|
+
* (the auth broker, etc.) call {@link setTransports} to swap in a console
|
|
7
|
+
* transport so a process supervisor (pm2, journald, k8s) captures the logs.
|
|
8
|
+
*
|
|
9
|
+
* Each entry includes `process.pid` so concurrent skc instances stay
|
|
10
|
+
* traceable.
|
|
11
|
+
*/
|
|
12
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import winston from "winston";
|
|
15
|
+
import DailyRotateFile from "winston-daily-rotate-file";
|
|
16
|
+
import { getLogsDir } from "./dirs";
|
|
17
|
+
|
|
18
|
+
/** Ensure a logs directory exists; return the resolved path. */
|
|
19
|
+
function ensureDir(dir: string): string {
|
|
20
|
+
if (!fs.existsSync(dir)) {
|
|
21
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Custom format that includes pid and flattens metadata */
|
|
27
|
+
const logFormat = winston.format.combine(
|
|
28
|
+
winston.format.timestamp({ format: "YYYY-MM-DDTHH:mm:ss.SSSZ" }),
|
|
29
|
+
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
|
30
|
+
const entry: Record<string, unknown> = {
|
|
31
|
+
timestamp,
|
|
32
|
+
level,
|
|
33
|
+
pid: process.pid,
|
|
34
|
+
message,
|
|
35
|
+
};
|
|
36
|
+
// Flatten metadata into entry
|
|
37
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
38
|
+
if (key !== "level" && key !== "timestamp" && key !== "message") {
|
|
39
|
+
entry[key] = value;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return JSON.stringify(entry);
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/** Build a rotating file transport, materializing the target directory lazily. */
|
|
47
|
+
function makeFileTransport(dir?: string): winston.transport {
|
|
48
|
+
return new DailyRotateFile({
|
|
49
|
+
dirname: ensureDir(dir ?? getLogsDir()),
|
|
50
|
+
filename: "skc.%DATE%.log",
|
|
51
|
+
datePattern: "YYYY-MM-DD",
|
|
52
|
+
maxSize: "10m",
|
|
53
|
+
maxFiles: 5,
|
|
54
|
+
zippedArchive: true,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeConsoleTransport(): winston.transport {
|
|
59
|
+
return new winston.transports.Console({ format: logFormat });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The winston logger instance. Default: file ON (TUI-safe), console OFF. */
|
|
63
|
+
const winstonLogger = winston.createLogger({
|
|
64
|
+
level: "debug",
|
|
65
|
+
format: logFormat,
|
|
66
|
+
transports: [makeFileTransport()],
|
|
67
|
+
// Don't exit on error - logging failures shouldn't crash the app
|
|
68
|
+
exitOnError: false,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Replace the active log transports. Pass `console: true, file: false` for
|
|
73
|
+
* long-running services (the auth broker, etc.) that want their structured
|
|
74
|
+
* logs piped into a process supervisor instead of the rotating file.
|
|
75
|
+
*/
|
|
76
|
+
export function setTransports(opts: { console?: boolean; file?: boolean | string }): void {
|
|
77
|
+
winstonLogger.clear();
|
|
78
|
+
if (opts.file) {
|
|
79
|
+
winstonLogger.add(makeFileTransport(typeof opts.file === "string" ? opts.file : undefined));
|
|
80
|
+
}
|
|
81
|
+
if (opts.console) winstonLogger.add(makeConsoleTransport());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Log an error message.
|
|
86
|
+
* @param message - The message to log.
|
|
87
|
+
* @param context - The context to log.
|
|
88
|
+
*/
|
|
89
|
+
export function error(message: string, context?: Record<string, unknown>): void {
|
|
90
|
+
try {
|
|
91
|
+
winstonLogger.error(message, context);
|
|
92
|
+
} catch {
|
|
93
|
+
// Silently ignore logging failures
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Log a warning message.
|
|
99
|
+
* @param message - The message to log.
|
|
100
|
+
* @param context - The context to log.
|
|
101
|
+
*/
|
|
102
|
+
export function warn(message: string, context?: Record<string, unknown>): void {
|
|
103
|
+
try {
|
|
104
|
+
winstonLogger.warn(message, context);
|
|
105
|
+
} catch {
|
|
106
|
+
// Silently ignore logging failures
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Log an informational message.
|
|
112
|
+
* @param message - The message to log.
|
|
113
|
+
* @param context - The context to log.
|
|
114
|
+
*/
|
|
115
|
+
export function info(message: string, context?: Record<string, unknown>): void {
|
|
116
|
+
try {
|
|
117
|
+
winstonLogger.info(message, context);
|
|
118
|
+
} catch {
|
|
119
|
+
// Silently ignore logging failures
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Log a debug message.
|
|
125
|
+
* @param message - The message to log.
|
|
126
|
+
* @param context - The context to log.
|
|
127
|
+
*/
|
|
128
|
+
export function debug(message: string, context?: Record<string, unknown>): void {
|
|
129
|
+
try {
|
|
130
|
+
winstonLogger.debug(message, context);
|
|
131
|
+
} catch {
|
|
132
|
+
// Silently ignore logging failures
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const LOGGED_TIMING_THRESHOLD_MS = 0.5;
|
|
137
|
+
|
|
138
|
+
interface Span {
|
|
139
|
+
op: string;
|
|
140
|
+
start: number;
|
|
141
|
+
end?: number;
|
|
142
|
+
parent?: Span;
|
|
143
|
+
children: Span[];
|
|
144
|
+
/** Marker / point event without a duration. */
|
|
145
|
+
point?: boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const spanStorage = new AsyncLocalStorage<Span>();
|
|
149
|
+
let gRootSpan: Span | undefined;
|
|
150
|
+
let gRecordTimings = false;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Print collected timings as an indented tree.
|
|
154
|
+
* Each span shows wall duration; parents with children also show "(self)" for unattributed time.
|
|
155
|
+
* Sibling spans are sorted by start time. Spans whose intervals overlap with siblings ran in parallel.
|
|
156
|
+
*/
|
|
157
|
+
export function printTimings(): void {
|
|
158
|
+
if (!gRecordTimings || !gRootSpan) {
|
|
159
|
+
console.error("\n--- Startup Timings ---\n(no markers)\n");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
gRootSpan.end = performance.now();
|
|
164
|
+
const lines: string[] = [];
|
|
165
|
+
lines.push("");
|
|
166
|
+
lines.push("--- Startup timings (hierarchical) ---");
|
|
167
|
+
const work: Span[] = [];
|
|
168
|
+
const loads: Span[] = [];
|
|
169
|
+
for (const child of gRootSpan.children) {
|
|
170
|
+
if (isModuleLoadSpan(child)) loads.push(child);
|
|
171
|
+
else work.push(child);
|
|
172
|
+
}
|
|
173
|
+
for (const child of work.sort((a, b) => a.start - b.start)) {
|
|
174
|
+
printSpan(child, 0, lines);
|
|
175
|
+
}
|
|
176
|
+
if (loads.length > 0) {
|
|
177
|
+
printModuleLoadSummary(loads, 0, lines);
|
|
178
|
+
}
|
|
179
|
+
const totalMs = (gRootSpan.end - gRootSpan.start).toFixed(1);
|
|
180
|
+
lines.push(`Total: ${totalMs}ms`);
|
|
181
|
+
lines.push("--------------------------------------");
|
|
182
|
+
lines.push("");
|
|
183
|
+
console.error(lines.join("\n"));
|
|
184
|
+
gRootSpan.end = undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Begin recording startup timings under a new root span.
|
|
189
|
+
* Idempotent: a second call while already recording is a no-op so that side-effect
|
|
190
|
+
* starters (see module-timer.ts) and explicit starters (main.ts) can coexist.
|
|
191
|
+
*/
|
|
192
|
+
export function startTiming(): void {
|
|
193
|
+
if (gRecordTimings) return;
|
|
194
|
+
gRootSpan = {
|
|
195
|
+
op: "(root)",
|
|
196
|
+
start: performance.now(),
|
|
197
|
+
parent: undefined,
|
|
198
|
+
children: [],
|
|
199
|
+
};
|
|
200
|
+
gRecordTimings = true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Record an externally-measured span as a leaf child of the active span (or root
|
|
205
|
+
* when no span is active). Used by the module-load timing plugin to splice load
|
|
206
|
+
* events into the tree retroactively.
|
|
207
|
+
*/
|
|
208
|
+
export function recordModuleLoadSpan(path: string, start: number, durationMs: number): void {
|
|
209
|
+
if (!gRecordTimings || !gRootSpan) return;
|
|
210
|
+
const parent = spanStorage.getStore() ?? gRootSpan;
|
|
211
|
+
const span: Span = {
|
|
212
|
+
op: `load:${shortenLoadPath(path)}`,
|
|
213
|
+
start,
|
|
214
|
+
end: start + durationMs,
|
|
215
|
+
parent,
|
|
216
|
+
children: [],
|
|
217
|
+
};
|
|
218
|
+
parent.children.push(span);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function shortenLoadPath(p: string): string {
|
|
222
|
+
const cwd = process.cwd();
|
|
223
|
+
if (p.startsWith(`${cwd}/`)) return p.slice(cwd.length + 1);
|
|
224
|
+
const home = process.env.HOME;
|
|
225
|
+
if (home && p.startsWith(`${home}/`)) return `~/${p.slice(home.length + 1)}`;
|
|
226
|
+
return p;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* End timing window and clear buffers.
|
|
231
|
+
*/
|
|
232
|
+
export function endTiming(): void {
|
|
233
|
+
gRootSpan = undefined;
|
|
234
|
+
gRecordTimings = false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function durationOf(span: Span): number {
|
|
238
|
+
if (span.point || span.end === undefined) return 0;
|
|
239
|
+
return span.end - span.start;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Self time = total - union of child intervals (handles parallel children correctly). */
|
|
243
|
+
function selfTimeOf(span: Span): number {
|
|
244
|
+
const dur = durationOf(span);
|
|
245
|
+
if (span.children.length === 0 || span.point) return dur;
|
|
246
|
+
const intervals = span.children
|
|
247
|
+
.filter(c => !c.point && c.end !== undefined)
|
|
248
|
+
.map(c => [c.start, c.end as number] as const)
|
|
249
|
+
.sort((a, b) => a[0] - b[0]);
|
|
250
|
+
if (intervals.length === 0) return dur;
|
|
251
|
+
let union = 0;
|
|
252
|
+
let curStart = intervals[0][0];
|
|
253
|
+
let curEnd = intervals[0][1];
|
|
254
|
+
for (let i = 1; i < intervals.length; i++) {
|
|
255
|
+
const [s, e] = intervals[i];
|
|
256
|
+
if (s > curEnd) {
|
|
257
|
+
union += curEnd - curStart;
|
|
258
|
+
curStart = s;
|
|
259
|
+
curEnd = e;
|
|
260
|
+
} else if (e > curEnd) {
|
|
261
|
+
curEnd = e;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
union += curEnd - curStart;
|
|
265
|
+
return Math.max(0, dur - union);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function fmtMs(ms: number): string {
|
|
269
|
+
if (ms < 1) return `${ms.toFixed(2)}ms`;
|
|
270
|
+
if (ms < 100) return `${ms.toFixed(1)}ms`;
|
|
271
|
+
return `${ms.toFixed(0)}ms`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const MODULE_LOAD_PREFIX = "load:";
|
|
275
|
+
const MODULE_LOAD_VERBOSE_TOP = 10;
|
|
276
|
+
|
|
277
|
+
function isModuleLoadSpan(span: Span): boolean {
|
|
278
|
+
return span.op.startsWith(MODULE_LOAD_PREFIX);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function printSpan(span: Span, depth: number, lines: string[]): void {
|
|
282
|
+
const indent = " ".repeat(depth);
|
|
283
|
+
if (span.point) {
|
|
284
|
+
lines.push(`${indent}• ${span.op}`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const dur = durationOf(span);
|
|
288
|
+
if (dur < LOGGED_TIMING_THRESHOLD_MS && span.children.length === 0) return;
|
|
289
|
+
const parallel = isParallel(span);
|
|
290
|
+
const tag = parallel ? " [parallel]" : "";
|
|
291
|
+
const self = selfTimeOf(span);
|
|
292
|
+
const selfStr = span.children.length > 0 && self > LOGGED_TIMING_THRESHOLD_MS ? ` (self ${fmtMs(self)})` : "";
|
|
293
|
+
lines.push(`${indent}${span.op}: ${fmtMs(dur)}${selfStr}${tag}`);
|
|
294
|
+
|
|
295
|
+
// Split children into work spans and module-load spans for summarization.
|
|
296
|
+
const work: Span[] = [];
|
|
297
|
+
const loads: Span[] = [];
|
|
298
|
+
for (const child of span.children) {
|
|
299
|
+
if (isModuleLoadSpan(child)) loads.push(child);
|
|
300
|
+
else work.push(child);
|
|
301
|
+
}
|
|
302
|
+
for (const child of work.sort((a, b) => a.start - b.start)) {
|
|
303
|
+
printSpan(child, depth + 1, lines);
|
|
304
|
+
}
|
|
305
|
+
if (loads.length > 0) {
|
|
306
|
+
printModuleLoadSummary(loads, depth + 1, lines);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Collapse the (typically hundreds of) module-load spans into one summary line. */
|
|
311
|
+
function printModuleLoadSummary(loads: Span[], depth: number, lines: string[]): void {
|
|
312
|
+
const childIndent = " ".repeat(depth);
|
|
313
|
+
const grandIndent = " ".repeat(depth + 1);
|
|
314
|
+
let unionStart = Number.POSITIVE_INFINITY;
|
|
315
|
+
let unionEnd = 0;
|
|
316
|
+
let totalSelf = 0;
|
|
317
|
+
for (const span of loads) {
|
|
318
|
+
if (span.end === undefined) continue;
|
|
319
|
+
if (span.start < unionStart) unionStart = span.start;
|
|
320
|
+
if (span.end > unionEnd) unionEnd = span.end;
|
|
321
|
+
totalSelf += span.end - span.start;
|
|
322
|
+
}
|
|
323
|
+
const wall = unionEnd > unionStart ? unionEnd - unionStart : 0;
|
|
324
|
+
lines.push(`${childIndent}(modules): ${loads.length} loaded, wall ${fmtMs(wall)}, sum ${fmtMs(totalSelf)}`);
|
|
325
|
+
const showAll = process.env.PI_TIMING === "full";
|
|
326
|
+
const sorted = [...loads].sort((a, b) => durationOf(b) - durationOf(a));
|
|
327
|
+
const visible = showAll ? sorted : sorted.slice(0, MODULE_LOAD_VERBOSE_TOP);
|
|
328
|
+
for (const span of visible) {
|
|
329
|
+
const dur = durationOf(span);
|
|
330
|
+
if (dur < LOGGED_TIMING_THRESHOLD_MS) break;
|
|
331
|
+
const tag = isParallel(span) ? " [parallel]" : "";
|
|
332
|
+
lines.push(`${grandIndent}${span.op}: ${fmtMs(dur)}${tag}`);
|
|
333
|
+
}
|
|
334
|
+
if (!showAll && sorted.length > MODULE_LOAD_VERBOSE_TOP) {
|
|
335
|
+
lines.push(`${grandIndent}… ${sorted.length - MODULE_LOAD_VERBOSE_TOP} more (PI_TIMING=full to show all)`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** A span is parallel if it overlaps a sibling that started before it. */
|
|
340
|
+
function isParallel(span: Span): boolean {
|
|
341
|
+
const parent = span.parent;
|
|
342
|
+
if (!parent || span.end === undefined) return false;
|
|
343
|
+
for (const sibling of parent.children) {
|
|
344
|
+
if (sibling === span || sibling.end === undefined || sibling.point) continue;
|
|
345
|
+
// Overlap test: A overlaps B iff A.start < B.end && B.start < A.end
|
|
346
|
+
if (sibling.start < span.end && span.start < sibling.end) return true;
|
|
347
|
+
}
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Time a span. Three forms:
|
|
353
|
+
* time(op) — point event (zero-duration breadcrumb)
|
|
354
|
+
* time(op, fn, ...args) — wrap fn in a span; returns fn's return value (sync or Promise)
|
|
355
|
+
*
|
|
356
|
+
* Spans nest hierarchically via AsyncLocalStorage: a child started inside another span's fn
|
|
357
|
+
* (even across awaits) becomes that span's child. Parallel children are recorded as siblings
|
|
358
|
+
* with overlapping intervals.
|
|
359
|
+
*/
|
|
360
|
+
export function time(op: string): void;
|
|
361
|
+
export function time<T, A extends unknown[]>(op: string, fn: (...args: A) => T, ...args: A): T;
|
|
362
|
+
export function time<T, A extends unknown[]>(op: string, fn?: (...args: A) => T, ...args: A): T | undefined {
|
|
363
|
+
if (!gRecordTimings || !gRootSpan) {
|
|
364
|
+
if (fn === undefined) return undefined as T;
|
|
365
|
+
return fn(...args);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const parent = spanStorage.getStore() ?? gRootSpan;
|
|
369
|
+
const span: Span = { op, start: performance.now(), parent, children: [] };
|
|
370
|
+
parent.children.push(span);
|
|
371
|
+
|
|
372
|
+
if (fn === undefined) {
|
|
373
|
+
span.end = span.start;
|
|
374
|
+
span.point = true;
|
|
375
|
+
return undefined as T;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const finish = (): void => {
|
|
379
|
+
span.end = performance.now();
|
|
380
|
+
};
|
|
381
|
+
try {
|
|
382
|
+
const result = spanStorage.run(span, () => fn(...args));
|
|
383
|
+
if (result instanceof Promise) {
|
|
384
|
+
return result.finally(finish) as T;
|
|
385
|
+
}
|
|
386
|
+
finish();
|
|
387
|
+
return result;
|
|
388
|
+
} catch (error) {
|
|
389
|
+
finish();
|
|
390
|
+
throw error;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type AsciiRenderOptions, renderMermaidASCII } from "beautiful-mermaid";
|
|
2
|
+
|
|
3
|
+
export type { AsciiRenderOptions as MermaidAsciiRenderOptions };
|
|
4
|
+
|
|
5
|
+
export function renderMermaidAscii(source: string, options?: AsciiRenderOptions): string {
|
|
6
|
+
return renderMermaidASCII(source, options);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function renderMermaidAsciiSafe(source: string, options?: AsciiRenderOptions): string | null {
|
|
10
|
+
try {
|
|
11
|
+
return renderMermaidASCII(source, options);
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extract mermaid code blocks from markdown text.
|
|
19
|
+
*/
|
|
20
|
+
export function extractMermaidBlocks(markdown: string): { source: string; hash: bigint | number }[] {
|
|
21
|
+
const blocks: { source: string; hash: bigint | number }[] = [];
|
|
22
|
+
const regex = /```mermaid\s*\n([\s\S]*?)```/g;
|
|
23
|
+
|
|
24
|
+
for (let match = regex.exec(markdown); match !== null; match = regex.exec(markdown)) {
|
|
25
|
+
const source = match[1].trim();
|
|
26
|
+
const hash = Bun.hash(source);
|
|
27
|
+
blocks.push({ source, hash });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return blocks;
|
|
31
|
+
}
|
package/src/mime.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { peekFile, peekFileSync } from "./peek-file";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_IMAGE_METADATA_HEADER_BYTES = 256 * 1024;
|
|
4
|
+
|
|
5
|
+
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
6
|
+
const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
|
|
7
|
+
const WEBP_RIFF_MAGIC = Buffer.from([0x52, 0x49, 0x46, 0x46]);
|
|
8
|
+
const WEBP_MAGIC = Buffer.from([0x57, 0x45, 0x42, 0x50]);
|
|
9
|
+
const PNG_IHDR = Buffer.from("IHDR");
|
|
10
|
+
const GIF87A = Buffer.from("GIF87a");
|
|
11
|
+
const GIF89A = Buffer.from("GIF89a");
|
|
12
|
+
const WEBP_VP8X = Buffer.from("VP8X");
|
|
13
|
+
const WEBP_VP8L = Buffer.from("VP8L");
|
|
14
|
+
const WEBP_VP8 = Buffer.from("VP8 ");
|
|
15
|
+
|
|
16
|
+
export const SUPPORTED_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
17
|
+
|
|
18
|
+
export type ImageMetadata =
|
|
19
|
+
| { mimeType: "image/png"; width?: number; height?: number; channels?: number; hasAlpha?: boolean }
|
|
20
|
+
| { mimeType: "image/jpeg"; width?: number; height?: number; channels?: number; hasAlpha?: false }
|
|
21
|
+
| { mimeType: "image/gif"; width?: number; height?: number; channels?: 3; hasAlpha?: never }
|
|
22
|
+
| { mimeType: "image/webp"; width?: number; height?: number; channels?: number; hasAlpha?: boolean };
|
|
23
|
+
|
|
24
|
+
function magicEquals(header: Uint8Array, offset: number, magic: Buffer): boolean {
|
|
25
|
+
if (header.length < offset + magic.length) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return magic.equals(header.subarray(offset, offset + magic.length));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parsePngMetadata(header: Uint8Array): ImageMetadata | null {
|
|
32
|
+
if (!magicEquals(header, 0, PNG_MAGIC)) return null;
|
|
33
|
+
if (!magicEquals(header, 12, PNG_IHDR)) return { mimeType: "image/png" };
|
|
34
|
+
if (header.length < 26) return { mimeType: "image/png" };
|
|
35
|
+
|
|
36
|
+
const view = new DataView(header.buffer, header.byteOffset, header.byteLength);
|
|
37
|
+
const width = view.getUint32(16, false);
|
|
38
|
+
const height = view.getUint32(20, false);
|
|
39
|
+
const colorType = view.getUint8(25);
|
|
40
|
+
if (colorType === 0) return { mimeType: "image/png", width, height, channels: 1, hasAlpha: false };
|
|
41
|
+
if (colorType === 2) return { mimeType: "image/png", width, height, channels: 3, hasAlpha: false };
|
|
42
|
+
if (colorType === 3) return { mimeType: "image/png", width, height, channels: 3 };
|
|
43
|
+
if (colorType === 4) return { mimeType: "image/png", width, height, channels: 2, hasAlpha: true };
|
|
44
|
+
if (colorType === 6) return { mimeType: "image/png", width, height, channels: 4, hasAlpha: true };
|
|
45
|
+
return { mimeType: "image/png", width, height };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseJpegMetadata(header: Uint8Array): ImageMetadata | null {
|
|
49
|
+
if (!magicEquals(header, 0, JPEG_MAGIC)) return null;
|
|
50
|
+
if (header.length < 4) return { mimeType: "image/jpeg" };
|
|
51
|
+
|
|
52
|
+
const view = new DataView(header.buffer, header.byteOffset, header.byteLength);
|
|
53
|
+
let offset = 2;
|
|
54
|
+
while (offset + 9 < header.length) {
|
|
55
|
+
if (header[offset] !== 0xff) {
|
|
56
|
+
offset += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let markerOffset = offset + 1;
|
|
61
|
+
while (markerOffset < header.length && header[markerOffset] === 0xff) {
|
|
62
|
+
markerOffset += 1;
|
|
63
|
+
}
|
|
64
|
+
if (markerOffset >= header.length) break;
|
|
65
|
+
|
|
66
|
+
const marker = header[markerOffset];
|
|
67
|
+
const segmentOffset = markerOffset + 1;
|
|
68
|
+
if (marker === 0xd8 || marker === 0xd9 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
69
|
+
offset = segmentOffset;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (segmentOffset + 1 >= header.length) break;
|
|
73
|
+
|
|
74
|
+
const segmentLength = view.getUint16(segmentOffset, false);
|
|
75
|
+
if (segmentLength < 2) break;
|
|
76
|
+
|
|
77
|
+
const isStartOfFrame = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
|
|
78
|
+
if (isStartOfFrame) {
|
|
79
|
+
if (segmentOffset + 7 >= header.length) break;
|
|
80
|
+
const height = view.getUint16(segmentOffset + 3, false);
|
|
81
|
+
const width = view.getUint16(segmentOffset + 5, false);
|
|
82
|
+
const channels = header[segmentOffset + 7];
|
|
83
|
+
return {
|
|
84
|
+
mimeType: "image/jpeg",
|
|
85
|
+
width,
|
|
86
|
+
height,
|
|
87
|
+
channels: Number.isFinite(channels) ? channels : undefined,
|
|
88
|
+
hasAlpha: false,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
offset = segmentOffset + segmentLength;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { mimeType: "image/jpeg" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseGifMetadata(header: Uint8Array): ImageMetadata | null {
|
|
99
|
+
if (!magicEquals(header, 0, GIF87A) && !magicEquals(header, 0, GIF89A)) return null;
|
|
100
|
+
if (header.length < 10) return { mimeType: "image/gif" };
|
|
101
|
+
const view = new DataView(header.buffer, header.byteOffset, header.byteLength);
|
|
102
|
+
return {
|
|
103
|
+
mimeType: "image/gif",
|
|
104
|
+
width: view.getUint16(6, true),
|
|
105
|
+
height: view.getUint16(8, true),
|
|
106
|
+
channels: 3,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseWebpMetadata(header: Uint8Array): ImageMetadata | null {
|
|
111
|
+
if (!magicEquals(header, 0, WEBP_RIFF_MAGIC)) return null;
|
|
112
|
+
if (!magicEquals(header, 8, WEBP_MAGIC)) return null;
|
|
113
|
+
if (header.length < 30) return { mimeType: "image/webp" };
|
|
114
|
+
|
|
115
|
+
if (magicEquals(header, 12, WEBP_VP8X)) {
|
|
116
|
+
const hasAlpha = (header[20] & 0x10) !== 0;
|
|
117
|
+
const width = (header[24] | (header[25] << 8) | (header[26] << 16)) + 1;
|
|
118
|
+
const height = (header[27] | (header[28] << 8) | (header[29] << 16)) + 1;
|
|
119
|
+
return { mimeType: "image/webp", width, height, channels: hasAlpha ? 4 : 3, hasAlpha };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const view = new DataView(header.buffer, header.byteOffset, header.byteLength);
|
|
123
|
+
if (magicEquals(header, 12, WEBP_VP8L)) {
|
|
124
|
+
if (header.length < 25) return { mimeType: "image/webp" };
|
|
125
|
+
const bits = view.getUint32(21, true);
|
|
126
|
+
const width = (bits & 0x3fff) + 1;
|
|
127
|
+
const height = ((bits >> 14) & 0x3fff) + 1;
|
|
128
|
+
const hasAlpha = ((bits >> 28) & 0x1) === 1;
|
|
129
|
+
return { mimeType: "image/webp", width, height, channels: hasAlpha ? 4 : 3, hasAlpha };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (magicEquals(header, 12, WEBP_VP8)) {
|
|
133
|
+
const width = view.getUint16(26, true) & 0x3fff;
|
|
134
|
+
const height = view.getUint16(28, true) & 0x3fff;
|
|
135
|
+
return { mimeType: "image/webp", width, height, channels: 3, hasAlpha: false };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { mimeType: "image/webp" };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function parseImageMetadata(header: Uint8Array): ImageMetadata | null {
|
|
142
|
+
return (
|
|
143
|
+
parsePngMetadata(header) ?? parseJpegMetadata(header) ?? parseGifMetadata(header) ?? parseWebpMetadata(header)
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function readImageMetadataSync(
|
|
148
|
+
filePath: string,
|
|
149
|
+
maxBytes = DEFAULT_IMAGE_METADATA_HEADER_BYTES,
|
|
150
|
+
): ImageMetadata | null {
|
|
151
|
+
return peekFileSync(filePath, maxBytes, parseImageMetadata);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function readImageMetadata(
|
|
155
|
+
filePath: string,
|
|
156
|
+
maxBytes = DEFAULT_IMAGE_METADATA_HEADER_BYTES,
|
|
157
|
+
): Promise<ImageMetadata | null> {
|
|
158
|
+
return peekFile(filePath, maxBytes, parseImageMetadata);
|
|
159
|
+
}
|
package/src/peek-file.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the first `maxBytes` of a file (offset 0) and pass that slice to `op`.
|
|
3
|
+
*
|
|
4
|
+
* Buffers are reused to avoid allocating on every peek: sync uses one growable
|
|
5
|
+
* `Uint8Array`; async uses a small fixed pool of `Buffer`s with a bounded wait
|
|
6
|
+
* queue, falling back to a fresh allocation when the pool and queue are saturated
|
|
7
|
+
* or when `maxBytes` exceeds the pool slot size.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
|
|
11
|
+
/** Async pool slot size; larger peeks allocate ad hoc. */
|
|
12
|
+
const POOLED_BUFFER_SIZE = 512;
|
|
13
|
+
const ASYNC_POOL_SIZE = 10;
|
|
14
|
+
/** Cap waiter queue so heavy concurrency does not queue unbounded; overflow uses alloc. */
|
|
15
|
+
const MAX_ASYNC_WAITERS = 4;
|
|
16
|
+
const INITIAL_SYNC_BUFFER_SIZE = 1024;
|
|
17
|
+
const EMPTY_BUFFER = Buffer.alloc(0);
|
|
18
|
+
|
|
19
|
+
const asyncPool = Array.from({ length: ASYNC_POOL_SIZE }, () => Buffer.allocUnsafe(POOLED_BUFFER_SIZE));
|
|
20
|
+
const availableAsyncPoolIndexes = Array.from({ length: ASYNC_POOL_SIZE }, (_, index) => index);
|
|
21
|
+
const asyncPoolWaiters: Array<(index: number) => void> = [];
|
|
22
|
+
let syncPool = new Uint8Array(INITIAL_SYNC_BUFFER_SIZE);
|
|
23
|
+
|
|
24
|
+
/** Returns a pool slot index, or `-1` when the caller should use a standalone buffer. */
|
|
25
|
+
function acquireAsyncPoolIndex(): Promise<number> | number {
|
|
26
|
+
const index = availableAsyncPoolIndexes.pop();
|
|
27
|
+
if (index !== undefined) {
|
|
28
|
+
return index;
|
|
29
|
+
}
|
|
30
|
+
if (asyncPoolWaiters.length >= MAX_ASYNC_WAITERS) {
|
|
31
|
+
return -1;
|
|
32
|
+
}
|
|
33
|
+
const { promise, resolve } = Promise.withResolvers<number>();
|
|
34
|
+
asyncPoolWaiters.push(resolve);
|
|
35
|
+
return promise;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function releaseAsyncPoolIndex(index: number): void {
|
|
39
|
+
if (index < 0) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const waiter = asyncPoolWaiters.shift();
|
|
43
|
+
if (waiter) {
|
|
44
|
+
waiter(index);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
availableAsyncPoolIndexes.push(index);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function withAsyncPoolBuffer<T>(maxBytes: number, op: (buffer: Buffer) => Promise<T>): Promise<T> {
|
|
51
|
+
if (maxBytes <= 0) {
|
|
52
|
+
return op(EMPTY_BUFFER);
|
|
53
|
+
}
|
|
54
|
+
if (maxBytes > POOLED_BUFFER_SIZE) {
|
|
55
|
+
return op(Buffer.allocUnsafe(maxBytes));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const poolIndex = await acquireAsyncPoolIndex();
|
|
59
|
+
const buffer = poolIndex >= 0 ? asyncPool[poolIndex] : Buffer.allocUnsafe(maxBytes);
|
|
60
|
+
try {
|
|
61
|
+
return await op(buffer.subarray(0, maxBytes));
|
|
62
|
+
} finally {
|
|
63
|
+
releaseAsyncPoolIndex(poolIndex);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function withSyncPoolBuffer<T>(maxBytes: number, op: (buffer: Uint8Array) => T): T {
|
|
68
|
+
if (maxBytes <= 0) {
|
|
69
|
+
return op(EMPTY_BUFFER);
|
|
70
|
+
}
|
|
71
|
+
if (maxBytes > syncPool.byteLength) {
|
|
72
|
+
syncPool = new Uint8Array(maxBytes + (maxBytes >> 1));
|
|
73
|
+
}
|
|
74
|
+
return op(syncPool.subarray(0, maxBytes));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Synchronously reads up to `maxBytes` from the start of `filePath` and returns `op(header)`.
|
|
79
|
+
* If the file is shorter, `header` is only the bytes actually read.
|
|
80
|
+
*/
|
|
81
|
+
export function peekFileSync<T>(filePath: string, maxBytes: number, op: (header: Uint8Array) => T): T {
|
|
82
|
+
if (maxBytes <= 0) {
|
|
83
|
+
return op(EMPTY_BUFFER);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const fileHandle = fs.openSync(filePath, "r");
|
|
87
|
+
try {
|
|
88
|
+
return withSyncPoolBuffer(maxBytes, buffer => {
|
|
89
|
+
const bytesRead = fs.readSync(fileHandle, buffer, 0, buffer.byteLength, 0);
|
|
90
|
+
return op(buffer.subarray(0, bytesRead));
|
|
91
|
+
});
|
|
92
|
+
} finally {
|
|
93
|
+
fs.closeSync(fileHandle);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Like {@link peekFileSync} but uses async I/O.
|
|
99
|
+
*/
|
|
100
|
+
export async function peekFile<T>(filePath: string, maxBytes: number, op: (header: Uint8Array) => T): Promise<T> {
|
|
101
|
+
if (maxBytes <= 0) {
|
|
102
|
+
return op(EMPTY_BUFFER);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const fileHandle = await fs.promises.open(filePath, "r");
|
|
106
|
+
try {
|
|
107
|
+
return await withAsyncPoolBuffer(maxBytes, async buffer => {
|
|
108
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, buffer.byteLength, 0);
|
|
109
|
+
return op(buffer.subarray(0, bytesRead));
|
|
110
|
+
});
|
|
111
|
+
} finally {
|
|
112
|
+
await fileHandle.close();
|
|
113
|
+
}
|
|
114
|
+
}
|