opencode-webfetch-plugin 0.1.1 → 0.1.3

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.
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Browser Worker Process
4
+ * Runs in Node.js to avoid Bun's connectOverCDP issues
5
+ * Communicates with parent process via IPC
6
+ */
7
+
8
+ import { BrowserManager } from './BrowserManager.js';
9
+
10
+ type PlaywrightModule = typeof import('playwright');
11
+
12
+ interface WorkerRequest {
13
+ id: string;
14
+ type: 'fetch' | 'dispose';
15
+ url?: string;
16
+ timeout?: number;
17
+ }
18
+
19
+ interface WorkerResponse {
20
+ id: string;
21
+ success: boolean;
22
+ data?: string;
23
+ error?: string;
24
+ }
25
+
26
+ let globalManager: BrowserManager | null = null;
27
+ let playwright: PlaywrightModule | null = null;
28
+
29
+ // Mock client for worker process
30
+ const workerClient = {
31
+ logger: {
32
+ info: (msg: string) => console.error(`[Worker Info] ${msg}`),
33
+ warn: (msg: string) => console.error(`[Worker Warn] ${msg}`),
34
+ error: (msg: string, err?: any) => console.error(`[Worker Error] ${msg}`, err),
35
+ },
36
+ tui: {
37
+ showToast: (opts: any) => {
38
+ // Send toast request to parent
39
+ sendToParent({ type: 'toast', data: opts });
40
+ }
41
+ }
42
+ };
43
+
44
+ // Mock ToolContext for worker
45
+ function createMockContext(): any {
46
+ const abortController = new AbortController();
47
+ return {
48
+ abort: abortController.signal,
49
+ metadata: () => {},
50
+ };
51
+ }
52
+
53
+ async function loadPlaywright(): Promise<PlaywrightModule> {
54
+ try {
55
+ return await import('playwright');
56
+ } catch (error) {
57
+ throw new Error(
58
+ 'webfetch plugin requires Playwright. Install it with: npm install playwright && npx playwright install chromium',
59
+ { cause: error },
60
+ );
61
+ }
62
+ }
63
+
64
+ async function handleRequest(req: WorkerRequest): Promise<WorkerResponse> {
65
+ try {
66
+ if (req.type === 'fetch') {
67
+ if (!req.url) {
68
+ throw new Error('URL is required for fetch request');
69
+ }
70
+
71
+ if (!globalManager) {
72
+ playwright = await loadPlaywright();
73
+ globalManager = new BrowserManager(playwright, workerClient);
74
+ }
75
+
76
+ const timeout = req.timeout || 30000;
77
+ const ctx = createMockContext();
78
+ const markdown = await globalManager.fetchWebpage(req.url, timeout, ctx);
79
+
80
+ return {
81
+ id: req.id,
82
+ success: true,
83
+ data: markdown,
84
+ };
85
+ } else if (req.type === 'dispose') {
86
+ if (globalManager) {
87
+ await globalManager.dispose();
88
+ globalManager = null;
89
+ }
90
+ return {
91
+ id: req.id,
92
+ success: true,
93
+ };
94
+ } else {
95
+ throw new Error(`Unknown request type: ${(req as any).type}`);
96
+ }
97
+ } catch (error: any) {
98
+ return {
99
+ id: req.id,
100
+ success: false,
101
+ error: error.message || String(error),
102
+ };
103
+ }
104
+ }
105
+
106
+ function sendToParent(message: any): void {
107
+ if (process.send) {
108
+ process.send(message);
109
+ }
110
+ }
111
+
112
+ // Listen for messages from parent process
113
+ process.on('message', async (message: any) => {
114
+ if (message && typeof message === 'object' && message.id) {
115
+ const response = await handleRequest(message as WorkerRequest);
116
+ sendToParent(response);
117
+ }
118
+ });
119
+
120
+ // Handle graceful shutdown
121
+ process.on('SIGTERM', async () => {
122
+ if (globalManager) {
123
+ await globalManager.dispose().catch(() => {});
124
+ }
125
+ process.exit(0);
126
+ });
127
+
128
+ process.on('SIGINT', async () => {
129
+ if (globalManager) {
130
+ await globalManager.dispose().catch(() => {});
131
+ }
132
+ process.exit(0);
133
+ });
134
+
135
+ // Signal ready
136
+ sendToParent({ type: 'ready' });
package/src/index.ts CHANGED
@@ -1,32 +1,23 @@
1
1
  import { type Plugin, tool } from "@opencode-ai/plugin";
2
- import { BrowserManager } from "./BrowserManager.js";
3
-
4
- type PlaywrightModule = typeof import("playwright");
2
+ import { BrowserWorkerManager } from './BrowserWorkerManager.js';
5
3
 
6
4
  const DEFAULT_TIMEOUT = 30_000;
7
5
  const MAX_TIMEOUT = 120_000;
8
6
 
9
- let globalManager: BrowserManager | null = null;
7
+ let globalWorkerManager: BrowserWorkerManager | null = null;
10
8
 
11
9
  export const WebfetchPlugin: Plugin = async ({ client }) => {
12
10
  const WebfetchTool = tool({
13
11
  description: "Fetch a webpage's main content in markdown.",
14
12
  args: {
15
13
  url: tool.schema.string().describe("The URL to fetch."),
16
- // timeout: tool.schema
17
- // .number()
18
- // .min(5)
19
- // .max(120)
20
- // .optional()
21
- // .describe("Timeout in seconds (default: 30, max: 120)"),
22
14
  },
23
15
  async execute(params: any, ctx: any) {
24
- if (!globalManager) {
25
- const playwright = await loadPlaywright();
26
- globalManager = new BrowserManager(playwright, client);
16
+ if (!globalWorkerManager) {
17
+ globalWorkerManager = new BrowserWorkerManager(client);
27
18
  }
28
19
 
29
- const manager = globalManager;
20
+ const manager = globalWorkerManager;
30
21
  const timeoutMs = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT);
31
22
 
32
23
  const abortHandler = () => {
@@ -41,7 +32,11 @@ export const WebfetchPlugin: Plugin = async ({ client }) => {
41
32
  targetUrl = `https://www.google.com/search?q=${encodeURIComponent(targetUrl)}`;
42
33
  }
43
34
 
44
- const markdownResult = await manager.fetchWebpage(targetUrl, timeoutMs, ctx);
35
+ const markdownResult = await manager.sendRequest({
36
+ type: 'fetch',
37
+ url: targetUrl,
38
+ timeout: timeoutMs,
39
+ });
45
40
 
46
41
  ctx.metadata({
47
42
  title: `Webfetch: ${targetUrl}`,
@@ -67,20 +62,4 @@ export const WebfetchPlugin: Plugin = async ({ client }) => {
67
62
  };
68
63
  };
69
64
 
70
- async function loadPlaywright(): Promise<PlaywrightModule> {
71
- try {
72
- return await import("playwright");
73
- } catch (error) {
74
- try {
75
- // @ts-ignore
76
- return await import("/tmp/node_modules/playwright");
77
- } catch {
78
- throw new Error(
79
- "webfetch plugin requires Playwright. Install it with: bun install playwright && bunx playwright install chromium",
80
- { cause: error },
81
- );
82
- }
83
- }
84
- }
85
-
86
65
  export default WebfetchPlugin;
package/test.md ADDED
@@ -0,0 +1 @@
1
+ webfetch https://z.weixin.qq.com/ https://bailian.console.aliyun.com/#/home 两个页面,并分别做总结
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { BrowserWorkerManager } from '../src/BrowserWorkerManager.js';
3
+
4
+ describe('BrowserWorkerManager', () => {
5
+ let manager: BrowserWorkerManager | null = null;
6
+
7
+ afterEach(async () => {
8
+ if (manager) {
9
+ await manager.dispose().catch(() => {});
10
+ manager = null;
11
+ }
12
+ });
13
+
14
+ it('should handle worker lifecycle and concurrent requests', async () => {
15
+ const mockClient = {
16
+ logger: {
17
+ info: () => {},
18
+ warn: () => {},
19
+ error: () => {},
20
+ },
21
+ tui: {
22
+ showToast: () => {},
23
+ },
24
+ };
25
+
26
+ manager = new BrowserWorkerManager(mockClient);
27
+
28
+ // Send two concurrent fetch requests
29
+ const [result1, result2] = await Promise.all([
30
+ manager.sendRequest({
31
+ type: 'fetch',
32
+ url: 'https://example.com',
33
+ timeout: 30000,
34
+ }),
35
+ manager.sendRequest({
36
+ type: 'fetch',
37
+ url: 'https://httpbin.org/html',
38
+ timeout: 30000,
39
+ }),
40
+ ]);
41
+
42
+ // Verify both requests returned markdown content
43
+ expect(result1).toContain('Example Domain');
44
+ expect(result2).toContain('httpbin');
45
+
46
+ // Verify worker can be disposed
47
+ await manager.dispose();
48
+ }, 60000);
49
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { BrowserServer } from '../src/BrowserServer.js';
3
+ import { BrowserManager } from '../src/BrowserManager.js';
4
+ import { fork } from 'child_process';
5
+ import * as path from 'path';
6
+
7
+ async function getPlaywright() {
8
+ return await import('playwright');
9
+ }
10
+
11
+ function resetBrowserServerSingleton() {
12
+ (BrowserServer as any).instance = null;
13
+ (BrowserServer as any).initPromise = null;
14
+ }
15
+
16
+ describe('Browser Tests', () => {
17
+ beforeAll(() => {
18
+ resetBrowserServerSingleton();
19
+ });
20
+
21
+ afterAll(async () => {
22
+ resetBrowserServerSingleton();
23
+ });
24
+
25
+ it('should fetch two pages concurrently in the same browser instance', async () => {
26
+ const playwright = await getPlaywright();
27
+ const client = { logger: console };
28
+
29
+ const manager = new BrowserManager(playwright, client);
30
+
31
+ const [result1, result2] = await Promise.all([
32
+ manager.fetchWebpage('https://example.com', 30000, { abort: new AbortController().signal } as any),
33
+ manager.fetchWebpage('https://httpbin.org/html', 30000, { abort: new AbortController().signal } as any),
34
+ ]);
35
+
36
+ expect(result1).toContain('Example Domain');
37
+ expect(result2).toContain('httpbin.org');
38
+ });
39
+
40
+ it('should share browser between two independent processes', async () => {
41
+ const workerScript = path.join(__dirname, 'helpers', 'cookie-worker.ts');
42
+
43
+ const runWorker = (action: string): Promise<{ content: string }> => {
44
+ return new Promise((resolve, reject) => {
45
+ const child = fork(workerScript, [action], {
46
+ execArgv: ['--import', 'tsx'],
47
+ stdio: ['pipe', 'pipe', 'pipe', 'ipc']
48
+ });
49
+
50
+ let stdout = '';
51
+ child.stdout?.on('data', (data) => { stdout += data; });
52
+ child.stderr?.on('data', (data) => { console.log(`[worker stderr] ${data}`); });
53
+
54
+ child.on('message', (result: { content: string }) => {
55
+ resolve(result);
56
+ child.disconnect();
57
+ });
58
+
59
+ child.on('error', reject);
60
+ child.on('exit', (code) => {
61
+ if (code !== 0) {
62
+ reject(new Error(`Worker exited with code ${code}: ${stdout}`));
63
+ }
64
+ });
65
+ });
66
+ };
67
+
68
+ const res = await runWorker('https://example.com');
69
+ expect(res.content).toContain('Example Domain');
70
+
71
+ const [result1, result2] = await Promise.all([runWorker('https://example.com'), runWorker('https://httpbin.org/html')]);
72
+
73
+ // console.log(result1)
74
+ // console.log(result2)
75
+ expect(result1.content).toContain('Example Domain');
76
+ expect(result2.content).toContain('httpbin.org');
77
+ // expect(result1.port).toBe(9222);
78
+ // expect(result1.cookies).toHaveProperty('test_cookie', 'test_value');
79
+
80
+ // // const result2 = await runWorker('get');
81
+ // expect(result2.port).toBe(9222);
82
+ // expect(result2.cookies).toHaveProperty('test_cookie', 'test_value');
83
+ });
84
+ });
@@ -0,0 +1,51 @@
1
+ import { BrowserManager } from '../../src/BrowserManager.js'
2
+
3
+ async function main () {
4
+ const action = process.argv[2]
5
+
6
+ const playwright = await import('playwright')
7
+ const client = { logger: console }
8
+
9
+ const manager = new BrowserManager(playwright, client)
10
+ // const server = await (manager as any).ensureBrowserServer();
11
+ // const context = server.getContext();
12
+
13
+ // if (!context) {
14
+ // process.send?.({ error: 'No context' });
15
+ // process.exit(1);
16
+ // }
17
+
18
+ // const page = await context.newPage();
19
+
20
+ // let cookies: Record<string, string> = {};
21
+
22
+ let content = await manager.fetchWebpage(action, 3000, {
23
+ abort: new AbortController().signal
24
+ } as any)
25
+
26
+ // await page.goto('https://httpbin.org/cookies', { waitUntil: 'domcontentloaded' });
27
+ // await page.waitForTimeout(1000);
28
+
29
+ // const content = await page.content();
30
+ // const match = content.match(/{\s*"cookies":\s*({[^}]*})\s*}/)
31
+ // if (match) {
32
+ // try {
33
+ // cookies = JSON.parse(match[1])
34
+ // } catch (e) {
35
+ // // ignore
36
+ // }
37
+ // }
38
+
39
+ // await page.close()
40
+
41
+ process.send?.({
42
+ content
43
+ })
44
+
45
+ process.exit(0)
46
+ }
47
+
48
+ main().catch(e => {
49
+ console.error(e)
50
+ process.exit(1)
51
+ })
@@ -0,0 +1,17 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ testTimeout: 60000,
8
+ hookTimeout: 60000,
9
+ teardownTimeout: 5000,
10
+ isolate: false,
11
+ maxConcurrency: 1,
12
+ fileParallelism: false,
13
+ sequence: {
14
+ concurrent: false,
15
+ },
16
+ },
17
+ });
package/tsconfig.json DELETED
@@ -1,17 +0,0 @@
1
- {
2
- "$schema": "https://json.schemastore.org/tsconfig",
3
- "compilerOptions": {
4
- "target": "ES2022",
5
- "module": "ES2022",
6
- "moduleResolution": "Bundler",
7
- "lib": ["ES2022", "DOM"],
8
- "strict": true,
9
- "declaration": true,
10
- "declarationMap": true,
11
- "sourceMap": true,
12
- "outDir": "dist",
13
- "esModuleInterop": true,
14
- "skipLibCheck": true
15
- },
16
- "include": ["src/**/*"]
17
- }