@linxiraos/pi-utils 1.1.15 → 1.1.16
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 +2 -2
- package/THIRD-PARTY-NOTICES.txt +54 -25
- package/dist/types/async.d.ts +18 -0
- package/dist/types/browsers.d.ts +2 -30
- package/dist/types/color.d.ts +2 -0
- package/dist/types/dirs.d.ts +18 -1
- package/dist/types/env.d.ts +12 -2
- package/dist/types/executable.d.ts +4 -0
- package/dist/types/fetch-retry.d.ts +8 -2
- package/dist/types/file-lock.d.ts +6 -0
- package/dist/types/format.d.ts +7 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/mime.d.ts +1 -0
- package/dist/types/path.d.ts +6 -0
- package/dist/types/peek-file.d.ts +2 -3
- package/dist/types/postmortem.d.ts +26 -5
- package/dist/types/procmgr.d.ts +2 -4
- package/dist/types/snowflake.d.ts +1 -0
- package/dist/types/sqlite.d.ts +27 -7
- package/dist/types/stream.d.ts +76 -1
- package/dist/types/which.d.ts +8 -2
- package/dist/types/yaml-config.d.ts +2 -0
- package/package.json +2 -2
- package/src/acp/transport.ts +37 -3
- package/src/async.ts +31 -0
- package/src/browsers.ts +10 -169
- package/src/color.ts +1 -1
- package/src/dirs.ts +28 -6
- package/src/env.ts +81 -34
- package/src/executable.ts +17 -0
- package/src/fetch-retry.ts +72 -13
- package/src/file-lock.ts +28 -0
- package/src/format.ts +16 -0
- package/src/index.ts +2 -1
- package/src/json.ts +12 -1
- package/src/logger/rotating-file.ts +40 -3
- package/src/logger.ts +16 -4
- package/src/mime.ts +3 -7
- package/src/path.ts +14 -0
- package/src/peek-file.ts +17 -67
- package/src/postmortem.ts +95 -47
- package/src/procmgr.ts +2 -12
- package/src/ptree.ts +36 -5
- package/src/snowflake.ts +12 -1
- package/src/sqlite.ts +242 -7
- package/src/stream.ts +167 -35
- package/src/which.ts +38 -14
- package/src/xml.ts +16 -0
- package/src/yaml-config.ts +8 -0
- package/dist/types/glob.d.ts +0 -28
- package/src/glob.ts +0 -189
package/src/fetch-retry.ts
CHANGED
|
@@ -11,17 +11,41 @@ const RETRY_DELAY_FIELD_PATTERN = /"retryDelay":\s*"([0-9.]+)(ms|s)"/i;
|
|
|
11
11
|
// "try again in 90 minutes" / "try again in 1 hour"
|
|
12
12
|
const TRY_AGAIN_PATTERN = /try again in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h)\b/i;
|
|
13
13
|
// "Your limit will reset in 13 minutes" / "reset in 13 minutes" / "will reset in 2h"
|
|
14
|
-
|
|
14
|
+
// OpenCode Go quota errors use "Resets in …" with day units and compound
|
|
15
|
+
// remainders ("Resets in 3 days", "Resets in 2hr 15min", "Resets in 45min").
|
|
16
|
+
const WILL_RESET_IN_PATTERN =
|
|
17
|
+
/(?:will\s+)?resets?\s+in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b/i;
|
|
18
|
+
// OpenCode Go compound remainder: "Resets in 2hr 15min".
|
|
19
|
+
const RESET_IN_HR_MIN_PATTERN = /resets?\s+in\s+~?\s*(\d+(?:\.\d+)?)\s*hr\s*(\d+(?:\.\d+)?)\s*min\b/i;
|
|
15
20
|
// "Your limit will reset at 2026-09-01 09:44:51" / "reset at 2026-09-01T09:44:51Z"
|
|
16
21
|
const WILL_RESET_AT_PATTERN =
|
|
17
22
|
/(?:will\s+)?reset at\s+([0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:?[0-9]{2})?)/i;
|
|
23
|
+
// Both grammars carry a timezone-naive wall clock. The default reading is UTC;
|
|
24
|
+
// a provider-specific offset (Z.AI/Zhipu Beijing time) is applied only through
|
|
25
|
+
// `RetryHintOptions.naiveResetTimezoneOffset`, never inferred from the language.
|
|
18
26
|
const CN_RESET_AT_PATTERN = /将在\s*([0-9]{4}-[0-9]{2}-[0-9]{2}\s+[0-9]{2}:[0-9]{2}:[0-9]{2})\s*重置/;
|
|
27
|
+
const RESET_AT_PATTERNS: readonly RegExp[] = [WILL_RESET_AT_PATTERN, CN_RESET_AT_PATTERN];
|
|
19
28
|
// "retry-after-ms=98497000" / "retry-after-ms: 7200000" / "retry-after-ms = 7200000"
|
|
20
29
|
const RETRY_AFTER_MS_BODY_PATTERN = /\bretry-after-ms\s*[:=]\s*([0-9]+)\b/i;
|
|
21
30
|
|
|
31
|
+
// A timezone-naive `reset at` stamp (no `Z`/offset) is the provider's
|
|
32
|
+
// wall clock in an unknown zone: it cannot be converted to a delay without
|
|
33
|
+
// guessing the zone, so it resolves only as a fallback when the body
|
|
34
|
+
// carries no unambiguous relative signal.
|
|
35
|
+
|
|
36
|
+
// A conflict probe needs no new signal: when a naive `reset at` wall stamp
|
|
37
|
+
// disagrees with the merged relative wait, the merged wait (which already
|
|
38
|
+
// ignores the naive stamp) sleeps first, and the retry after it is the
|
|
39
|
+
// probe — success proves skew, a fresh 429 re-anchors with live timing.
|
|
40
|
+
|
|
41
|
+
/** Provider-specific interpretation for timezone-naive retry timestamps. */
|
|
42
|
+
export interface RetryHintOptions {
|
|
43
|
+
/** UTC offset appended to an absolute reset stamp that omits its timezone. */
|
|
44
|
+
naiveResetTimezoneOffset?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
22
47
|
/**
|
|
23
48
|
* Server-suggested retry delay extraction. Merges the patterns historically used
|
|
24
|
-
* by the OpenAI Codex and Google Gemini retry helpers.
|
|
25
49
|
*
|
|
26
50
|
* Header sources (checked in order):
|
|
27
51
|
* - `retry-after-ms` (milliseconds)
|
|
@@ -37,12 +61,18 @@ const RETRY_AFTER_MS_BODY_PATTERN = /\bretry-after-ms\s*[:=]\s*([0-9]+)\b/i;
|
|
|
37
61
|
* - `try again in 250ms` / `try again in 12s` / `try again in 5 min` / `try again in ~158 min`
|
|
38
62
|
* - `retry-after-ms=98497000` / `retry-after-ms: 7200000` / `retry-after-ms = 7200000`
|
|
39
63
|
* - `Your limit will reset at 2026-09-01 09:44:51` / `将在 2026-09-01 09:44:51 重置`
|
|
64
|
+
* (a provider offset makes a naive wall clock authoritative; otherwise it
|
|
65
|
+
* resolves only when no relative signal is present)
|
|
40
66
|
*
|
|
41
67
|
* Returns `undefined` if no signal is found, or `0` when the provider
|
|
42
68
|
* explicitly asks for an immediate retry (`retry-after…=0`, or an absolute
|
|
43
69
|
* reset timestamp that has already elapsed).
|
|
44
70
|
*/
|
|
45
|
-
export function extractRetryHint(
|
|
71
|
+
export function extractRetryHint(
|
|
72
|
+
source: Response | Headers | null | undefined,
|
|
73
|
+
body?: string,
|
|
74
|
+
options?: RetryHintOptions,
|
|
75
|
+
): number | undefined {
|
|
46
76
|
const headers = source instanceof Headers ? source : (source?.headers ?? undefined);
|
|
47
77
|
if (headers) {
|
|
48
78
|
const retryAfterMs = headers.get("retry-after-ms");
|
|
@@ -98,6 +128,15 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
|
|
|
98
128
|
// when the parse returns undefined, which would sleep a session the
|
|
99
129
|
// provider told to retry immediately.
|
|
100
130
|
let retryNow = false;
|
|
131
|
+
|
|
132
|
+
// Timezone-naive `reset at` stamps (no `Z`/offset) are the provider's
|
|
133
|
+
// wall clock in an unknown zone — converting them to a delay requires
|
|
134
|
+
// guessing the zone. They resolve AFTER every unambiguous signal below,
|
|
135
|
+
// and only as a fallback when none was found.
|
|
136
|
+
let longestNaiveMs: number | undefined;
|
|
137
|
+
const considerNaive = (ms: number | undefined): void => {
|
|
138
|
+
if (ms !== undefined && ms > 0 && (longestNaiveMs === undefined || ms > longestNaiveMs)) longestNaiveMs = ms;
|
|
139
|
+
};
|
|
101
140
|
const consider = (ms: number | undefined): void => {
|
|
102
141
|
if (ms !== undefined && ms > 0 && (longestMs === undefined || ms > longestMs)) longestMs = ms;
|
|
103
142
|
};
|
|
@@ -117,16 +156,30 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
|
|
|
117
156
|
consider(totalMs > 0 ? totalMs : undefined);
|
|
118
157
|
}
|
|
119
158
|
}
|
|
120
|
-
for (const pattern of
|
|
159
|
+
for (const pattern of RESET_AT_PATTERNS) {
|
|
121
160
|
const match = pattern.exec(body);
|
|
122
|
-
if (match?.[1])
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
161
|
+
if (!match?.[1]) continue;
|
|
162
|
+
// Offset-bearing stamps are unambiguous and compete by longest-wins.
|
|
163
|
+
// A configured provider offset makes an otherwise naive wall clock
|
|
164
|
+
// unambiguous too. Without one, preserve the relative-signal-first
|
|
165
|
+
// fallback and interpret the wall clock as UTC only when it stands alone.
|
|
166
|
+
const normalized = match[1].replace(" ", "T");
|
|
167
|
+
const hasOffset = /(?:Z|[+-][0-9]{2}:?[0-9]{2})$/i.test(normalized);
|
|
168
|
+
const configuredOffset = options?.naiveResetTimezoneOffset;
|
|
169
|
+
const parsed = Date.parse(hasOffset ? normalized : `${normalized}${configuredOffset ?? "Z"}`);
|
|
170
|
+
if (!Number.isNaN(parsed) && parsed > Date.now()) {
|
|
171
|
+
if (hasOffset || configuredOffset !== undefined) consider(parsed - Date.now());
|
|
172
|
+
else considerNaive(parsed - Date.now());
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// OpenCode Go compound remainder ("Resets in 2hr 15min"): the generic
|
|
176
|
+
// pattern only captures the leading "2hr", so add the trailing minutes.
|
|
177
|
+
const compoundResetMatch = RESET_IN_HR_MIN_PATTERN.exec(body);
|
|
178
|
+
if (compoundResetMatch?.[1] && compoundResetMatch[2]) {
|
|
179
|
+
const hours = Number.parseFloat(compoundResetMatch[1]);
|
|
180
|
+
const minutes = Number.parseFloat(compoundResetMatch[2]);
|
|
181
|
+
if (Number.isFinite(hours) && Number.isFinite(minutes) && hours >= 0 && minutes > 0) {
|
|
182
|
+
consider(hours * 60 * 60_000 + minutes * 60_000);
|
|
130
183
|
}
|
|
131
184
|
}
|
|
132
185
|
const accountResetMatch = WILL_RESET_IN_PATTERN.exec(body);
|
|
@@ -188,7 +241,9 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
|
|
|
188
241
|
considerClamped(resetSeconds > 1_000_000_000 ? resetSeconds * 1000 - Date.now() : resetSeconds * 1000);
|
|
189
242
|
}
|
|
190
243
|
}
|
|
191
|
-
|
|
244
|
+
// Elapsed naive stamps were ignored before this change and stay ignored:
|
|
245
|
+
// only an explicit zero/expired relative signal is authoritative retry-now.
|
|
246
|
+
return longestMs ?? longestNaiveMs ?? (retryNow ? 0 : undefined);
|
|
192
247
|
}
|
|
193
248
|
|
|
194
249
|
function unitToMs(unit: string): number | undefined {
|
|
@@ -210,6 +265,10 @@ function unitToMs(unit: string): number | undefined {
|
|
|
210
265
|
case "hour":
|
|
211
266
|
case "hours":
|
|
212
267
|
return 60 * 60_000;
|
|
268
|
+
case "d":
|
|
269
|
+
case "day":
|
|
270
|
+
case "days":
|
|
271
|
+
return 24 * 60 * 60_000;
|
|
213
272
|
default:
|
|
214
273
|
return undefined;
|
|
215
274
|
}
|
package/src/file-lock.ts
CHANGED
|
@@ -22,6 +22,11 @@ export interface FileLockHandle {
|
|
|
22
22
|
release(): void;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** An exclusive OS-backed lease. Releasing an already released handle is safe. */
|
|
26
|
+
export interface FileLockHandle {
|
|
27
|
+
release(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
25
30
|
const DEFAULT_OPTIONS: Required<FileLockOptions> = {
|
|
26
31
|
retries: 50,
|
|
27
32
|
retryDelayMs: 100,
|
|
@@ -51,6 +56,19 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
51
56
|
throw new Error(`Failed to acquire lock for ${filePath} after ${opts.retries} attempts`);
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
function acquireLockSync(filePath: string, options: FileLockOptions = {}): NativeFileLock {
|
|
60
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
61
|
+
const lockPath = getLockPath(filePath);
|
|
62
|
+
|
|
63
|
+
for (let attempt = 0; attempt < opts.retries; attempt++) {
|
|
64
|
+
const lock = tryAcquireLock(lockPath);
|
|
65
|
+
if (lock) return lock;
|
|
66
|
+
if (attempt + 1 < opts.retries && opts.retryDelayMs > 0) Bun.sleepSync(opts.retryDelayMs);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
throw new Error(`Failed to acquire lock for ${filePath} after ${opts.retries} attempts`);
|
|
70
|
+
}
|
|
71
|
+
|
|
54
72
|
/** Run `fn` while holding an OS-backed exclusive lock for `filePath`. */
|
|
55
73
|
export async function withFileLock<T>(
|
|
56
74
|
filePath: string,
|
|
@@ -65,6 +83,16 @@ export async function withFileLock<T>(
|
|
|
65
83
|
}
|
|
66
84
|
}
|
|
67
85
|
|
|
86
|
+
/** Run synchronous `fn` while holding an OS-backed exclusive lock for `filePath`. */
|
|
87
|
+
export function withFileLockSync<T>(filePath: string, fn: () => T, options: FileLockOptions = {}): T {
|
|
88
|
+
const lock = acquireLockSync(filePath, options);
|
|
89
|
+
try {
|
|
90
|
+
return fn();
|
|
91
|
+
} finally {
|
|
92
|
+
lock.release();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
68
96
|
/**
|
|
69
97
|
* Test-only acquisition handle for forcing ownership handoffs. This is not
|
|
70
98
|
* part of the supported package API.
|
package/src/format.ts
CHANGED
|
@@ -58,6 +58,22 @@ export function formatBytes(bytes: number): string {
|
|
|
58
58
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Count `\n` code units via native `indexOf` — no split array, roughly an
|
|
63
|
+
* order of magnitude cheaper than a per-code-unit loop on multi-MiB text.
|
|
64
|
+
* Line-count semantics are the caller's (empty text is 0 or 1 lines
|
|
65
|
+
* depending on the contract).
|
|
66
|
+
*/
|
|
67
|
+
export function countNewlines(text: string): number {
|
|
68
|
+
let count = 0;
|
|
69
|
+
let pos = text.indexOf("\n");
|
|
70
|
+
while (pos !== -1) {
|
|
71
|
+
count++;
|
|
72
|
+
pos = text.indexOf("\n", pos + 1);
|
|
73
|
+
}
|
|
74
|
+
return count;
|
|
75
|
+
}
|
|
76
|
+
|
|
61
77
|
/**
|
|
62
78
|
* Truncate a string to maxLen characters, appending an ellipsis if truncated.
|
|
63
79
|
* For display-width-aware truncation (terminals), use truncateToWidth from @linxiraos/pi-tui.
|
package/src/index.ts
CHANGED
|
@@ -4,12 +4,12 @@ export * from "./binary";
|
|
|
4
4
|
export * from "./color";
|
|
5
5
|
export * from "./dirs";
|
|
6
6
|
export * from "./env";
|
|
7
|
+
export * from "./executable";
|
|
7
8
|
export * from "./fetch-retry";
|
|
8
9
|
export * from "./file-lock";
|
|
9
10
|
export * from "./format";
|
|
10
11
|
export * from "./frontmatter";
|
|
11
12
|
export * from "./fs-error";
|
|
12
|
-
export * from "./glob";
|
|
13
13
|
export * from "./incoming-json";
|
|
14
14
|
export * from "./json";
|
|
15
15
|
export * from "./json-parse";
|
|
@@ -40,6 +40,7 @@ export * from "./tls-fetch";
|
|
|
40
40
|
export * from "./type-guards";
|
|
41
41
|
export * from "./version";
|
|
42
42
|
export * from "./which";
|
|
43
|
+
export * from "./yaml-config";
|
|
43
44
|
|
|
44
45
|
function isPlainObject(val: object): val is Record<string, unknown> {
|
|
45
46
|
return Object.getPrototypeOf(val) === Object.prototype || Array.isArray(val);
|
package/src/json.ts
CHANGED
|
@@ -19,7 +19,18 @@ export function tryParseJson<T = unknown>(content: string): T | null {
|
|
|
19
19
|
* only lossless JSON representation.
|
|
20
20
|
*/
|
|
21
21
|
export function stringifyJson(value: unknown, space?: string | number): string | undefined {
|
|
22
|
-
|
|
22
|
+
// Fast path: a replacer forces the slow stringify on every call, but
|
|
23
|
+
// bigint payloads are vanishingly rare. Try plain stringify first and
|
|
24
|
+
// retry with the coercing replacer only on TypeError (the only error a
|
|
25
|
+
// bigint raises). A TypeError from elsewhere (circular value, throwing
|
|
26
|
+
// toJSON) recurs on the retry and surfaces from that second walk, so
|
|
27
|
+
// stateful serializers ahead of a bigint run twice on that path.
|
|
28
|
+
try {
|
|
29
|
+
return JSON.stringify(value, undefined, space);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (!(error instanceof TypeError)) throw error;
|
|
32
|
+
return JSON.stringify(value, (_key, item) => (typeof item === "bigint" ? item.toString() : item), space);
|
|
33
|
+
}
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
function stableJsonClone(value: unknown): unknown {
|
|
@@ -47,6 +47,12 @@ export class RotatingFileSink {
|
|
|
47
47
|
#activePath: string | undefined;
|
|
48
48
|
#activeBytes = 0;
|
|
49
49
|
#closed = false;
|
|
50
|
+
// Held append fd: the old code did open+write+close per line
|
|
51
|
+
// (appendFileSync) plus a throwaway open in the constructor. A single fd
|
|
52
|
+
// per active file removes two syscalls per log line; it is reopened on
|
|
53
|
+
// rotation and closed on close()/rotation (required for Windows
|
|
54
|
+
// delete-on-prune semantics).
|
|
55
|
+
#fd: number | undefined;
|
|
50
56
|
|
|
51
57
|
constructor(options: RotatingFileOptions) {
|
|
52
58
|
this.#directory = options.directory;
|
|
@@ -61,26 +67,57 @@ export class RotatingFileSink {
|
|
|
61
67
|
const activePath = this.#activePath;
|
|
62
68
|
if (activePath) {
|
|
63
69
|
this.#registerFile(activePath, now.getTime());
|
|
64
|
-
|
|
70
|
+
this.#openFd(activePath);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#openFd(filePath: string): void {
|
|
75
|
+
this.#closeFd();
|
|
76
|
+
this.#fd = fs.openSync(filePath, "a");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#closeFd(): void {
|
|
80
|
+
if (this.#fd !== undefined) {
|
|
81
|
+
try {
|
|
82
|
+
fs.closeSync(this.#fd);
|
|
83
|
+
} catch {
|
|
84
|
+
// Best-effort: the fd may already be invalid after rotation.
|
|
85
|
+
}
|
|
86
|
+
this.#fd = undefined;
|
|
65
87
|
}
|
|
66
88
|
}
|
|
67
89
|
|
|
68
90
|
/** Append one already-formatted log record. */
|
|
69
91
|
write(line: string): void {
|
|
70
92
|
if (this.#closed) return;
|
|
93
|
+
const prevPath = this.#activePath;
|
|
71
94
|
const now = new Date();
|
|
72
95
|
this.#selectFile(this.#localDay(now));
|
|
73
96
|
const activePath = this.#activePath;
|
|
74
97
|
if (!activePath) return;
|
|
98
|
+
// Rotation moved the active path: close the old descriptor BEFORE
|
|
99
|
+
// registering the new path, because registration prunes beyond
|
|
100
|
+
// maxFiles — on Windows the pruned predecessor cannot be deleted
|
|
101
|
+
// while still open, which would leak it (audit entry removed, file
|
|
102
|
+
// left on disk, retention unbounded).
|
|
103
|
+
if (activePath !== prevPath) this.#closeFd();
|
|
75
104
|
this.#registerFile(activePath, now.getTime());
|
|
105
|
+
if (this.#fd === undefined) this.#openFd(activePath);
|
|
76
106
|
const record = `${line}${os.EOL}`;
|
|
77
|
-
|
|
78
|
-
|
|
107
|
+
const buf = Buffer.from(record, "utf8");
|
|
108
|
+
let off = 0;
|
|
109
|
+
while (off < buf.length) {
|
|
110
|
+
const written = fs.writeSync(this.#fd!, buf, off);
|
|
111
|
+
if (written <= 0) break;
|
|
112
|
+
off += written;
|
|
113
|
+
}
|
|
114
|
+
this.#activeBytes += buf.length;
|
|
79
115
|
}
|
|
80
116
|
|
|
81
117
|
/** Stop accepting records. Synchronous writes require no drain phase. */
|
|
82
118
|
close(): void {
|
|
83
119
|
this.#closed = true;
|
|
120
|
+
this.#closeFd();
|
|
84
121
|
}
|
|
85
122
|
|
|
86
123
|
#localDay(date: Date): string {
|
package/src/logger.ts
CHANGED
|
@@ -152,6 +152,19 @@ function pruneStaleProcessLogs(dir: string): void {
|
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
const scheduledPruneDirs = new Set<string>();
|
|
156
|
+
|
|
157
|
+
/** Run shared retention only after logger construction has returned to the event loop. */
|
|
158
|
+
function schedulePruneStaleProcessLogs(dir: string): void {
|
|
159
|
+
if (scheduledPruneDirs.has(dir)) return;
|
|
160
|
+
scheduledPruneDirs.add(dir);
|
|
161
|
+
const immediate = setImmediate(() => {
|
|
162
|
+
scheduledPruneDirs.delete(dir);
|
|
163
|
+
pruneStaleProcessLogs(dir);
|
|
164
|
+
});
|
|
165
|
+
immediate.unref();
|
|
166
|
+
}
|
|
167
|
+
|
|
155
168
|
/** Ensure a logs directory exists; return the resolved path. */
|
|
156
169
|
function ensureDir(dir: string): string {
|
|
157
170
|
if (!fs.existsSync(dir)) {
|
|
@@ -237,7 +250,7 @@ function formatLogInfo(info: NormalizedLogInfo): string {
|
|
|
237
250
|
/** Build a rotating file sink with process-local rotation and shared retention. */
|
|
238
251
|
function makeFileTransport(dir?: string): RotatingFileSink {
|
|
239
252
|
const logsDir = ensureDir(dir ?? getLogsDir());
|
|
240
|
-
|
|
253
|
+
schedulePruneStaleProcessLogs(logsDir);
|
|
241
254
|
return new RotatingFileSink({
|
|
242
255
|
directory: logsDir,
|
|
243
256
|
filenamePrefix: "zeta",
|
|
@@ -276,12 +289,11 @@ function getLocalTransports(): LocalTransports {
|
|
|
276
289
|
|
|
277
290
|
function emitLocally(level: LogLevel, message: string, context: Record<string, unknown> | undefined): void {
|
|
278
291
|
const transports = getLocalTransports();
|
|
279
|
-
const info = normalizeLogInfo(level, message, context);
|
|
280
292
|
if (!transports.file && !transports.console) return;
|
|
281
|
-
|
|
293
|
+
const info = normalizeLogInfo(level, message, context);
|
|
282
294
|
const line = formatLogInfo(info);
|
|
283
295
|
if (transports.file) transports.file.write(line);
|
|
284
|
-
if (transports.console) fs.writeSync(1, `${
|
|
296
|
+
if (transports.console) fs.writeSync(1, `${line}${os.EOL}`);
|
|
285
297
|
}
|
|
286
298
|
|
|
287
299
|
/**
|
package/src/mime.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { peekFile, peekFileSync } from "./peek-file";
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
|
|
3
|
+
export const IMAGE_METADATA_HEADER_BYTES = 256 * 1024;
|
|
5
4
|
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
6
5
|
const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
|
|
7
6
|
const WEBP_RIFF_MAGIC = Buffer.from([0x52, 0x49, 0x46, 0x46]);
|
|
@@ -144,16 +143,13 @@ export function parseImageMetadata(header: Uint8Array): ImageMetadata | null {
|
|
|
144
143
|
);
|
|
145
144
|
}
|
|
146
145
|
|
|
147
|
-
export function readImageMetadataSync(
|
|
148
|
-
filePath: string,
|
|
149
|
-
maxBytes = DEFAULT_IMAGE_METADATA_HEADER_BYTES,
|
|
150
|
-
): ImageMetadata | null {
|
|
146
|
+
export function readImageMetadataSync(filePath: string, maxBytes = IMAGE_METADATA_HEADER_BYTES): ImageMetadata | null {
|
|
151
147
|
return peekFileSync(filePath, maxBytes, parseImageMetadata);
|
|
152
148
|
}
|
|
153
149
|
|
|
154
150
|
export function readImageMetadata(
|
|
155
151
|
filePath: string,
|
|
156
|
-
maxBytes =
|
|
152
|
+
maxBytes = IMAGE_METADATA_HEADER_BYTES,
|
|
157
153
|
): Promise<ImageMetadata | null> {
|
|
158
154
|
return peekFile(filePath, maxBytes, parseImageMetadata);
|
|
159
155
|
}
|
package/src/path.ts
CHANGED
|
@@ -40,3 +40,17 @@ export function stripWindowsExtendedLengthPathPrefix(
|
|
|
40
40
|
|
|
41
41
|
return filePath;
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Test whether a path is fully qualified and drive-independent.
|
|
46
|
+
* On Windows, requires a drive letter with separator (e.g. `C:\`) or UNC (`\\server\share` or `//server/share`).
|
|
47
|
+
* On POSIX, requires an absolute path.
|
|
48
|
+
*/
|
|
49
|
+
export function isFullyQualifiedPath(filePath: string, platform: NodeJS.Platform = process.platform): boolean {
|
|
50
|
+
const p = platform === "win32" ? path.win32 : path.posix;
|
|
51
|
+
if (!p.isAbsolute(filePath)) return false;
|
|
52
|
+
if (platform === "win32") {
|
|
53
|
+
return /^[a-zA-Z]:[/\\]/.test(filePath) || /^[\\/]{2}[^\\/]/.test(filePath);
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
}
|
package/src/peek-file.ts
CHANGED
|
@@ -1,67 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Read the first `maxBytes` of a file (offset 0) and pass that slice to `op`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* queue, falling back to a fresh allocation when the pool and queue are saturated
|
|
7
|
-
* or when `maxBytes` exceeds the pool slot size.
|
|
4
|
+
* Synchronous peeks reuse one growable `Uint8Array`; asynchronous peeks allocate
|
|
5
|
+
* their result window so callback-retained slices cannot be changed by a later read.
|
|
8
6
|
*/
|
|
9
7
|
import * as fs from "node:fs";
|
|
10
8
|
|
|
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
9
|
const INITIAL_SYNC_BUFFER_SIZE = 1024;
|
|
17
|
-
const EMPTY_BUFFER =
|
|
10
|
+
const EMPTY_BUFFER = new Uint8Array(0);
|
|
18
11
|
|
|
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
12
|
let syncPool = new Uint8Array(INITIAL_SYNC_BUFFER_SIZE);
|
|
23
13
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
}
|
|
14
|
+
function allocateWindow(length: number): Uint8Array {
|
|
15
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
16
|
+
// A plain view preserves Uint8Array.slice semantics without zeroing bytes the read replaces.
|
|
17
|
+
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
65
18
|
}
|
|
66
19
|
|
|
67
20
|
function withSyncPoolBuffer<T>(maxBytes: number, op: (buffer: Uint8Array) => T): T {
|
|
@@ -104,10 +57,9 @@ export async function peekFile<T>(filePath: string, maxBytes: number, op: (heade
|
|
|
104
57
|
|
|
105
58
|
const fileHandle = await fs.promises.open(filePath, "r");
|
|
106
59
|
try {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
});
|
|
60
|
+
const buffer = allocateWindow(maxBytes);
|
|
61
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, buffer.byteLength, 0);
|
|
62
|
+
return op(buffer.subarray(0, bytesRead));
|
|
111
63
|
} finally {
|
|
112
64
|
await fileHandle.close();
|
|
113
65
|
}
|
|
@@ -116,9 +68,8 @@ export async function peekFile<T>(filePath: string, maxBytes: number, op: (heade
|
|
|
116
68
|
/**
|
|
117
69
|
* Read up to the last `maxBytes` of `filePath` and pass that slice to `op`.
|
|
118
70
|
*
|
|
119
|
-
* The tail mirror of {@link peekFile}:
|
|
120
|
-
*
|
|
121
|
-
* window ends at EOF. When the file is shorter than `maxBytes`, the whole file is
|
|
71
|
+
* The tail mirror of {@link peekFile}: the read is positioned at `size - len` so
|
|
72
|
+
* the window ends at EOF. When the file is shorter than `maxBytes`, the whole file is
|
|
122
73
|
* returned. A multi-byte codepoint straddling the leading cut decodes to a
|
|
123
74
|
* replacement char — callers that parse line-oriented tails drop the partial
|
|
124
75
|
* leading line anyway.
|
|
@@ -135,10 +86,9 @@ export async function peekFileTail<T>(filePath: string, maxBytes: number, op: (t
|
|
|
135
86
|
if (len <= 0) {
|
|
136
87
|
return op(EMPTY_BUFFER);
|
|
137
88
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
});
|
|
89
|
+
const buffer = allocateWindow(len);
|
|
90
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, buffer.byteLength, size - len);
|
|
91
|
+
return op(buffer.subarray(0, bytesRead));
|
|
142
92
|
} finally {
|
|
143
93
|
await fileHandle.close();
|
|
144
94
|
}
|
|
@@ -168,7 +118,7 @@ export async function peekFileEnds<T>(
|
|
|
168
118
|
const headLen = prefixBytes > 0 ? Math.min(prefixBytes, size) : 0;
|
|
169
119
|
const tailLen = suffixBytes > 0 ? Math.min(suffixBytes, size) : 0;
|
|
170
120
|
|
|
171
|
-
const head = headLen > 0 ?
|
|
121
|
+
const head = headLen > 0 ? allocateWindow(headLen) : EMPTY_BUFFER;
|
|
172
122
|
const headBytesRead = headLen > 0 ? (await fileHandle.read(head, 0, head.byteLength, 0)).bytesRead : 0;
|
|
173
123
|
const headSlice = head.subarray(0, headBytesRead);
|
|
174
124
|
|
|
@@ -179,7 +129,7 @@ export async function peekFileEnds<T>(
|
|
|
179
129
|
return op(headSlice, headSlice.subarray(Math.max(0, headBytesRead - tailLen)));
|
|
180
130
|
}
|
|
181
131
|
|
|
182
|
-
const tail =
|
|
132
|
+
const tail = allocateWindow(tailLen);
|
|
183
133
|
const { bytesRead: tailBytesRead } = await fileHandle.read(tail, 0, tail.byteLength, size - tailLen);
|
|
184
134
|
return op(headSlice, tail.subarray(0, tailBytesRead));
|
|
185
135
|
} finally {
|