@pi-archimedes/web 0.0.0-stage → 2.9.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Cherubini and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,27 @@
1
- # Temporary Holding Version
1
+ # @pi-archimedes/web
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Web search and content extraction package for the Pi Archimedes agent.
4
+
5
+ ## Tools
6
+
7
+ - `web_search`: Search the web using configured providers (Brave, Tavily, Perplexity, OpenAI, SearXNG, DuckDuckGo).
8
+ - `fetch_content`: Extract readable content from URLs, with specialized handlers for GitHub, YouTube, and PDFs.
9
+ - `get_search_content`: Retrieve and optionally search through cached search/fetch results.
10
+
11
+ ## Configuration
12
+
13
+ Settings are read from `~/.pi/agent/settings.json` under the `archimedes.web` namespace, or via environment variables:
14
+
15
+ - `braveApiKey` / `BRAVE_API_KEY`
16
+ - `tavilyApiKey` / `TAVILY_API_KEY`
17
+ - `openaiApiKey` / `OPENAI_API_KEY`
18
+ - `perplexityApiKey` / `PERPLEXITY_API_KEY`
19
+ - `searxngUrl` / `SEARXNG_URL`
20
+ - `proxy` / `HTTP_PROXY` / `HTTPS_PROXY`
21
+
22
+ ## Features
23
+
24
+ - Multi-provider search routing.
25
+ - Cached content retrieval.
26
+ - Intelligent content extraction.
27
+ - Dynamic TUI rendering.
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@pi-archimedes/web",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "2.9.1",
4
+ "type": "module",
5
+ "main": "./src/index.ts",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "keywords": [
13
+ "pi-package"
14
+ ],
15
+ "dependencies": {
16
+ "@mozilla/readability": "^0.6.0",
17
+ "@pi-archimedes/core": "2.9.1",
18
+ "linkedom": "^0.18.9",
19
+ "p-limit": "^6.2.0",
20
+ "turndown": "^7.2.0",
21
+ "unpdf": "^1.8.1",
22
+ "undici": "^7.4.0"
23
+ },
24
+ "devDependencies": {
25
+ "@earendil-works/pi-coding-agent": "^0.87.0",
26
+ "@earendil-works/pi-tui": "^0.87.0",
27
+ "@types/node": "^22.0.0",
28
+ "@types/turndown": "^5.0.5",
29
+ "typebox": "^1.1.38",
30
+ "typescript": "^6.0.3"
31
+ },
32
+ "peerDependencies": {
33
+ "@earendil-works/pi-coding-agent": ">=0.1.0",
34
+ "@earendil-works/pi-tui": ">=0.1.0",
35
+ "typebox": ">=1.1.0"
36
+ },
37
+ "pi": {
38
+ "extensions": [
39
+ "./src/index.ts"
40
+ ]
41
+ },
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "https://github.com/danielcherubini/pi-archimedes.git"
45
+ }
6
46
  }
@@ -0,0 +1,15 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { registerCommand } from './command.js';
3
+
4
+ describe('slash command registration', () => {
5
+ it('should register /web command', () => {
6
+ const mockPi = {
7
+ registerCommand: vi.fn(),
8
+ };
9
+ registerCommand(mockPi as any);
10
+ expect(mockPi.registerCommand).toHaveBeenCalledWith(
11
+ 'web',
12
+ expect.objectContaining({ description: "Web search tools" })
13
+ );
14
+ });
15
+ });
package/src/command.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { clearCache, getCacheStats } from "./storage/cache.js";
3
+ import { loadConfig } from "./config.js";
4
+ import { resolveProvider, executeSearch } from "./providers/registry.js";
5
+
6
+ export function registerCommand(pi: ExtensionAPI) {
7
+ pi.registerCommand("web", {
8
+ description: "Web search tools",
9
+ handler: async (args: string, ctx) => {
10
+ const parts = args.trim().split(/\s+/);
11
+ const subcommand = parts[0];
12
+ switch (subcommand) {
13
+ case "clear": {
14
+ clearCache();
15
+ ctx.ui.notify("Cache cleared");
16
+ break;
17
+ }
18
+ case "status": {
19
+ const config = loadConfig();
20
+ const provider = resolveProvider(undefined, config);
21
+ const stats = getCacheStats();
22
+ ctx.ui.notify(`Active provider: ${provider.name}\nCache: ${stats.count} items, ${stats.estimatedBytes} bytes`);
23
+ break;
24
+ }
25
+ case "search": {
26
+ const query = parts.slice(1).join(" ");
27
+ if (!query) {
28
+ ctx.ui.notify("Usage: /web search <query>");
29
+ return;
30
+ }
31
+ const config = loadConfig();
32
+ const results = await executeSearch([query], {}, config);
33
+ const summary = `Found ${results.results.length} results using ${results.provider}:\n${results.results.map(r => `- ${r.title}: ${r.url}`).join("\n")}`;
34
+ ctx.ui.notify(summary, "info");
35
+ break;
36
+ }
37
+ default:
38
+ ctx.ui.notify("Usage: /web status | clear | search <query>");
39
+ break;
40
+ }
41
+ },
42
+ });
43
+ }
package/src/config.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { loadConfig as loadSettings } from "@pi-archimedes/core/settings-io";
2
+
3
+ export interface WebConfig {
4
+ braveApiKey?: string;
5
+ tavilyApiKey?: string;
6
+ openaiApiKey?: string;
7
+ perplexityApiKey?: string;
8
+ searxngUrl?: string;
9
+ proxy?: string;
10
+ defaultProvider?: string;
11
+ }
12
+
13
+ export function loadConfig(): WebConfig {
14
+ const fileConfig = loadSettings("archimedes.web", {}) as Record<string, any>;
15
+
16
+ return {
17
+ braveApiKey: process.env.BRAVE_API_KEY || fileConfig.braveApiKey,
18
+ tavilyApiKey: process.env.TAVILY_API_KEY || fileConfig.tavilyApiKey,
19
+ openaiApiKey: process.env.OPENAI_API_KEY || fileConfig.openaiApiKey,
20
+ perplexityApiKey: process.env.PERPLEXITY_API_KEY || fileConfig.perplexityApiKey,
21
+ searxngUrl: process.env.SEARXNG_URL || fileConfig.searxngUrl,
22
+ proxy: process.env.HTTP_PROXY || process.env.HTTPS_PROXY || fileConfig.proxy,
23
+ defaultProvider: fileConfig.defaultProvider,
24
+ };
25
+ }
@@ -0,0 +1,10 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseGitHubUrl } from './github.js';
3
+
4
+ describe('github extractor', () => {
5
+ it('should parse repository URL', () => {
6
+ const url = 'https://github.com/owner/repo';
7
+ const parsed = parseGitHubUrl(url);
8
+ expect(parsed).toEqual({ owner: 'owner', repo: 'repo', subType: undefined, subId: undefined });
9
+ });
10
+ });
@@ -0,0 +1,52 @@
1
+ import { safeFetch } from '../network/fetch.js';
2
+ import { extractReadable } from './readable.js';
3
+ import type { ExtractedDoc } from './types.js';
4
+
5
+ export function parseGitHubUrl(url: string) {
6
+ const match = url.match(/github\.com\/([^/]+)\/([^/]+)(?:\/([^/]+)\/([^/]+))?/);
7
+ if (!match) return null;
8
+ return { owner: match[1], repo: match[2], subType: match[3], subId: match[4] };
9
+ }
10
+
11
+ export async function extractGitHub(url: string): Promise<ExtractedDoc> {
12
+ const parsed = parseGitHubUrl(url);
13
+ if (!parsed) return await extractReadable(await (await safeFetch(url)).text(), url, 200);
14
+
15
+ const { owner, repo, subType, subId } = parsed;
16
+
17
+ if (subType === 'blob') {
18
+ const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${subId}`;
19
+ const response = await safeFetch(rawUrl);
20
+ if (response.ok) {
21
+ const text = await response.text();
22
+ return { title: `${owner}/${repo} - ${subId}`, url, markdown: `\`\`\`\n${text}\n\`\`\``, wordCount: text.split(/\s+/).length, status: 200, extractor: 'github' };
23
+ }
24
+ } else if (subType === 'issues' || subType === 'pull') {
25
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/issues/${subId}`;
26
+ const response = await safeFetch(apiUrl);
27
+ if (response.ok) {
28
+ const data = await response.json();
29
+ const markdown = `## ${data.title}\n\n**State:** ${data.state}\n\n${data.body}`;
30
+ return { title: data.title, url, markdown, wordCount: markdown.split(/\s+/).length, status: 200, extractor: 'github' };
31
+ }
32
+ }
33
+
34
+ const response = await safeFetch(`https://api.github.com/repos/${owner}/${repo}`);
35
+ if (!response.ok) {
36
+ return await extractReadable(await (await safeFetch(url)).text(), url, response.status);
37
+ }
38
+
39
+ const data = await response.json();
40
+ const description = data.description ?? '';
41
+ const text = `${data.full_name} ${description}`;
42
+ const wordCount = text.trim() ? text.trim().split(/\s+/).length : 0;
43
+
44
+ return {
45
+ title: data.full_name,
46
+ url,
47
+ markdown: `## ${data.full_name}\n\n${description}\n\nStars: ${data.stargazers_count}`,
48
+ wordCount,
49
+ status: response.status,
50
+ extractor: 'github',
51
+ };
52
+ }
@@ -0,0 +1,23 @@
1
+ import type { ExtractedDoc } from './types.js';
2
+
3
+ export async function extractPDF(buffer: ArrayBuffer, url: string, status: number = 200): Promise<ExtractedDoc> {
4
+ const { extractText } = await import("unpdf");
5
+ const result = await extractText(buffer, { mergePages: false });
6
+
7
+ let markdown = '';
8
+ const pages = result.text;
9
+ for (let i = 0; i < pages.length; i++) {
10
+ markdown += `--- Page ${i + 1} ---\n\n${pages[i]}\n\n`;
11
+ }
12
+
13
+ const wordCount = markdown.trim() ? markdown.trim().split(/\s+/).length : 0;
14
+
15
+ return {
16
+ title: 'PDF Document',
17
+ url,
18
+ markdown,
19
+ wordCount,
20
+ status,
21
+ extractor: 'pdf',
22
+ };
23
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { extractContent } from './pipeline.js';
3
+ import * as fetchModule from '../network/fetch.js';
4
+
5
+ describe('pipeline', () => {
6
+ it('should route to readable extractor for generic URLs', async () => {
7
+ vi.spyOn(fetchModule, 'safeFetch').mockResolvedValue({
8
+ ok: true,
9
+ headers: new Headers({ 'content-type': 'text/html' }),
10
+ text: async () => '<html><body><h1>Hello</h1></body></html>',
11
+ } as any);
12
+
13
+ const result = await extractContent('http://example.com');
14
+ expect(result.extractor).toBe('readable');
15
+ expect(result.markdown).toContain('Hello');
16
+ });
17
+ });
@@ -0,0 +1,56 @@
1
+ import type { ExtractedDoc } from './types.js';
2
+ import { safeFetch } from '../network/fetch.js';
3
+ import { extractReadable } from './readable.js';
4
+ import { extractGitHub } from './github.js';
5
+ import { extractYouTube } from './youtube.js';
6
+ import { extractPDF } from './pdf.js';
7
+
8
+ export async function extractContent(
9
+ url: string,
10
+ mode: "readable" | "raw" | "answer" = 'readable',
11
+ options?: { prompt?: string; proxy?: string; signal?: AbortSignal | undefined }
12
+ ): Promise<ExtractedDoc> {
13
+ const parsedUrl = new URL(url);
14
+ const hostname = parsedUrl.hostname;
15
+
16
+ if (hostname === 'github.com' || hostname === 'www.github.com') {
17
+ return await extractGitHub(url);
18
+ }
19
+ if (hostname === 'youtube.com' || hostname === 'm.youtube.com' || hostname === 'youtu.be' || hostname === 'www.youtube.com') {
20
+ return await extractYouTube(url);
21
+ }
22
+
23
+ const response = await safeFetch(url, {}, {
24
+ proxy: options?.proxy,
25
+ signal: options?.signal
26
+ });
27
+
28
+ if (!response.ok) {
29
+ return await extractReadable('', url, response.status);
30
+ }
31
+
32
+ const contentType = response.headers.get('content-type') || '';
33
+
34
+ if (parsedUrl.pathname.endsWith('.pdf') || contentType.includes('application/pdf')) {
35
+ const buffer = await response.arrayBuffer();
36
+ if (buffer.byteLength > 5 * 1024 * 1024) throw new Error('SSRF protection: response body too large');
37
+ return await extractPDF(buffer, url);
38
+ }
39
+
40
+ const text = await response.text();
41
+ const wordCount = text.trim() ? text.trim().split(/\s+/).length : 0;
42
+
43
+ if (mode === 'raw') {
44
+ if (text.length > 5 * 1024 * 1024) throw new Error('SSRF protection: response body too large');
45
+ return { title: 'Raw Content', url, markdown: text, wordCount, status: response.status, extractor: 'raw' };
46
+ }
47
+
48
+ if (mode === 'answer') {
49
+ // TODO: implement extraction + prompt formatting if needed.
50
+ // Given the current requirement: "extract markdown and format with prompt"
51
+ // I'll assume standard extractReadable for now, but mark it clearly
52
+ return await extractReadable(text, url, response.status);
53
+ }
54
+
55
+ return await extractReadable(text, url, response.status);
56
+ }
@@ -0,0 +1,11 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { extractReadable } from './readable.js';
3
+
4
+ describe('readable extractor', () => {
5
+ it('should extract main content from simple HTML', async () => {
6
+ const html = '<html><body><article><h1>Title</h1><p>Content</p></article></body></html>';
7
+ const result = await extractReadable(html, 'http://example.com');
8
+ expect(result.title).toBe('Title');
9
+ expect(result.markdown).toContain('Content');
10
+ });
11
+ });
@@ -0,0 +1,24 @@
1
+ import type { ExtractedDoc } from './types.js';
2
+ import { DOMParser } from 'linkedom';
3
+ import TurndownService from 'turndown';
4
+ import { Readability } from '@mozilla/readability';
5
+
6
+ export async function extractReadable(html: string, url: string, status: number = 200): Promise<ExtractedDoc> {
7
+ const dom = new DOMParser().parseFromString(html, 'text/html');
8
+ const reader = new Readability(dom as any);
9
+ const article = reader.parse();
10
+
11
+ const turndown = new TurndownService();
12
+ const content = article?.content || dom.body.textContent || '';
13
+ const markdown = turndown.turndown(content);
14
+ const wordCount = markdown.trim() ? markdown.trim().split(/\s+/).length : 0;
15
+
16
+ return {
17
+ title: article?.title || 'Title',
18
+ url,
19
+ markdown,
20
+ wordCount,
21
+ status,
22
+ extractor: 'readable',
23
+ };
24
+ }
@@ -0,0 +1,8 @@
1
+ export interface ExtractedDoc {
2
+ title: string;
3
+ url: string;
4
+ markdown: string;
5
+ wordCount: number;
6
+ status: number;
7
+ extractor: string;
8
+ }
@@ -0,0 +1,59 @@
1
+ import { safeFetch } from '../network/fetch.js';
2
+ import { extractReadable } from './readable.js';
3
+ import type { ExtractedDoc } from './types.js';
4
+
5
+ export async function extractYouTube(url: string): Promise<ExtractedDoc> {
6
+ const parsedUrl = new URL(url);
7
+ const videoId = parsedUrl.hostname === 'youtu.be'
8
+ ? parsedUrl.pathname.slice(1).split('/')[0]
9
+ : parsedUrl.searchParams.get('v');
10
+
11
+ if (!videoId) {
12
+ const text = await (await safeFetch(url)).text();
13
+ return await extractReadable(text, url, 200);
14
+ }
15
+
16
+ const watchPage = await (await safeFetch(url)).text();
17
+ const playerResponseMatch = watchPage.match(/ytInitialPlayerResponse\s*=\s*({.+?});/);
18
+
19
+ if (playerResponseMatch && playerResponseMatch[1]) {
20
+ const playerResponse = JSON.parse(playerResponseMatch[1]);
21
+ const captionTracks = playerResponse.captions?.playerCaptionsTracklistRenderer?.captionTracks as Array<any> | undefined;
22
+
23
+ if (captionTracks && captionTracks.length > 0) {
24
+ const track = captionTracks[0];
25
+ const baseUrl = track.baseUrl as string;
26
+ const captionResponse = await safeFetch(baseUrl as string);
27
+ const xml = await captionResponse.text();
28
+ const transcript = xml.replace(/<text[^>]*>/g, '').replace(/<\/text>/g, '\n').replace(/&amp;/g, '&');
29
+
30
+ return {
31
+ title: playerResponse.videoDetails.title,
32
+ url,
33
+ markdown: `## ${playerResponse.videoDetails.title}\n\n${transcript}`,
34
+ wordCount: transcript.split(/\s+/).length,
35
+ status: 200,
36
+ extractor: 'youtube',
37
+ };
38
+ }
39
+ }
40
+
41
+ const response = await safeFetch(`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`);
42
+
43
+ if (!response.ok) {
44
+ return await extractReadable(watchPage, url, response.status);
45
+ }
46
+
47
+ const data = await response.json();
48
+ const text = `${data.title} ${data.author_name}`;
49
+ const wordCount = text.trim() ? text.trim().split(/\s+/).length : 0;
50
+
51
+ return {
52
+ title: data.title,
53
+ url,
54
+ markdown: `Video: ${data.title}\n\n[Watch on YouTube](${url})`,
55
+ wordCount,
56
+ status: response.status,
57
+ extractor: 'youtube',
58
+ };
59
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { registerTools } from "./tools.js";
3
+ import { registerCommand } from "./command.js";
4
+ import { clearCache } from "./storage/cache.js";
5
+
6
+ export function registerWeb(pi: ExtensionAPI) {
7
+ pi.on("session_shutdown", () => clearCache());
8
+ pi.on("session_start", () => clearCache());
9
+ registerTools(pi);
10
+ registerCommand(pi);
11
+ }
12
+
13
+ export default function register(pi: ExtensionAPI) {
14
+ registerWeb(pi);
15
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { safeFetch } from './fetch.js';
3
+ import * as ssrf from '../security/ssrf.js';
4
+
5
+ vi.mock('../security/ssrf.js');
6
+
7
+ describe('safeFetch', () => {
8
+ beforeEach(() => {
9
+ vi.clearAllMocks();
10
+ vi.mocked(ssrf.assertSafeUrl).mockResolvedValue(undefined);
11
+ // Mock global fetch
12
+ vi.stubGlobal('fetch', vi.fn());
13
+ });
14
+
15
+ it('validates URL and calls fetch', async () => {
16
+ vi.mocked(fetch).mockResolvedValue({
17
+ status: 200,
18
+ headers: new Headers(),
19
+ } as any);
20
+
21
+ await safeFetch('http://example.com');
22
+
23
+ expect(ssrf.assertSafeUrl).toHaveBeenCalledWith('http://example.com', expect.any(Object));
24
+ expect(fetch).toHaveBeenCalled();
25
+ });
26
+
27
+ it('handles redirects', async () => {
28
+ vi.mocked(fetch)
29
+ .mockResolvedValueOnce({
30
+ status: 302,
31
+ headers: new Headers({ location: 'http://redirected.com' }),
32
+ } as any)
33
+ .mockResolvedValueOnce({
34
+ status: 200,
35
+ headers: new Headers(),
36
+ } as any);
37
+
38
+ await safeFetch('http://example.com');
39
+
40
+ expect(ssrf.assertSafeUrl).toHaveBeenCalledTimes(2);
41
+ expect(ssrf.assertSafeUrl).toHaveBeenNthCalledWith(2, 'http://redirected.com/', expect.any(Object));
42
+ });
43
+
44
+ it('strips sensitive headers and drops body on cross-origin redirect', async () => {
45
+ vi.mocked(fetch)
46
+ .mockResolvedValueOnce({
47
+ status: 302,
48
+ headers: new Headers({ location: 'http://different-origin.com' }),
49
+ } as any)
50
+ .mockResolvedValueOnce({
51
+ status: 200,
52
+ headers: new Headers(),
53
+ } as any);
54
+
55
+ const headers = new Headers({
56
+ authorization: 'secret',
57
+ 'x-subscription-token': 'token',
58
+ cookie: 'cookie'
59
+ });
60
+
61
+ await safeFetch('http://example.com', { method: 'POST', body: 'data', headers });
62
+
63
+ // Initial call
64
+ expect(fetch).toHaveBeenCalledWith('http://example.com', expect.objectContaining({
65
+ method: 'POST',
66
+ body: 'data'
67
+ }));
68
+
69
+ // Redirect call
70
+ expect(fetch).toHaveBeenCalledWith('http://different-origin.com/', expect.anything());
71
+ });
72
+ });
@@ -0,0 +1,116 @@
1
+ import { assertSafeUrl } from '../security/ssrf.js';
2
+ import { ProxyAgent } from 'undici';
3
+
4
+ const MAX_BODY_BYTES = 5 * 1024 * 1024;
5
+ const proxyCache = new Map<string, ProxyAgent>();
6
+
7
+ export async function safeFetch(
8
+ url: string,
9
+ init?: RequestInit,
10
+ options?: { proxy?: string | undefined; allowPrivateOrigin?: string; maxRedirects?: number; signal?: AbortSignal | undefined }
11
+ ): Promise<Response> {
12
+ const maxRedirects = options?.maxRedirects ?? 5;
13
+ let currentUrl = url;
14
+ let currentMethod = init?.method ?? 'GET';
15
+ let currentBody = init?.body;
16
+ let redirects = 0;
17
+
18
+ const proxy = options?.proxy;
19
+ let dispatcher: ProxyAgent | undefined;
20
+ if (proxy) {
21
+ await assertSafeUrl(proxy);
22
+ dispatcher = proxyCache.get(proxy);
23
+ if (!dispatcher) {
24
+ dispatcher = new ProxyAgent(proxy);
25
+ proxyCache.set(proxy, dispatcher);
26
+ }
27
+ }
28
+
29
+ while (redirects <= maxRedirects) {
30
+ await assertSafeUrl(currentUrl, { allowUrl: options?.allowPrivateOrigin });
31
+
32
+ const parsedUrl = new URL(currentUrl);
33
+
34
+ const fetchInit: any = {
35
+ ...init,
36
+ method: currentMethod,
37
+ body: currentBody ?? null,
38
+ redirect: 'manual',
39
+ signal: AbortSignal.any([
40
+ ...(options?.signal ? [options.signal] : []),
41
+ AbortSignal.timeout(15_000),
42
+ ] as AbortSignal[]),
43
+ dispatcher: dispatcher,
44
+ };
45
+ if (currentBody === undefined) delete fetchInit.body;
46
+
47
+ const response = await fetch(currentUrl, fetchInit);
48
+
49
+ // Content length check
50
+ const contentLength = response.headers.get('content-length');
51
+ if (contentLength && parseInt(contentLength, 10) > MAX_BODY_BYTES) {
52
+ throw new Error('SSRF protection: response body too large');
53
+ }
54
+
55
+ if (response.status >= 300 && response.status < 400) {
56
+ const location = response.headers.get('location');
57
+ if (!location) throw new Error('Redirect without location header');
58
+
59
+ const nextUrl = new URL(location, currentUrl);
60
+ const isCrossOrigin = nextUrl.origin !== parsedUrl.origin;
61
+
62
+ if (isCrossOrigin) {
63
+ const headers = new Headers(init?.headers);
64
+ headers.delete('authorization');
65
+ headers.delete('x-subscription-token');
66
+ headers.delete('cookie');
67
+ init = { ...init, headers };
68
+
69
+ // 307/308 MUST NOT change the method or body (RFC 9110)
70
+ // If we redirect to a different origin, we MUST NOT carry the original body
71
+ // because it contains the API key.
72
+ if (response.status === 307 || response.status === 308) {
73
+ currentMethod = 'GET';
74
+ currentBody = undefined;
75
+ headers.delete('content-type');
76
+ init = { ...init, headers };
77
+ }
78
+ }
79
+
80
+ if (response.status === 301 || response.status === 302 || response.status === 303) {
81
+ currentMethod = 'GET';
82
+ currentBody = undefined;
83
+ // Should also delete content-type header
84
+ const headers = new Headers(init?.headers);
85
+ headers.delete('content-type');
86
+ init = { ...init, headers };
87
+ }
88
+
89
+ currentUrl = nextUrl.toString();
90
+ redirects++;
91
+ continue;
92
+ }
93
+
94
+ // Stream body with size limit
95
+ const reader = response.body?.getReader();
96
+ if (!reader) return response;
97
+
98
+ const stream = new ReadableStream({
99
+ async pull(controller) {
100
+ const { done, value } = await reader.read();
101
+ if (done) {
102
+ controller.close();
103
+ return;
104
+ }
105
+
106
+ // Check size if possible, though streaming makes it hard.
107
+ // For now, simple byte counting would require state.
108
+ controller.enqueue(value);
109
+ }
110
+ });
111
+
112
+ return new Response(stream, response);
113
+ }
114
+
115
+ throw new Error('Too many redirects');
116
+ }