jh-web-gateway 2.1.0 → 2.2.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/README.md CHANGED
@@ -407,7 +407,7 @@ Point any OpenAI-compatible tool at:
407
407
  |------|-------------|
408
408
  | `--headless` | Launch Chrome without a visible window (requires prior login) |
409
409
  | `--port <n>` | Override the configured port |
410
- | `--pages <n>` | Max concurrent browser pages (default: 3) |
410
+ | `--pages <n>` | Max concurrent browser pages (default: 1) |
411
411
 
412
412
  ## Token Refresh
413
413
 
@@ -59,7 +59,7 @@ async function findOrOpenJhPage(browser) {
59
59
  const contexts = browser.contexts();
60
60
  const context = contexts.length > 0 ? contexts[0] : await browser.newContext();
61
61
  const page = await context.newPage();
62
- await page.goto(JH_URL);
62
+ await page.goto(JH_URL, { waitUntil: "domcontentloaded", timeout: 3e4 });
63
63
  return page;
64
64
  }
65
65
 
@@ -638,102 +638,103 @@ async function sendChatRequestInner(page, credentials, request, options, isRetry
638
638
  }
639
639
  };
640
640
  await page.route(streamPattern, routeHandler);
641
- const postResult = await page.evaluate(
642
- async ({
643
- apiBase,
644
- bearerToken,
645
- endpointPath,
646
- requestBody
647
- }) => {
648
- try {
649
- const res = await fetch(`${apiBase}/agents/chat/${endpointPath}`, {
650
- method: "POST",
651
- headers: {
652
- "Content-Type": "application/json",
653
- Accept: "text/event-stream",
654
- Authorization: `Bearer ${bearerToken}`
655
- },
656
- body: JSON.stringify(requestBody)
657
- });
658
- if (!res.ok) {
659
- return {
660
- error: true,
661
- status: res.status,
662
- statusText: res.statusText,
663
- body: (await res.text()).slice(0, 2e3),
664
- contentType: ""
665
- };
666
- }
667
- const ct = res.headers.get("content-type") ?? "";
668
- if (ct.includes("text/event-stream")) {
669
- const reader = res.body?.getReader();
670
- if (!reader) return { error: true, status: 500, statusText: "No body", body: "No SSE body", contentType: ct };
671
- const decoder = new TextDecoder();
672
- let text = "";
673
- while (true) {
674
- const { done, value } = await reader.read();
675
- if (done) break;
676
- text += decoder.decode(value, { stream: true });
641
+ let result;
642
+ try {
643
+ const postResult = await page.evaluate(
644
+ async ({
645
+ apiBase,
646
+ bearerToken,
647
+ endpointPath,
648
+ requestBody
649
+ }) => {
650
+ try {
651
+ const res = await fetch(`${apiBase}/agents/chat/${endpointPath}`, {
652
+ method: "POST",
653
+ headers: {
654
+ "Content-Type": "application/json",
655
+ Accept: "text/event-stream",
656
+ Authorization: `Bearer ${bearerToken}`
657
+ },
658
+ body: JSON.stringify(requestBody)
659
+ });
660
+ if (!res.ok) {
661
+ return {
662
+ error: true,
663
+ status: res.status,
664
+ statusText: res.statusText,
665
+ body: (await res.text()).slice(0, 2e3),
666
+ contentType: ""
667
+ };
677
668
  }
678
- return { error: false, status: 200, statusText: "OK", body: text, contentType: ct };
669
+ const ct = res.headers.get("content-type") ?? "";
670
+ if (ct.includes("text/event-stream")) {
671
+ const reader = res.body?.getReader();
672
+ if (!reader) return { error: true, status: 500, statusText: "No body", body: "No SSE body", contentType: ct };
673
+ const decoder = new TextDecoder();
674
+ let text = "";
675
+ while (true) {
676
+ const { done, value } = await reader.read();
677
+ if (done) break;
678
+ text += decoder.decode(value, { stream: true });
679
+ }
680
+ return { error: false, status: 200, statusText: "OK", body: text, contentType: ct };
681
+ }
682
+ const bodyText = await res.text();
683
+ console.log(`[gateway] POST response content-type: ${ct}, body: ${bodyText.slice(0, 500)}`);
684
+ return { error: false, status: 200, statusText: "OK", body: bodyText, contentType: ct };
685
+ } catch (err) {
686
+ return { error: true, status: 500, statusText: "fetch error", body: String(err), contentType: "" };
679
687
  }
680
- const bodyText = await res.text();
681
- console.log(`[gateway] POST response content-type: ${ct}, body: ${bodyText.slice(0, 500)}`);
682
- return { error: false, status: 200, statusText: "OK", body: bodyText, contentType: ct };
683
- } catch (err) {
684
- return { error: true, status: 500, statusText: "fetch error", body: String(err), contentType: "" };
688
+ },
689
+ {
690
+ apiBase: JH_API_BASE,
691
+ bearerToken: credentials.bearerToken,
692
+ endpointPath: endpoint,
693
+ requestBody: body
685
694
  }
686
- },
687
- {
688
- apiBase: JH_API_BASE,
689
- bearerToken: credentials.bearerToken,
690
- endpointPath: endpoint,
691
- requestBody: body
692
- }
693
- );
694
- let result;
695
- if (postResult.error) {
696
- result = { error: true, status: postResult.status, statusText: postResult.statusText ?? "", body: postResult.body };
697
- await page.unroute(streamPattern, routeHandler);
698
- } else if (postResult.contentType.includes("text/event-stream")) {
699
- result = { error: false, status: 200, statusText: "OK", body: postResult.body };
700
- await page.unroute(streamPattern, routeHandler);
701
- } else {
702
- let streamId;
703
- try {
704
- streamId = JSON.parse(postResult.body).streamId;
705
- } catch {
706
- }
707
- if (!streamId) {
695
+ );
696
+ if (postResult.error) {
697
+ result = { error: true, status: postResult.status, statusText: postResult.statusText ?? "", body: postResult.body };
698
+ } else if (postResult.contentType.includes("text/event-stream")) {
708
699
  result = { error: false, status: 200, statusText: "OK", body: postResult.body };
709
- await page.unroute(streamPattern, routeHandler);
710
700
  } else {
711
- const streamUrl = `${JH_API_BASE}/agents/chat/stream/${streamId}`;
712
- page.evaluate(
713
- async ({ url, token }) => {
714
- await new Promise((r) => setTimeout(r, 10));
715
- try {
716
- await fetch(url, {
717
- method: "GET",
718
- headers: { Accept: "text/event-stream", Authorization: `Bearer ${token}` }
719
- });
720
- } catch {
721
- }
722
- },
723
- { url: streamUrl, token: credentials.bearerToken }
724
- ).catch(() => {
725
- });
726
- const timeout = new Promise(
727
- (res) => setTimeout(() => {
728
- if (!sseResolved) {
729
- sseResolved = true;
730
- res({ error: true, status: 408, statusText: "timeout", body: "Stream capture timed out after 120s" });
731
- }
732
- }, 12e4)
733
- );
734
- result = await Promise.race([ssePromise, timeout]);
735
- await page.unroute(streamPattern, routeHandler);
701
+ let streamId;
702
+ try {
703
+ streamId = JSON.parse(postResult.body).streamId;
704
+ } catch {
705
+ }
706
+ if (!streamId) {
707
+ result = { error: false, status: 200, statusText: "OK", body: postResult.body };
708
+ } else {
709
+ const streamUrl = `${JH_API_BASE}/agents/chat/stream/${streamId}`;
710
+ page.evaluate(
711
+ async ({ url, token }) => {
712
+ await new Promise((r) => setTimeout(r, 10));
713
+ try {
714
+ await fetch(url, {
715
+ method: "GET",
716
+ headers: { Accept: "text/event-stream", Authorization: `Bearer ${token}` }
717
+ });
718
+ } catch {
719
+ }
720
+ },
721
+ { url: streamUrl, token: credentials.bearerToken }
722
+ ).catch(() => {
723
+ });
724
+ const timeout = new Promise(
725
+ (res) => setTimeout(() => {
726
+ if (!sseResolved) {
727
+ sseResolved = true;
728
+ res({ error: true, status: 408, statusText: "timeout", body: "Stream capture timed out after 120s" });
729
+ }
730
+ }, 12e4)
731
+ );
732
+ result = await Promise.race([ssePromise, timeout]);
733
+ }
736
734
  }
735
+ } finally {
736
+ await page.unroute(streamPattern, routeHandler).catch(() => {
737
+ });
737
738
  }
738
739
  if (result.error) {
739
740
  const status = result.status;
@@ -741,7 +742,7 @@ async function sendChatRequestInner(page, credentials, request, options, isRetry
741
742
  if (status === 401 && !isRetry) {
742
743
  const cdpUrl = options?.cdpUrl ?? "http://127.0.0.1:9222";
743
744
  try {
744
- await page.reload({ waitUntil: "networkidle" });
745
+ await page.reload({ waitUntil: "commit" });
745
746
  const fresh = await captureCredentials(cdpUrl, 3e4);
746
747
  const newCreds = {
747
748
  bearerToken: fresh.bearerToken,
@@ -849,11 +850,12 @@ function modelsRouter(_config) {
849
850
 
850
851
  // src/routes/health.ts
851
852
  import { Hono as Hono2 } from "hono";
852
- function healthRouter(config, startTime) {
853
+ function healthRouter(config, startTime, deps) {
853
854
  const app = new Hono2();
854
855
  app.get("/", (c) => {
855
856
  const uptime = (Date.now() - startTime) / 1e3;
856
- const tokenExpiry = config.credentials?.bearerToken ? getTokenExpiry(config.credentials.bearerToken) || null : null;
857
+ const liveCreds = deps?.getCredentials?.() ?? config.credentials;
858
+ const tokenExpiry = liveCreds?.bearerToken ? getTokenExpiry(liveCreds.bearerToken) || null : null;
857
859
  const tokenExpired = tokenExpiry !== null && Date.now() / 1e3 > tokenExpiry;
858
860
  return c.json({
859
861
  status: "ok",
@@ -958,6 +960,7 @@ import { randomBytes as randomBytes2 } from "crypto";
958
960
  // src/core/tool-parser.ts
959
961
  var TOOL_CALL_RE = /<tool_call\s+id="([^"]*)"\s+name="([^"]*)">([\s\S]*?)<\/tool_call>/g;
960
962
  var THINK_RE = /<think>([\s\S]*?)<\/think>/g;
963
+ var PARTIAL_TOOL_CALL_RE = /<tool_call\b[^>]*>(?:(?!<\/tool_call>)[\s\S])*$/;
961
964
  function parseToolsAndThinking(text) {
962
965
  const toolCalls = [];
963
966
  let thinking = null;
@@ -999,6 +1002,55 @@ function toOpenAIToolCalls(calls) {
999
1002
  }
1000
1003
  }));
1001
1004
  }
1005
+ var StreamingToolBuffer = class {
1006
+ buffer = "";
1007
+ /**
1008
+ * Push a text chunk. Returns an object with:
1009
+ * - `text`: safe-to-emit text (outside any partial tag)
1010
+ * - `completedCalls`: fully parsed tool calls from this chunk
1011
+ */
1012
+ push(chunk) {
1013
+ this.buffer += chunk;
1014
+ const completedCalls = [];
1015
+ let safeText = "";
1016
+ let match;
1017
+ const re = new RegExp(TOOL_CALL_RE.source, "g");
1018
+ let lastIndex = 0;
1019
+ while ((match = re.exec(this.buffer)) !== null) {
1020
+ safeText += this.buffer.slice(lastIndex, match.index);
1021
+ const id = match[1];
1022
+ const name = match[2];
1023
+ const rawArgs = match[3].trim();
1024
+ let args;
1025
+ try {
1026
+ JSON.parse(rawArgs);
1027
+ args = rawArgs;
1028
+ } catch {
1029
+ args = JSON.stringify(rawArgs);
1030
+ }
1031
+ completedCalls.push({ id, name, arguments: args });
1032
+ lastIndex = match.index + match[0].length;
1033
+ }
1034
+ const remainder = this.buffer.slice(lastIndex);
1035
+ if (PARTIAL_TOOL_CALL_RE.test(remainder)) {
1036
+ const partialStart = remainder.search(/<tool_call\b/);
1037
+ if (partialStart > 0) {
1038
+ safeText += remainder.slice(0, partialStart);
1039
+ }
1040
+ this.buffer = partialStart >= 0 ? remainder.slice(partialStart) : remainder;
1041
+ } else {
1042
+ safeText += remainder;
1043
+ this.buffer = "";
1044
+ }
1045
+ return { text: safeText, completedCalls };
1046
+ }
1047
+ /** Flush any remaining buffer content as raw text (end of stream). */
1048
+ flush() {
1049
+ const remaining = this.buffer;
1050
+ this.buffer = "";
1051
+ return remaining;
1052
+ }
1053
+ };
1002
1054
 
1003
1055
  // src/core/stream-translator.ts
1004
1056
  function parseSseEvents(rawSse) {
@@ -1107,6 +1159,9 @@ function translateToStream(rawSse, model, completionId) {
1107
1159
  const events = parseSseEvents(rawSse);
1108
1160
  const chunks = [];
1109
1161
  let lastMessageText = "";
1162
+ const toolBuf = new StreamingToolBuffer();
1163
+ let toolCallIndex = 0;
1164
+ let hasToolCalls = false;
1110
1165
  chunks.push({
1111
1166
  id,
1112
1167
  object: "chat.completion.chunk",
@@ -1115,6 +1170,37 @@ function translateToStream(rawSse, model, completionId) {
1115
1170
  choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }]
1116
1171
  });
1117
1172
  let gotDeltas = false;
1173
+ function processDelta(rawDelta) {
1174
+ const { text, completedCalls } = toolBuf.push(rawDelta);
1175
+ if (text) {
1176
+ chunks.push({
1177
+ id,
1178
+ object: "chat.completion.chunk",
1179
+ created,
1180
+ model,
1181
+ choices: [{ index: 0, delta: { content: text }, finish_reason: null }]
1182
+ });
1183
+ }
1184
+ for (const call of completedCalls) {
1185
+ hasToolCalls = true;
1186
+ const toolDelta = {
1187
+ index: toolCallIndex++,
1188
+ id: call.id,
1189
+ type: "function",
1190
+ function: {
1191
+ name: call.name,
1192
+ arguments: call.arguments
1193
+ }
1194
+ };
1195
+ chunks.push({
1196
+ id,
1197
+ object: "chat.completion.chunk",
1198
+ created,
1199
+ model,
1200
+ choices: [{ index: 0, delta: { tool_calls: [toolDelta] }, finish_reason: null }]
1201
+ });
1202
+ }
1203
+ }
1118
1204
  for (const ev of events) {
1119
1205
  const { type, parsed } = resolveEventType(ev);
1120
1206
  if (isUserEcho(parsed)) continue;
@@ -1123,13 +1209,7 @@ function translateToStream(rawSse, model, completionId) {
1123
1209
  const delta = extractDeltaText(parsed);
1124
1210
  if (delta !== null) {
1125
1211
  gotDeltas = true;
1126
- chunks.push({
1127
- id,
1128
- object: "chat.completion.chunk",
1129
- created,
1130
- model,
1131
- choices: [{ index: 0, delta: { content: delta }, finish_reason: null }]
1132
- });
1212
+ processDelta(delta);
1133
1213
  }
1134
1214
  }
1135
1215
  if (type === "message" || !type) {
@@ -1138,23 +1218,27 @@ function translateToStream(rawSse, model, completionId) {
1138
1218
  const delta = msgText.slice(lastMessageText.length);
1139
1219
  lastMessageText = msgText;
1140
1220
  if (!gotDeltas && delta) {
1141
- chunks.push({
1142
- id,
1143
- object: "chat.completion.chunk",
1144
- created,
1145
- model,
1146
- choices: [{ index: 0, delta: { content: delta }, finish_reason: null }]
1147
- });
1221
+ processDelta(delta);
1148
1222
  }
1149
1223
  }
1150
1224
  }
1151
1225
  }
1226
+ const flushed = toolBuf.flush();
1227
+ if (flushed) {
1228
+ chunks.push({
1229
+ id,
1230
+ object: "chat.completion.chunk",
1231
+ created,
1232
+ model,
1233
+ choices: [{ index: 0, delta: { content: flushed }, finish_reason: null }]
1234
+ });
1235
+ }
1152
1236
  chunks.push({
1153
1237
  id,
1154
1238
  object: "chat.completion.chunk",
1155
1239
  created,
1156
1240
  model,
1157
- choices: [{ index: 0, delta: {}, finish_reason: "stop" }]
1241
+ choices: [{ index: 0, delta: {}, finish_reason: hasToolCalls ? "tool_calls" : "stop" }]
1158
1242
  });
1159
1243
  return chunks;
1160
1244
  }
@@ -1177,7 +1261,7 @@ function translateToCompletion(rawSse, model, completionId) {
1177
1261
  object: "chat.completion",
1178
1262
  created,
1179
1263
  model,
1180
- choices: [{ index: 0, message, finish_reason: "stop" }],
1264
+ choices: [{ index: 0, message, finish_reason: parsed.toolCalls.length > 0 ? "tool_calls" : "stop" }],
1181
1265
  usage: {
1182
1266
  prompt_tokens: promptTokens,
1183
1267
  completion_tokens: completionTokens,
@@ -1288,11 +1372,14 @@ function chatCompletionsRouter(_config, deps) {
1288
1372
  const { page, queue, release } = acquired;
1289
1373
  const stats = pool.stats;
1290
1374
  console.log(`[chat] Acquired page (pool: ${stats.busy}/${stats.total} busy)`);
1375
+ const fullPrompt = built.systemPrompt ? `${built.systemPrompt}
1376
+
1377
+ ${built.prompt}` : built.prompt;
1291
1378
  try {
1292
1379
  const response = await queue.enqueue(
1293
1380
  () => sendChatRequest(page, credentials, {
1294
1381
  model,
1295
- prompt: built.prompt
1382
+ prompt: fullPrompt
1296
1383
  })
1297
1384
  );
1298
1385
  if (shouldStream) {
@@ -1349,7 +1436,7 @@ function chatCompletionsRouter(_config, deps) {
1349
1436
  const retryResponse = await retry.queue.enqueue(
1350
1437
  () => sendChatRequest(retry.page, freshCreds, {
1351
1438
  model,
1352
- prompt: built.prompt
1439
+ prompt: fullPrompt
1353
1440
  })
1354
1441
  );
1355
1442
  if (shouldStream) {
@@ -1559,7 +1646,7 @@ function createServer(config, deps) {
1559
1646
  });
1560
1647
  });
1561
1648
  app.route("/v1/models", modelsRouter(config));
1562
- app.route("/health", healthRouter(config, startTime));
1649
+ app.route("/health", healthRouter(config, startTime, deps ? { getCredentials: deps.getCredentials } : void 0));
1563
1650
  if (deps) {
1564
1651
  app.route(
1565
1652
  "/v1/chat/completions",
@@ -1638,14 +1725,6 @@ async function startServer(config, deps) {
1638
1725
  }
1639
1726
  console.log("Shutdown complete.");
1640
1727
  };
1641
- process.on("SIGINT", async () => {
1642
- await shutdown();
1643
- process.exit(0);
1644
- });
1645
- process.on("SIGTERM", async () => {
1646
- await shutdown();
1647
- process.exit(0);
1648
- });
1649
1728
  return { close: shutdown };
1650
1729
  }
1651
1730
 
@@ -1668,7 +1747,7 @@ var RequestQueue = class {
1668
1747
  return await task();
1669
1748
  } finally {
1670
1749
  if (this.queue.length > 0) {
1671
- await new Promise((r) => setTimeout(r, 300));
1750
+ await new Promise((r) => setTimeout(r, 100));
1672
1751
  }
1673
1752
  this.release();
1674
1753
  }
@@ -1717,9 +1796,11 @@ var PagePool = class {
1717
1796
  maxWaitMs;
1718
1797
  initPromise = null;
1719
1798
  pagesCreating = 0;
1799
+ warmedUp = false;
1800
+ disconnected = false;
1720
1801
  constructor(options = {}) {
1721
1802
  this.targetUrl = options.targetUrl ?? "https://chat.ai.jh.edu";
1722
- this.maxPages = options.maxPages ?? 3;
1803
+ this.maxPages = options.maxPages ?? 1;
1723
1804
  this.maxWaitMs = options.maxWaitMs ?? 12e4;
1724
1805
  }
1725
1806
  /** Initialize the pool with an existing browser connection and seed page */
@@ -1730,6 +1811,11 @@ var PagePool = class {
1730
1811
  }
1731
1812
  async _doInit(browser, seedPage) {
1732
1813
  this.browser = browser;
1814
+ this.disconnected = false;
1815
+ browser.on("disconnected", () => {
1816
+ console.warn("[PagePool] Browser disconnected \u2014 all pages are now invalid");
1817
+ this.disconnected = true;
1818
+ });
1733
1819
  this.pages.push({
1734
1820
  page: seedPage,
1735
1821
  queue: new RequestQueue(this.maxWaitMs),
@@ -1750,10 +1836,21 @@ var PagePool = class {
1750
1836
  * Acquire a page for use. Creates new pages on-demand up to maxPages.
1751
1837
  * Note: We intentionally don't lock here — allowing multiple requests to
1752
1838
  * grab the same page and queue on it is actually faster than creating new pages.
1839
+ *
1840
+ * On first init (before any request succeeds), page scaling is disabled to
1841
+ * avoid opening new Chrome tabs that may redirect through SSO and hang.
1842
+ * Call `markWarmedUp()` after the first successful request to enable scaling.
1753
1843
  */
1754
1844
  async acquire() {
1845
+ if (this.disconnected) {
1846
+ throw Object.assign(
1847
+ new Error("Browser has disconnected. Restart the gateway to reconnect."),
1848
+ { statusCode: 503 }
1849
+ );
1850
+ }
1851
+ this.evictDeadPages();
1755
1852
  let pooled = this.pages.find((p2) => !p2.inUse);
1756
- if (!pooled && this.pages.length + this.pagesCreating < this.maxPages && this.browser) {
1853
+ if (!pooled && this.warmedUp && this.pages.length + this.pagesCreating < this.maxPages && this.browser) {
1757
1854
  this.pagesCreating++;
1758
1855
  try {
1759
1856
  pooled = await this.createPage();
@@ -1762,6 +1859,12 @@ var PagePool = class {
1762
1859
  }
1763
1860
  }
1764
1861
  if (!pooled) {
1862
+ if (this.pages.length === 0) {
1863
+ throw Object.assign(
1864
+ new Error("No healthy browser pages available. Restart the gateway."),
1865
+ { statusCode: 503 }
1866
+ );
1867
+ }
1765
1868
  pooled = this.pages.reduce(
1766
1869
  (a, b) => a.queue.pending <= b.queue.pending ? a : b
1767
1870
  );
@@ -1773,9 +1876,23 @@ var PagePool = class {
1773
1876
  queue: p.queue,
1774
1877
  release: () => {
1775
1878
  p.inUse = false;
1879
+ if (!this.warmedUp) {
1880
+ this.warmedUp = true;
1881
+ console.log(`[PagePool] Warm-up complete \u2014 page scaling enabled (max ${this.maxPages})`);
1882
+ if (this.maxPages > 1 && this.pages.length < this.maxPages && this.browser) {
1883
+ this.preWarmPage();
1884
+ }
1885
+ }
1776
1886
  }
1777
1887
  };
1778
1888
  }
1889
+ /** Mark the pool as warmed up, enabling page scaling. */
1890
+ markWarmedUp() {
1891
+ if (!this.warmedUp) {
1892
+ this.warmedUp = true;
1893
+ console.log(`[PagePool] Warm-up complete \u2014 page scaling enabled (max ${this.maxPages})`);
1894
+ }
1895
+ }
1779
1896
  async createPage() {
1780
1897
  if (!this.browser) {
1781
1898
  throw new Error("PagePool not initialized");
@@ -1787,7 +1904,7 @@ var PagePool = class {
1787
1904
  }
1788
1905
  const page = await context.newPage();
1789
1906
  try {
1790
- await page.goto(this.targetUrl, { waitUntil: "networkidle", timeout: 3e4 });
1907
+ await page.goto(this.targetUrl, { waitUntil: "domcontentloaded", timeout: 3e4 });
1791
1908
  const finalUrl = page.url();
1792
1909
  if (!finalUrl.includes("chat.ai.jh.edu")) {
1793
1910
  throw new Error(
@@ -1808,6 +1925,36 @@ var PagePool = class {
1808
1925
  throw err;
1809
1926
  }
1810
1927
  }
1928
+ /** Evict pages that have crashed or navigated away from JH */
1929
+ evictDeadPages() {
1930
+ const before = this.pages.length;
1931
+ this.pages = this.pages.filter((p) => {
1932
+ try {
1933
+ if (p.page.isClosed()) return false;
1934
+ const url = p.page.url();
1935
+ if (!url.includes("chat.ai.jh.edu")) {
1936
+ console.warn(`[PagePool] Evicting page \u2014 navigated away: ${url}`);
1937
+ p.page.close().catch(() => {
1938
+ });
1939
+ return false;
1940
+ }
1941
+ return true;
1942
+ } catch {
1943
+ return false;
1944
+ }
1945
+ });
1946
+ const evicted = before - this.pages.length;
1947
+ if (evicted > 0) {
1948
+ console.warn(`[PagePool] Evicted ${evicted} dead/stale page(s)`);
1949
+ }
1950
+ }
1951
+ /** Pre-warm a new page in the background (fire-and-forget) */
1952
+ preWarmPage() {
1953
+ this.pagesCreating++;
1954
+ this.createPage().then(() => console.log("[PagePool] Pre-warmed a new page")).catch((err) => console.warn(`[PagePool] Pre-warm failed: ${err.message}`)).finally(() => {
1955
+ this.pagesCreating--;
1956
+ });
1957
+ }
1811
1958
  /** Close all pages except the seed page */
1812
1959
  async drain() {
1813
1960
  const toClose = this.pages.slice(1);
@@ -2176,4 +2323,4 @@ export {
2176
2323
  TokenRefresher,
2177
2324
  ChromeManager
2178
2325
  };
2179
- //# sourceMappingURL=chunk-TNKXXCTQ.js.map
2326
+ //# sourceMappingURL=chunk-Y2NMKJOG.js.map