@oh-my-pi/pi-utils 18.6.3 → 18.8.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 +16 -0
- package/dist/types/ar/index.d.ts +1 -0
- package/dist/types/ar/zip-package.d.ts +21 -0
- package/dist/types/docx/converter.d.ts +2 -0
- package/dist/types/dom/core.d.ts +15 -4
- package/dist/types/dom/parser.d.ts +5 -3
- package/dist/types/dom/selector.d.ts +7 -1
- package/dist/types/logger/rotating-file.d.ts +4 -2
- package/dist/types/marked/core.d.ts +7 -6
- package/dist/types/marked-list.d.ts +38 -0
- package/dist/types/math-delimiters.d.ts +10 -0
- package/dist/types/readability/types.d.ts +2 -0
- package/dist/types/turndown/types.d.ts +7 -0
- package/dist/types/vterm/buffer.d.ts +16 -5
- package/package.json +2 -2
- package/src/ar/index.ts +1 -0
- package/src/ar/zip-package.ts +57 -0
- package/src/chalk.ts +66 -23
- package/src/docx/converter.ts +26 -12
- package/src/dom/core.ts +354 -111
- package/src/dom/parser.ts +42 -19
- package/src/dom/selector.ts +328 -95
- package/src/fetch-retry.ts +2 -0
- package/src/json-parse.ts +21 -6
- package/src/logger/rotating-file.ts +5 -3
- package/src/logger.ts +39 -23
- package/src/marked/core.ts +42 -18
- package/src/marked-list.ts +73 -0
- package/src/math-delimiters.ts +16 -0
- package/src/prompt.ts +3 -1
- package/src/ptree.ts +28 -35
- package/src/readability/readability.ts +349 -39
- package/src/readability/types.ts +2 -0
- package/src/stream.ts +34 -13
- package/src/temp.ts +1 -10
- package/src/template.ts +7 -4
- package/src/turndown/gfm.ts +16 -5
- package/src/turndown/html.ts +28 -8
- package/src/turndown/service.ts +43 -15
- package/src/turndown/types.ts +7 -0
- package/src/vterm/buffer.ts +31 -11
- package/src/vterm/terminal.ts +185 -92
package/src/json-parse.ts
CHANGED
|
@@ -309,15 +309,30 @@ export function parseJsonWithRepair<T>(json: string): T {
|
|
|
309
309
|
export function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T {
|
|
310
310
|
const trimmed = partialJson?.trimStart();
|
|
311
311
|
if (!trimmed) return {} as T;
|
|
312
|
+
// A container can only be strict JSON once its closer arrived; skip the
|
|
313
|
+
// doomed full-buffer JSON.parse (and its SyntaxError) while it streams.
|
|
314
|
+
const first = trimmed.charCodeAt(0);
|
|
315
|
+
if ((first !== 0x7b && first !== 0x5b) || canCloseContainer(trimmed)) {
|
|
316
|
+
try {
|
|
317
|
+
return JSON.parse(trimmed) as T;
|
|
318
|
+
} catch {}
|
|
319
|
+
}
|
|
312
320
|
try {
|
|
313
|
-
return
|
|
321
|
+
return (new RelaxedJson(trimmed, true).parse() ?? {}) as T;
|
|
314
322
|
} catch {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
323
|
+
return {} as T;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** True when the last non-whitespace character is `}` or `]`. */
|
|
328
|
+
function canCloseContainer(text: string): boolean {
|
|
329
|
+
for (let i = text.length - 1; i >= 0; i--) {
|
|
330
|
+
const code = text.charCodeAt(i);
|
|
331
|
+
// JSON whitespace: space, \t, \n, \r
|
|
332
|
+
if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d) continue;
|
|
333
|
+
return code === 0x7d || code === 0x5d;
|
|
320
334
|
}
|
|
335
|
+
return false;
|
|
321
336
|
}
|
|
322
337
|
|
|
323
338
|
/**
|
|
@@ -109,11 +109,13 @@ export class RotatingFileSink {
|
|
|
109
109
|
/**
|
|
110
110
|
* Append one already-formatted log record. A batching sink buffers it unless
|
|
111
111
|
* `flushNow` is set or the buffer reached its size threshold; either way the
|
|
112
|
-
* whole buffer, this record included, is then written in order.
|
|
112
|
+
* whole buffer, this record included, is then written in order. `day` is the
|
|
113
|
+
* record's `localDay` key, so callers that already took the record's `Date`
|
|
114
|
+
* reuse it.
|
|
113
115
|
*/
|
|
114
|
-
write(line: string, flushNow = false): void {
|
|
116
|
+
write(line: string, flushNow = false, day = localDay(new Date())): void {
|
|
115
117
|
if (this.#closed) return;
|
|
116
|
-
this.#selectFile(
|
|
118
|
+
this.#selectFile(day);
|
|
117
119
|
const record = `${line}${os.EOL}`;
|
|
118
120
|
const bytes = Buffer.byteLength(record, "utf8");
|
|
119
121
|
this.#pending += record;
|
package/src/logger.ts
CHANGED
|
@@ -209,9 +209,28 @@ function jsonReplacer(_key: string, value: unknown): unknown {
|
|
|
209
209
|
return value;
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
212
|
+
// Values visited by the Error pre-scan before falling back to the replacer.
|
|
213
|
+
const ERROR_SCAN_BUDGET = 128;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Whether `value` may contain an {@link Error} that {@link jsonReplacer} must
|
|
217
|
+
* unwrap. Walks what `JSON.stringify` serializes (arrays and own enumerable
|
|
218
|
+
* keys); objects with a `toJSON` other than `Date` may return an Error, and an
|
|
219
|
+
* exhausted budget answers true.
|
|
220
|
+
*/
|
|
221
|
+
function mayContainError(root: unknown): boolean {
|
|
222
|
+
const stack: unknown[] = [root];
|
|
223
|
+
let budget = ERROR_SCAN_BUDGET;
|
|
224
|
+
while (stack.length > 0) {
|
|
225
|
+
const value = stack.pop();
|
|
226
|
+
if (value === null || typeof value !== "object") continue;
|
|
227
|
+
if (value instanceof Error) return true;
|
|
228
|
+
if (--budget < 0) return true;
|
|
229
|
+
if (value instanceof Date) continue;
|
|
230
|
+
if ("toJSON" in value && typeof value.toJSON === "function") return true;
|
|
231
|
+
for (const child of Object.values(value)) stack.push(child);
|
|
232
|
+
}
|
|
233
|
+
return false;
|
|
215
234
|
}
|
|
216
235
|
|
|
217
236
|
function padTimestampPart(value: number, width = 2): string {
|
|
@@ -232,33 +251,30 @@ function formatLocalTimestamp(date: Date): string {
|
|
|
232
251
|
|
|
233
252
|
const FORMAT_TOKEN_PATTERN = /%[scdjifoO%]/;
|
|
234
253
|
|
|
235
|
-
|
|
254
|
+
/** Serialize one log record: timestamp, level, pid, message, then context fields. */
|
|
255
|
+
function formatLogRecord(
|
|
236
256
|
level: LogLevel,
|
|
237
257
|
message: string,
|
|
238
258
|
context: Record<string, unknown> | undefined,
|
|
239
|
-
|
|
259
|
+
timestamp: string,
|
|
260
|
+
): string {
|
|
240
261
|
const metadata =
|
|
241
262
|
!FORMAT_TOKEN_PATTERN.test(message) && context !== null && typeof context === "object" ? context : undefined;
|
|
242
|
-
const info = Object.assign({}, metadata, { level, message }) as NormalizedLogInfo;
|
|
243
|
-
if (metadata?.message) info.message = `${message} ${metadata.message}`;
|
|
244
|
-
if (metadata?.stack) info.stack = metadata.stack;
|
|
245
|
-
if (metadata?.cause) info.cause = metadata.cause;
|
|
246
|
-
return info;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function formatLogInfo(info: NormalizedLogInfo): string {
|
|
250
|
-
const timestamp = formatLocalTimestamp(new Date());
|
|
251
|
-
info.timestamp = timestamp;
|
|
252
263
|
const entry: Record<string, unknown> = {
|
|
253
264
|
timestamp,
|
|
254
|
-
level
|
|
265
|
+
level,
|
|
255
266
|
pid: process.pid,
|
|
256
|
-
message:
|
|
267
|
+
message: metadata?.message ? `${message} ${metadata.message}` : message,
|
|
257
268
|
};
|
|
258
|
-
|
|
259
|
-
|
|
269
|
+
if (metadata) {
|
|
270
|
+
for (const key of Object.keys(metadata)) {
|
|
271
|
+
if (key !== "level" && key !== "timestamp" && key !== "message") entry[key] = metadata[key];
|
|
272
|
+
}
|
|
273
|
+
// An Error passed as the context carries these as non-enumerable fields.
|
|
274
|
+
if (metadata.stack) entry.stack = metadata.stack;
|
|
275
|
+
if (metadata.cause) entry.cause = metadata.cause;
|
|
260
276
|
}
|
|
261
|
-
return JSON.stringify(entry, jsonReplacer)
|
|
277
|
+
return mayContainError(entry) ? JSON.stringify(entry, jsonReplacer) : JSON.stringify(entry);
|
|
262
278
|
}
|
|
263
279
|
|
|
264
280
|
/** Longest a buffered file record waits before it is written. */
|
|
@@ -361,9 +377,9 @@ function emitLocally(level: LogLevel, message: string, context: Record<string, u
|
|
|
361
377
|
const rank = LOG_LEVEL_RANK[level];
|
|
362
378
|
const file = rank <= transports.fileLevelRank ? transports.file : undefined;
|
|
363
379
|
if (!file && !transports.console) return;
|
|
364
|
-
const
|
|
365
|
-
const line =
|
|
366
|
-
file?.write(line, rank <= IMMEDIATE_FLUSH_RANK || exiting);
|
|
380
|
+
const now = new Date();
|
|
381
|
+
const line = formatLogRecord(level, message, context, formatLocalTimestamp(now));
|
|
382
|
+
file?.write(line, rank <= IMMEDIATE_FLUSH_RANK || exiting, localDay(now));
|
|
367
383
|
if (transports.console) fs.writeSync(1, `${line}${os.EOL}`);
|
|
368
384
|
}
|
|
369
385
|
|
package/src/marked/core.ts
CHANGED
|
@@ -248,14 +248,15 @@ export type TokensList = Token[] & { links: Links };
|
|
|
248
248
|
export interface TokenizerThis {
|
|
249
249
|
lexer: Lexer;
|
|
250
250
|
/**
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
251
|
+
* The whole source the lexer works through. The `src` a tokenizer receives is the part of it before `end` that
|
|
252
|
+
* starts at `end - src.length`. For a block tokenizer, `source` is the text passed to one `blockTokens` call and
|
|
253
|
+
* `end` its length. For an inline tokenizer, it is the inline source, such as a paragraph's text: inside a link
|
|
254
|
+
* label or the text of emphasis, `end` is where that text ends, right before its closer, and a token there must
|
|
255
|
+
* not reach past it. Tokenizers get one context object per source, the same for every call while the lexer works
|
|
256
|
+
* through it (and, inline, the labels and emphasis inside it), so a tokenizer can keep state for it in a `WeakMap`.
|
|
256
257
|
*/
|
|
257
258
|
source?: string;
|
|
258
|
-
/**
|
|
259
|
+
/** Where the text the lexer works through ends in `source` (see `source`). */
|
|
259
260
|
end?: number;
|
|
260
261
|
}
|
|
261
262
|
/** A tokenizer extension callback. */
|
|
@@ -971,13 +972,30 @@ function parseList(lines: string[], index: number, lexer: Lexer): { token: Token
|
|
|
971
972
|
|
|
972
973
|
function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] {
|
|
973
974
|
const lines = lineArray(src);
|
|
975
|
+
// `lines` tiles `src` exactly, so the unconsumed source at line `i` is
|
|
976
|
+
// `src.slice(lineStarts[i])`. Only block extensions and an lheading override
|
|
977
|
+
// read it; computing offsets once keeps the loop linear.
|
|
978
|
+
const blockExtensions = lexer.extensions.block;
|
|
979
|
+
const context: TokenizerThis = { lexer, source: src, end: src.length };
|
|
980
|
+
const lheadingOverride = lexer.tokenizerOverrides.lheading;
|
|
981
|
+
let lineStarts: number[] | undefined;
|
|
982
|
+
if (blockExtensions.length > 0 || lheadingOverride) {
|
|
983
|
+
lineStarts = [];
|
|
984
|
+
let offset = 0;
|
|
985
|
+
for (let index = 0; index < lines.length; index++) {
|
|
986
|
+
lineStarts.push(offset);
|
|
987
|
+
offset += lines[index]!.length;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
974
990
|
let i = 0;
|
|
975
991
|
while (i < lines.length) {
|
|
976
|
-
const remaining = lines.slice(i).join("");
|
|
977
992
|
let custom: Tokens.Generic | undefined;
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
993
|
+
if (blockExtensions.length > 0) {
|
|
994
|
+
const remaining = src.slice(lineStarts![i]);
|
|
995
|
+
for (const extension of blockExtensions) {
|
|
996
|
+
custom = extension.tokenizer.call(context, remaining, output);
|
|
997
|
+
if (custom?.raw) break;
|
|
998
|
+
}
|
|
981
999
|
}
|
|
982
1000
|
if (custom?.raw) {
|
|
983
1001
|
output.push(custom);
|
|
@@ -1006,9 +1024,10 @@ function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] {
|
|
|
1006
1024
|
let raw = line;
|
|
1007
1025
|
let text = "";
|
|
1008
1026
|
i++;
|
|
1027
|
+
const closer = new RegExp(`^ {0,3}${marker[0]}{${marker.length},}[ \\t]*(?:\\n|$)$`);
|
|
1009
1028
|
while (i < lines.length) {
|
|
1010
1029
|
const next = lines[i++]!;
|
|
1011
|
-
if (
|
|
1030
|
+
if (closer.test(next)) {
|
|
1012
1031
|
raw += next;
|
|
1013
1032
|
break;
|
|
1014
1033
|
}
|
|
@@ -1090,10 +1109,16 @@ function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] {
|
|
|
1090
1109
|
i++;
|
|
1091
1110
|
if (line.trimStart().startsWith("<!--")) {
|
|
1092
1111
|
while (!raw.includes("-->") && i < lines.length) raw += lines[i++]!;
|
|
1093
|
-
} else if (tag
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1112
|
+
} else if (tag) {
|
|
1113
|
+
// A closing tag never spans lines, so testing each appended line is
|
|
1114
|
+
// equivalent to re-testing the accumulated block.
|
|
1115
|
+
const closeTag = new RegExp(`</${tag}>`, "i");
|
|
1116
|
+
if (!closeTag.test(raw)) {
|
|
1117
|
+
while (i < lines.length) {
|
|
1118
|
+
const next = lines[i++]!;
|
|
1119
|
+
raw += next;
|
|
1120
|
+
if (closeTag.test(next)) break;
|
|
1121
|
+
}
|
|
1097
1122
|
}
|
|
1098
1123
|
}
|
|
1099
1124
|
const value = i < lines.length && /^\s*\n$/.test(lines[i]!) ? stripFinalNewline(raw) : raw;
|
|
@@ -1129,9 +1154,8 @@ function blockTokens(src: string, lexer: Lexer, output: Token[]): Token[] {
|
|
|
1129
1154
|
}
|
|
1130
1155
|
if (i + 1 < lines.length && /^ {0,3}(=+|-+)[ \t]*(?:\n|$)$/.test(lines[i + 1]!)) {
|
|
1131
1156
|
let fallback: Tokens.Heading | undefined | false;
|
|
1132
|
-
|
|
1133
|
-
if (
|
|
1134
|
-
if (!override || fallback === false) {
|
|
1157
|
+
if (lheadingOverride) fallback = lheadingOverride.call(lexer.tokenizer, src.slice(lineStarts![i]));
|
|
1158
|
+
if (!lheadingOverride || fallback === false) {
|
|
1135
1159
|
const raw = line + lines[i + 1]!;
|
|
1136
1160
|
const text = stripFinalNewline(line);
|
|
1137
1161
|
const tokens: Token[] = [];
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List-continuation grammar of `@oh-my-pi/pi-utils/marked`, for streaming
|
|
3
|
+
* renderers that freeze a lexed prefix at blank-line boundaries (TUI and
|
|
4
|
+
* collab web).
|
|
5
|
+
*
|
|
6
|
+
* marked's list tokenizer (Tokenizer.list, marked v18) continues a list across
|
|
7
|
+
* blank lines only when the remaining source matches
|
|
8
|
+
* `listItemRegex(marker)` = `^( {0,3}${marker})((?:[\t ][^\n]*)?(?:\n|$))`,
|
|
9
|
+
* where `marker` is the exact bullet char for unordered lists (`\${char}`) or
|
|
10
|
+
* 1-9 digits plus the exact delimiter for ordered lists (`\d{1,9}\${delim}`).
|
|
11
|
+
* The marker is derived from the list's FIRST item (`n = t[1].trim()`), which
|
|
12
|
+
* sits at the start of a top-level list token's raw.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A list's marker, read from the start of its raw: bullet in group 1, ordered delimiter in group 2. */
|
|
16
|
+
const LIST_MARKER_RE = /^ {0,3}(?:([*+-])|\d{1,9}([.)]))/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether a same-marker item could still continue the list `listRaw` at
|
|
20
|
+
* `tailStart` (directly after the blank line ending the list), now or after
|
|
21
|
+
* any append to `text`.
|
|
22
|
+
*
|
|
23
|
+
* Streaming-freeze equivalence invariant: lex(prefix) ++ lex(tail) must equal
|
|
24
|
+
* lex(full text) for the current text and every append-only extension of it,
|
|
25
|
+
* because a frozen prefix is sticky. At a blank-line cut directly after a
|
|
26
|
+
* top-level `list` token, the only construct that can straddle the cut is a
|
|
27
|
+
* continuation item of that list: marked consumed the blank line into the last
|
|
28
|
+
* item's raw and re-ran `listItemRegex` at exactly `tailStart`, merging a
|
|
29
|
+
* same-marker item into one renumbered loose list. The cut is safe only when
|
|
30
|
+
* that regex can NEVER match at `tailStart`, no matter what is appended later.
|
|
31
|
+
*
|
|
32
|
+
* Append-only growth means "closed" may only be concluded from a present
|
|
33
|
+
* character that contradicts every possible continuation (tail "1x" can never
|
|
34
|
+
* grow into an ordered item, but tail "1" can become "1. c"). Running out of
|
|
35
|
+
* text mid-marker therefore answers "may continue".
|
|
36
|
+
*
|
|
37
|
+
* Returns true when the tail could still continue the list (or the list's
|
|
38
|
+
* marker is unrecognizable) — the conservative "don't freeze" answer. marked
|
|
39
|
+
* may break the list anyway when the matching line is also an hr (`- - -`);
|
|
40
|
+
* treating that as "may continue" merely skips a freeze, never corrupts one.
|
|
41
|
+
*/
|
|
42
|
+
export function listMayContinueAt(text: string, tailStart: number, listRaw: string): boolean {
|
|
43
|
+
const marker = LIST_MARKER_RE.exec(listRaw);
|
|
44
|
+
if (marker === null) return true; // unrecognized list shape — stay conservative
|
|
45
|
+
const n = text.length;
|
|
46
|
+
let i = tailStart;
|
|
47
|
+
// `listItemRegex` allows up to 3 leading spaces.
|
|
48
|
+
while (i < n && i - tailStart < 3 && text.charCodeAt(i) === 0x20 /* space */) i++;
|
|
49
|
+
if (i >= n) return true;
|
|
50
|
+
const bullet = marker[1];
|
|
51
|
+
if (bullet !== undefined) {
|
|
52
|
+
if (text[i] !== bullet) return false; // wrong marker char — closed forever
|
|
53
|
+
i++;
|
|
54
|
+
} else {
|
|
55
|
+
// Ordered: 1-9 digits, then the same `.`/`)` delimiter.
|
|
56
|
+
let digits = 0;
|
|
57
|
+
while (i < n && digits < 10) {
|
|
58
|
+
const c = text.charCodeAt(i);
|
|
59
|
+
if (c < 0x30 /* 0 */ || c > 0x39 /* 9 */) break;
|
|
60
|
+
digits++;
|
|
61
|
+
i++;
|
|
62
|
+
}
|
|
63
|
+
if (digits === 0 || digits > 9) return false; // no digit run / too long — closed forever
|
|
64
|
+
if (i >= n) return true; // delimiter (or more digits) may still arrive
|
|
65
|
+
if (text[i] !== marker[2]) return false; // wrong delimiter — closed forever
|
|
66
|
+
i++;
|
|
67
|
+
}
|
|
68
|
+
// After the marker: `(?:[\t ][^\n]*)?(?:\n|$)` — tab/space + anything, a
|
|
69
|
+
// bare newline, or end-of-input (which appends can still extend).
|
|
70
|
+
if (i >= n) return true;
|
|
71
|
+
const after = text.charCodeAt(i);
|
|
72
|
+
return after === 0x20 /* space */ || after === 0x09 /* tab */ || after === 0x0a; /* \n */
|
|
73
|
+
}
|
package/src/math-delimiters.ts
CHANGED
|
@@ -235,6 +235,22 @@ export class MathBlockScan {
|
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
const blockScansByContext = new WeakMap<object, MathBlockScan>();
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The own-line display block at the start of `src`, for a marked block tokenizer: `src` is the part of
|
|
242
|
+
* `context.source` that starts at `context.end - src.length`. One {@link MathBlockScan} is kept per context, which
|
|
243
|
+
* marked keeps for one `blockTokens` source, so asking at every block start takes linear time even when an opener
|
|
244
|
+
* never closes. Without `context.source` it is {@link mathBlockAt}.
|
|
245
|
+
*/
|
|
246
|
+
export function mathBlockInContext(context: { source?: string; end?: number }, src: string): MathBlock | undefined {
|
|
247
|
+
const source = context.source;
|
|
248
|
+
if (source === undefined) return mathBlockAt(src);
|
|
249
|
+
let scan = blockScansByContext.get(context);
|
|
250
|
+
if (!scan) blockScansByContext.set(context, (scan = new MathBlockScan(source)));
|
|
251
|
+
return scan.at((context.end ?? source.length) - src.length);
|
|
252
|
+
}
|
|
253
|
+
|
|
238
254
|
/**
|
|
239
255
|
* The display opener alone on the line at `from` (up to 3 leading spaces,
|
|
240
256
|
* trailing spaces or tabs; the source's last line may be unterminated), or
|
package/src/prompt.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LRUCache } from "./lru";
|
|
1
2
|
import type { HelperDelegate, Template } from "./template";
|
|
2
3
|
import * as Handlebars from "./template";
|
|
3
4
|
|
|
@@ -526,7 +527,8 @@ export function registerPartial(name: string, fn: Template): void {
|
|
|
526
527
|
handlebars.registerPartial(name, fn);
|
|
527
528
|
}
|
|
528
529
|
|
|
529
|
-
|
|
530
|
+
// Bounded: some callers compile template text that already embeds user arguments.
|
|
531
|
+
const compiledTemplateCache = new LRUCache<string, (context: TemplateContext) => string>({ max: 256 });
|
|
530
532
|
|
|
531
533
|
export function compile(template: string): (context: TemplateContext) => string {
|
|
532
534
|
// Keyed on the raw template so repeat renders skip parsing.
|
package/src/ptree.ts
CHANGED
|
@@ -183,10 +183,12 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
183
183
|
#stderrDone: Promise<void>;
|
|
184
184
|
#exited: Promise<number>;
|
|
185
185
|
#openPipeReaders = 1;
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
#
|
|
186
|
+
// Set by attachTimeout() at the command deadline; untimed commands keep
|
|
187
|
+
// complete EOF-based capture. Active pipe readers are cancelled at cutoff so
|
|
188
|
+
// their pending read() settles as done and partial output is kept.
|
|
189
|
+
#cutoff = false;
|
|
190
|
+
/** Readers of piped stdio; the timeout cancels them so pending reads settle. */
|
|
191
|
+
#pipeReaders = new Set<{ cancel(reason?: unknown): Promise<void> }>();
|
|
190
192
|
#timeoutTimer?: NodeJS.Timeout;
|
|
191
193
|
#stderrStream?: ReadableStream<Uint8Array>;
|
|
192
194
|
// Termination in flight after kill(); aborted exits await it before reporting.
|
|
@@ -224,31 +226,24 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
224
226
|
// Normalize Bun's exited promise into our exitReason / exitedCleanly model.
|
|
225
227
|
const { promise, resolve, reject } = Promise.withResolvers<number>();
|
|
226
228
|
this.#exited = promise;
|
|
227
|
-
const drainCutoff = Promise.withResolvers<void>();
|
|
228
|
-
this.#drainCutoff = drainCutoff.promise;
|
|
229
|
-
this.#resolveDrainCutoff = drainCutoff.resolve;
|
|
230
|
-
// The cutoff remains pending for untimed commands, preserving complete
|
|
231
|
-
// EOF-based capture. attachTimeout() resolves it at the command deadline.
|
|
232
229
|
|
|
233
|
-
const pipeCutoff = this.#drainCutoff;
|
|
234
230
|
this.#stderrDone = (async () => {
|
|
235
231
|
const reader = stderrStream.getReader();
|
|
232
|
+
this.#pipeReaders.add(reader);
|
|
236
233
|
try {
|
|
237
234
|
for (;;) {
|
|
238
|
-
|
|
239
|
-
reader.read().then(r => ({ cutoff: false as const, r })),
|
|
240
|
-
pipeCutoff.then(() => ({ cutoff: true as const })),
|
|
241
|
-
]);
|
|
242
|
-
if (chunk.cutoff) {
|
|
235
|
+
if (this.#cutoff) {
|
|
243
236
|
await reader.cancel().catch(() => {});
|
|
244
237
|
break;
|
|
245
238
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
this.#
|
|
239
|
+
const r = await reader.read();
|
|
240
|
+
if (r.done) break;
|
|
241
|
+
this.#stderrChunks?.push(r.value);
|
|
242
|
+
this.#stderrTail += dec.decode(r.value, { stream: true });
|
|
249
243
|
trim();
|
|
250
244
|
}
|
|
251
245
|
} catch {}
|
|
246
|
+
this.#pipeReaders.delete(reader);
|
|
252
247
|
this.#openPipeReaders--;
|
|
253
248
|
this.#stderrTail += dec.decode();
|
|
254
249
|
trim();
|
|
@@ -440,24 +435,23 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
440
435
|
async #readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
441
436
|
this.#openPipeReaders++;
|
|
442
437
|
const reader = stream.getReader();
|
|
438
|
+
this.#pipeReaders.add(reader);
|
|
443
439
|
const dec = new TextDecoder();
|
|
444
440
|
let out = "";
|
|
445
441
|
try {
|
|
446
442
|
for (;;) {
|
|
447
|
-
|
|
448
|
-
reader.read().then(r => ({ cutoff: false as const, r })),
|
|
449
|
-
this.#drainCutoff.then(() => ({ cutoff: true as const })),
|
|
450
|
-
]);
|
|
451
|
-
if (chunk.cutoff) {
|
|
443
|
+
if (this.#cutoff) {
|
|
452
444
|
await reader.cancel().catch(() => {});
|
|
453
445
|
break;
|
|
454
446
|
}
|
|
455
|
-
|
|
456
|
-
|
|
447
|
+
const r = await reader.read();
|
|
448
|
+
if (r.done) break;
|
|
449
|
+
out += dec.decode(r.value, { stream: true });
|
|
457
450
|
}
|
|
458
451
|
} catch {
|
|
459
452
|
// A cancelled or failed read keeps whatever was already collected.
|
|
460
453
|
}
|
|
454
|
+
this.#pipeReaders.delete(reader);
|
|
461
455
|
this.#openPipeReaders--;
|
|
462
456
|
return out + dec.decode();
|
|
463
457
|
}
|
|
@@ -465,29 +459,27 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
465
459
|
async #readBytes(): Promise<Uint8Array<ArrayBuffer>> {
|
|
466
460
|
const reader = this.proc.stdout.getReader();
|
|
467
461
|
this.#openPipeReaders++;
|
|
462
|
+
this.#pipeReaders.add(reader);
|
|
468
463
|
const chunks: Uint8Array[] = [];
|
|
469
464
|
let length = 0;
|
|
470
465
|
try {
|
|
471
466
|
for (;;) {
|
|
472
|
-
|
|
473
|
-
reader.read().then(r => ({ cutoff: false as const, r })),
|
|
474
|
-
this.#drainCutoff.then(() => ({ cutoff: true as const })),
|
|
475
|
-
]);
|
|
476
|
-
if (chunk.cutoff) {
|
|
467
|
+
if (this.#cutoff) {
|
|
477
468
|
await reader.cancel().catch(() => {});
|
|
478
469
|
break;
|
|
479
470
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
471
|
+
const r = await reader.read();
|
|
472
|
+
if (r.done) break;
|
|
473
|
+
chunks.push(r.value);
|
|
474
|
+
length += r.value.byteLength;
|
|
483
475
|
}
|
|
484
476
|
} catch {
|
|
485
477
|
// A cancelled or failed read keeps whatever was already collected.
|
|
486
478
|
} finally {
|
|
479
|
+
this.#pipeReaders.delete(reader);
|
|
487
480
|
this.#openPipeReaders--;
|
|
488
481
|
reader.releaseLock();
|
|
489
482
|
}
|
|
490
|
-
|
|
491
483
|
const bytes = new Uint8Array(length);
|
|
492
484
|
let offset = 0;
|
|
493
485
|
for (const chunk of chunks) {
|
|
@@ -597,7 +589,8 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
597
589
|
) {
|
|
598
590
|
this.kill(new TimeoutError(ms, this.#stderrTail), -1);
|
|
599
591
|
}
|
|
600
|
-
this.#
|
|
592
|
+
this.#cutoff = true;
|
|
593
|
+
for (const reader of this.#pipeReaders) reader.cancel().catch(() => {});
|
|
601
594
|
}, ms);
|
|
602
595
|
timer.unref?.();
|
|
603
596
|
this.#timeoutTimer = timer;
|