@oh-my-pi/pi-utils 16.1.22 → 16.2.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 +14 -0
- package/dist/types/dirs.d.ts +28 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/json-parse.d.ts +48 -0
- package/dist/types/temp.d.ts +2 -0
- package/package.json +2 -2
- package/src/dirs.ts +61 -2
- package/src/index.ts +1 -0
- package/src/json-parse.ts +558 -0
- package/src/stream.ts +32 -3
- package/src/temp.ts +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.2.0] - 2026-06-27
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added a relaxed JSON parser supporting single-quoted strings, unquoted keys, and comments.
|
|
10
|
+
- Added `parseStreamingJson` and `parseStreamingJsonThrottled` for robust, efficient parsing of truncated or incremental streaming JSON.
|
|
11
|
+
- Added an XDG-aware document conversion cache directory helper.
|
|
12
|
+
- Exported `removeWithRetries()` as a standalone asynchronous function to handle retry-on-EBUSY cleanup logic.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Improved `readSseJson` to gracefully recover truncated or malformed final events using the streaming JSON parser, ending the stream cleanly instead of throwing.
|
|
17
|
+
- Increased the retry delay for EBUSY file-lock errors from 25ms to 50ms (extending the total retry window to 2 seconds) to improve reliability on Windows.
|
|
18
|
+
|
|
5
19
|
## [16.1.8] - 2026-06-20
|
|
6
20
|
|
|
7
21
|
### Added
|
package/dist/types/dirs.d.ts
CHANGED
|
@@ -79,6 +79,13 @@ export declare function setAgentDir(dir: string): void;
|
|
|
79
79
|
* no business clearing it.
|
|
80
80
|
*/
|
|
81
81
|
export declare function __resetProfileSnapshotForTests(): void;
|
|
82
|
+
/**
|
|
83
|
+
* Test-only: rebuild profile + directory state from the current process env.
|
|
84
|
+
* Production code keeps the module-load profile stable; tests that mutate
|
|
85
|
+
* `setAgentDir`/`setProfile` need an exact restore point after they put env vars
|
|
86
|
+
* back.
|
|
87
|
+
*/
|
|
88
|
+
export declare function __resetDirsFromEnvForTests(): void;
|
|
82
89
|
/** Activate a named profile. Passing undefined or "default" returns to the default profile. */
|
|
83
90
|
export declare function setProfile(profile: string | undefined): void;
|
|
84
91
|
/** Get the active named profile. Undefined means the default profile. */
|
|
@@ -114,7 +121,25 @@ export declare function getPluginsPackageJson(home?: string): string;
|
|
|
114
121
|
export declare function getPluginsLockfile(home?: string): string;
|
|
115
122
|
/** Get the remote mount directory (~/.omp/remote). */
|
|
116
123
|
export declare function getRemoteDir(): string;
|
|
117
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* Relocate the base directory for agent-managed worktrees (PR checkouts, task
|
|
126
|
+
* isolation, and `omp worktree` cleanup all read the same base). Driven by the
|
|
127
|
+
* `worktree.base` setting in coding-agent; pass `undefined`/empty to clear and
|
|
128
|
+
* fall back to `OMP_WORKTREE_DIR` or the `~/.omp/wt` default.
|
|
129
|
+
*
|
|
130
|
+
* `~` is expanded and a relative path is rejected (see {@link resolveWorktreeBase}).
|
|
131
|
+
* Returns the absolute path that took effect, or `undefined` if the input was
|
|
132
|
+
* cleared or rejected — callers can warn on a non-empty input that returns
|
|
133
|
+
* `undefined`.
|
|
134
|
+
*/
|
|
135
|
+
export declare function setWorktreesDir(dir: string | undefined): string | undefined;
|
|
136
|
+
/**
|
|
137
|
+
* Get the agent-managed worktrees directory. Resolution order: the
|
|
138
|
+
* `OMP_WORKTREE_DIR` env var, then the {@link setWorktreesDir} override (the
|
|
139
|
+
* `worktree.base` setting), then the `~/.omp/wt` default. The env var and the
|
|
140
|
+
* override are both `~`-expanded and must be absolute; a relative value is
|
|
141
|
+
* ignored and resolution falls through.
|
|
142
|
+
*/
|
|
118
143
|
export declare function getWorktreesDir(): string;
|
|
119
144
|
/** Get the SSH control socket directory (~/.omp/ssh-control). */
|
|
120
145
|
export declare function getSshControlDir(): string;
|
|
@@ -182,6 +207,8 @@ export declare function getHistoryDbPath(agentDir?: string): string;
|
|
|
182
207
|
export declare function getModelDbPath(agentDir?: string): string;
|
|
183
208
|
/** Get the tiny title model cache directory (~/.omp/agent/cache/tiny-models). */
|
|
184
209
|
export declare function getTinyModelsCacheDir(agentDir?: string): string;
|
|
210
|
+
/** Get the document conversion cache directory (~/.omp/agent/cache/document-conversions; XDG default: $XDG_CACHE_HOME/omp/cache/document-conversions). */
|
|
211
|
+
export declare function getDocumentConversionCacheDir(agentDir?: string): string;
|
|
185
212
|
/** Get the sessions directory (~/.omp/agent/sessions). */
|
|
186
213
|
export declare function getSessionsDir(agentDir?: string): string;
|
|
187
214
|
/** Get the content-addressed blob store directory (~/.omp/agent/blobs). */
|
package/dist/types/index.d.ts
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight string-level repair of the escape/control-char hazards that make
|
|
3
|
+
* otherwise-valid JSON fail `JSON.parse`: raw control characters inside strings
|
|
4
|
+
* are escaped, and invalid `\x` escapes have their backslash escaped. Returns the
|
|
5
|
+
* input unchanged when no repair is needed. Pure string→string; does not parse.
|
|
6
|
+
*/
|
|
7
|
+
export declare function repairJson(json: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Final-parse a JSON value, repairing the common LLM malformations
|
|
10
|
+
* ({@link RelaxedJson}). Tries strict `JSON.parse` first (fast path, exact JSON
|
|
11
|
+
* semantics), then the relaxed parser. Throws when the input is unrepairable,
|
|
12
|
+
* truncated, or carries trailing garbage — so callers can skip a bad tool call
|
|
13
|
+
* rather than execute a half-formed one.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseJsonWithRepair<T>(json: string): T;
|
|
16
|
+
/**
|
|
17
|
+
* Parse possibly-incomplete JSON during streaming. Always returns a value, never
|
|
18
|
+
* throws: `{}` for empty/whitespace/unrecoverable buffers, and an auto-closed
|
|
19
|
+
* best-effort object for truncated ones.
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T;
|
|
22
|
+
/**
|
|
23
|
+
* Default minimum byte growth before `parseStreamingJsonThrottled` will
|
|
24
|
+
* re-parse a streaming tool-call argument buffer. Bounds the mid-stream
|
|
25
|
+
* partial-parse cost from quadratic to linear in N.
|
|
26
|
+
*/
|
|
27
|
+
export declare const STREAMING_JSON_PARSE_MIN_GROWTH = 256;
|
|
28
|
+
/**
|
|
29
|
+
* Throttled variant of {@link parseStreamingJson} for the per-delta hot path.
|
|
30
|
+
*
|
|
31
|
+
* Tool calls arrive as a long sequence of small deltas — calling
|
|
32
|
+
* `parseStreamingJson(buffer)` on every delta re-parses the entire buffer
|
|
33
|
+
* each time, giving O(N²) work in the total buffer length. Throttling skips
|
|
34
|
+
* the re-parse until at least `minGrowthBytes` of new content has arrived
|
|
35
|
+
* since the last successful parse, bounding mid-stream cost to O(N).
|
|
36
|
+
*
|
|
37
|
+
* Each provider tracks the last parsed length on its tool-call block, so the
|
|
38
|
+
* final `toolcall_end` parse (which providers already perform unconditionally)
|
|
39
|
+
* is the authoritative full parse — the throttle only delays mid-stream UI
|
|
40
|
+
* updates by at most `minGrowthBytes` of accumulated partial content.
|
|
41
|
+
*
|
|
42
|
+
* @returns the parsed object plus the new `parsedLen` to persist; or `null`
|
|
43
|
+
* when the buffer has not grown enough to warrant a re-parse.
|
|
44
|
+
*/
|
|
45
|
+
export declare function parseStreamingJsonThrottled<T = Record<string, unknown>>(partialJson: string | undefined, lastParsedLen: number, minGrowthBytes?: number): {
|
|
46
|
+
value: T;
|
|
47
|
+
parsedLen: number;
|
|
48
|
+
} | null;
|
package/dist/types/temp.d.ts
CHANGED
|
@@ -12,4 +12,6 @@ export declare class TempDir {
|
|
|
12
12
|
[Symbol.asyncDispose](): Promise<void>;
|
|
13
13
|
[Symbol.dispose](): void;
|
|
14
14
|
}
|
|
15
|
+
/** Removes a path recursively, retrying transient Windows deletion failures. */
|
|
16
|
+
export declare function removeWithRetries(target: string): Promise<void>;
|
|
15
17
|
export declare function removeSyncWithRetries(target: string): void;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-utils",
|
|
4
|
-
"version": "16.
|
|
4
|
+
"version": "16.2.0",
|
|
5
5
|
"description": "Shared utilities for pi packages",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"fmt": "biome format --write ."
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@oh-my-pi/pi-natives": "16.
|
|
34
|
+
"@oh-my-pi/pi-natives": "16.2.0",
|
|
35
35
|
"handlebars": "^4.7.9",
|
|
36
36
|
"winston": "^3.19.0",
|
|
37
37
|
"winston-daily-rotate-file": "^5.0.0"
|
package/src/dirs.ts
CHANGED
|
@@ -431,6 +431,18 @@ export function __resetProfileSnapshotForTests(): void {
|
|
|
431
431
|
);
|
|
432
432
|
}
|
|
433
433
|
|
|
434
|
+
/**
|
|
435
|
+
* Test-only: rebuild profile + directory state from the current process env.
|
|
436
|
+
* Production code keeps the module-load profile stable; tests that mutate
|
|
437
|
+
* `setAgentDir`/`setProfile` need an exact restore point after they put env vars
|
|
438
|
+
* back.
|
|
439
|
+
*/
|
|
440
|
+
export function __resetDirsFromEnvForTests(): void {
|
|
441
|
+
activeProfile = readProfileFromEnvSafe();
|
|
442
|
+
__resetProfileSnapshotForTests();
|
|
443
|
+
refreshDirsFromEnv();
|
|
444
|
+
}
|
|
445
|
+
|
|
434
446
|
/** Activate a named profile. Passing undefined or "default" returns to the default profile. */
|
|
435
447
|
export function setProfile(profile: string | undefined): void {
|
|
436
448
|
const next = normalizeProfileName(profile);
|
|
@@ -540,9 +552,51 @@ export function getRemoteDir(): string {
|
|
|
540
552
|
return dirs.rootSubdir("remote", "data");
|
|
541
553
|
}
|
|
542
554
|
|
|
543
|
-
/**
|
|
555
|
+
/**
|
|
556
|
+
* Expand a leading `~` and require an absolute result. Returns `undefined` for
|
|
557
|
+
* empty/whitespace input or a path that is still relative after expansion.
|
|
558
|
+
*
|
|
559
|
+
* A worktree base is process-global and consumed by both creation
|
|
560
|
+
* (PR checkout, task isolation) and cleanup (`omp worktree`). A relative value
|
|
561
|
+
* would resolve against whatever cwd happened to launch `omp`, so checkout and
|
|
562
|
+
* cleanup could disagree — we refuse it rather than silently bind it to cwd.
|
|
563
|
+
*/
|
|
564
|
+
function resolveWorktreeBase(value: string | undefined): string | undefined {
|
|
565
|
+
const trimmed = value?.trim();
|
|
566
|
+
if (!trimmed) return undefined;
|
|
567
|
+
let p = trimmed;
|
|
568
|
+
if (p === "~") p = os.homedir();
|
|
569
|
+
else if (p.startsWith("~/") || p.startsWith("~\\")) p = os.homedir() + p.slice(1);
|
|
570
|
+
return path.isAbsolute(p) ? path.normalize(p) : undefined;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
let worktreesDirOverride: string | undefined;
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Relocate the base directory for agent-managed worktrees (PR checkouts, task
|
|
577
|
+
* isolation, and `omp worktree` cleanup all read the same base). Driven by the
|
|
578
|
+
* `worktree.base` setting in coding-agent; pass `undefined`/empty to clear and
|
|
579
|
+
* fall back to `OMP_WORKTREE_DIR` or the `~/.omp/wt` default.
|
|
580
|
+
*
|
|
581
|
+
* `~` is expanded and a relative path is rejected (see {@link resolveWorktreeBase}).
|
|
582
|
+
* Returns the absolute path that took effect, or `undefined` if the input was
|
|
583
|
+
* cleared or rejected — callers can warn on a non-empty input that returns
|
|
584
|
+
* `undefined`.
|
|
585
|
+
*/
|
|
586
|
+
export function setWorktreesDir(dir: string | undefined): string | undefined {
|
|
587
|
+
worktreesDirOverride = resolveWorktreeBase(dir);
|
|
588
|
+
return worktreesDirOverride;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Get the agent-managed worktrees directory. Resolution order: the
|
|
593
|
+
* `OMP_WORKTREE_DIR` env var, then the {@link setWorktreesDir} override (the
|
|
594
|
+
* `worktree.base` setting), then the `~/.omp/wt` default. The env var and the
|
|
595
|
+
* override are both `~`-expanded and must be absolute; a relative value is
|
|
596
|
+
* ignored and resolution falls through.
|
|
597
|
+
*/
|
|
544
598
|
export function getWorktreesDir(): string {
|
|
545
|
-
return dirs.rootSubdir("wt", "data");
|
|
599
|
+
return resolveWorktreeBase(process.env.OMP_WORKTREE_DIR) ?? worktreesDirOverride ?? dirs.rootSubdir("wt", "data");
|
|
546
600
|
}
|
|
547
601
|
|
|
548
602
|
/** Get the SSH control socket directory (~/.omp/ssh-control). */
|
|
@@ -693,6 +747,11 @@ export function getTinyModelsCacheDir(agentDir?: string): string {
|
|
|
693
747
|
return dirs.agentSubdir(agentDir, path.join("cache", "tiny-models"), "cache");
|
|
694
748
|
}
|
|
695
749
|
|
|
750
|
+
/** Get the document conversion cache directory (~/.omp/agent/cache/document-conversions; XDG default: $XDG_CACHE_HOME/omp/cache/document-conversions). */
|
|
751
|
+
export function getDocumentConversionCacheDir(agentDir?: string): string {
|
|
752
|
+
return dirs.agentSubdir(agentDir, path.join("cache", "document-conversions"), "cache");
|
|
753
|
+
}
|
|
754
|
+
|
|
696
755
|
/** Get the sessions directory (~/.omp/agent/sessions). */
|
|
697
756
|
export function getSessionsDir(agentDir?: string): string {
|
|
698
757
|
return dirs.agentSubdir(agentDir, "sessions", "data");
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
const QUOTE = 0x22;
|
|
2
|
+
const BACKSLASH = 0x5c;
|
|
3
|
+
const U = 0x75;
|
|
4
|
+
const SQUOTE = 0x27;
|
|
5
|
+
|
|
6
|
+
// Valid chars after `\`: " \ / b f n r t u
|
|
7
|
+
const VALID_ESCAPE_CHAR = new Uint8Array(128);
|
|
8
|
+
for (const ch of '"\\/bfnrtu') VALID_ESCAPE_CHAR[ch.charCodeAt(0)] = 1;
|
|
9
|
+
|
|
10
|
+
const CONTROL_ESCAPES: readonly string[] = (() => {
|
|
11
|
+
const e: string[] = [];
|
|
12
|
+
e[0x08] = "\\b";
|
|
13
|
+
e[0x09] = "\\t";
|
|
14
|
+
e[0x0a] = "\\n";
|
|
15
|
+
e[0x0c] = "\\f";
|
|
16
|
+
e[0x0d] = "\\r";
|
|
17
|
+
for (let cp = 0; cp <= 0x1f; cp++) {
|
|
18
|
+
e[cp] ??= `\\u${cp.toString(16).padStart(4, "0")}`;
|
|
19
|
+
}
|
|
20
|
+
return e;
|
|
21
|
+
})();
|
|
22
|
+
|
|
23
|
+
const HEX4_RE = /^[0-9a-fA-F]{4}$/;
|
|
24
|
+
|
|
25
|
+
function isHexDigit(cp: number): boolean {
|
|
26
|
+
return (cp >= 0x30 && cp <= 0x39) || ((cp | 0x20) >= 0x61 && (cp | 0x20) <= 0x66);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isWhitespace(cp: number): boolean {
|
|
30
|
+
return cp === 0x20 || cp === 0x09 || cp === 0x0a || cp === 0x0d;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isIdentChar(cp: number): boolean {
|
|
34
|
+
return (
|
|
35
|
+
(cp >= 0x30 && cp <= 0x39) ||
|
|
36
|
+
((cp | 0x20) >= 0x61 && (cp | 0x20) <= 0x7a) ||
|
|
37
|
+
cp === 0x5f /* _ */ ||
|
|
38
|
+
cp === 0x24 /* $ */
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Bareword literals: standard JSON plus Python `True`/`False`/`None`. */
|
|
43
|
+
const KEYWORDS: readonly (readonly [string, unknown])[] = [
|
|
44
|
+
["true", true],
|
|
45
|
+
["false", false],
|
|
46
|
+
["null", null],
|
|
47
|
+
["True", true],
|
|
48
|
+
["False", false],
|
|
49
|
+
["None", null],
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Sentinel returned by partial-mode value parsing when an atomic value
|
|
54
|
+
* (number / keyword) is incomplete at the streaming edge, so the enclosing
|
|
55
|
+
* object/array rolls back to the last valid prefix instead of committing junk.
|
|
56
|
+
*/
|
|
57
|
+
const INCOMPLETE = Symbol("incomplete");
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Lightweight string-level repair of the escape/control-char hazards that make
|
|
61
|
+
* otherwise-valid JSON fail `JSON.parse`: raw control characters inside strings
|
|
62
|
+
* are escaped, and invalid `\x` escapes have their backslash escaped. Returns the
|
|
63
|
+
* input unchanged when no repair is needed. Pure string→string; does not parse.
|
|
64
|
+
*/
|
|
65
|
+
export function repairJson(json: string): string {
|
|
66
|
+
const len = json.length;
|
|
67
|
+
const parts: string[] = [];
|
|
68
|
+
let lastEmit = 0;
|
|
69
|
+
let inString = false;
|
|
70
|
+
let i = 0;
|
|
71
|
+
|
|
72
|
+
while (i < len) {
|
|
73
|
+
if (!inString) {
|
|
74
|
+
// Fast scan: skip to next quote.
|
|
75
|
+
while (i < len && json.charCodeAt(i) !== QUOTE) i++;
|
|
76
|
+
if (i >= len) break;
|
|
77
|
+
inString = true;
|
|
78
|
+
i++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Fast scan inside string: advance past chars that need no handling.
|
|
83
|
+
while (i < len) {
|
|
84
|
+
const cp = json.charCodeAt(i);
|
|
85
|
+
if (cp < 0x20 || cp === QUOTE || cp === BACKSLASH) break;
|
|
86
|
+
i++;
|
|
87
|
+
}
|
|
88
|
+
if (i >= len) break;
|
|
89
|
+
|
|
90
|
+
const cp = json.charCodeAt(i);
|
|
91
|
+
|
|
92
|
+
if (cp === QUOTE) {
|
|
93
|
+
inString = false;
|
|
94
|
+
i++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (cp === BACKSLASH) {
|
|
99
|
+
// Need at least one char after the backslash; treat EOI as invalid escape.
|
|
100
|
+
if (i + 1 >= len) {
|
|
101
|
+
parts.push(json.slice(lastEmit, i), "\\\\");
|
|
102
|
+
lastEmit = i + 1;
|
|
103
|
+
i++;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const nextCp = json.charCodeAt(i + 1);
|
|
108
|
+
|
|
109
|
+
if (nextCp === U) {
|
|
110
|
+
// Need full \uXXXX, all four digits, all hex.
|
|
111
|
+
if (
|
|
112
|
+
i + 5 < len &&
|
|
113
|
+
isHexDigit(json.charCodeAt(i + 2)) &&
|
|
114
|
+
isHexDigit(json.charCodeAt(i + 3)) &&
|
|
115
|
+
isHexDigit(json.charCodeAt(i + 4)) &&
|
|
116
|
+
isHexDigit(json.charCodeAt(i + 5))
|
|
117
|
+
) {
|
|
118
|
+
i += 6;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// Truncated or non-hex \u — escape the backslash, re-process the rest.
|
|
122
|
+
parts.push(json.slice(lastEmit, i), "\\\\");
|
|
123
|
+
lastEmit = i + 1;
|
|
124
|
+
i++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (nextCp < 128 && VALID_ESCAPE_CHAR[nextCp] === 1) {
|
|
129
|
+
i += 2;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
parts.push(json.slice(lastEmit, i), "\\\\");
|
|
134
|
+
lastEmit = i + 1;
|
|
135
|
+
i++;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Control character (cp < 0x20).
|
|
140
|
+
parts.push(json.slice(lastEmit, i), CONTROL_ESCAPES[cp]);
|
|
141
|
+
lastEmit = i + 1;
|
|
142
|
+
i++;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!parts.length) return json;
|
|
146
|
+
if (lastEmit < len) parts.push(json.slice(lastEmit));
|
|
147
|
+
return parts.join("");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Recursive-descent parser for a forgiving superset of JSON. Beyond strict JSON
|
|
152
|
+
* it accepts, and normalizes, the malformations LLM tool-call bodies leak in
|
|
153
|
+
* practice:
|
|
154
|
+
*
|
|
155
|
+
* - single-quoted strings and unquoted object keys (JSON5);
|
|
156
|
+
* - trailing / stray commas, and `//` + block comments;
|
|
157
|
+
* - Python literals `True` / `False` / `None` and JS `NaN` / `Infinity`;
|
|
158
|
+
* - raw control characters and invalid `\x` escapes inside strings (kept literally);
|
|
159
|
+
* - unescaped quotes inside strings — a quote only closes a string when followed
|
|
160
|
+
* by a value terminator, recovering apostrophes such as `'it's'`.
|
|
161
|
+
*
|
|
162
|
+
* In `partial` mode an unterminated string/object/array (or a value cut off at
|
|
163
|
+
* end-of-input) is auto-closed with whatever was parsed so far — for streaming.
|
|
164
|
+
* In strict mode, end-of-input mid-value and trailing garbage both throw, so a
|
|
165
|
+
* final parse never silently accepts a half-formed tool call.
|
|
166
|
+
*/
|
|
167
|
+
class RelaxedJson {
|
|
168
|
+
readonly #s: string;
|
|
169
|
+
readonly #n: number;
|
|
170
|
+
readonly #partial: boolean;
|
|
171
|
+
#i = 0;
|
|
172
|
+
|
|
173
|
+
constructor(source: string, partial: boolean) {
|
|
174
|
+
this.#s = source;
|
|
175
|
+
this.#n = source.length;
|
|
176
|
+
this.#partial = partial;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
parse(): unknown {
|
|
180
|
+
this.#ws();
|
|
181
|
+
if (this.#i >= this.#n) {
|
|
182
|
+
if (this.#partial) return undefined;
|
|
183
|
+
throw new SyntaxError("Unexpected end of JSON input");
|
|
184
|
+
}
|
|
185
|
+
const value = this.#value();
|
|
186
|
+
if (value === INCOMPLETE) return undefined;
|
|
187
|
+
this.#ws();
|
|
188
|
+
if (!this.#partial && this.#i < this.#n) {
|
|
189
|
+
throw new SyntaxError(`Unexpected trailing characters at position ${this.#i}`);
|
|
190
|
+
}
|
|
191
|
+
return value;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#ws(): void {
|
|
195
|
+
const s = this.#s;
|
|
196
|
+
for (;;) {
|
|
197
|
+
while (this.#i < this.#n && isWhitespace(s.charCodeAt(this.#i))) this.#i++;
|
|
198
|
+
if (this.#i + 1 < this.#n && s.charCodeAt(this.#i) === 0x2f /* / */) {
|
|
199
|
+
const next = s.charCodeAt(this.#i + 1);
|
|
200
|
+
if (next === 0x2f /* / line comment */) {
|
|
201
|
+
this.#i += 2;
|
|
202
|
+
while (this.#i < this.#n && s.charCodeAt(this.#i) !== 0x0a) this.#i++;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (next === 0x2a /* * block comment */) {
|
|
206
|
+
this.#i += 2;
|
|
207
|
+
while (
|
|
208
|
+
this.#i + 1 < this.#n &&
|
|
209
|
+
!(s.charCodeAt(this.#i) === 0x2a && s.charCodeAt(this.#i + 1) === 0x2f)
|
|
210
|
+
) {
|
|
211
|
+
this.#i++;
|
|
212
|
+
}
|
|
213
|
+
this.#i = Math.min(this.#i + 2, this.#n);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#value(): unknown {
|
|
222
|
+
const s = this.#s;
|
|
223
|
+
const c = s[this.#i];
|
|
224
|
+
if (c === "{") return this.#object();
|
|
225
|
+
if (c === "[") return this.#array();
|
|
226
|
+
if (c === '"' || c === "'") return this.#string(s.charCodeAt(this.#i));
|
|
227
|
+
const cc = s.charCodeAt(this.#i);
|
|
228
|
+
if (cc === 0x2d /* - */ || cc === 0x2b /* + */ || cc === 0x2e /* . */ || (cc >= 0x30 && cc <= 0x39)) {
|
|
229
|
+
// JS-only NaN / Infinity are deliberately not accepted: a tool must not
|
|
230
|
+
// execute with a non-finite numeric arg; they fall through #number's
|
|
231
|
+
// NaN guard (strict throw / partial rollback) like other bad tokens.
|
|
232
|
+
return this.#number();
|
|
233
|
+
}
|
|
234
|
+
return this.#keyword();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#object(): Record<string, unknown> {
|
|
238
|
+
this.#i++; // consume {
|
|
239
|
+
const out: Record<string, unknown> = {};
|
|
240
|
+
for (;;) {
|
|
241
|
+
this.#ws();
|
|
242
|
+
if (this.#i >= this.#n) {
|
|
243
|
+
if (this.#partial) return out;
|
|
244
|
+
throw new SyntaxError("Unterminated object");
|
|
245
|
+
}
|
|
246
|
+
const c = this.#s[this.#i];
|
|
247
|
+
if (c === "}") {
|
|
248
|
+
this.#i++;
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
if (c === ",") {
|
|
252
|
+
// Tolerate leading / doubled / trailing commas.
|
|
253
|
+
this.#i++;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const key = this.#key();
|
|
257
|
+
this.#ws();
|
|
258
|
+
if (this.#i < this.#n && this.#s[this.#i] === ":") {
|
|
259
|
+
this.#i++;
|
|
260
|
+
} else if (this.#partial) {
|
|
261
|
+
return out;
|
|
262
|
+
} else {
|
|
263
|
+
throw new SyntaxError("Expected ':' in object");
|
|
264
|
+
}
|
|
265
|
+
this.#ws();
|
|
266
|
+
if (this.#i >= this.#n) {
|
|
267
|
+
if (this.#partial) return out;
|
|
268
|
+
throw new SyntaxError("Expected value after ':'");
|
|
269
|
+
}
|
|
270
|
+
const value = this.#value();
|
|
271
|
+
if (value === INCOMPLETE) return out;
|
|
272
|
+
out[key] = value;
|
|
273
|
+
this.#ws();
|
|
274
|
+
const d = this.#i < this.#n ? this.#s[this.#i] : "";
|
|
275
|
+
if (d === ",") {
|
|
276
|
+
this.#i++;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (d === "}") {
|
|
280
|
+
this.#i++;
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
if (this.#partial) return out;
|
|
284
|
+
throw new SyntaxError("Expected ',' or '}' in object");
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#array(): unknown[] {
|
|
289
|
+
this.#i++; // consume [
|
|
290
|
+
const out: unknown[] = [];
|
|
291
|
+
for (;;) {
|
|
292
|
+
this.#ws();
|
|
293
|
+
if (this.#i >= this.#n) {
|
|
294
|
+
if (this.#partial) return out;
|
|
295
|
+
throw new SyntaxError("Unterminated array");
|
|
296
|
+
}
|
|
297
|
+
const c = this.#s[this.#i];
|
|
298
|
+
if (c === "]") {
|
|
299
|
+
this.#i++;
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
if (c === ",") {
|
|
303
|
+
this.#i++;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const value = this.#value();
|
|
307
|
+
if (value === INCOMPLETE) return out;
|
|
308
|
+
out.push(value);
|
|
309
|
+
this.#ws();
|
|
310
|
+
const d = this.#i < this.#n ? this.#s[this.#i] : "";
|
|
311
|
+
if (d === ",") {
|
|
312
|
+
this.#i++;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (d === "]") {
|
|
316
|
+
this.#i++;
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
if (this.#partial) return out;
|
|
320
|
+
throw new SyntaxError("Expected ',' or ']' in array");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
#key(): string {
|
|
325
|
+
const c = this.#s[this.#i];
|
|
326
|
+
if (c === '"' || c === "'") return this.#string(this.#s.charCodeAt(this.#i));
|
|
327
|
+
// Unquoted identifier key: read until a structural delimiter / whitespace.
|
|
328
|
+
const start = this.#i;
|
|
329
|
+
while (this.#i < this.#n) {
|
|
330
|
+
const ch = this.#s[this.#i];
|
|
331
|
+
if (ch === ":" || ch === "," || ch === "}" || isWhitespace(this.#s.charCodeAt(this.#i))) break;
|
|
332
|
+
this.#i++;
|
|
333
|
+
}
|
|
334
|
+
if (this.#i === start) {
|
|
335
|
+
if (this.#partial) return "";
|
|
336
|
+
throw new SyntaxError("Expected object key");
|
|
337
|
+
}
|
|
338
|
+
return this.#s.slice(start, this.#i);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
#string(quote: number): string {
|
|
342
|
+
const s = this.#s;
|
|
343
|
+
const n = this.#n;
|
|
344
|
+
let i = this.#i + 1; // skip opening quote
|
|
345
|
+
let out = "";
|
|
346
|
+
let runStart = i;
|
|
347
|
+
while (i < n) {
|
|
348
|
+
const cc = s.charCodeAt(i);
|
|
349
|
+
if (cc !== BACKSLASH && cc !== quote) {
|
|
350
|
+
i++;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (cc === quote) {
|
|
354
|
+
// Apostrophe / inner-quote recovery (a quote that isn't followed by a
|
|
355
|
+
// value terminator is literal) is safe for single quotes and in partial
|
|
356
|
+
// mode. For double quotes in strict mode, close on the first unescaped
|
|
357
|
+
// quote like standard JSON so malformed structure fails loudly instead
|
|
358
|
+
// of silently swallowing commas/colons into one string.
|
|
359
|
+
const lenient = quote === SQUOTE || this.#partial;
|
|
360
|
+
if (!lenient || this.#closesString(i + 1)) {
|
|
361
|
+
out += s.slice(runStart, i);
|
|
362
|
+
this.#i = i + 1;
|
|
363
|
+
return out;
|
|
364
|
+
}
|
|
365
|
+
// Unescaped inner quote (e.g. apostrophe in `'it's'`) — keep it literal.
|
|
366
|
+
i++;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
// Backslash escape.
|
|
370
|
+
out += s.slice(runStart, i);
|
|
371
|
+
i++;
|
|
372
|
+
if (i >= n) {
|
|
373
|
+
out += "\\";
|
|
374
|
+
runStart = i;
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
const esc = s.charCodeAt(i);
|
|
378
|
+
switch (esc) {
|
|
379
|
+
case QUOTE:
|
|
380
|
+
out += '"';
|
|
381
|
+
break;
|
|
382
|
+
case SQUOTE:
|
|
383
|
+
out += "'";
|
|
384
|
+
break;
|
|
385
|
+
case BACKSLASH:
|
|
386
|
+
out += "\\";
|
|
387
|
+
break;
|
|
388
|
+
case 0x2f:
|
|
389
|
+
out += "/";
|
|
390
|
+
break;
|
|
391
|
+
case 0x62:
|
|
392
|
+
out += "\b";
|
|
393
|
+
break;
|
|
394
|
+
case 0x66:
|
|
395
|
+
out += "\f";
|
|
396
|
+
break;
|
|
397
|
+
case 0x6e:
|
|
398
|
+
out += "\n";
|
|
399
|
+
break;
|
|
400
|
+
case 0x72:
|
|
401
|
+
out += "\r";
|
|
402
|
+
break;
|
|
403
|
+
case 0x74:
|
|
404
|
+
out += "\t";
|
|
405
|
+
break;
|
|
406
|
+
case U: {
|
|
407
|
+
const hex = s.slice(i + 1, i + 5);
|
|
408
|
+
if (HEX4_RE.test(hex)) {
|
|
409
|
+
out += String.fromCharCode(parseInt(hex, 16));
|
|
410
|
+
i += 4;
|
|
411
|
+
} else {
|
|
412
|
+
out += "\\u"; // invalid \u — keep literal
|
|
413
|
+
}
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
default:
|
|
417
|
+
out += `\\${s[i]}`; // invalid escape — keep backslash literal
|
|
418
|
+
}
|
|
419
|
+
i++;
|
|
420
|
+
runStart = i;
|
|
421
|
+
}
|
|
422
|
+
out += s.slice(runStart, i);
|
|
423
|
+
if (this.#partial) {
|
|
424
|
+
this.#i = i;
|
|
425
|
+
return out;
|
|
426
|
+
}
|
|
427
|
+
throw new SyntaxError("Unterminated string");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** A quote closes a string only when the next non-space char ends a value. */
|
|
431
|
+
#closesString(from: number): boolean {
|
|
432
|
+
const s = this.#s;
|
|
433
|
+
let k = from;
|
|
434
|
+
while (k < this.#n && isWhitespace(s.charCodeAt(k))) k++;
|
|
435
|
+
if (k >= this.#n) return true;
|
|
436
|
+
const c = s[k];
|
|
437
|
+
return c === "," || c === "}" || c === "]" || c === ":";
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
#number(): unknown {
|
|
441
|
+
const s = this.#s;
|
|
442
|
+
const start = this.#i;
|
|
443
|
+
while (this.#i < this.#n) {
|
|
444
|
+
const ch = s[this.#i];
|
|
445
|
+
if (
|
|
446
|
+
(ch >= "0" && ch <= "9") ||
|
|
447
|
+
ch === "-" ||
|
|
448
|
+
ch === "+" ||
|
|
449
|
+
ch === "." ||
|
|
450
|
+
ch === "e" ||
|
|
451
|
+
ch === "E" ||
|
|
452
|
+
ch === "x" ||
|
|
453
|
+
ch === "X" ||
|
|
454
|
+
(ch >= "a" && ch <= "f") ||
|
|
455
|
+
(ch >= "A" && ch <= "F")
|
|
456
|
+
) {
|
|
457
|
+
this.#i++;
|
|
458
|
+
} else {
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const token = s.slice(start, this.#i);
|
|
463
|
+
const num = Number(token);
|
|
464
|
+
if (Number.isNaN(num)) {
|
|
465
|
+
if (this.#partial) return INCOMPLETE;
|
|
466
|
+
throw new SyntaxError(`Invalid number: ${token}`);
|
|
467
|
+
}
|
|
468
|
+
return num;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
#keyword(): unknown {
|
|
472
|
+
const s = this.#s;
|
|
473
|
+
const i = this.#i;
|
|
474
|
+
for (const [word, value] of KEYWORDS) {
|
|
475
|
+
// Require a non-identifier boundary so `Truex` / `nullish` are not misread
|
|
476
|
+
// as the keyword followed by junk.
|
|
477
|
+
if (s.startsWith(word, i) && !isIdentChar(s.charCodeAt(i + word.length))) {
|
|
478
|
+
this.#i += word.length;
|
|
479
|
+
return value;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (this.#partial) {
|
|
483
|
+
// Incomplete / unrecognized atomic token at the streaming edge — signal the
|
|
484
|
+
// caller to roll back to the last valid prefix instead of committing junk.
|
|
485
|
+
this.#i = this.#n;
|
|
486
|
+
return INCOMPLETE;
|
|
487
|
+
}
|
|
488
|
+
throw new SyntaxError(`Unexpected token at position ${this.#i}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Final-parse a JSON value, repairing the common LLM malformations
|
|
494
|
+
* ({@link RelaxedJson}). Tries strict `JSON.parse` first (fast path, exact JSON
|
|
495
|
+
* semantics), then the relaxed parser. Throws when the input is unrepairable,
|
|
496
|
+
* truncated, or carries trailing garbage — so callers can skip a bad tool call
|
|
497
|
+
* rather than execute a half-formed one.
|
|
498
|
+
*/
|
|
499
|
+
export function parseJsonWithRepair<T>(json: string): T {
|
|
500
|
+
try {
|
|
501
|
+
return JSON.parse(json) as T;
|
|
502
|
+
} catch {
|
|
503
|
+
return new RelaxedJson(json, false).parse() as T;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Parse possibly-incomplete JSON during streaming. Always returns a value, never
|
|
509
|
+
* throws: `{}` for empty/whitespace/unrecoverable buffers, and an auto-closed
|
|
510
|
+
* best-effort object for truncated ones.
|
|
511
|
+
*/
|
|
512
|
+
export function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T {
|
|
513
|
+
const trimmed = partialJson?.trimStart();
|
|
514
|
+
if (!trimmed) return {} as T;
|
|
515
|
+
try {
|
|
516
|
+
return JSON.parse(trimmed) as T;
|
|
517
|
+
} catch {
|
|
518
|
+
try {
|
|
519
|
+
return (new RelaxedJson(trimmed, true).parse() ?? {}) as T;
|
|
520
|
+
} catch {
|
|
521
|
+
return {} as T;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Default minimum byte growth before `parseStreamingJsonThrottled` will
|
|
528
|
+
* re-parse a streaming tool-call argument buffer. Bounds the mid-stream
|
|
529
|
+
* partial-parse cost from quadratic to linear in N.
|
|
530
|
+
*/
|
|
531
|
+
export const STREAMING_JSON_PARSE_MIN_GROWTH = 256;
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Throttled variant of {@link parseStreamingJson} for the per-delta hot path.
|
|
535
|
+
*
|
|
536
|
+
* Tool calls arrive as a long sequence of small deltas — calling
|
|
537
|
+
* `parseStreamingJson(buffer)` on every delta re-parses the entire buffer
|
|
538
|
+
* each time, giving O(N²) work in the total buffer length. Throttling skips
|
|
539
|
+
* the re-parse until at least `minGrowthBytes` of new content has arrived
|
|
540
|
+
* since the last successful parse, bounding mid-stream cost to O(N).
|
|
541
|
+
*
|
|
542
|
+
* Each provider tracks the last parsed length on its tool-call block, so the
|
|
543
|
+
* final `toolcall_end` parse (which providers already perform unconditionally)
|
|
544
|
+
* is the authoritative full parse — the throttle only delays mid-stream UI
|
|
545
|
+
* updates by at most `minGrowthBytes` of accumulated partial content.
|
|
546
|
+
*
|
|
547
|
+
* @returns the parsed object plus the new `parsedLen` to persist; or `null`
|
|
548
|
+
* when the buffer has not grown enough to warrant a re-parse.
|
|
549
|
+
*/
|
|
550
|
+
export function parseStreamingJsonThrottled<T = Record<string, unknown>>(
|
|
551
|
+
partialJson: string | undefined,
|
|
552
|
+
lastParsedLen: number,
|
|
553
|
+
minGrowthBytes: number = STREAMING_JSON_PARSE_MIN_GROWTH,
|
|
554
|
+
): { value: T; parsedLen: number } | null {
|
|
555
|
+
const len = partialJson?.length ?? 0;
|
|
556
|
+
if (len === 0 || (lastParsedLen > 0 && len - lastParsedLen < minGrowthBytes)) return null;
|
|
557
|
+
return { value: parseStreamingJson<T>(partialJson), parsedLen: len };
|
|
558
|
+
}
|
package/src/stream.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
const trailingEvents = new WeakSet<ServerSentEvent>();
|
|
2
|
+
|
|
1
3
|
import { abortableSource } from "./abortable";
|
|
4
|
+
import { parseStreamingJson } from "./json-parse";
|
|
2
5
|
|
|
3
6
|
const LF = 0x0a;
|
|
4
7
|
type JsonlChunkResult = {
|
|
@@ -210,19 +213,39 @@ function notifySseEventObserver(observer: SseEventObserver | undefined, event: S
|
|
|
210
213
|
}
|
|
211
214
|
}
|
|
212
215
|
|
|
216
|
+
function isRecoverableTrailingJson(data: string): boolean {
|
|
217
|
+
const first = data.trimStart()[0];
|
|
218
|
+
if (first !== "{" && first !== "[") return false;
|
|
219
|
+
// Best-effort relaxed recovery via the shared streaming JSON parser: a
|
|
220
|
+
// container-shaped final event that fails strict `JSON.parse` is treated as a
|
|
221
|
+
// cut-off (or lightly malformed) stream tail and ends iteration cleanly instead
|
|
222
|
+
// of throwing. Non-container final events (plain-text errors, bare scalars) are
|
|
223
|
+
// not recoverable and still surface as a SyntaxError.
|
|
224
|
+
const recovered = parseStreamingJson<unknown>(data);
|
|
225
|
+
return typeof recovered === "object" && recovered !== null;
|
|
226
|
+
}
|
|
227
|
+
|
|
213
228
|
export async function* readSseJson<T>(
|
|
214
229
|
stream: ReadableStream<Uint8Array>,
|
|
215
230
|
signal?: AbortSignal,
|
|
216
231
|
onEvent?: SseEventObserver,
|
|
217
232
|
): AsyncGenerator<T> {
|
|
218
233
|
for await (const sse of readSseEvents(stream, signal)) {
|
|
234
|
+
const isTrailing = trailingEvents.has(sse);
|
|
219
235
|
notifySseEventObserver(onEvent, sse);
|
|
220
236
|
const data = sse.data;
|
|
221
237
|
if (data === "" || data === "[DONE]") {
|
|
222
238
|
if (data === "[DONE]") return;
|
|
223
239
|
continue;
|
|
224
240
|
}
|
|
225
|
-
|
|
241
|
+
try {
|
|
242
|
+
yield JSON.parse(data) as T;
|
|
243
|
+
} catch (err) {
|
|
244
|
+
if (err instanceof SyntaxError && isTrailing && isRecoverableTrailingJson(data)) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
226
249
|
}
|
|
227
250
|
}
|
|
228
251
|
|
|
@@ -353,12 +376,18 @@ export async function* readSseEvents(
|
|
|
353
376
|
if (tail) {
|
|
354
377
|
lineBuffer.clear();
|
|
355
378
|
const event = pushSseLine(tail, state);
|
|
356
|
-
if (event)
|
|
379
|
+
if (event) {
|
|
380
|
+
trailingEvents.add(event);
|
|
381
|
+
yield event;
|
|
382
|
+
}
|
|
357
383
|
}
|
|
358
384
|
}
|
|
359
385
|
// Real services don't always close on a blank line — flush any pending event.
|
|
360
386
|
const trailing = flushSseEvent(state);
|
|
361
|
-
if (trailing)
|
|
387
|
+
if (trailing) {
|
|
388
|
+
trailingEvents.add(trailing);
|
|
389
|
+
yield trailing;
|
|
390
|
+
}
|
|
362
391
|
} catch (err) {
|
|
363
392
|
if (signal?.aborted) return;
|
|
364
393
|
throw err;
|
package/src/temp.ts
CHANGED
|
@@ -79,11 +79,15 @@ function normalizePrefix(prefix?: string): string {
|
|
|
79
79
|
|
|
80
80
|
const kRemoveOptions = { recursive: true, force: true } as const;
|
|
81
81
|
const kRemoveRetries = 40;
|
|
82
|
-
|
|
82
|
+
// 50ms × 40 retries = 2s total retry window. Windows holds file locks on
|
|
83
|
+
// SQLite DBs for up to ~1.5s after close(); the previous 25ms (1s total)
|
|
84
|
+
// was too short for some test cleanup scenarios.
|
|
85
|
+
const kRemoveRetryDelayMs = 50;
|
|
83
86
|
const kRetryableRemoveErrorCodes = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]);
|
|
84
87
|
const kSleepBuffer = new Int32Array(new SharedArrayBuffer(4));
|
|
85
88
|
|
|
86
|
-
|
|
89
|
+
/** Removes a path recursively, retrying transient Windows deletion failures. */
|
|
90
|
+
export async function removeWithRetries(target: string): Promise<void> {
|
|
87
91
|
for (let attempt = 0; ; attempt++) {
|
|
88
92
|
try {
|
|
89
93
|
await fsPromises.rm(target, kRemoveOptions);
|