@demigodmode/pi-web-agent 1.10.0 → 1.11.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 +13 -0
- package/README.md +19 -4
- package/dist/backends/config.d.ts +27 -0
- package/dist/backends/config.js +97 -1
- package/dist/backends/factory.d.ts +2 -1
- package/dist/backends/factory.js +81 -23
- package/dist/commands/web-agent-config.d.ts +3 -0
- package/dist/commands/web-agent-config.js +57 -5
- package/dist/extension.d.ts +1 -0
- package/dist/extension.js +2 -0
- package/dist/fetch/headless-fetch.d.ts +8 -1
- package/dist/fetch/headless-fetch.js +3 -3
- package/dist/fetch/proxy-fetch.d.ts +22 -0
- package/dist/fetch/proxy-fetch.js +46 -0
- package/dist/jiti-compat-run.d.ts +1 -0
- package/dist/jiti-compat-run.js +9 -0
- package/dist/jiti-compat.d.ts +32 -0
- package/dist/jiti-compat.js +215 -0
- package/dist/presentation/config-store.js +4 -0
- package/dist/readers/youtube-reader.d.ts +3 -1
- package/dist/readers/youtube-reader.js +11 -3
- package/package.json +2 -1
- package/scripts/patch-jiti-compat.mjs +52 -8
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,19 @@ The format is intentionally simple and release-oriented.
|
|
|
18
18
|
### Breaking
|
|
19
19
|
- None.
|
|
20
20
|
|
|
21
|
+
## [1.11.0] - 2026-09-16
|
|
22
|
+
### Added
|
|
23
|
+
- Optional proxy support. Point web_explore at an HTTP/HTTPS proxy and everything outbound goes through it: search, fetch, Firecrawl, the GitHub/PDF/YouTube readers, doctor health checks, and the headless browser. Off unless you set it, and nothing changes if you don't. Set it in Settings → Backends, and keep credentials in `PI_WEB_AGENT_PROXY_USERNAME` / `PI_WEB_AGENT_PROXY_PASSWORD` rather than in the URL. Thanks to @lo-tp for building this. (#50)
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- `/web-agent doctor` reports whether the jsdom compat patch is in place, and prints the command to reapply it if it isn't.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- Pi failing to start with `Cannot find module 'punycode/'` or `Set operation called on non-Set object`. The patch for this was only applied at install time, but every pi extension shares one node_modules tree, so installing or updating anything else quietly reverted it. It now reapplies every time the extension loads. Also fixes it picking the wrong copy of the dependency when the shared tree holds more than one. (#34)
|
|
30
|
+
|
|
31
|
+
### Breaking
|
|
32
|
+
- None.
|
|
33
|
+
|
|
21
34
|
## [1.10.0] - 2026-08-25
|
|
22
35
|
### Added
|
|
23
36
|
- The zero-config default has a safety net now. When DuckDuckGo blocks a search (which happens fast on VPS and datacenter IPs), web search falls back to Tavily's keyless endpoint, no account or API key needed, so a fresh install still returns results instead of an error. Set `PI_WEB_AGENT_DISABLE_KEYLESS_FALLBACK=1` if you'd rather it just fail. (#42)
|
package/README.md
CHANGED
|
@@ -4,9 +4,24 @@
|
|
|
4
4
|
|
|
5
5
|
# pi-web-agent
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://github.com/demigodmode/pi-web-agent/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/demigodmode/pi-web-agent/ci.yml?branch=main&style=flat-square&logo=github&label=CI" alt="CI"></a>
|
|
9
|
+
<a href="https://www.npmjs.com/package/@demigodmode/pi-web-agent"><img src="https://img.shields.io/npm/v/@demigodmode/pi-web-agent?style=flat-square&logo=npm&logoColor=white&color=CB3837" alt="npm version"></a>
|
|
10
|
+
<a href="https://www.npmjs.com/package/@demigodmode/pi-web-agent"><img src="https://img.shields.io/npm/dm/@demigodmode/pi-web-agent?style=flat-square&color=0A7BBB&label=downloads" alt="npm downloads"></a>
|
|
11
|
+
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-475569?style=flat-square" alt="Platform: macOS, Linux, Windows">
|
|
12
|
+
<a href="https://demigodmode.github.io/pi-web-agent/"><img src="https://img.shields.io/badge/docs-github%20pages-2088FF?style=flat-square&logo=readthedocs&logoColor=white" alt="Docs"></a>
|
|
13
|
+
<a href="https://github.com/demigodmode/pi-web-agent/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/@demigodmode/pi-web-agent?style=flat-square&color=6F42C1" alt="License"></a>
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
<p align="center">
|
|
17
|
+
<img src="https://img.shields.io/badge/search%20backends-475569?style=flat-square" alt="Search backends">
|
|
18
|
+
<img src="https://img.shields.io/badge/DuckDuckGo-475569?style=flat-square&logo=duckduckgo&logoColor=white" alt="DuckDuckGo">
|
|
19
|
+
<img src="https://img.shields.io/badge/SearXNG-475569?style=flat-square&logo=searxng&logoColor=white" alt="SearXNG">
|
|
20
|
+
<img src="https://img.shields.io/badge/Brave-475569?style=flat-square&logo=brave&logoColor=white" alt="Brave Search">
|
|
21
|
+
<img src="https://img.shields.io/badge/You.com-475569?style=flat-square" alt="You.com">
|
|
22
|
+
<img src="https://img.shields.io/badge/Exa-475569?style=flat-square" alt="Exa">
|
|
23
|
+
<img src="https://img.shields.io/badge/Tavily-475569?style=flat-square" alt="Tavily">
|
|
24
|
+
</p>
|
|
10
25
|
|
|
11
26
|
One public tool, `web_explore`, that does bounded web research for Pi: search, fetch, targeted browser rendering, ranking, and honest caveats, all behind a single call.
|
|
12
27
|
|
|
@@ -26,7 +41,7 @@ One public tool, `web_explore`, that does bounded web research for Pi: search, f
|
|
|
26
41
|
|
|
27
42
|
Compared to other web tooling for agents:
|
|
28
43
|
|
|
29
|
-
- **Hands-off.** No curator, no browser windows to approve, no step that pops you out of your session. Ask `web_explore` once and the answer comes back
|
|
44
|
+
- **Hands-off.** No curator, no browser windows to approve, no step that pops you out of your session. Ask `web_explore` once and the answer comes back cleanly. Nothing to babysit.
|
|
30
45
|
- **Keyless by default.** Search, page reads, and the GitHub/PDF/YouTube readers all work with no API keys. Add hosted providers only when you want them.
|
|
31
46
|
- **Bounded and honest.** Compact output by default, and it says when a read was not good enough instead of returning fake confidence.
|
|
32
47
|
|
|
@@ -12,6 +12,11 @@ export type FirecrawlOptions = {
|
|
|
12
12
|
formats?: string[];
|
|
13
13
|
onlyMainContent?: boolean;
|
|
14
14
|
};
|
|
15
|
+
export type ProxyConfig = {
|
|
16
|
+
url: string;
|
|
17
|
+
username?: string;
|
|
18
|
+
password?: string;
|
|
19
|
+
};
|
|
15
20
|
export type SearchBackendConfig = {
|
|
16
21
|
provider: 'duckduckgo' | 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily';
|
|
17
22
|
baseUrl?: string;
|
|
@@ -33,11 +38,13 @@ export type BackendConfig = {
|
|
|
33
38
|
search: SearchBackendConfig;
|
|
34
39
|
fetch: FetchBackendConfig;
|
|
35
40
|
headless: HeadlessBackendConfig;
|
|
41
|
+
proxy?: ProxyConfig;
|
|
36
42
|
};
|
|
37
43
|
export type BackendConfigOverride = {
|
|
38
44
|
search?: Partial<SearchBackendConfig>;
|
|
39
45
|
fetch?: Partial<FetchBackendConfig>;
|
|
40
46
|
headless?: Partial<HeadlessBackendConfig>;
|
|
47
|
+
proxy?: ProxyConfig;
|
|
41
48
|
};
|
|
42
49
|
export type BackendConfigFile = {
|
|
43
50
|
backends?: {
|
|
@@ -58,9 +65,29 @@ export type BackendConfigFile = {
|
|
|
58
65
|
headless?: {
|
|
59
66
|
provider?: unknown;
|
|
60
67
|
};
|
|
68
|
+
proxy?: {
|
|
69
|
+
url?: unknown;
|
|
70
|
+
username?: unknown;
|
|
71
|
+
password?: unknown;
|
|
72
|
+
};
|
|
61
73
|
};
|
|
62
74
|
};
|
|
63
75
|
export declare const DEFAULT_BACKEND_CONFIG: BackendConfig;
|
|
76
|
+
/**
|
|
77
|
+
* Remove any credentials (user:pass@) embedded in a proxy URL. pi-web-agent
|
|
78
|
+
* never reads or sends credentials from the URL; proxy auth comes from
|
|
79
|
+
* backends.proxy.username/password or PI_WEB_AGENT_PROXY_USERNAME /
|
|
80
|
+
* PI_WEB_AGENT_PROXY_PASSWORD. Stripping them here keeps them out of logs,
|
|
81
|
+
* doctor output, the settings UI, and the proxy connection itself.
|
|
82
|
+
*/
|
|
83
|
+
export declare function stripProxyCredentials(url: string): string;
|
|
84
|
+
/**
|
|
85
|
+
* Whether a proxy url passes validation: a blank url is the "disable proxy"
|
|
86
|
+
* marker and passes; anything else must parse as an http(s) URL. This is a
|
|
87
|
+
* pure syntax check — no connectivity check is performed.
|
|
88
|
+
*/
|
|
89
|
+
export declare function isValidProxyUrl(url: string): boolean;
|
|
90
|
+
export declare function extractProxyConfig(value: unknown): ProxyConfig | undefined;
|
|
64
91
|
export declare function usableSearchProviders(search: SearchBackendConfig, env?: NodeJS.ProcessEnv): SearchProviderName[];
|
|
65
92
|
export declare function extractBackendConfigOverride(file: BackendConfigFile | null | undefined): BackendConfigOverride;
|
|
66
93
|
export declare function validateBackendConfig(config: BackendConfig): string[];
|
package/dist/backends/config.js
CHANGED
|
@@ -9,6 +9,76 @@ function extractStringArray(value) {
|
|
|
9
9
|
const strings = value.filter((item) => typeof item === 'string');
|
|
10
10
|
return strings.length === value.length ? strings : undefined;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Remove any credentials (user:pass@) embedded in a proxy URL. pi-web-agent
|
|
14
|
+
* never reads or sends credentials from the URL; proxy auth comes from
|
|
15
|
+
* backends.proxy.username/password or PI_WEB_AGENT_PROXY_USERNAME /
|
|
16
|
+
* PI_WEB_AGENT_PROXY_PASSWORD. Stripping them here keeps them out of logs,
|
|
17
|
+
* doctor output, the settings UI, and the proxy connection itself.
|
|
18
|
+
*/
|
|
19
|
+
export function stripProxyCredentials(url) {
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
parsed = new URL(url);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return url;
|
|
26
|
+
}
|
|
27
|
+
if (!parsed.username && !parsed.password)
|
|
28
|
+
return url; // already credential-free
|
|
29
|
+
parsed.username = '';
|
|
30
|
+
parsed.password = '';
|
|
31
|
+
return parsed.toString().replace(/\/$/, '');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Whether a proxy url passes validation: a blank url is the "disable proxy"
|
|
35
|
+
* marker and passes; anything else must parse as an http(s) URL. This is a
|
|
36
|
+
* pure syntax check — no connectivity check is performed.
|
|
37
|
+
*/
|
|
38
|
+
export function isValidProxyUrl(url) {
|
|
39
|
+
if (url.trim() === '')
|
|
40
|
+
return true;
|
|
41
|
+
try {
|
|
42
|
+
const parsed = new URL(url);
|
|
43
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function extractProxyConfig(value) {
|
|
50
|
+
if (!value || typeof value !== 'object')
|
|
51
|
+
return undefined;
|
|
52
|
+
const raw = value;
|
|
53
|
+
// An explicitly present but blank url is the "disable proxy" marker: it lets a
|
|
54
|
+
// higher-priority layer (e.g. a project) clear a proxy set in a lower layer.
|
|
55
|
+
if (typeof raw.url === 'string' && raw.url.trim() === '') {
|
|
56
|
+
return { url: '' };
|
|
57
|
+
}
|
|
58
|
+
if (typeof raw.url !== 'string' || !raw.url.trim())
|
|
59
|
+
return undefined;
|
|
60
|
+
const url = raw.url.trim();
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = new URL(url);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
parsed = undefined;
|
|
67
|
+
}
|
|
68
|
+
// A valid url is normalized. A malformed url is kept as-is (rather than
|
|
69
|
+
// dropped) so validation can flag it and the backend factory can fail loudly
|
|
70
|
+
// instead of silently sending traffic direct to the websites.
|
|
71
|
+
const config = {
|
|
72
|
+
url: parsed && (parsed.protocol === 'http:' || parsed.protocol === 'https:')
|
|
73
|
+
? parsed.toString().replace(/\/$/, '')
|
|
74
|
+
: url
|
|
75
|
+
};
|
|
76
|
+
if (typeof raw.username === 'string' && raw.username.trim())
|
|
77
|
+
config.username = raw.username;
|
|
78
|
+
if (typeof raw.password === 'string')
|
|
79
|
+
config.password = raw.password;
|
|
80
|
+
return config;
|
|
81
|
+
}
|
|
12
82
|
function extractSearxngOptions(value) {
|
|
13
83
|
if (!value || typeof value !== 'object')
|
|
14
84
|
return undefined;
|
|
@@ -113,6 +183,10 @@ export function extractBackendConfigOverride(file) {
|
|
|
113
183
|
if (backends?.headless?.provider === 'local-browser') {
|
|
114
184
|
override.headless = { provider: 'local-browser' };
|
|
115
185
|
}
|
|
186
|
+
const proxy = extractProxyConfig(backends?.proxy);
|
|
187
|
+
if (proxy) {
|
|
188
|
+
override.proxy = proxy;
|
|
189
|
+
}
|
|
116
190
|
return override;
|
|
117
191
|
}
|
|
118
192
|
export function validateBackendConfig(config) {
|
|
@@ -120,6 +194,23 @@ export function validateBackendConfig(config) {
|
|
|
120
194
|
if (config.search.provider === 'searxng' && !config.search.baseUrl) {
|
|
121
195
|
issues.push('search provider searxng requires backends.search.baseUrl');
|
|
122
196
|
}
|
|
197
|
+
if (config.proxy && config.proxy.url.trim() !== '') {
|
|
198
|
+
let parsed;
|
|
199
|
+
try {
|
|
200
|
+
parsed = new URL(config.proxy.url);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
parsed = undefined;
|
|
204
|
+
}
|
|
205
|
+
if (!isValidProxyUrl(config.proxy.url)) {
|
|
206
|
+
issues.push('backends.proxy.url must be an http or https URL');
|
|
207
|
+
}
|
|
208
|
+
else if (parsed && (parsed.username || parsed.password)) {
|
|
209
|
+
// Credentials belong in backends.proxy.username/password or the env vars
|
|
210
|
+
// below, never in the URL itself.
|
|
211
|
+
issues.push('backends.proxy.url must not include credentials (user:pass@); set PI_WEB_AGENT_PROXY_USERNAME and PI_WEB_AGENT_PROXY_PASSWORD (or backends.proxy.username / backends.proxy.password) instead');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
123
214
|
if (config.fetch.provider === 'firecrawl' && !config.fetch.baseUrl) {
|
|
124
215
|
issues.push('fetch provider firecrawl requires backends.fetch.baseUrl');
|
|
125
216
|
}
|
|
@@ -173,6 +264,11 @@ export function mergeBackendConfigLayers(...layers) {
|
|
|
173
264
|
return layers.reduce((merged, layer) => ({
|
|
174
265
|
search: mergeSearchConfig(merged.search, layer?.search),
|
|
175
266
|
fetch: mergeFetchConfig(merged.fetch, layer?.fetch),
|
|
176
|
-
headless: { ...merged.headless, ...layer?.headless }
|
|
267
|
+
headless: { ...merged.headless, ...layer?.headless },
|
|
268
|
+
proxy: layer?.proxy
|
|
269
|
+
? layer.proxy.url === ''
|
|
270
|
+
? undefined // explicit disable overrides any proxy from lower layers
|
|
271
|
+
: { ...merged.proxy, ...layer.proxy }
|
|
272
|
+
: merged.proxy
|
|
177
273
|
}), DEFAULT_BACKEND_CONFIG);
|
|
178
274
|
}
|
|
@@ -8,7 +8,7 @@ import { createWebFetchHeadlessTool } from '../tools/web-fetch-headless.js';
|
|
|
8
8
|
import { createWebFetchTool } from '../tools/web-fetch.js';
|
|
9
9
|
import { createWebSearchTool } from '../tools/web-search.js';
|
|
10
10
|
import type { WebFetchHeadlessResponse, WebFetchResponse, WebSearchResponse } from '../types.js';
|
|
11
|
-
import { type BackendConfig } from './config.js';
|
|
11
|
+
import { type BackendConfig, type ProxyConfig } from './config.js';
|
|
12
12
|
export type BackendSet = {
|
|
13
13
|
search: (input: {
|
|
14
14
|
query: string;
|
|
@@ -30,5 +30,6 @@ export type BackendFactoryDeps = {
|
|
|
30
30
|
createHttpFetch?: typeof createWebFetchTool;
|
|
31
31
|
createFirecrawlFetch?: typeof createFirecrawlFetcher;
|
|
32
32
|
createHeadlessFetch?: typeof createWebFetchHeadlessTool;
|
|
33
|
+
createProxyFetch?: (proxy: ProxyConfig) => typeof fetch;
|
|
33
34
|
};
|
|
34
35
|
export declare function createBackendSet(config?: BackendConfig, deps?: BackendFactoryDeps): BackendSet;
|
package/dist/backends/factory.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { createFirecrawlFetcher } from '../fetch/firecrawl-fetch.js';
|
|
2
|
+
import { createHttpFetcher } from '../fetch/http-fetch.js';
|
|
3
|
+
import { createProxyFetch, resolveProxyCredentials } from '../fetch/proxy-fetch.js';
|
|
4
|
+
import { headlessFetch } from '../fetch/headless-fetch.js';
|
|
2
5
|
import { createBraveSearchTool } from '../search/brave.js';
|
|
3
6
|
import { createYouComSearchTool } from '../search/youcom.js';
|
|
7
|
+
import { fetchDuckDuckGoHtml } from '../search/duckduckgo.js';
|
|
4
8
|
import { createExaSearchTool } from '../search/exa.js';
|
|
5
9
|
import { createTavilySearchTool } from '../search/tavily.js';
|
|
6
10
|
import { createSearxngSearchTool } from '../search/searxng.js';
|
|
@@ -10,7 +14,7 @@ import { buildSearchPresentation } from '../presentation/search-presentation.js'
|
|
|
10
14
|
import { createWebFetchHeadlessTool } from '../tools/web-fetch-headless.js';
|
|
11
15
|
import { createWebFetchTool } from '../tools/web-fetch.js';
|
|
12
16
|
import { createWebSearchTool } from '../tools/web-search.js';
|
|
13
|
-
import { DEFAULT_BACKEND_CONFIG, usableSearchProviders } from './config.js';
|
|
17
|
+
import { DEFAULT_BACKEND_CONFIG, isValidProxyUrl, stripProxyCredentials, usableSearchProviders } from './config.js';
|
|
14
18
|
import { createSpecialContentResolver } from '../readers/resolver.js';
|
|
15
19
|
import { createGithubReader } from '../readers/github-reader.js';
|
|
16
20
|
import { createPdfReader } from '../readers/pdf-reader.js';
|
|
@@ -90,52 +94,104 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
90
94
|
const createHttpFetch = deps.createHttpFetch ?? createWebFetchTool;
|
|
91
95
|
const createFirecrawlFetch = deps.createFirecrawlFetch ?? createFirecrawlFetcher;
|
|
92
96
|
const createHeadlessFetch = deps.createHeadlessFetch ?? createWebFetchHeadlessTool;
|
|
97
|
+
const makeProxyFetch = deps.createProxyFetch ?? createProxyFetch;
|
|
98
|
+
// A blank url is the "disable proxy" marker: treat it as no proxy at all.
|
|
99
|
+
const proxy = config.proxy && config.proxy.url.trim() !== '' ? config.proxy : undefined;
|
|
100
|
+
// A configured proxy whose url fails validation must not be silently ignored
|
|
101
|
+
// — that would send traffic direct to the websites. Every request errors out
|
|
102
|
+
// instead. No connectivity check is needed: the url itself is the problem.
|
|
103
|
+
if (proxy && !isValidProxyUrl(proxy.url)) {
|
|
104
|
+
const message = `backends.proxy.url (${proxy.url}) is not a valid http or https URL. ` +
|
|
105
|
+
'Web requests are blocked until it is fixed; set backends.proxy.url to "" to disable the proxy.';
|
|
106
|
+
return {
|
|
107
|
+
search: async () => {
|
|
108
|
+
const result = {
|
|
109
|
+
status: 'error',
|
|
110
|
+
results: [],
|
|
111
|
+
metadata: { backend: config.search.provider, cacheHit: false },
|
|
112
|
+
error: { code: 'BACKEND_CONFIG_INVALID', message }
|
|
113
|
+
};
|
|
114
|
+
return { ...result, presentation: buildSearchPresentation(result) };
|
|
115
|
+
},
|
|
116
|
+
fetchPage: async ({ url }) => {
|
|
117
|
+
const result = {
|
|
118
|
+
status: 'error',
|
|
119
|
+
url,
|
|
120
|
+
metadata: { method: 'http', cacheHit: false },
|
|
121
|
+
error: { code: 'BACKEND_CONFIG_INVALID', message }
|
|
122
|
+
};
|
|
123
|
+
return { ...result, presentation: buildFetchPresentation(result) };
|
|
124
|
+
},
|
|
125
|
+
headlessFetch: async ({ url }) => {
|
|
126
|
+
const result = {
|
|
127
|
+
status: 'error',
|
|
128
|
+
url,
|
|
129
|
+
metadata: { method: 'headless', cacheHit: false },
|
|
130
|
+
error: { code: 'BACKEND_CONFIG_INVALID', message }
|
|
131
|
+
};
|
|
132
|
+
return { ...result, presentation: buildFetchPresentation(result) };
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// When a proxy is configured, every outbound HTTP request (search, fetch,
|
|
137
|
+
// readers, and doctor-style checks) goes through it; headless browser
|
|
138
|
+
// traffic gets the same proxy via Playwright launch options.
|
|
139
|
+
const fetchImpl = proxy ? makeProxyFetch(proxy) : fetch;
|
|
140
|
+
const proxyCredentials = proxy ? resolveProxyCredentials(proxy) : undefined;
|
|
141
|
+
const proxyBrowserOptions = proxy
|
|
142
|
+
? {
|
|
143
|
+
server: stripProxyCredentials(proxy.url),
|
|
144
|
+
...(proxyCredentials?.username !== undefined ? { username: proxyCredentials.username } : {}),
|
|
145
|
+
...(proxyCredentials?.password !== undefined ? { password: proxyCredentials.password } : {})
|
|
146
|
+
}
|
|
147
|
+
: undefined;
|
|
148
|
+
const createDuckDuckGo = () => createDuckDuckGoSearch({ searchHtml: (query) => fetchDuckDuckGoHtml(query, { fetchImpl }) });
|
|
93
149
|
function buildProviderSearch(name) {
|
|
94
150
|
switch (name) {
|
|
95
151
|
case 'searxng':
|
|
96
152
|
return config.search.baseUrl
|
|
97
|
-
? createSearxngSearch({ baseUrl: config.search.baseUrl, options: config.search.options })
|
|
153
|
+
? createSearxngSearch({ baseUrl: config.search.baseUrl, options: config.search.options, fetchImpl })
|
|
98
154
|
: invalidSearxngSearch();
|
|
99
155
|
case 'brave':
|
|
100
|
-
return createBraveSearch({ apiKey: process.env.PI_WEB_AGENT_BRAVE_API_KEY });
|
|
156
|
+
return createBraveSearch({ apiKey: process.env.PI_WEB_AGENT_BRAVE_API_KEY, fetchImpl });
|
|
101
157
|
case 'youcom':
|
|
102
|
-
return createYouComSearch({ apiKey: process.env.YDC_API_KEY });
|
|
158
|
+
return createYouComSearch({ apiKey: process.env.YDC_API_KEY, fetchImpl });
|
|
103
159
|
case 'exa':
|
|
104
|
-
return createExaSearch({ apiKey: process.env.EXA_API_KEY });
|
|
160
|
+
return createExaSearch({ apiKey: process.env.EXA_API_KEY, fetchImpl });
|
|
105
161
|
case 'tavily':
|
|
106
|
-
return createTavilySearch({ apiKey: process.env.TAVILY_API_KEY });
|
|
162
|
+
return createTavilySearch({ apiKey: process.env.TAVILY_API_KEY, fetchImpl });
|
|
107
163
|
case 'duckduckgo':
|
|
108
164
|
default:
|
|
109
|
-
return
|
|
165
|
+
return createDuckDuckGo();
|
|
110
166
|
}
|
|
111
167
|
}
|
|
112
168
|
let search = config.search.provider === 'searxng'
|
|
113
169
|
? config.search.baseUrl
|
|
114
|
-
? createSearxngSearch({ baseUrl: config.search.baseUrl, options: config.search.options })
|
|
170
|
+
? createSearxngSearch({ baseUrl: config.search.baseUrl, options: config.search.options, fetchImpl })
|
|
115
171
|
: invalidSearxngSearch()
|
|
116
172
|
: config.search.provider === 'brave'
|
|
117
|
-
? createBraveSearch({ apiKey: process.env.PI_WEB_AGENT_BRAVE_API_KEY })
|
|
173
|
+
? createBraveSearch({ apiKey: process.env.PI_WEB_AGENT_BRAVE_API_KEY, fetchImpl })
|
|
118
174
|
: config.search.provider === 'youcom'
|
|
119
|
-
? createYouComSearch({ apiKey: process.env.YDC_API_KEY })
|
|
175
|
+
? createYouComSearch({ apiKey: process.env.YDC_API_KEY, fetchImpl })
|
|
120
176
|
: config.search.provider === 'exa'
|
|
121
|
-
? createExaSearch({ apiKey: process.env.EXA_API_KEY })
|
|
177
|
+
? createExaSearch({ apiKey: process.env.EXA_API_KEY, fetchImpl })
|
|
122
178
|
: config.search.provider === 'tavily'
|
|
123
|
-
? createTavilySearch({ apiKey: process.env.TAVILY_API_KEY })
|
|
124
|
-
:
|
|
179
|
+
? createTavilySearch({ apiKey: process.env.TAVILY_API_KEY, fetchImpl })
|
|
180
|
+
: createDuckDuckGo();
|
|
125
181
|
if (config.search.provider === 'searxng' && config.search.fallback === 'duckduckgo') {
|
|
126
|
-
search = withSearchFallback(search,
|
|
182
|
+
search = withSearchFallback(search, createDuckDuckGo(), 'searxng');
|
|
127
183
|
}
|
|
128
184
|
if (config.search.provider === 'brave' && config.search.fallback === 'duckduckgo') {
|
|
129
|
-
search = withSearchFallback(search,
|
|
185
|
+
search = withSearchFallback(search, createDuckDuckGo(), 'brave');
|
|
130
186
|
}
|
|
131
187
|
if (config.search.provider === 'youcom' && config.search.fallback === 'duckduckgo') {
|
|
132
|
-
search = withSearchFallback(search,
|
|
188
|
+
search = withSearchFallback(search, createDuckDuckGo(), 'youcom');
|
|
133
189
|
}
|
|
134
190
|
if (config.search.provider === 'exa' && config.search.fallback === 'duckduckgo') {
|
|
135
|
-
search = withSearchFallback(search,
|
|
191
|
+
search = withSearchFallback(search, createDuckDuckGo(), 'exa');
|
|
136
192
|
}
|
|
137
193
|
if (config.search.provider === 'tavily' && config.search.fallback === 'duckduckgo') {
|
|
138
|
-
search = withSearchFallback(search,
|
|
194
|
+
search = withSearchFallback(search, createDuckDuckGo(), 'tavily');
|
|
139
195
|
}
|
|
140
196
|
const fanoutConfig = config.search.fanout;
|
|
141
197
|
if (fanoutConfig && fanoutConfig.mode !== 'off') {
|
|
@@ -158,16 +214,17 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
158
214
|
const keylessFallbackDisabled = process.env.PI_WEB_AGENT_DISABLE_KEYLESS_FALLBACK === '1';
|
|
159
215
|
const usingDuckDuckGoDefault = config.search.provider === 'duckduckgo' || !config.search.provider;
|
|
160
216
|
if (usingDuckDuckGoDefault && !keylessFallbackDisabled) {
|
|
161
|
-
search = withSearchFallback(search, createTavilySearch({ keyless: true }), 'duckduckgo');
|
|
217
|
+
search = withSearchFallback(search, createTavilySearch({ keyless: true, fetchImpl }), 'duckduckgo');
|
|
162
218
|
}
|
|
163
|
-
const httpFetch = createHttpFetch();
|
|
219
|
+
const httpFetch = createHttpFetch({ fetchPage: createHttpFetcher({ fetchImpl }) });
|
|
164
220
|
let fetchPage = config.fetch.provider === 'firecrawl'
|
|
165
221
|
? config.fetch.baseUrl
|
|
166
222
|
? createHttpFetch({
|
|
167
223
|
fetchPage: createFirecrawlFetch({
|
|
168
224
|
baseUrl: config.fetch.baseUrl,
|
|
169
225
|
apiKey: config.fetch.apiKey ?? process.env.PI_WEB_AGENT_FIRECRAWL_API_KEY,
|
|
170
|
-
options: config.fetch.options
|
|
226
|
+
options: config.fetch.options,
|
|
227
|
+
fetchImpl
|
|
171
228
|
})
|
|
172
229
|
})
|
|
173
230
|
: createHttpFetch({ fetchPage: invalidFirecrawlFetch() })
|
|
@@ -176,12 +233,13 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
176
233
|
fetchPage = withFetchFallback(fetchPage, httpFetch);
|
|
177
234
|
}
|
|
178
235
|
const fetchPageWithReaders = createSpecialContentResolver({
|
|
179
|
-
readers: [createGithubReader(), createPdfReader(), createYoutubeReader()],
|
|
236
|
+
readers: [createGithubReader({ fetchImpl }), createPdfReader({ fetchImpl }), createYoutubeReader({ fetchImpl })],
|
|
180
237
|
fallback: fetchPage
|
|
181
238
|
});
|
|
239
|
+
const headlessPage = (url) => proxyBrowserOptions ? headlessFetch(url, { proxy: proxyBrowserOptions }) : headlessFetch(url);
|
|
182
240
|
return {
|
|
183
241
|
search,
|
|
184
242
|
fetchPage: fetchPageWithReaders,
|
|
185
|
-
headlessFetch: createHeadlessFetch()
|
|
243
|
+
headlessFetch: createHeadlessFetch({ fetchPage: headlessPage })
|
|
186
244
|
};
|
|
187
245
|
}
|
|
@@ -16,6 +16,9 @@ type CommandDeps = {
|
|
|
16
16
|
arch: string;
|
|
17
17
|
};
|
|
18
18
|
checkTypebox?: () => Promise<boolean>;
|
|
19
|
+
checkJitiCompat?: () => {
|
|
20
|
+
pending: string[];
|
|
21
|
+
};
|
|
19
22
|
checkBackends?: (config: BackendConfig) => Promise<string[]>;
|
|
20
23
|
getChangelog?: () => Promise<string | undefined>;
|
|
21
24
|
};
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { DEFAULT_BACKEND_CONFIG, mergeBackendConfigLayers, validateBackendConfig, usableSearchProviders } from '../backends/config.js';
|
|
1
|
+
import { DEFAULT_BACKEND_CONFIG, isValidProxyUrl, mergeBackendConfigLayers, stripProxyCredentials, validateBackendConfig, usableSearchProviders } from '../backends/config.js';
|
|
2
2
|
import { checkBackendHealth } from '../backends/doctor.js';
|
|
3
3
|
import { DynamicBorder, getSettingsListTheme } from '@earendil-works/pi-coding-agent';
|
|
4
4
|
import { Container, Input, SelectList, SettingsList, Text } from '@earendil-works/pi-tui';
|
|
5
5
|
import { DEFAULT_PRESENTATION_CONFIG, mergePresentationConfigLayers, resolvePresentationMode } from '../presentation/config.js';
|
|
6
6
|
import { loadPresentationConfigLayers, resetPresentationConfigScope, saveBackendConfigScope, savePresentationConfigScope } from '../presentation/config-store.js';
|
|
7
7
|
import { resolveBrowserExecutable } from '../fetch/browser-resolution.js';
|
|
8
|
+
import { createProxyFetch } from '../fetch/proxy-fetch.js';
|
|
9
|
+
import { checkJitiCompat } from '../jiti-compat.js';
|
|
8
10
|
import { getLatestChangelogEntry } from '../changelog-notice.js';
|
|
9
11
|
const PRESENTATION_TOOL_NAMES = ['web_explore'];
|
|
10
12
|
function parseScopeToken(token) {
|
|
@@ -29,7 +31,8 @@ function cloneBackendConfig(config) {
|
|
|
29
31
|
...config.fetch,
|
|
30
32
|
options: config.fetch.options ? { ...config.fetch.options } : undefined
|
|
31
33
|
},
|
|
32
|
-
headless: { ...config.headless }
|
|
34
|
+
headless: { ...config.headless },
|
|
35
|
+
proxy: config.proxy ? { ...config.proxy } : undefined
|
|
33
36
|
};
|
|
34
37
|
}
|
|
35
38
|
function sameJson(left, right) {
|
|
@@ -47,6 +50,21 @@ export function validateBackendUrl(value) {
|
|
|
47
50
|
return { ok: false, message: 'Invalid URL. Include http:// or https://.' };
|
|
48
51
|
}
|
|
49
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* The extension re-applies this patch on load, so `pending` here means the
|
|
55
|
+
* patch could not be written (read-only install, permissions, or a dependency
|
|
56
|
+
* whose shape changed). Point at the manual script in that case: it is the
|
|
57
|
+
* same recovery step people have been finding by digging through issue #34.
|
|
58
|
+
*/
|
|
59
|
+
function formatJitiCompatLine(status) {
|
|
60
|
+
if (status.pending.length === 0)
|
|
61
|
+
return 'jsdom compat patch: ok';
|
|
62
|
+
return [
|
|
63
|
+
`jsdom compat patch: needed (${status.pending.join(', ')})`,
|
|
64
|
+
'Run: node ~/.pi/agent/npm/node_modules/@demigodmode/pi-web-agent/scripts/patch-jiti-compat.mjs',
|
|
65
|
+
'then restart Pi.'
|
|
66
|
+
].join('\n');
|
|
67
|
+
}
|
|
50
68
|
async function defaultCheckTypebox() {
|
|
51
69
|
try {
|
|
52
70
|
await import('typebox');
|
|
@@ -87,8 +105,11 @@ function formatBackendSummary(config = DEFAULT_BACKEND_CONFIG) {
|
|
|
87
105
|
return [
|
|
88
106
|
searchSuffix ? `${searchBase} ${searchSuffix}` : searchBase,
|
|
89
107
|
fetchSuffix ? `${fetchBase} ${fetchSuffix}` : fetchBase,
|
|
90
|
-
`headless: ${config.headless.provider}
|
|
91
|
-
|
|
108
|
+
`headless: ${config.headless.provider}`,
|
|
109
|
+
config.proxy ? `proxy: ${stripProxyCredentials(config.proxy.url)}` : undefined
|
|
110
|
+
]
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.join('\n');
|
|
92
113
|
}
|
|
93
114
|
function formatConfigSummary(config) {
|
|
94
115
|
const lines = [`defaultMode: ${config.defaultMode}`];
|
|
@@ -249,6 +270,12 @@ function buildBackendSettingsItems(scope, backends, theme, onUrlEditorOpenChange
|
|
|
249
270
|
label: 'Firecrawl API key',
|
|
250
271
|
currentValue: 'env var',
|
|
251
272
|
values: ['env var']
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
id: 'backend:proxy:url',
|
|
276
|
+
label: 'Proxy URL',
|
|
277
|
+
currentValue: backends.proxy ? stripProxyCredentials(backends.proxy.url) : 'not set',
|
|
278
|
+
submenu: createBackendUrlEditor(theme, 'HTTP proxy URL', 'http://127.0.0.1:7890', onUrlEditorOpenChange)
|
|
252
279
|
}
|
|
253
280
|
];
|
|
254
281
|
}
|
|
@@ -421,6 +448,14 @@ export function applySettingsValue(state, id, newValue) {
|
|
|
421
448
|
delete currentBackends.fetch.baseUrl;
|
|
422
449
|
}
|
|
423
450
|
}
|
|
451
|
+
if (id === 'backend:proxy:url') {
|
|
452
|
+
if (newValue.trim()) {
|
|
453
|
+
currentBackends.proxy = { ...currentBackends.proxy, url: newValue.trim() };
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
delete currentBackends.proxy;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
424
459
|
nextDrafts[nextScope] = currentDraft;
|
|
425
460
|
nextBackendDrafts[nextScope] = currentBackends;
|
|
426
461
|
return {
|
|
@@ -485,6 +520,17 @@ export function collapseBackendConfigToOverride(config, inheritedConfig) {
|
|
|
485
520
|
if (!sameJson(config.headless, inheritedConfig.headless)) {
|
|
486
521
|
override.headless = { ...config.headless };
|
|
487
522
|
}
|
|
523
|
+
if (!sameJson(config.proxy, inheritedConfig.proxy)) {
|
|
524
|
+
if (config.proxy) {
|
|
525
|
+
const { password: _password, ...proxy } = config.proxy;
|
|
526
|
+
override.proxy = { ...proxy };
|
|
527
|
+
}
|
|
528
|
+
else if (inheritedConfig.proxy) {
|
|
529
|
+
// The proxy was cleared at this scope; record an explicit disable so it
|
|
530
|
+
// overrides a proxy set in a lower (e.g. global) layer.
|
|
531
|
+
override.proxy = { url: '' };
|
|
532
|
+
}
|
|
533
|
+
}
|
|
488
534
|
return override;
|
|
489
535
|
}
|
|
490
536
|
export function handleSettingsShortcut(data) {
|
|
@@ -669,7 +715,12 @@ export function registerWebAgentConfigCommands(pi, deps = {}) {
|
|
|
669
715
|
arch: process.arch
|
|
670
716
|
};
|
|
671
717
|
const checkTypebox = deps.checkTypebox ?? defaultCheckTypebox;
|
|
672
|
-
const
|
|
718
|
+
const checkCompat = deps.checkJitiCompat ?? checkJitiCompat;
|
|
719
|
+
const checkBackends = deps.checkBackends ?? ((config) => checkBackendHealth(config, {
|
|
720
|
+
// An invalid proxy url is reported by validateBackendConfig below; never
|
|
721
|
+
// build a proxy agent from it (no connectivity check is attempted).
|
|
722
|
+
fetchImpl: config.proxy && isValidProxyUrl(config.proxy.url) ? createProxyFetch(config.proxy) : fetch
|
|
723
|
+
}));
|
|
673
724
|
const getChangelog = deps.getChangelog ?? (() => getLatestChangelogEntry());
|
|
674
725
|
pi.registerCommand('web-agent', {
|
|
675
726
|
description: 'Open settings or manage pi-web-agent presentation config',
|
|
@@ -700,6 +751,7 @@ export function registerWebAgentConfigCommands(pi, deps = {}) {
|
|
|
700
751
|
'pi-web-agent: loaded',
|
|
701
752
|
`runtime: node ${runtime.nodeVersion} ${runtime.platform} ${runtime.arch}`,
|
|
702
753
|
`typebox: ${typeboxOk ? 'ok' : 'missing'}`,
|
|
754
|
+
formatJitiCompatLine(checkCompat()),
|
|
703
755
|
formatBackendSummary(backendConfig),
|
|
704
756
|
backendIssues.length > 0 ? `backend config: warning\n${backendIssues.join('\n')}` : 'backend config: ok',
|
|
705
757
|
...backendHealth
|
package/dist/extension.d.ts
CHANGED
package/dist/extension.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// Keep first: re-applies the jsdom/jiti compat patch before jsdom loads (#34).
|
|
2
|
+
import './jiti-compat-run.js';
|
|
1
3
|
import { DEFAULT_BACKEND_CONFIG } from './backends/config.js';
|
|
2
4
|
import { Type } from 'typebox';
|
|
3
5
|
import { registerWebAgentConfigCommands } from './commands/web-agent-config.js';
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { type BrowserResolutionResult } from './browser-resolution.js';
|
|
2
2
|
import type { WebFetchHeadlessResponse } from '../types.js';
|
|
3
|
-
export
|
|
3
|
+
export type BrowserProxyOptions = {
|
|
4
|
+
server: string;
|
|
5
|
+
username?: string;
|
|
6
|
+
password?: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function headlessFetch(url: string, { configuredPath, proxy, resolveBrowser, launchBrowser, now }?: {
|
|
4
9
|
configuredPath?: string;
|
|
10
|
+
proxy?: BrowserProxyOptions;
|
|
5
11
|
resolveBrowser?: (options?: {
|
|
6
12
|
configuredPath?: string;
|
|
7
13
|
}) => Promise<BrowserResolutionResult>;
|
|
8
14
|
launchBrowser?: (options: {
|
|
9
15
|
executablePath?: string;
|
|
10
16
|
headless: true;
|
|
17
|
+
proxy?: BrowserProxyOptions;
|
|
11
18
|
}) => Promise<{
|
|
12
19
|
newContext: () => Promise<{
|
|
13
20
|
newPage: () => Promise<any>;
|
|
@@ -7,7 +7,7 @@ function cleanupRenderedText(text) {
|
|
|
7
7
|
cleaned = cleaned.replace(/\s+/g, ' ').trim();
|
|
8
8
|
return cleaned;
|
|
9
9
|
}
|
|
10
|
-
export async function headlessFetch(url, { configuredPath, resolveBrowser = (options) => resolveBrowserExecutable({ configuredPath: options?.configuredPath }), launchBrowser = ({ executablePath, headless }) => chromium.launch(executablePath ? { executablePath, headless } : { headless }), now = () => Date.now() } = {}) {
|
|
10
|
+
export async function headlessFetch(url, { configuredPath, proxy, resolveBrowser = (options) => resolveBrowserExecutable({ configuredPath: options?.configuredPath }), launchBrowser = ({ executablePath, headless, proxy }) => chromium.launch(executablePath ? { executablePath, headless, ...(proxy ? { proxy } : {}) } : { headless, ...(proxy ? { proxy } : {}) }), now = () => Date.now() } = {}) {
|
|
11
11
|
const resolved = await resolveBrowser({ configuredPath });
|
|
12
12
|
if (!resolved.ok && resolved.error.code === 'CONFIGURED_BROWSER_NOT_FOUND') {
|
|
13
13
|
return {
|
|
@@ -19,8 +19,8 @@ export async function headlessFetch(url, { configuredPath, resolveBrowser = (opt
|
|
|
19
19
|
}
|
|
20
20
|
const browserName = resolved.ok ? resolved.browser : 'chromium';
|
|
21
21
|
const launchOptions = resolved.ok
|
|
22
|
-
? { executablePath: resolved.executablePath, headless: true }
|
|
23
|
-
: { headless: true };
|
|
22
|
+
? { executablePath: resolved.executablePath, headless: true, ...(proxy ? { proxy } : {}) }
|
|
23
|
+
: { headless: true, ...(proxy ? { proxy } : {}) };
|
|
24
24
|
let browser;
|
|
25
25
|
let context;
|
|
26
26
|
let page;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ProxyConfig } from '../backends/config.js';
|
|
2
|
+
export type ProxyCredentials = {
|
|
3
|
+
username?: string;
|
|
4
|
+
password?: string;
|
|
5
|
+
};
|
|
6
|
+
export type ProxyFetchOptions = {
|
|
7
|
+
/** TLS options for the tunneled connection to the origin (https targets). */
|
|
8
|
+
tls?: {
|
|
9
|
+
rejectUnauthorized?: boolean;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Resolve proxy credentials from the config, falling back to environment
|
|
14
|
+
* variables so secrets can stay out of config files (same policy as API keys).
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveProxyCredentials(proxy: ProxyConfig, env?: NodeJS.ProcessEnv): ProxyCredentials;
|
|
17
|
+
/**
|
|
18
|
+
* Build a fetch implementation that routes all traffic through the configured
|
|
19
|
+
* HTTP/HTTPS proxy. Uses undici (the engine behind Node's global fetch) with a
|
|
20
|
+
* ProxyAgent dispatcher while keeping the standard fetch API surface.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createProxyFetch(proxy: ProxyConfig, options?: ProxyFetchOptions): typeof fetch;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { fetch as undiciFetch, ProxyAgent } from 'undici';
|
|
2
|
+
import { stripProxyCredentials } from '../backends/config.js';
|
|
3
|
+
/**
|
|
4
|
+
* Resolve proxy credentials from the config, falling back to environment
|
|
5
|
+
* variables so secrets can stay out of config files (same policy as API keys).
|
|
6
|
+
*/
|
|
7
|
+
export function resolveProxyCredentials(proxy, env = process.env) {
|
|
8
|
+
const username = proxy.username ?? env.PI_WEB_AGENT_PROXY_USERNAME;
|
|
9
|
+
const password = proxy.password ?? env.PI_WEB_AGENT_PROXY_PASSWORD;
|
|
10
|
+
return {
|
|
11
|
+
username: username?.trim() ? username : undefined,
|
|
12
|
+
password: password ? password : undefined
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Build a fetch implementation that routes all traffic through the configured
|
|
17
|
+
* HTTP/HTTPS proxy. Uses undici (the engine behind Node's global fetch) with a
|
|
18
|
+
* ProxyAgent dispatcher while keeping the standard fetch API surface.
|
|
19
|
+
*/
|
|
20
|
+
export function createProxyFetch(proxy, options = {}) {
|
|
21
|
+
const credentials = resolveProxyCredentials(proxy);
|
|
22
|
+
const agent = new ProxyAgent({
|
|
23
|
+
// Never trust credentials embedded in the URL; they come from config fields
|
|
24
|
+
// or the PI_WEB_AGENT_PROXY_* env vars via resolveProxyCredentials.
|
|
25
|
+
uri: stripProxyCredentials(proxy.url),
|
|
26
|
+
// undici's `token` option is used verbatim as the Proxy-Authorization header
|
|
27
|
+
// value for both CONNECT tunnels and forwarded HTTP requests.
|
|
28
|
+
...(credentials.username !== undefined
|
|
29
|
+
? {
|
|
30
|
+
token: `Basic ${Buffer.from(credentials.password !== undefined
|
|
31
|
+
? `${credentials.username}:${credentials.password}`
|
|
32
|
+
: `${credentials.username}:`).toString('base64')}`
|
|
33
|
+
}
|
|
34
|
+
: {}),
|
|
35
|
+
...(options.tls ? { requestTls: { rejectUnauthorized: options.tls.rejectUnauthorized } } : {})
|
|
36
|
+
});
|
|
37
|
+
// undici's Request/Response types are structurally near-identical to Node's
|
|
38
|
+
// global fetch types but not nominatively identical, so the wrapper is cast
|
|
39
|
+
// to the standard fetch signature (runtime behavior is identical: Node's
|
|
40
|
+
// global fetch is built on this same undici engine).
|
|
41
|
+
const proxyFetch = (input, init) => undiciFetch(input, {
|
|
42
|
+
...init,
|
|
43
|
+
dispatcher: agent
|
|
44
|
+
});
|
|
45
|
+
return proxyFetch;
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ensureJitiCompat } from './jiti-compat.js';
|
|
2
|
+
// Side-effect-only module. It exists so `extension.ts` can run the compat
|
|
3
|
+
// patch as its *first* import: ESM evaluates a module's dependency subtree,
|
|
4
|
+
// including that module's body, before moving on to the next import. A plain
|
|
5
|
+
// `ensureJitiCompat()` call in the extension body would run too late, after
|
|
6
|
+
// jsdom (and therefore tr46/cssstyle) had already been evaluated.
|
|
7
|
+
//
|
|
8
|
+
// Keep this import first in extension.ts.
|
|
9
|
+
ensureJitiCompat();
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem/resolver seam so tests can point this at a throwaway directory
|
|
3
|
+
* instead of the real `node_modules/tr46` and `node_modules/cssstyle`. All
|
|
4
|
+
* production call sites use the defaults (real `fs` + real module
|
|
5
|
+
* resolution) and never pass this in.
|
|
6
|
+
*/
|
|
7
|
+
export type JitiCompatDeps = {
|
|
8
|
+
/**
|
|
9
|
+
* Resolve `specifier` as `fromFile` would. Omitting `fromFile` resolves from
|
|
10
|
+
* this module, which is only correct when nothing else claims the package.
|
|
11
|
+
*/
|
|
12
|
+
resolve: (specifier: string, fromFile?: string) => string;
|
|
13
|
+
existsSync: (path: string) => boolean;
|
|
14
|
+
readFileSync: (path: string) => string;
|
|
15
|
+
writeFileSync: (path: string, contents: string) => void;
|
|
16
|
+
};
|
|
17
|
+
export type JitiCompatStatus = {
|
|
18
|
+
/** Files that still need patching. Empty means the tree is healthy. */
|
|
19
|
+
pending: string[];
|
|
20
|
+
/** Files patched during this call. */
|
|
21
|
+
patched: string[];
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Apply both patches if they are missing. Safe to call repeatedly: an already
|
|
25
|
+
* patched tree is a few `readFileSync` calls and no writes. Never throws.
|
|
26
|
+
*/
|
|
27
|
+
export declare function ensureJitiCompat(deps?: JitiCompatDeps): JitiCompatStatus;
|
|
28
|
+
/**
|
|
29
|
+
* Read-only view of the same checks, for `/web-agent doctor`. Never throws and
|
|
30
|
+
* never writes.
|
|
31
|
+
*/
|
|
32
|
+
export declare function checkJitiCompat(deps?: JitiCompatDeps): JitiCompatStatus;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Works around two incompatibilities between pi's extension loader (a patched
|
|
6
|
+
* jiti) and jsdom's dependency tree:
|
|
7
|
+
*
|
|
8
|
+
* 1. jiti can't resolve the trailing-slash bare specifier require("punycode/")
|
|
9
|
+
* used by tr46.
|
|
10
|
+
* 2. jiti wraps `module.exports = new Set(...)` (cssstyle) in a Proxy, which
|
|
11
|
+
* breaks native Set methods on the exported value.
|
|
12
|
+
*
|
|
13
|
+
* Both files live in the shared `~/.pi/agent/npm/node_modules` tree, so
|
|
14
|
+
* installing or updating any *other* pi extension re-extracts them and reverts
|
|
15
|
+
* the patch. A postinstall hook alone therefore can't keep this healthy, which
|
|
16
|
+
* is why `ensureJitiCompat()` also runs on extension load, before jsdom is
|
|
17
|
+
* evaluated. See https://github.com/demigodmode/pi-web-agent/issues/34.
|
|
18
|
+
*
|
|
19
|
+
* scripts/patch-jiti-compat.mjs duplicates this logic for the postinstall
|
|
20
|
+
* hook. That hook runs before `npm run build`, so it cannot import dist/.
|
|
21
|
+
* Keep the two in sync.
|
|
22
|
+
*/
|
|
23
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
24
|
+
/**
|
|
25
|
+
* Write via a temp file + rename rather than in place. Two reasons, both of
|
|
26
|
+
* which bite harder now that this runs on every extension load instead of once
|
|
27
|
+
* per install:
|
|
28
|
+
*
|
|
29
|
+
* - `writeFileSync` truncates first. A crash, a full disk, or an OOM kill
|
|
30
|
+
* mid-write leaves a half-written file that still contains the marker
|
|
31
|
+
* comment, so every later run would skip it as "already patched" and the
|
|
32
|
+
* doctor would report a broken tree as healthy.
|
|
33
|
+
* - Another Pi session can be `require`-ing the same file while we write it.
|
|
34
|
+
* `rename` is atomic within a filesystem, so readers see the old file or
|
|
35
|
+
* the new one, never a partial one.
|
|
36
|
+
*
|
|
37
|
+
* The rename also breaks a pnpm-style hardlink instead of writing through it
|
|
38
|
+
* into the shared content-addressable store.
|
|
39
|
+
*/
|
|
40
|
+
function writeFileAtomic(path, contents) {
|
|
41
|
+
const temp = `${path}.pi-web-agent-${process.pid}.tmp`;
|
|
42
|
+
try {
|
|
43
|
+
writeFileSync(temp, contents);
|
|
44
|
+
renameSync(temp, path);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
try {
|
|
48
|
+
if (existsSync(temp))
|
|
49
|
+
unlinkSync(temp);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Best effort. Leaving a stray temp file is better than masking the
|
|
53
|
+
// original write failure.
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const defaultDeps = {
|
|
59
|
+
resolve: (specifier, fromFile) => (fromFile ? createRequire(fromFile) : requireFromHere).resolve(specifier),
|
|
60
|
+
existsSync,
|
|
61
|
+
readFileSync: (path) => readFileSync(path, 'utf8'),
|
|
62
|
+
writeFileSync: writeFileAtomic
|
|
63
|
+
};
|
|
64
|
+
const SET_SHIM_MARKER = 'pi/jiti workaround';
|
|
65
|
+
const SET_SHIM = `
|
|
66
|
+
// ${SET_SHIM_MARKER}: expose bound native Set methods as own properties so a
|
|
67
|
+
// Proxy wrapper around this export does not break Set brand checks.
|
|
68
|
+
for (const k of ["has", "add", "delete", "forEach", "keys", "values", "entries"]) {
|
|
69
|
+
module.exports[k] = Set.prototype[k].bind(module.exports);
|
|
70
|
+
}
|
|
71
|
+
module.exports[Symbol.iterator] = Set.prototype[Symbol.iterator].bind(module.exports);
|
|
72
|
+
`;
|
|
73
|
+
const PUNYCODE_SPECIFIER = 'require("punycode/")';
|
|
74
|
+
const PUNYCODE_REPLACEMENT = 'require("punycode/punycode.js")';
|
|
75
|
+
function findPackageRoot(deps, entryFile, packageName) {
|
|
76
|
+
let directory = dirname(entryFile);
|
|
77
|
+
for (;;) {
|
|
78
|
+
const manifestFile = join(directory, 'package.json');
|
|
79
|
+
if (deps.existsSync(manifestFile)) {
|
|
80
|
+
try {
|
|
81
|
+
const manifest = JSON.parse(deps.readFileSync(manifestFile));
|
|
82
|
+
if (manifest.name === packageName)
|
|
83
|
+
return directory;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// An unreadable or malformed package.json on the way up is not our
|
|
87
|
+
// problem to report. Keep walking.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const parent = dirname(directory);
|
|
91
|
+
if (parent === directory) {
|
|
92
|
+
throw new Error(`could not find the ${packageName} package root from ${entryFile}`);
|
|
93
|
+
}
|
|
94
|
+
directory = parent;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the copies jsdom actually loads, not whichever copy happens to sit
|
|
99
|
+
* highest in the tree.
|
|
100
|
+
*
|
|
101
|
+
* `~/.pi/agent/npm/node_modules` is shared by every pi extension, so version
|
|
102
|
+
* conflicts routinely push a second copy of a package into a nested
|
|
103
|
+
* `node_modules`. Resolving `cssstyle` or `tr46` from *this* module can then
|
|
104
|
+
* find a hoisted copy that jsdom never requires: the patch lands on the wrong
|
|
105
|
+
* file, the load still fails, and the doctor reports a healthy tree because it
|
|
106
|
+
* checked the same wrong file. Walk the real import chain instead
|
|
107
|
+
* (jsdom -> cssstyle, jsdom -> whatwg-url -> tr46) and only fall back to a
|
|
108
|
+
* direct resolve when jsdom is not resolvable at all.
|
|
109
|
+
*/
|
|
110
|
+
function resolveFromJsdom(deps, specifier, via) {
|
|
111
|
+
try {
|
|
112
|
+
const jsdomEntry = deps.resolve('jsdom');
|
|
113
|
+
const importer = via ? deps.resolve(via, jsdomEntry) : jsdomEntry;
|
|
114
|
+
return deps.resolve(specifier, importer);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// jsdom (or the intermediate package) is not resolvable from here, e.g. a
|
|
118
|
+
// layout we do not recognize. Fall back to a plain resolve rather than
|
|
119
|
+
// giving up on the patch entirely.
|
|
120
|
+
return deps.resolve(specifier);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function tr46Entry(deps) {
|
|
124
|
+
return resolveFromJsdom(deps, 'tr46', 'whatwg-url');
|
|
125
|
+
}
|
|
126
|
+
function cssstyleTargets(deps) {
|
|
127
|
+
const packageRoot = findPackageRoot(deps, resolveFromJsdom(deps, 'cssstyle'), 'cssstyle');
|
|
128
|
+
return [
|
|
129
|
+
join(packageRoot, 'lib', 'allExtraProperties.js'),
|
|
130
|
+
join(packageRoot, 'lib', 'generated', 'allProperties.js'),
|
|
131
|
+
join(packageRoot, 'lib', 'generated', 'implementedProperties.js')
|
|
132
|
+
].map((file) => ({
|
|
133
|
+
file,
|
|
134
|
+
label: `cssstyle/${relative(packageRoot, file).split(sep).join('/')}`
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Apply both patches if they are missing. Safe to call repeatedly: an already
|
|
139
|
+
* patched tree is a few `readFileSync` calls and no writes. Never throws.
|
|
140
|
+
*/
|
|
141
|
+
export function ensureJitiCompat(deps = defaultDeps) {
|
|
142
|
+
const status = { pending: [], patched: [] };
|
|
143
|
+
try {
|
|
144
|
+
const file = tr46Entry(deps);
|
|
145
|
+
const contents = deps.readFileSync(file);
|
|
146
|
+
if (contents.includes(PUNYCODE_SPECIFIER)) {
|
|
147
|
+
deps.writeFileSync(file, contents.replaceAll(PUNYCODE_SPECIFIER, PUNYCODE_REPLACEMENT));
|
|
148
|
+
status.patched.push('tr46/index.js');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// A read-only install, a missing dependency, or a future dependency bump
|
|
153
|
+
// that changes the shape. Report it rather than blocking extension load.
|
|
154
|
+
status.pending.push('tr46/index.js');
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
for (const { file, label } of cssstyleTargets(deps)) {
|
|
158
|
+
// Guard the read too: one unreadable file (EACCES, odd permissions) must
|
|
159
|
+
// not abandon the other two, which may be perfectly writable.
|
|
160
|
+
try {
|
|
161
|
+
if (!deps.existsSync(file))
|
|
162
|
+
continue;
|
|
163
|
+
const contents = deps.readFileSync(file);
|
|
164
|
+
if (contents.includes(SET_SHIM_MARKER))
|
|
165
|
+
continue;
|
|
166
|
+
if (!contents.includes('module.exports = new Set('))
|
|
167
|
+
continue;
|
|
168
|
+
deps.writeFileSync(file, contents + SET_SHIM);
|
|
169
|
+
status.patched.push(label);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
status.pending.push(label);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
status.pending.push('cssstyle');
|
|
178
|
+
}
|
|
179
|
+
return status;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Read-only view of the same checks, for `/web-agent doctor`. Never throws and
|
|
183
|
+
* never writes.
|
|
184
|
+
*/
|
|
185
|
+
export function checkJitiCompat(deps = defaultDeps) {
|
|
186
|
+
const status = { pending: [], patched: [] };
|
|
187
|
+
try {
|
|
188
|
+
const contents = deps.readFileSync(tr46Entry(deps));
|
|
189
|
+
if (contents.includes(PUNYCODE_SPECIFIER))
|
|
190
|
+
status.pending.push('tr46/index.js');
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
status.pending.push('tr46/index.js');
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
for (const { file, label } of cssstyleTargets(deps)) {
|
|
197
|
+
try {
|
|
198
|
+
if (!deps.existsSync(file))
|
|
199
|
+
continue;
|
|
200
|
+
const contents = deps.readFileSync(file);
|
|
201
|
+
if (contents.includes(SET_SHIM_MARKER))
|
|
202
|
+
continue;
|
|
203
|
+
if (contents.includes('module.exports = new Set('))
|
|
204
|
+
status.pending.push(label);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
status.pending.push(label);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
status.pending.push('cssstyle');
|
|
213
|
+
}
|
|
214
|
+
return status;
|
|
215
|
+
}
|
|
@@ -61,6 +61,10 @@ function serializeBackendConfigOverride(config) {
|
|
|
61
61
|
if (config.headless && Object.keys(config.headless).length > 0) {
|
|
62
62
|
backends.headless = { ...config.headless };
|
|
63
63
|
}
|
|
64
|
+
if (config.proxy && Object.keys(config.proxy).length > 0) {
|
|
65
|
+
const { password: _password, ...proxy } = config.proxy;
|
|
66
|
+
backends.proxy = { ...proxy };
|
|
67
|
+
}
|
|
64
68
|
return { backends };
|
|
65
69
|
}
|
|
66
70
|
async function readConfigFileForWrite(filePath) {
|
|
@@ -5,6 +5,8 @@ type Subtitle = {
|
|
|
5
5
|
text: string;
|
|
6
6
|
};
|
|
7
7
|
type YoutubeReaderDeps = {
|
|
8
|
+
/** Fetch implementation used for YouTube's requests (defaults to global fetch). */
|
|
9
|
+
fetchImpl?: typeof fetch;
|
|
8
10
|
fetchSubtitles?: (input: {
|
|
9
11
|
videoID: string;
|
|
10
12
|
lang: string;
|
|
@@ -17,5 +19,5 @@ type YoutubeReaderDeps = {
|
|
|
17
19
|
description?: string;
|
|
18
20
|
}>;
|
|
19
21
|
};
|
|
20
|
-
export declare function createYoutubeReader({ fetchSubtitles, fetchDetails }?: YoutubeReaderDeps): SpecialContentReader;
|
|
22
|
+
export declare function createYoutubeReader({ fetchImpl, fetchSubtitles, fetchDetails }?: YoutubeReaderDeps): SpecialContentReader;
|
|
21
23
|
export {};
|
|
@@ -21,7 +21,15 @@ function extractVideoId(url) {
|
|
|
21
21
|
}
|
|
22
22
|
return undefined;
|
|
23
23
|
}
|
|
24
|
-
export function createYoutubeReader({
|
|
24
|
+
export function createYoutubeReader({ fetchImpl = fetch, fetchSubtitles, fetchDetails } = {}) {
|
|
25
|
+
// Route YouTube's caption/metadata requests through the configured fetch
|
|
26
|
+
// client (e.g. the proxy) unless an explicit override is provided.
|
|
27
|
+
const doFetchSubtitles = fetchSubtitles ?? ((input) => getSubtitles({ ...input, fetch: fetchImpl }));
|
|
28
|
+
const doFetchDetails = fetchDetails ??
|
|
29
|
+
((input) => getVideoDetails({ ...input, fetch: fetchImpl }).then((details) => ({
|
|
30
|
+
title: details.title,
|
|
31
|
+
description: details.description
|
|
32
|
+
})));
|
|
25
33
|
return {
|
|
26
34
|
name: 'youtube',
|
|
27
35
|
canHandle(url) {
|
|
@@ -39,8 +47,8 @@ export function createYoutubeReader({ fetchSubtitles = getSubtitles, fetchDetail
|
|
|
39
47
|
}
|
|
40
48
|
try {
|
|
41
49
|
const [subtitles, details] = await Promise.all([
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
doFetchSubtitles({ videoID, lang: 'en' }),
|
|
51
|
+
doFetchDetails({ videoID, lang: 'en' }).catch(() => ({ title: undefined }))
|
|
44
52
|
]);
|
|
45
53
|
if (!subtitles || subtitles.length === 0) {
|
|
46
54
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@demigodmode/pi-web-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "Pi package for reliable web access with explicit search, fetch, and headless boundaries.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/extension.js",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"jsdom": "^26.0.0",
|
|
69
69
|
"playwright": "^1.60.0",
|
|
70
70
|
"typebox": "^1.1.37",
|
|
71
|
+
"undici": "^8.10.2",
|
|
71
72
|
"unpdf": "^1.8.1",
|
|
72
73
|
"youtube-caption-extractor": "^1.10.2"
|
|
73
74
|
},
|
|
@@ -10,19 +10,63 @@
|
|
|
10
10
|
// when this package is installed as a pi extension via `pi install npm:...`.
|
|
11
11
|
// Safe to run multiple times and safe to no-op if the target files or
|
|
12
12
|
// patterns are missing (e.g. a future dependency bump changes the shape).
|
|
13
|
-
|
|
13
|
+
//
|
|
14
|
+
// src/jiti-compat.ts does the same two patches at extension load time, because
|
|
15
|
+
// the shared ~/.pi/agent/npm tree means another extension's install can revert
|
|
16
|
+
// them long after this hook ran (#34). The logic is deliberately duplicated
|
|
17
|
+
// rather than shared: postinstall runs before `npm run build` in CI and in a
|
|
18
|
+
// fresh clone, so this file cannot depend on dist/. Keep the two in sync.
|
|
19
|
+
import { readFileSync, writeFileSync, existsSync, renameSync, unlinkSync } from "node:fs";
|
|
14
20
|
import { createRequire } from "node:module";
|
|
15
21
|
import { dirname, join, relative, sep } from "node:path";
|
|
16
22
|
|
|
17
|
-
const
|
|
23
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
24
|
+
const resolveFromHere = requireFromHere.resolve;
|
|
25
|
+
|
|
26
|
+
// Resolve the copies jsdom actually loads. The shared ~/.pi/agent/npm tree
|
|
27
|
+
// often holds a second, nested copy of a package when extensions disagree on
|
|
28
|
+
// versions, and patching a hoisted copy jsdom never requires would fix
|
|
29
|
+
// nothing. Mirrors resolveFromJsdom in src/jiti-compat.ts.
|
|
30
|
+
function resolveFromJsdom(specifier, via) {
|
|
31
|
+
try {
|
|
32
|
+
const jsdomEntry = resolveFromHere("jsdom");
|
|
33
|
+
const importer = via ? createRequire(jsdomEntry).resolve(via) : jsdomEntry;
|
|
34
|
+
return createRequire(importer).resolve(specifier);
|
|
35
|
+
} catch {
|
|
36
|
+
return resolveFromHere(specifier);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Temp file + rename, not an in-place write. `writeFileSync` truncates first,
|
|
41
|
+
// so an interrupted write would leave a half-patched file that still contains
|
|
42
|
+
// the marker comment and would be skipped as "already patched" forever.
|
|
43
|
+
// Mirrors writeFileAtomic in src/jiti-compat.ts.
|
|
44
|
+
function writeFileAtomic(path, contents) {
|
|
45
|
+
const temp = `${path}.pi-web-agent-${process.pid}.tmp`;
|
|
46
|
+
try {
|
|
47
|
+
writeFileSync(temp, contents);
|
|
48
|
+
renameSync(temp, path);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
try {
|
|
51
|
+
if (existsSync(temp)) unlinkSync(temp);
|
|
52
|
+
} catch {
|
|
53
|
+
// Best effort.
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
18
58
|
|
|
19
59
|
function findPackageRoot(entryFile, packageName) {
|
|
20
60
|
let directory = dirname(entryFile);
|
|
21
61
|
while (true) {
|
|
22
62
|
const manifestFile = join(directory, "package.json");
|
|
23
63
|
if (existsSync(manifestFile)) {
|
|
24
|
-
|
|
25
|
-
|
|
64
|
+
try {
|
|
65
|
+
const manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
|
|
66
|
+
if (manifest.name === packageName) return directory;
|
|
67
|
+
} catch {
|
|
68
|
+
// Malformed package.json on the way up. Keep walking.
|
|
69
|
+
}
|
|
26
70
|
}
|
|
27
71
|
|
|
28
72
|
const parent = dirname(directory);
|
|
@@ -34,7 +78,7 @@ function findPackageRoot(entryFile, packageName) {
|
|
|
34
78
|
}
|
|
35
79
|
|
|
36
80
|
function patchTr46() {
|
|
37
|
-
const file =
|
|
81
|
+
const file = resolveFromJsdom("tr46", "whatwg-url");
|
|
38
82
|
if (!existsSync(file)) {
|
|
39
83
|
console.debug("patch-jiti-compat: tr46/index.js not found, skipping");
|
|
40
84
|
return;
|
|
@@ -44,7 +88,7 @@ function patchTr46() {
|
|
|
44
88
|
console.debug("patch-jiti-compat: tr46/index.js does not match expected pattern, skipping");
|
|
45
89
|
return;
|
|
46
90
|
}
|
|
47
|
-
|
|
91
|
+
writeFileAtomic(
|
|
48
92
|
file,
|
|
49
93
|
contents.replaceAll('require("punycode/")', 'require("punycode/punycode.js")'),
|
|
50
94
|
);
|
|
@@ -61,7 +105,7 @@ module.exports[Symbol.iterator] = Set.prototype[Symbol.iterator].bind(module.exp
|
|
|
61
105
|
`;
|
|
62
106
|
|
|
63
107
|
function patchCssstyleSetExports() {
|
|
64
|
-
const packageRoot = findPackageRoot(
|
|
108
|
+
const packageRoot = findPackageRoot(resolveFromJsdom("cssstyle"), "cssstyle");
|
|
65
109
|
const files = [
|
|
66
110
|
join(packageRoot, "lib", "allExtraProperties.js"),
|
|
67
111
|
join(packageRoot, "lib", "generated", "allProperties.js"),
|
|
@@ -79,7 +123,7 @@ function patchCssstyleSetExports() {
|
|
|
79
123
|
console.debug(`patch-jiti-compat: ${label} does not match expected pattern, skipping`);
|
|
80
124
|
continue;
|
|
81
125
|
}
|
|
82
|
-
|
|
126
|
+
writeFileAtomic(file, contents + SET_SHIM);
|
|
83
127
|
console.log(`patch-jiti-compat: patched ${label}`);
|
|
84
128
|
}
|
|
85
129
|
}
|