@oh-my-pi/pi-utils 16.2.13 → 16.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -11
- package/dist/types/index.d.ts +1 -0
- package/dist/types/tls-fetch.d.ts +37 -0
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/json-parse.ts +61 -7
- package/src/tls-fetch.ts +178 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.3.1] - 2026-07-02
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed `parseJsonWithRepair` failing tool calls whose streamed arguments contain an unquoted string value (e.g. `{"paths": packages/foo/*, "i": "…"}`). Final parsing now recovers such barewords in object/array value position as strings, terminating at `,` / `}` / `]` / newline. Recovery deliberately refuses anything that could mask real structure or bad data — truncated values, tokens containing `"` / `{` / `[` or a key-like `:` (URL `://` and Windows `:\` colons stay literal), and non-finite atoms (`NaN`, `Infinity`, `undefined`) — and streaming partial parses still roll back unfinished barewords instead of committing them.
|
|
10
|
+
|
|
11
|
+
## [16.3.0] - 2026-07-02
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Added `wrapFetchForExtraCa` and `withExtraCaFetch` utility functions to apply `NODE_EXTRA_CA_CERTS` to Bun's `RequestInit.tls.ca` configuration.
|
|
16
|
+
|
|
5
17
|
## [16.2.9] - 2026-06-30
|
|
6
18
|
|
|
7
19
|
### Added
|
|
@@ -109,24 +121,16 @@
|
|
|
109
121
|
|
|
110
122
|
- Added profile-aware directory helpers and isolated profile state roots, while keeping the install ID shared across profiles.
|
|
111
123
|
- Added a named-profile API to the `dirs` module — `setProfile()`, `getActiveProfile()`, `getProfileRootDir()`, and `normalizeProfileName()` — plus `resolveProfileEnv()`, which selects the active profile from `OMP_PROFILE` (canonical; takes precedence) then `PI_PROFILE` (legacy fallback, consulted only when `OMP_PROFILE` is unset).
|
|
112
|
-
- Added the side-effect-free `@oh-my-pi/pi-utils/worker-host` module (`declareWorkerHostEntry()` / `workerHostEntry()`), extracted from `env` (still re-exported there) so worker spawn sites can resolve the self-dispatching CLI host entry without importing `env`'s side-effecting module graph.
|
|
113
|
-
|
|
114
|
-
### Fixed
|
|
115
|
-
|
|
116
|
-
- Fixed profile directory isolation when a profile's agent `.env` customizes directory roots: directory-affecting keys (`XDG_DATA_HOME`/`XDG_STATE_HOME`/`XDG_CACHE_HOME`, and a default-mode `PI_CODING_AGENT_DIR`) are now honored. The `env` loader rebuilds the `dirs` resolver after applying `.env` files (`refreshDirsFromEnv()`), so a profile `.env` that points XDG roots elsewhere no longer leaks state into the home-based config dir.
|
|
117
|
-
- Fixed `installRuntimeModuleResolver()` to keep bare requests from runtime-cache modules inside that registered runtime before falling back to host/workspace packages.
|
|
118
|
-
|
|
119
|
-
## [15.13.1] - 2026-06-15
|
|
120
|
-
|
|
121
|
-
### Added
|
|
122
|
-
|
|
123
124
|
- Added support for a runtime `overrides` map in `RuntimeInstallSpec`, which is now written into generated runtime `package.json` manifests to force dependency pins (including transitive ones) across the runtime tree
|
|
124
125
|
- Added a lightweight loop-phase breadcrumb stack (`pushLoopPhase`/`popLoopPhase`/`currentLoopPhase`, plus `takeRecentLoopPhase` which returns the live phase or the most recently popped one and clears it) so the TUI event-loop watchdog can attribute a main-thread block to the phase that caused it — including a synchronous phase already popped before the watchdog's delayed tick runs ([#2485](https://github.com/can1357/oh-my-pi/issues/2485))
|
|
125
126
|
- Added `FetchWithRetryOptions.timeout` (forwarded to the underlying `fetch` call). `false` disables Bun's native ~300s pre-response timeout; a positive number overrides the ceiling. Bare browser/Node fetch ignores it ([#2422](https://github.com/can1357/oh-my-pi/issues/2422))
|
|
127
|
+
- Added the side-effect-free `@oh-my-pi/pi-utils/worker-host` module (`declareWorkerHostEntry()` / `workerHostEntry()`), extracted from `env` (still re-exported there) so worker spawn sites can resolve the self-dispatching CLI host entry without importing `env`'s side-effecting module graph.
|
|
126
128
|
|
|
127
129
|
### Fixed
|
|
128
130
|
|
|
131
|
+
- Fixed profile directory isolation when a profile's agent `.env` customizes directory roots: directory-affecting keys (`XDG_DATA_HOME`/`XDG_STATE_HOME`/`XDG_CACHE_HOME`, and a default-mode `PI_CODING_AGENT_DIR`) are now honored. The `env` loader rebuilds the `dirs` resolver after applying `.env` files (`refreshDirsFromEnv()`), so a profile `.env` that points XDG roots elsewhere no longer leaks state into the home-based config dir.
|
|
129
132
|
- Made `TempDir` cleanup retry transient Windows `EBUSY`/`EPERM`/`ENOTEMPTY` removal failures so tests are less likely to fail when deleting just-used temp directories.
|
|
133
|
+
- Fixed `installRuntimeModuleResolver()` to keep bare requests from runtime-cache modules inside that registered runtime before falling back to host/workspace packages.
|
|
130
134
|
|
|
131
135
|
## [15.12.4] - 2026-06-13
|
|
132
136
|
|
package/dist/types/index.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export * from "./snowflake";
|
|
|
29
29
|
export * from "./stream";
|
|
30
30
|
export * from "./tab-spacing";
|
|
31
31
|
export * from "./temp";
|
|
32
|
+
export * from "./tls-fetch";
|
|
32
33
|
export * from "./type-guards";
|
|
33
34
|
export * from "./which";
|
|
34
35
|
export declare function structuredCloneJSON<T>(value: T): T;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fetch`-compatible function. Accepts any callable matching the standard
|
|
3
|
+
* fetch signature; `preconnect` is optional because non-Bun runtimes
|
|
4
|
+
* (browsers, test mocks) won't expose it.
|
|
5
|
+
*/
|
|
6
|
+
export type FetchImpl = ((input: string | URL | Request, init?: RequestInit) => Promise<Response>) & {
|
|
7
|
+
preconnect?: typeof globalThis.fetch.preconnect;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* `NODE_EXTRA_CA_CERTS` was set but unusable (path does not exist). This is
|
|
11
|
+
* a config/contract error, not a transient transport fault — it is never
|
|
12
|
+
* retried.
|
|
13
|
+
*/
|
|
14
|
+
export declare class ExtraCaError extends Error {
|
|
15
|
+
constructor(message: string, options?: {
|
|
16
|
+
cause?: unknown;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** Test seam: drop the cached PEM so a follow-up call re-reads the env. */
|
|
20
|
+
export declare function __resetExtraCaCache(): void;
|
|
21
|
+
/**
|
|
22
|
+
* Wrap `fetchImpl` so every call honours `NODE_EXTRA_CA_CERTS`. Idempotent:
|
|
23
|
+
* a fetch already wrapped is returned unchanged so repeated composition
|
|
24
|
+
* (Anthropic auth-retry replays, request-debug fan-out) never stacks
|
|
25
|
+
* wrappers. When the env var is unset the original fetch is returned, so
|
|
26
|
+
* default deployments pay nothing.
|
|
27
|
+
*/
|
|
28
|
+
export declare function wrapFetchForExtraCa(fetchImpl: FetchImpl): FetchImpl;
|
|
29
|
+
/**
|
|
30
|
+
* Convenience for options-bag composition (e.g. the stream-entry path in
|
|
31
|
+
* `@oh-my-pi/pi-ai`'s `stream.ts`, which mirrors `withRequestDebugFetch` so
|
|
32
|
+
* the proxy/debug/extra-CA wrappers compose uniformly). No-op when the env
|
|
33
|
+
* var is unset.
|
|
34
|
+
*/
|
|
35
|
+
export declare function withExtraCaFetch<T extends {
|
|
36
|
+
fetch?: FetchImpl;
|
|
37
|
+
} | undefined>(options: T): T;
|
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.2
|
|
4
|
+
"version": "16.3.2",
|
|
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.2
|
|
34
|
+
"@oh-my-pi/pi-natives": "16.3.2",
|
|
35
35
|
"handlebars": "^4.7.9",
|
|
36
36
|
"winston": "^3.19.0",
|
|
37
37
|
"winston-daily-rotate-file": "^5.0.0"
|
package/src/index.ts
CHANGED
package/src/json-parse.ts
CHANGED
|
@@ -49,6 +49,18 @@ const KEYWORDS: readonly (readonly [string, unknown])[] = [
|
|
|
49
49
|
["None", null],
|
|
50
50
|
];
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* JS-only atoms never recovered as bareword strings — a tool must not execute
|
|
54
|
+
* with a non-finite or undefined argument masquerading as a string.
|
|
55
|
+
*/
|
|
56
|
+
const NON_RECOVERABLE_BAREWORDS: Record<string, true> = {
|
|
57
|
+
NaN: true,
|
|
58
|
+
Infinity: true,
|
|
59
|
+
"-Infinity": true,
|
|
60
|
+
"+Infinity": true,
|
|
61
|
+
undefined: true,
|
|
62
|
+
};
|
|
63
|
+
|
|
52
64
|
/**
|
|
53
65
|
* Sentinel returned by partial-mode value parsing when an atomic value
|
|
54
66
|
* (number / keyword) is incomplete at the streaming edge, so the enclosing
|
|
@@ -157,7 +169,10 @@ export function repairJson(json: string): string {
|
|
|
157
169
|
* - Python literals `True` / `False` / `None` and JS `NaN` / `Infinity`;
|
|
158
170
|
* - raw control characters and invalid `\x` escapes inside strings (kept literally);
|
|
159
171
|
* - unescaped quotes inside strings — a quote only closes a string when followed
|
|
160
|
-
* by a value terminator, recovering apostrophes such as `'it's'
|
|
172
|
+
* by a value terminator, recovering apostrophes such as `'it's'`;
|
|
173
|
+
* - unquoted string values in object/array value position (strict mode only) —
|
|
174
|
+
* an unrecognized bareword such as `{"paths": packages/foo/*}` is recovered as
|
|
175
|
+
* a string up to the next `,` / `}` / `]` / newline.
|
|
161
176
|
*
|
|
162
177
|
* In `partial` mode an unterminated string/object/array (or a value cut off at
|
|
163
178
|
* end-of-input) is auto-closed with whatever was parsed so far — for streaming.
|
|
@@ -182,7 +197,7 @@ class RelaxedJson {
|
|
|
182
197
|
if (this.#partial) return undefined;
|
|
183
198
|
throw new SyntaxError("Unexpected end of JSON input");
|
|
184
199
|
}
|
|
185
|
-
const value = this.#value();
|
|
200
|
+
const value = this.#value(false);
|
|
186
201
|
if (value === INCOMPLETE) return undefined;
|
|
187
202
|
this.#ws();
|
|
188
203
|
if (!this.#partial && this.#i < this.#n) {
|
|
@@ -218,7 +233,7 @@ class RelaxedJson {
|
|
|
218
233
|
}
|
|
219
234
|
}
|
|
220
235
|
|
|
221
|
-
#value(): unknown {
|
|
236
|
+
#value(allowBareword: boolean): unknown {
|
|
222
237
|
const s = this.#s;
|
|
223
238
|
const c = s[this.#i];
|
|
224
239
|
if (c === "{") return this.#object();
|
|
@@ -231,7 +246,7 @@ class RelaxedJson {
|
|
|
231
246
|
// NaN guard (strict throw / partial rollback) like other bad tokens.
|
|
232
247
|
return this.#number();
|
|
233
248
|
}
|
|
234
|
-
return this.#keyword();
|
|
249
|
+
return this.#keyword(allowBareword);
|
|
235
250
|
}
|
|
236
251
|
|
|
237
252
|
#object(): Record<string, unknown> {
|
|
@@ -267,7 +282,7 @@ class RelaxedJson {
|
|
|
267
282
|
if (this.#partial) return out;
|
|
268
283
|
throw new SyntaxError("Expected value after ':'");
|
|
269
284
|
}
|
|
270
|
-
const value = this.#value();
|
|
285
|
+
const value = this.#value(true);
|
|
271
286
|
if (value === INCOMPLETE) return out;
|
|
272
287
|
out[key] = value;
|
|
273
288
|
this.#ws();
|
|
@@ -303,7 +318,7 @@ class RelaxedJson {
|
|
|
303
318
|
this.#i++;
|
|
304
319
|
continue;
|
|
305
320
|
}
|
|
306
|
-
const value = this.#value();
|
|
321
|
+
const value = this.#value(true);
|
|
307
322
|
if (value === INCOMPLETE) return out;
|
|
308
323
|
out.push(value);
|
|
309
324
|
this.#ws();
|
|
@@ -468,7 +483,7 @@ class RelaxedJson {
|
|
|
468
483
|
return num;
|
|
469
484
|
}
|
|
470
485
|
|
|
471
|
-
#keyword(): unknown {
|
|
486
|
+
#keyword(allowBareword: boolean): unknown {
|
|
472
487
|
const s = this.#s;
|
|
473
488
|
const i = this.#i;
|
|
474
489
|
for (const [word, value] of KEYWORDS) {
|
|
@@ -485,8 +500,47 @@ class RelaxedJson {
|
|
|
485
500
|
this.#i = this.#n;
|
|
486
501
|
return INCOMPLETE;
|
|
487
502
|
}
|
|
503
|
+
if (allowBareword) return this.#bareword();
|
|
488
504
|
throw new SyntaxError(`Unexpected token at position ${this.#i}`);
|
|
489
505
|
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Strict-mode recovery of an unquoted string value, e.g.
|
|
509
|
+
* `{"paths": packages/foo/*}`: consume until `,` / `}` / `]` / newline and
|
|
510
|
+
* trim trailing whitespace. Recovery still throws — so a final parse never
|
|
511
|
+
* accepts a half-formed or non-finite argument — when the token:
|
|
512
|
+
* - hits end-of-input before a delimiter (truncated value);
|
|
513
|
+
* - contains a `"`, `{`, `[`, or a key-like `:` — this parser accepts
|
|
514
|
+
* unquoted keys, so a missed comma (`{"a": foo "b": 1}`, `{a: foo b: 1}`)
|
|
515
|
+
* would otherwise silently swallow the following field. A colon followed
|
|
516
|
+
* by `/` or `\` stays literal so URL and Windows-path values recover;
|
|
517
|
+
* - is a non-finite atom ({@link NON_RECOVERABLE_BAREWORDS}).
|
|
518
|
+
*/
|
|
519
|
+
#bareword(): string {
|
|
520
|
+
const s = this.#s;
|
|
521
|
+
const start = this.#i;
|
|
522
|
+
let i = start;
|
|
523
|
+
while (i < this.#n) {
|
|
524
|
+
const cc = s.charCodeAt(i);
|
|
525
|
+
if (cc === 0x2c /* , */ || cc === 0x7d /* } */ || cc === 0x5d /* ] */ || cc === 0x0a || cc === 0x0d) break;
|
|
526
|
+
if (
|
|
527
|
+
cc === QUOTE ||
|
|
528
|
+
cc === 0x7b /* { */ ||
|
|
529
|
+
cc === 0x5b /* [ */ ||
|
|
530
|
+
(cc === 0x3a /* : */ && s.charCodeAt(i + 1) !== 0x2f /* / */ && s.charCodeAt(i + 1) !== 0x5c) /* \ */
|
|
531
|
+
) {
|
|
532
|
+
throw new SyntaxError(`Unexpected token at position ${start}`);
|
|
533
|
+
}
|
|
534
|
+
i++;
|
|
535
|
+
}
|
|
536
|
+
if (i >= this.#n) throw new SyntaxError(`Unexpected token at position ${start}`);
|
|
537
|
+
let end = i;
|
|
538
|
+
while (end > start && isWhitespace(s.charCodeAt(end - 1))) end--;
|
|
539
|
+
const word = s.slice(start, end);
|
|
540
|
+
if (NON_RECOVERABLE_BAREWORDS[word]) throw new SyntaxError(`Unexpected token at position ${start}`);
|
|
541
|
+
this.#i = i;
|
|
542
|
+
return word;
|
|
543
|
+
}
|
|
490
544
|
}
|
|
491
545
|
|
|
492
546
|
/**
|
package/src/tls-fetch.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `NODE_EXTRA_CA_CERTS` shim for Bun's `fetch`.
|
|
3
|
+
*
|
|
4
|
+
* Node's TLS layer honours `NODE_EXTRA_CA_CERTS` natively, but Bun's
|
|
5
|
+
* `fetch` does not, and both the provider streams (`openai-responses`,
|
|
6
|
+
* `openai-completions`, `openai-codex-responses`, `ollama-chat`, ...) and
|
|
7
|
+
* catalog model discovery (`/models` probes) route every request through
|
|
8
|
+
* Bun's runtime. Without this wrapper, corporate relays and private
|
|
9
|
+
* gateways behind a custom CA bundle fail with
|
|
10
|
+
* `unknown certificate verification error` even when the env var is set.
|
|
11
|
+
*
|
|
12
|
+
* The wrapper merges the resolved CA bundle into Bun's `RequestInit.tls.ca`.
|
|
13
|
+
* Bun's `tls.ca` REPLACES the default trust store when set, so the wrapper
|
|
14
|
+
* always seeds {@link tls.rootCertificates} when the caller has not already
|
|
15
|
+
* curated their own CA list.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as tls from "node:tls";
|
|
19
|
+
import { $env } from "./env";
|
|
20
|
+
import { isEnoent } from "./fs-error";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `fetch`-compatible function. Accepts any callable matching the standard
|
|
24
|
+
* fetch signature; `preconnect` is optional because non-Bun runtimes
|
|
25
|
+
* (browsers, test mocks) won't expose it.
|
|
26
|
+
*/
|
|
27
|
+
export type FetchImpl = ((input: string | URL | Request, init?: RequestInit) => Promise<Response>) & {
|
|
28
|
+
preconnect?: typeof globalThis.fetch.preconnect;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `NODE_EXTRA_CA_CERTS` was set but unusable (path does not exist). This is
|
|
33
|
+
* a config/contract error, not a transient transport fault — it is never
|
|
34
|
+
* retried.
|
|
35
|
+
*/
|
|
36
|
+
export class ExtraCaError extends Error {
|
|
37
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
38
|
+
super(message, options?.cause === undefined ? undefined : { cause: options.cause });
|
|
39
|
+
this.name = "ExtraCaError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Bun extension to `RequestInit` for the TLS options we touch. */
|
|
44
|
+
type BunTlsOptions = {
|
|
45
|
+
ca?: string | string[];
|
|
46
|
+
cert?: string;
|
|
47
|
+
key?: string;
|
|
48
|
+
rejectUnauthorized?: boolean;
|
|
49
|
+
serverName?: string;
|
|
50
|
+
ciphers?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type BunTlsRequestInit = RequestInit & { tls?: BunTlsOptions };
|
|
54
|
+
|
|
55
|
+
const EXTRA_CA_FETCH_MARKER = Symbol("omp.extraCaFetch");
|
|
56
|
+
type ExtraCaFetch = FetchImpl & { [EXTRA_CA_FETCH_MARKER]?: true };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Cached resolution of `NODE_EXTRA_CA_CERTS`. Keyed on the env value plus
|
|
60
|
+
* the file mtime for path values so on-disk cert rotation (short-lived
|
|
61
|
+
* corporate bundles) invalidates the cache instead of pinning the first
|
|
62
|
+
* read forever.
|
|
63
|
+
*/
|
|
64
|
+
let cacheKey: string | undefined;
|
|
65
|
+
let cacheValue: string | undefined;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Returns the PEM bytes referenced by `NODE_EXTRA_CA_CERTS`, or `undefined`
|
|
69
|
+
* when the env var is unset/empty.
|
|
70
|
+
*
|
|
71
|
+
* Accepts the same shapes Node accepts plus an inline-PEM escape hatch:
|
|
72
|
+
* - Inline PEM (`-----BEGIN CERTIFICATE-----...`). Literal `\n` escapes in
|
|
73
|
+
* the env value are expanded so callers can ship single-line PEMs through
|
|
74
|
+
* shell exports.
|
|
75
|
+
* - File path. Anything that does not contain a PEM header is treated as a
|
|
76
|
+
* path, matching Node's "extensionless filename is still a path" contract.
|
|
77
|
+
* `ENOENT` becomes {@link ExtraCaError}; other I/O errors bubble.
|
|
78
|
+
*/
|
|
79
|
+
function resolveExtraCa(): string | undefined {
|
|
80
|
+
const raw = $env.NODE_EXTRA_CA_CERTS?.trim();
|
|
81
|
+
if (!raw) return undefined;
|
|
82
|
+
|
|
83
|
+
let key: string;
|
|
84
|
+
if (raw.includes("-----BEGIN")) {
|
|
85
|
+
key = raw;
|
|
86
|
+
} else {
|
|
87
|
+
try {
|
|
88
|
+
key = `${raw}@${fs.statSync(raw).mtimeMs}`;
|
|
89
|
+
} catch {
|
|
90
|
+
key = raw;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (key === cacheKey) return cacheValue;
|
|
94
|
+
|
|
95
|
+
if (raw.includes("-----BEGIN")) {
|
|
96
|
+
cacheValue = raw.replace(/\\n/g, "\n");
|
|
97
|
+
} else {
|
|
98
|
+
try {
|
|
99
|
+
cacheValue = fs.readFileSync(raw, "utf8");
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (isEnoent(error)) {
|
|
102
|
+
throw new ExtraCaError(`NODE_EXTRA_CA_CERTS path does not exist: ${raw}`);
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
cacheKey = key;
|
|
108
|
+
return cacheValue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Test seam: drop the cached PEM so a follow-up call re-reads the env. */
|
|
112
|
+
export function __resetExtraCaCache(): void {
|
|
113
|
+
cacheKey = undefined;
|
|
114
|
+
cacheValue = undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Merge `extraCa` into `init.tls.ca`. When the caller has not supplied a CA
|
|
119
|
+
* list, the system root store is included alongside the extra bundle —
|
|
120
|
+
* Bun's `tls.ca` replaces the default trust store, so omitting roots would
|
|
121
|
+
* break every public host. When the caller already curated a list (e.g.
|
|
122
|
+
* Anthropic Foundry's mTLS options, which already seed
|
|
123
|
+
* `tls.rootCertificates`), only the extra CA is appended.
|
|
124
|
+
*/
|
|
125
|
+
function withExtraCaInit(init: RequestInit | undefined, extraCa: string): RequestInit {
|
|
126
|
+
const existingTls = (init as BunTlsRequestInit | undefined)?.tls;
|
|
127
|
+
const existingCa = existingTls?.ca;
|
|
128
|
+
let mergedCa: string[];
|
|
129
|
+
if (existingCa === undefined) {
|
|
130
|
+
mergedCa = [...tls.rootCertificates, extraCa];
|
|
131
|
+
} else if (Array.isArray(existingCa)) {
|
|
132
|
+
mergedCa = [...existingCa, extraCa];
|
|
133
|
+
} else {
|
|
134
|
+
mergedCa = [existingCa, extraCa];
|
|
135
|
+
}
|
|
136
|
+
return { ...init, tls: { ...existingTls, ca: mergedCa } } as RequestInit;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Wrap `fetchImpl` so every call honours `NODE_EXTRA_CA_CERTS`. Idempotent:
|
|
141
|
+
* a fetch already wrapped is returned unchanged so repeated composition
|
|
142
|
+
* (Anthropic auth-retry replays, request-debug fan-out) never stacks
|
|
143
|
+
* wrappers. When the env var is unset the original fetch is returned, so
|
|
144
|
+
* default deployments pay nothing.
|
|
145
|
+
*/
|
|
146
|
+
export function wrapFetchForExtraCa(fetchImpl: FetchImpl): FetchImpl {
|
|
147
|
+
const maybeWrapped = fetchImpl as ExtraCaFetch;
|
|
148
|
+
if (maybeWrapped[EXTRA_CA_FETCH_MARKER]) return fetchImpl;
|
|
149
|
+
// Peek once at construction — if the env var is unset, skip the wrapper
|
|
150
|
+
// entirely so the hot fetch path stays a single function call. The env
|
|
151
|
+
// is evaluated again per request below to catch in-process updates from
|
|
152
|
+
// tests (`__resetExtraCaCache` + env mutation).
|
|
153
|
+
if (!$env.NODE_EXTRA_CA_CERTS?.trim()) return fetchImpl;
|
|
154
|
+
|
|
155
|
+
const wrapped = Object.assign(
|
|
156
|
+
async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
157
|
+
const extraCa = resolveExtraCa();
|
|
158
|
+
return extraCa ? fetchImpl(input, withExtraCaInit(init, extraCa)) : fetchImpl(input, init);
|
|
159
|
+
},
|
|
160
|
+
fetchImpl.preconnect ? { preconnect: fetchImpl.preconnect } : {},
|
|
161
|
+
{ [EXTRA_CA_FETCH_MARKER]: true as const },
|
|
162
|
+
);
|
|
163
|
+
return wrapped;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Convenience for options-bag composition (e.g. the stream-entry path in
|
|
168
|
+
* `@oh-my-pi/pi-ai`'s `stream.ts`, which mirrors `withRequestDebugFetch` so
|
|
169
|
+
* the proxy/debug/extra-CA wrappers compose uniformly). No-op when the env
|
|
170
|
+
* var is unset.
|
|
171
|
+
*/
|
|
172
|
+
export function withExtraCaFetch<T extends { fetch?: FetchImpl } | undefined>(options: T): T {
|
|
173
|
+
if (!$env.NODE_EXTRA_CA_CERTS?.trim()) return options;
|
|
174
|
+
const fetchImpl = options?.fetch ?? (globalThis.fetch as FetchImpl);
|
|
175
|
+
const wrapped = wrapFetchForExtraCa(fetchImpl);
|
|
176
|
+
if (wrapped === fetchImpl && options?.fetch !== undefined) return options;
|
|
177
|
+
return { ...(options ?? {}), fetch: wrapped } as T;
|
|
178
|
+
}
|