@myagentroam/node 0.11.61 → 0.11.63

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.
@@ -1,15 +1,13 @@
1
- import { request as httpRequest } from 'node:http';
2
- import { request as httpsRequest } from 'node:https';
3
- import { TLSSocket } from 'node:tls';
4
- import { createBrotliDecompress, createGunzip, createInflate } from 'node:zlib';
5
- import { pipeline } from 'node:stream/promises';
6
1
  import { setTimeout as delay } from 'node:timers/promises';
7
2
  import { AgentError } from '@myagentroam/agent';
8
3
  import { chromium, errors } from 'playwright';
9
- import { WEB_FETCH_MAX_BYTES, webFetchRequestSchema } from './web-fetch-contracts.js';
4
+ import { convertWebDocument } from './web-markdown.js';
5
+ import { WEB_FETCH_MAX_BYTES, browserFetchRequestSchema } from './web-fetch-contracts.js';
10
6
  const TIMEOUT_MS = 30_000;
11
7
  const MAX_REDIRECTS = 5;
12
- const MAX_BROWSER_REQUESTS = 2;
8
+ const MAX_BROWSER_REQUESTS = 4;
9
+ const BROWSER_IDLE_MS = 30 * 60_000;
10
+ const BROWSER_MAX_AGE_MS = 60 * 60_000;
13
11
  export class WebFetchError extends AgentError {
14
12
  code;
15
13
  status;
@@ -29,6 +27,12 @@ export class WebFetchService {
29
27
  #active = new Set();
30
28
  #allowUntrustedTls;
31
29
  #launchBrowser;
30
+ #pendingContexts = new Set();
31
+ #browser;
32
+ #browserStartedAt;
33
+ #launching;
34
+ #browserClosing;
35
+ #idle;
32
36
  #browserRequests = 0;
33
37
  #closing;
34
38
  constructor(options = {}) {
@@ -37,7 +41,7 @@ export class WebFetchService {
37
41
  this.#launchBrowser = options.launchBrowser ?? chromium.launch.bind(chromium);
38
42
  }
39
43
  fetch(input, signal, policy = {}) {
40
- const parsed = webFetchRequestSchema.safeParse(input);
44
+ const parsed = browserFetchRequestSchema.safeParse(input);
41
45
  if (!parsed.success)
42
46
  return Promise.reject(new WebFetchError('MAR_AGENT_TOOL_INPUT_INVALID', 400, 'Web fetch input is invalid.'));
43
47
  const timeout = AbortSignal.timeout(TIMEOUT_MS);
@@ -49,7 +53,7 @@ export class WebFetchService {
49
53
  throw new WebFetchError('MAR_AGENT_WEB_FETCH_CANCELLED', 503, 'Web fetch was cancelled.');
50
54
  if (error instanceof WebFetchError)
51
55
  throw error;
52
- throw upstreamFailure(error, parsed.data.mode, parsed.data.mode === 'http' ? 'response' : 'browser');
56
+ throw upstreamFailure(error, 'browser', 'browser');
53
57
  });
54
58
  this.#active.add(task);
55
59
  void task.then(() => this.#active.delete(task), () => this.#active.delete(task));
@@ -60,119 +64,136 @@ export class WebFetchService {
60
64
  }
61
65
  async #close() {
62
66
  this.#shutdown.abort();
67
+ if (this.#idle)
68
+ clearTimeout(this.#idle);
63
69
  await Promise.allSettled([...this.#active]);
70
+ await this.#launching?.catch(() => undefined);
71
+ await Promise.allSettled([...this.#pendingContexts]);
72
+ await this.#browserClosing;
73
+ await this.#browser?.close().catch(() => undefined);
74
+ this.#browser = undefined;
64
75
  }
65
- async #execute(input, signal, allowUntrustedTls) {
66
- signal.throwIfAborted();
67
- return input.mode === 'http'
68
- ? this.#fetchHttp(input, signal, allowUntrustedTls)
69
- : this.#fetchBrowser(input, signal, allowUntrustedTls);
76
+ #scheduleIdle() {
77
+ if (this.#shutdown.signal.aborted || this.#browserRequests || !this.#browser || this.#idle)
78
+ return;
79
+ if (this.#expired()) {
80
+ this.#retireBrowser();
81
+ return;
82
+ }
83
+ this.#idle = setTimeout(() => {
84
+ this.#idle = undefined;
85
+ if (this.#browserRequests || !this.#browser)
86
+ return;
87
+ this.#retireBrowser();
88
+ }, BROWSER_IDLE_MS);
89
+ this.#idle.unref();
70
90
  }
71
- async #fetchHttp(input, signal, allowUntrustedTls) {
72
- let url = new URL(input.url);
73
- for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
74
- const response = await requestHttp(url, signal, allowUntrustedTls);
75
- const status = response.statusCode ?? 0;
76
- const location = response.headers.location;
77
- if ([301, 302, 303, 307, 308].includes(status) && location) {
78
- response.destroy();
79
- const next = new URL(location, url);
80
- if (!webFetchRequestSchema.safeParse({ url: next.href, mode: 'http' }).success)
81
- throw new WebFetchError('MAR_AGENT_TOOL_INPUT_INVALID', 400, 'Redirect URL is not supported.');
82
- url = next;
83
- continue;
84
- }
85
- const contentType = (response.headers['content-type'] ?? 'text/plain').slice(0, 256);
86
- if (status < 200 || status >= 300 || !readableContentType(contentType)) {
87
- response.destroy();
88
- validateResponse(status, contentType);
89
- }
90
- const encoding = response.headers['content-encoding']?.trim().toLowerCase() ?? 'identity';
91
- const decompressor = encoding === 'gzip'
92
- ? createGunzip()
93
- : encoding === 'br'
94
- ? createBrotliDecompress()
95
- : encoding === 'deflate'
96
- ? createInflate()
97
- : undefined;
98
- if (!decompressor && encoding !== 'identity' && encoding !== '') {
99
- response.destroy();
100
- throw new WebFetchError('MAR_AGENT_WEB_FETCH_UNSUPPORTED_CONTENT', 415, 'Response content encoding is not supported.');
101
- }
102
- const body = decompressor ?? response;
103
- let transferError;
104
- const transfer = decompressor
105
- ? pipeline(response, decompressor, { signal }).catch((error) => {
106
- transferError = error;
107
- })
108
- : undefined;
109
- const chunks = [];
110
- let bytes = 0;
111
- let truncated = false;
112
- try {
113
- for await (const chunk of body) {
114
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
115
- const remaining = WEB_FETCH_MAX_BYTES - bytes;
116
- chunks.push(buffer.subarray(0, remaining));
117
- bytes += Math.min(remaining, buffer.byteLength);
118
- if (buffer.byteLength > remaining) {
119
- truncated = true;
120
- break;
91
+ #expired() {
92
+ return (this.#browserStartedAt !== undefined &&
93
+ Date.now() - this.#browserStartedAt >= BROWSER_MAX_AGE_MS);
94
+ }
95
+ #retireBrowser() {
96
+ const browser = this.#browser;
97
+ if (!browser)
98
+ return;
99
+ this.#browser = undefined;
100
+ this.#browserStartedAt = undefined;
101
+ const closing = browser
102
+ .close()
103
+ .catch(() => undefined)
104
+ .finally(() => {
105
+ if (this.#browserClosing === closing)
106
+ this.#browserClosing = undefined;
107
+ });
108
+ this.#browserClosing = closing;
109
+ }
110
+ #getBrowser() {
111
+ if (this.#browserClosing)
112
+ return this.#browserClosing.then(() => {
113
+ this.#shutdown.signal.throwIfAborted();
114
+ return this.#getBrowser();
115
+ });
116
+ if (this.#browser?.isConnected())
117
+ return Promise.resolve(this.#browser);
118
+ if (!this.#launching) {
119
+ const launching = this.#launchBrowser({
120
+ headless: true,
121
+ channel: 'chromium',
122
+ timeout: 15_000
123
+ })
124
+ .then((browser) => {
125
+ this.#browser = browser;
126
+ this.#browserStartedAt = Date.now();
127
+ browser.on('disconnected', () => {
128
+ if (this.#browser === browser) {
129
+ this.#browser = undefined;
130
+ this.#browserStartedAt = undefined;
121
131
  }
122
- }
123
- }
124
- finally {
125
- response.destroy();
126
- decompressor?.destroy();
127
- await transfer;
128
- }
129
- if (!truncated && transferError)
130
- throw transferError;
131
- signal.throwIfAborted();
132
- const text = new TextDecoder().decode(Buffer.concat(chunks), { stream: truncated });
133
- const output = Buffer.from(text);
134
- truncated ||= output.byteLength > WEB_FETCH_MAX_BYTES;
135
- const content = new TextDecoder().decode(output.subarray(0, WEB_FETCH_MAX_BYTES), {
136
- stream: truncated
132
+ });
133
+ return browser;
134
+ })
135
+ .finally(() => {
136
+ this.#launching = undefined;
137
+ this.#scheduleIdle();
137
138
  });
138
- return { content, data: { url: url.href, mode: 'http', status, contentType }, truncated };
139
+ this.#launching = launching;
139
140
  }
140
- throw new WebFetchError('MAR_AGENT_WEB_FETCH_UPSTREAM_FAILED', 502, 'Too many web fetch redirects.');
141
+ return this.#launching;
142
+ }
143
+ async #execute(input, signal, allowUntrustedTls) {
144
+ signal.throwIfAborted();
145
+ return this.#fetchBrowser(input, signal, allowUntrustedTls);
141
146
  }
142
147
  async #fetchBrowser(input, signal, allowUntrustedTls) {
148
+ if (this.#expired()) {
149
+ if (this.#browserRequests > 0)
150
+ throw new WebFetchError('MAR_AGENT_WEB_FETCH_BUSY', 429, 'Node web fetch browser capacity is exhausted.');
151
+ if (this.#idle)
152
+ clearTimeout(this.#idle);
153
+ this.#idle = undefined;
154
+ this.#retireBrowser();
155
+ }
143
156
  if (this.#browserRequests >= MAX_BROWSER_REQUESTS)
144
157
  throw new WebFetchError('MAR_AGENT_WEB_FETCH_BUSY', 429, 'Node web fetch browser capacity is exhausted.');
145
158
  this.#browserRequests++;
159
+ if (this.#idle)
160
+ clearTimeout(this.#idle);
161
+ this.#idle = undefined;
146
162
  let browser;
147
163
  let context;
148
164
  let navigationFailure;
149
165
  const cancel = () => {
150
- void browser?.close().catch(() => undefined);
166
+ void context?.close().catch(() => undefined);
151
167
  };
152
168
  signal.addEventListener('abort', cancel, { once: true });
153
169
  try {
154
170
  try {
155
- browser = await this.#launchBrowser({
156
- headless: true,
157
- channel: 'chromium',
158
- timeout: 15_000
159
- });
171
+ browser = await abortable(this.#getBrowser(), signal);
160
172
  }
161
173
  catch {
162
174
  throw new WebFetchError('MAR_AGENT_WEB_FETCH_BROWSER_UNAVAILABLE', 503, 'Node Chromium could not start. Prepare the matching runtime with the Node browser installation script under the service account. On Linux, check Chromium system libraries; an administrator may need to install them.');
163
175
  }
164
176
  signal.throwIfAborted();
165
- context = await browser.newContext({
177
+ const creatingContext = browser.newContext({
166
178
  acceptDownloads: false,
167
179
  ignoreHTTPSErrors: allowUntrustedTls,
168
180
  serviceWorkers: 'block'
169
181
  });
182
+ const cleanup = creatingContext
183
+ .then(async (created) => {
184
+ if (signal.aborted)
185
+ await created.close().catch(() => undefined);
186
+ }, () => undefined)
187
+ .finally(() => this.#pendingContexts.delete(cleanup));
188
+ this.#pendingContexts.add(cleanup);
189
+ context = await abortable(creatingContext, signal);
190
+ signal.throwIfAborted();
170
191
  let blockedNavigation = false;
171
192
  let resourceRequests = 0;
172
193
  await context.route('**/*', async (route) => {
173
194
  const request = route.request();
174
195
  resourceRequests++;
175
- const valid = webFetchRequestSchema.safeParse({
196
+ const valid = browserFetchRequestSchema.safeParse({
176
197
  url: request.url(),
177
198
  mode: 'browser'
178
199
  }).success;
@@ -199,8 +220,28 @@ export class WebFetchService {
199
220
  const mainFrameId = frameTree.frame.id;
200
221
  let navigationRequests = 0;
201
222
  let interceptedRequests = 0;
223
+ let mainResponse;
224
+ cdp.on('Network.responseReceived', (event) => {
225
+ if (event.type === 'Document' && event.frameId === mainFrameId) {
226
+ mainResponse = {
227
+ requestId: event.requestId,
228
+ url: event.response.url,
229
+ status: event.response.status,
230
+ mimeType: event.response.mimeType
231
+ };
232
+ if (!navigationFailure && !readableContentType(mainResponse.mimeType)) {
233
+ try {
234
+ validateResponse(mainResponse.status, mainResponse.mimeType);
235
+ }
236
+ catch (error) {
237
+ if (error instanceof WebFetchError)
238
+ navigationFailure = error;
239
+ }
240
+ }
241
+ }
242
+ });
202
243
  cdp.on('Fetch.requestPaused', (event) => {
203
- const valid = webFetchRequestSchema.safeParse({
244
+ const valid = browserFetchRequestSchema.safeParse({
204
245
  url: event.request.url,
205
246
  mode: 'browser'
206
247
  }).success;
@@ -225,6 +266,7 @@ export class WebFetchService {
225
266
  }
226
267
  });
227
268
  });
269
+ await cdp.send('Network.enable');
228
270
  await cdp.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] });
229
271
  let navigationResponse;
230
272
  page.on('response', (response) => {
@@ -239,14 +281,27 @@ export class WebFetchService {
239
281
  page.on('dialog', (dialog) => {
240
282
  void dialog.dismiss().catch(() => undefined);
241
283
  });
242
- const response = await page.goto(input.url, {
243
- waitUntil: 'domcontentloaded',
244
- timeout: 25_000
245
- });
284
+ let response;
285
+ try {
286
+ response = await page.goto(input.url, {
287
+ waitUntil: 'domcontentloaded',
288
+ timeout: 25_000
289
+ });
290
+ }
291
+ catch (error) {
292
+ if (navigationFailure)
293
+ throw navigationFailure;
294
+ if (!signal.aborted && mainResponse && !readableContentType(mainResponse.mimeType))
295
+ validateResponse(mainResponse.status, mainResponse.mimeType);
296
+ throw error;
297
+ }
246
298
  if (!response)
247
299
  throw new WebFetchError('MAR_AGENT_WEB_FETCH_UPSTREAM_FAILED', 502, 'Page navigation returned no response.');
248
300
  navigationResponse ??= response;
249
- validateResponse(response.status(), (await response.allHeaders())['content-type'] ?? 'text/html');
301
+ const initialContentType = (await response.allHeaders())['content-type'] ?? 'text/html';
302
+ validateResponse(response.status(), initialContentType);
303
+ if (!/^text\/html\b/iu.test(initialContentType))
304
+ return await readTextResponse(response, initialContentType, mainResponse, cdp, signal);
250
305
  // Wait for visible text to settle, not for long-lived network connections to become idle.
251
306
  const start = Date.now();
252
307
  let previous = '';
@@ -289,14 +344,78 @@ export class WebFetchService {
289
344
  throw navigationFailure;
290
345
  const finalUrl = page.url();
291
346
  if (blockedNavigation ||
292
- !webFetchRequestSchema.safeParse({ url: finalUrl, mode: 'browser' }).success)
347
+ !browserFetchRequestSchema.safeParse({ url: finalUrl, mode: 'browser' }).success)
293
348
  throw new WebFetchError('MAR_AGENT_TOOL_INPUT_INVALID', 400, 'Page navigation URL is not supported.');
294
- if (!snapshot?.content || snapshot.url !== finalUrl)
349
+ if (!snapshot || snapshot.url !== finalUrl)
295
350
  throw new WebFetchError('MAR_AGENT_WEB_FETCH_EMPTY_CONTENT', 422, 'The rendered page has no readable text.');
296
351
  const contentType = ((await navigationResponse.allHeaders())['content-type'] ?? 'text/html').slice(0, 256);
297
352
  validateResponse(navigationResponse.status(), contentType);
353
+ const html = /^text\/html\b/iu.test(contentType);
354
+ if (!html)
355
+ return await readTextResponse(navigationResponse, contentType, mainResponse, cdp, signal);
356
+ if (!snapshot.content)
357
+ throw new WebFetchError('MAR_AGENT_WEB_FETCH_EMPTY_CONTENT', 422, 'The rendered page has no readable text.');
358
+ const source = html
359
+ ? await page.evaluate((maxBytes) => {
360
+ const original = document.body;
361
+ if (!original)
362
+ return { content: '', truncated: false };
363
+ const clone = original.cloneNode(true);
364
+ const originals = [original, ...original.querySelectorAll('*')];
365
+ const copies = [clone, ...clone.querySelectorAll('*')];
366
+ for (let i = originals.length - 1; i >= 0; i--) {
367
+ const node = originals[i];
368
+ const copy = copies[i];
369
+ const style = getComputedStyle(node);
370
+ if (['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE'].includes(node.tagName) ||
371
+ style.display === 'none') {
372
+ if (i === 0)
373
+ clone.replaceChildren();
374
+ else
375
+ copy.remove();
376
+ continue;
377
+ }
378
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') {
379
+ // Descendants can override visibility; only this node's text is hidden.
380
+ for (const child of [...copy.childNodes]) {
381
+ if (child.nodeType === Node.TEXT_NODE)
382
+ child.remove();
383
+ }
384
+ if (copy.children.length === 0) {
385
+ if (i === 0)
386
+ clone.replaceChildren();
387
+ else
388
+ copy.remove();
389
+ continue;
390
+ }
391
+ }
392
+ for (const attribute of ['href', 'src']) {
393
+ const value = copy.getAttribute(attribute);
394
+ if (value) {
395
+ try {
396
+ copy.setAttribute(attribute, new URL(value, document.baseURI).href);
397
+ }
398
+ catch {
399
+ /* Keep original. */
400
+ }
401
+ }
402
+ }
403
+ }
404
+ const bytes = new TextEncoder().encode(clone.innerHTML);
405
+ return {
406
+ content: new TextDecoder().decode(bytes.subarray(0, maxBytes), {
407
+ stream: bytes.length > maxBytes
408
+ }),
409
+ truncated: bytes.length > maxBytes
410
+ };
411
+ }, WEB_FETCH_MAX_BYTES)
412
+ : { content: snapshot.content, truncated: snapshot.truncated };
413
+ signal.throwIfAborted();
414
+ const converted = convertWebDocument(source.content, html);
415
+ if (!converted.content)
416
+ throw new WebFetchError('MAR_AGENT_WEB_FETCH_EMPTY_CONTENT', 422, 'The rendered page has no readable text.');
298
417
  return {
299
- content: snapshot.content,
418
+ content: converted.content,
300
419
  data: {
301
420
  url: finalUrl,
302
421
  mode: 'browser',
@@ -304,7 +423,7 @@ export class WebFetchService {
304
423
  contentType,
305
424
  title: snapshot.title
306
425
  },
307
- truncated: snapshot.truncated
426
+ truncated: source.truncated || converted.truncated
308
427
  };
309
428
  }
310
429
  catch (error) {
@@ -315,11 +434,58 @@ export class WebFetchService {
315
434
  finally {
316
435
  signal.removeEventListener('abort', cancel);
317
436
  await context?.close().catch(() => undefined);
318
- await browser?.close().catch(() => undefined);
319
437
  this.#browserRequests--;
438
+ this.#scheduleIdle();
320
439
  }
321
440
  }
322
441
  }
442
+ function abortable(promise, signal) {
443
+ if (signal.aborted)
444
+ return Promise.reject(signal.reason);
445
+ return new Promise((resolve, reject) => {
446
+ const abort = () => reject(signal.reason);
447
+ signal.addEventListener('abort', abort, { once: true });
448
+ promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
449
+ });
450
+ }
451
+ async function readTextResponse(response, contentType, navigation, cdp, signal) {
452
+ const failure = await abortable(response.finished(), signal);
453
+ if (failure)
454
+ throw failure;
455
+ if (!navigation || navigation.url !== response.url())
456
+ throw new Error('BROWSER_RESPONSE_UNAVAILABLE');
457
+ // Response.body() can re-request an empty response; use this navigation only.
458
+ const body = await abortable(cdp.send('Network.getResponseBody', { requestId: navigation.requestId }), signal);
459
+ const bytes = Buffer.from(body.body, body.base64Encoded ? 'base64' : 'utf8');
460
+ signal.throwIfAborted();
461
+ const charset = body.base64Encoded
462
+ ? (/charset\s*=\s*["']?([^\s;"']+)/iu.exec(contentType)?.[1] ?? 'utf-8')
463
+ : 'utf-8';
464
+ let decoder;
465
+ try {
466
+ decoder = new TextDecoder(charset);
467
+ }
468
+ catch {
469
+ decoder = new TextDecoder('utf-8');
470
+ }
471
+ const sourceTruncated = bytes.length > WEB_FETCH_MAX_BYTES;
472
+ const text = decoder.decode(bytes.subarray(0, WEB_FETCH_MAX_BYTES), {
473
+ stream: sourceTruncated
474
+ });
475
+ const converted = convertWebDocument(text, false);
476
+ if (!converted.content)
477
+ throw new WebFetchError('MAR_AGENT_WEB_FETCH_EMPTY_CONTENT', 422, 'The rendered page has no readable text.');
478
+ return {
479
+ content: converted.content,
480
+ data: {
481
+ url: response.url(),
482
+ mode: 'browser',
483
+ status: response.status(),
484
+ contentType: contentType.slice(0, 256)
485
+ },
486
+ truncated: sourceTruncated || converted.truncated
487
+ };
488
+ }
323
489
  function readableContentType(contentType) {
324
490
  return /^(?:text\/|application\/(?:[\w.+-]+\+)?(?:json|xml)(?:[;\s]|$))/iu.test(contentType);
325
491
  }
@@ -344,29 +510,3 @@ function validateResponse(status, contentType) {
344
510
  if (!readableContentType(contentType))
345
511
  throw new WebFetchError('MAR_AGENT_WEB_FETCH_UNSUPPORTED_CONTENT', 415, 'Response is not readable text.');
346
512
  }
347
- function requestHttp(url, signal, allowUntrustedTls) {
348
- return new Promise((resolve, reject) => {
349
- let phase = 'request';
350
- const request = (url.protocol === 'https:' ? httpsRequest : httpRequest)(url, {
351
- signal,
352
- headers: {
353
- accept: 'text/html,application/json,text/plain;q=0.9,*/*;q=0.1',
354
- 'accept-encoding': 'identity',
355
- 'user-agent': 'MyAgentRoam/0.9'
356
- },
357
- ...(url.protocol === 'https:' ? { rejectUnauthorized: !allowUntrustedTls } : {})
358
- }, resolve);
359
- request.once('socket', (socket) => {
360
- if (socket instanceof TLSSocket && !request.reusedSocket) {
361
- socket.once('connect', () => {
362
- phase = 'tls';
363
- });
364
- socket.once('secureConnect', () => {
365
- phase = 'request';
366
- });
367
- }
368
- });
369
- request.once('error', (error) => reject(upstreamFailure(error, 'http', phase)));
370
- request.end();
371
- });
372
- }
@@ -0,0 +1,16 @@
1
+ import TurndownService from 'turndown';
2
+ import { gfm } from 'turndown-plugin-gfm';
3
+ import { WEB_FETCH_MAX_BYTES } from './web-fetch-contracts.js';
4
+ const converter = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
5
+ converter.use(gfm);
6
+ export function convertWebDocument(content, html) {
7
+ const text = html ? converter.turndown(content) : content;
8
+ const bytes = Buffer.from(text);
9
+ const truncated = bytes.length > WEB_FETCH_MAX_BYTES;
10
+ return {
11
+ content: new TextDecoder().decode(bytes.subarray(0, WEB_FETCH_MAX_BYTES), {
12
+ stream: truncated
13
+ }),
14
+ truncated
15
+ };
16
+ }