jh-web-gateway 2.1.1 → 2.3.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.
@@ -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",
@@ -956,8 +958,11 @@ function escapeXmlAttr(s) {
956
958
  import { randomBytes as randomBytes2 } from "crypto";
957
959
 
958
960
  // src/core/tool-parser.ts
959
- var TOOL_CALL_RE = /<tool_call\s+id="([^"]*)"\s+name="([^"]*)">([\s\S]*?)<\/tool_call>/g;
961
+ var TOOL_CALL_RE = /<tool_call\s+(?=[^>]*\bid="([^"]*)")(?=[^>]*\bname="([^"]*)")(?:[^>]*)>([\s\S]*?)<\/tool_call>/g;
962
+ var TOOL_RESPONSE_RE = /<tool_response\b[^>]*>[\s\S]*?<\/tool_response>/g;
960
963
  var THINK_RE = /<think>([\s\S]*?)<\/think>/g;
964
+ var PARTIAL_TOOL_CALL_RE = /<tool_call\b[^>]*>(?:(?!<\/tool_call>)[\s\S])*$/;
965
+ var PARTIAL_TOOL_RESPONSE_RE = /<tool_response\b[^>]*>(?:(?!<\/tool_response>)[\s\S])*$/;
961
966
  function parseToolsAndThinking(text) {
962
967
  const toolCalls = [];
963
968
  let thinking = null;
@@ -966,6 +971,7 @@ function parseToolsAndThinking(text) {
966
971
  thinking = thinkMatches.map((m) => m[1]).join("\n");
967
972
  }
968
973
  let remaining = text.replace(THINK_RE, "");
974
+ remaining = remaining.replace(TOOL_RESPONSE_RE, "");
969
975
  const toolMatches = [...remaining.matchAll(TOOL_CALL_RE)];
970
976
  for (const match of toolMatches) {
971
977
  const id = match[1];
@@ -999,6 +1005,107 @@ function toOpenAIToolCalls(calls) {
999
1005
  }
1000
1006
  }));
1001
1007
  }
1008
+ var WATCHED_TAGS = ["<tool_call", "<tool_response"];
1009
+ var MAX_TAG_PREFIX_LEN = Math.max(...WATCHED_TAGS.map((t) => t.length));
1010
+ function findWatchedTagPrefixAtEnd(text) {
1011
+ const maxLen = Math.min(text.length, MAX_TAG_PREFIX_LEN);
1012
+ for (let len = maxLen; len >= 1; len--) {
1013
+ const tail = text.slice(-len);
1014
+ for (const tag of WATCHED_TAGS) {
1015
+ if (tag.startsWith(tail)) {
1016
+ return text.length - len;
1017
+ }
1018
+ }
1019
+ }
1020
+ return -1;
1021
+ }
1022
+ function findPartialWatchedTag(text) {
1023
+ if (PARTIAL_TOOL_RESPONSE_RE.test(text)) {
1024
+ const idx = text.search(/<tool_response\b/);
1025
+ if (idx >= 0) return idx;
1026
+ }
1027
+ if (PARTIAL_TOOL_CALL_RE.test(text)) {
1028
+ const idx = text.search(/<tool_call\b/);
1029
+ if (idx >= 0) return idx;
1030
+ }
1031
+ return -1;
1032
+ }
1033
+ function findUnclosedWatchedTag(text) {
1034
+ for (const tag of WATCHED_TAGS) {
1035
+ const idx = text.lastIndexOf(tag);
1036
+ if (idx >= 0) {
1037
+ const afterTag = text.slice(idx);
1038
+ if (!afterTag.includes(">")) {
1039
+ return idx;
1040
+ }
1041
+ }
1042
+ }
1043
+ return -1;
1044
+ }
1045
+ var StreamingToolBuffer = class {
1046
+ buffer = "";
1047
+ /**
1048
+ * Push a text chunk. Returns an object with:
1049
+ * - `text`: safe-to-emit text (outside any partial/complete tag)
1050
+ * - `completedCalls`: fully parsed tool calls from this chunk
1051
+ */
1052
+ push(chunk) {
1053
+ this.buffer += chunk;
1054
+ const completedCalls = [];
1055
+ let safeText = "";
1056
+ this.buffer = this.buffer.replace(TOOL_RESPONSE_RE, (match2, offset) => {
1057
+ return "";
1058
+ });
1059
+ let match;
1060
+ const re = new RegExp(TOOL_CALL_RE.source, "g");
1061
+ let lastIndex = 0;
1062
+ while ((match = re.exec(this.buffer)) !== null) {
1063
+ safeText += this.buffer.slice(lastIndex, match.index);
1064
+ const id = match[1];
1065
+ const name = match[2];
1066
+ const rawArgs = match[3].trim();
1067
+ let args;
1068
+ try {
1069
+ JSON.parse(rawArgs);
1070
+ args = rawArgs;
1071
+ } catch {
1072
+ args = JSON.stringify(rawArgs);
1073
+ }
1074
+ completedCalls.push({ id, name, arguments: args });
1075
+ lastIndex = match.index + match[0].length;
1076
+ }
1077
+ const remainder = this.buffer.slice(lastIndex);
1078
+ const partialIdx = findPartialWatchedTag(remainder);
1079
+ if (partialIdx >= 0) {
1080
+ if (partialIdx > 0) {
1081
+ safeText += remainder.slice(0, partialIdx);
1082
+ }
1083
+ this.buffer = remainder.slice(partialIdx);
1084
+ } else {
1085
+ const unclosedIdx = findUnclosedWatchedTag(remainder);
1086
+ if (unclosedIdx >= 0) {
1087
+ safeText += remainder.slice(0, unclosedIdx);
1088
+ this.buffer = remainder.slice(unclosedIdx);
1089
+ } else {
1090
+ const prefixStart = findWatchedTagPrefixAtEnd(remainder);
1091
+ if (prefixStart >= 0) {
1092
+ safeText += remainder.slice(0, prefixStart);
1093
+ this.buffer = remainder.slice(prefixStart);
1094
+ } else {
1095
+ safeText += remainder;
1096
+ this.buffer = "";
1097
+ }
1098
+ }
1099
+ }
1100
+ return { text: safeText, completedCalls };
1101
+ }
1102
+ /** Flush any remaining buffer content as raw text (end of stream). */
1103
+ flush() {
1104
+ const remaining = this.buffer;
1105
+ this.buffer = "";
1106
+ return remaining;
1107
+ }
1108
+ };
1002
1109
 
1003
1110
  // src/core/stream-translator.ts
1004
1111
  function parseSseEvents(rawSse) {
@@ -1107,6 +1214,9 @@ function translateToStream(rawSse, model, completionId) {
1107
1214
  const events = parseSseEvents(rawSse);
1108
1215
  const chunks = [];
1109
1216
  let lastMessageText = "";
1217
+ const toolBuf = new StreamingToolBuffer();
1218
+ let toolCallIndex = 0;
1219
+ let hasToolCalls = false;
1110
1220
  chunks.push({
1111
1221
  id,
1112
1222
  object: "chat.completion.chunk",
@@ -1115,6 +1225,37 @@ function translateToStream(rawSse, model, completionId) {
1115
1225
  choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }]
1116
1226
  });
1117
1227
  let gotDeltas = false;
1228
+ function processDelta(rawDelta) {
1229
+ const { text, completedCalls } = toolBuf.push(rawDelta);
1230
+ if (text) {
1231
+ chunks.push({
1232
+ id,
1233
+ object: "chat.completion.chunk",
1234
+ created,
1235
+ model,
1236
+ choices: [{ index: 0, delta: { content: text }, finish_reason: null }]
1237
+ });
1238
+ }
1239
+ for (const call of completedCalls) {
1240
+ hasToolCalls = true;
1241
+ const toolDelta = {
1242
+ index: toolCallIndex++,
1243
+ id: call.id,
1244
+ type: "function",
1245
+ function: {
1246
+ name: call.name,
1247
+ arguments: call.arguments
1248
+ }
1249
+ };
1250
+ chunks.push({
1251
+ id,
1252
+ object: "chat.completion.chunk",
1253
+ created,
1254
+ model,
1255
+ choices: [{ index: 0, delta: { tool_calls: [toolDelta] }, finish_reason: null }]
1256
+ });
1257
+ }
1258
+ }
1118
1259
  for (const ev of events) {
1119
1260
  const { type, parsed } = resolveEventType(ev);
1120
1261
  if (isUserEcho(parsed)) continue;
@@ -1123,13 +1264,7 @@ function translateToStream(rawSse, model, completionId) {
1123
1264
  const delta = extractDeltaText(parsed);
1124
1265
  if (delta !== null) {
1125
1266
  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
- });
1267
+ processDelta(delta);
1133
1268
  }
1134
1269
  }
1135
1270
  if (type === "message" || !type) {
@@ -1138,23 +1273,27 @@ function translateToStream(rawSse, model, completionId) {
1138
1273
  const delta = msgText.slice(lastMessageText.length);
1139
1274
  lastMessageText = msgText;
1140
1275
  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
- });
1276
+ processDelta(delta);
1148
1277
  }
1149
1278
  }
1150
1279
  }
1151
1280
  }
1281
+ const flushed = toolBuf.flush();
1282
+ if (flushed) {
1283
+ chunks.push({
1284
+ id,
1285
+ object: "chat.completion.chunk",
1286
+ created,
1287
+ model,
1288
+ choices: [{ index: 0, delta: { content: flushed }, finish_reason: null }]
1289
+ });
1290
+ }
1152
1291
  chunks.push({
1153
1292
  id,
1154
1293
  object: "chat.completion.chunk",
1155
1294
  created,
1156
1295
  model,
1157
- choices: [{ index: 0, delta: {}, finish_reason: "stop" }]
1296
+ choices: [{ index: 0, delta: {}, finish_reason: hasToolCalls ? "tool_calls" : "stop" }]
1158
1297
  });
1159
1298
  return chunks;
1160
1299
  }
@@ -1177,7 +1316,7 @@ function translateToCompletion(rawSse, model, completionId) {
1177
1316
  object: "chat.completion",
1178
1317
  created,
1179
1318
  model,
1180
- choices: [{ index: 0, message, finish_reason: "stop" }],
1319
+ choices: [{ index: 0, message, finish_reason: parsed.toolCalls.length > 0 ? "tool_calls" : "stop" }],
1181
1320
  usage: {
1182
1321
  prompt_tokens: promptTokens,
1183
1322
  completion_tokens: completionTokens,
@@ -1288,11 +1427,14 @@ function chatCompletionsRouter(_config, deps) {
1288
1427
  const { page, queue, release } = acquired;
1289
1428
  const stats = pool.stats;
1290
1429
  console.log(`[chat] Acquired page (pool: ${stats.busy}/${stats.total} busy)`);
1430
+ const fullPrompt = built.systemPrompt ? `${built.systemPrompt}
1431
+
1432
+ ${built.prompt}` : built.prompt;
1291
1433
  try {
1292
1434
  const response = await queue.enqueue(
1293
1435
  () => sendChatRequest(page, credentials, {
1294
1436
  model,
1295
- prompt: built.prompt
1437
+ prompt: fullPrompt
1296
1438
  })
1297
1439
  );
1298
1440
  if (shouldStream) {
@@ -1349,7 +1491,7 @@ function chatCompletionsRouter(_config, deps) {
1349
1491
  const retryResponse = await retry.queue.enqueue(
1350
1492
  () => sendChatRequest(retry.page, freshCreds, {
1351
1493
  model,
1352
- prompt: built.prompt
1494
+ prompt: fullPrompt
1353
1495
  })
1354
1496
  );
1355
1497
  if (shouldStream) {
@@ -1523,10 +1665,27 @@ var Logger = class {
1523
1665
  };
1524
1666
 
1525
1667
  // src/server.ts
1668
+ function requestTrackerMiddleware(tracker) {
1669
+ return async (c, next) => {
1670
+ const id = crypto.randomUUID();
1671
+ c.set("requestId", id);
1672
+ tracker.start(id, c.req.method, c.req.path);
1673
+ try {
1674
+ await next();
1675
+ tracker.end(id, c.res.status);
1676
+ } catch (err) {
1677
+ tracker.end(id, 500);
1678
+ throw err;
1679
+ }
1680
+ };
1681
+ }
1526
1682
  function createServer(config, deps) {
1527
1683
  const app = new Hono4();
1528
1684
  const startTime = Date.now();
1529
1685
  const logger = new Logger();
1686
+ if (deps?.requestTracker) {
1687
+ app.use("*", requestTrackerMiddleware(deps.requestTracker));
1688
+ }
1530
1689
  app.use("/v1/*", authMiddleware(config));
1531
1690
  app.use("*", async (c, next) => {
1532
1691
  const start = Date.now();
@@ -1559,7 +1718,7 @@ function createServer(config, deps) {
1559
1718
  });
1560
1719
  });
1561
1720
  app.route("/v1/models", modelsRouter(config));
1562
- app.route("/health", healthRouter(config, startTime));
1721
+ app.route("/health", healthRouter(config, startTime, deps ? { getCredentials: deps.getCredentials } : void 0));
1563
1722
  if (deps) {
1564
1723
  app.route(
1565
1724
  "/v1/chat/completions",
@@ -1638,14 +1797,6 @@ async function startServer(config, deps) {
1638
1797
  }
1639
1798
  console.log("Shutdown complete.");
1640
1799
  };
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
1800
  return { close: shutdown };
1650
1801
  }
1651
1802
 
@@ -1668,7 +1819,7 @@ var RequestQueue = class {
1668
1819
  return await task();
1669
1820
  } finally {
1670
1821
  if (this.queue.length > 0) {
1671
- await new Promise((r) => setTimeout(r, 300));
1822
+ await new Promise((r) => setTimeout(r, 100));
1672
1823
  }
1673
1824
  this.release();
1674
1825
  }
@@ -1718,6 +1869,7 @@ var PagePool = class {
1718
1869
  initPromise = null;
1719
1870
  pagesCreating = 0;
1720
1871
  warmedUp = false;
1872
+ disconnected = false;
1721
1873
  constructor(options = {}) {
1722
1874
  this.targetUrl = options.targetUrl ?? "https://chat.ai.jh.edu";
1723
1875
  this.maxPages = options.maxPages ?? 1;
@@ -1731,6 +1883,11 @@ var PagePool = class {
1731
1883
  }
1732
1884
  async _doInit(browser, seedPage) {
1733
1885
  this.browser = browser;
1886
+ this.disconnected = false;
1887
+ browser.on("disconnected", () => {
1888
+ console.warn("[PagePool] Browser disconnected \u2014 all pages are now invalid");
1889
+ this.disconnected = true;
1890
+ });
1734
1891
  this.pages.push({
1735
1892
  page: seedPage,
1736
1893
  queue: new RequestQueue(this.maxWaitMs),
@@ -1757,6 +1914,13 @@ var PagePool = class {
1757
1914
  * Call `markWarmedUp()` after the first successful request to enable scaling.
1758
1915
  */
1759
1916
  async acquire() {
1917
+ if (this.disconnected) {
1918
+ throw Object.assign(
1919
+ new Error("Browser has disconnected. Restart the gateway to reconnect."),
1920
+ { statusCode: 503 }
1921
+ );
1922
+ }
1923
+ this.evictDeadPages();
1760
1924
  let pooled = this.pages.find((p2) => !p2.inUse);
1761
1925
  if (!pooled && this.warmedUp && this.pages.length + this.pagesCreating < this.maxPages && this.browser) {
1762
1926
  this.pagesCreating++;
@@ -1767,6 +1931,12 @@ var PagePool = class {
1767
1931
  }
1768
1932
  }
1769
1933
  if (!pooled) {
1934
+ if (this.pages.length === 0) {
1935
+ throw Object.assign(
1936
+ new Error("No healthy browser pages available. Restart the gateway."),
1937
+ { statusCode: 503 }
1938
+ );
1939
+ }
1770
1940
  pooled = this.pages.reduce(
1771
1941
  (a, b) => a.queue.pending <= b.queue.pending ? a : b
1772
1942
  );
@@ -1781,6 +1951,9 @@ var PagePool = class {
1781
1951
  if (!this.warmedUp) {
1782
1952
  this.warmedUp = true;
1783
1953
  console.log(`[PagePool] Warm-up complete \u2014 page scaling enabled (max ${this.maxPages})`);
1954
+ if (this.maxPages > 1 && this.pages.length < this.maxPages && this.browser) {
1955
+ this.preWarmPage();
1956
+ }
1784
1957
  }
1785
1958
  }
1786
1959
  };
@@ -1803,7 +1976,7 @@ var PagePool = class {
1803
1976
  }
1804
1977
  const page = await context.newPage();
1805
1978
  try {
1806
- await page.goto(this.targetUrl, { waitUntil: "networkidle", timeout: 3e4 });
1979
+ await page.goto(this.targetUrl, { waitUntil: "domcontentloaded", timeout: 3e4 });
1807
1980
  const finalUrl = page.url();
1808
1981
  if (!finalUrl.includes("chat.ai.jh.edu")) {
1809
1982
  throw new Error(
@@ -1824,6 +1997,36 @@ var PagePool = class {
1824
1997
  throw err;
1825
1998
  }
1826
1999
  }
2000
+ /** Evict pages that have crashed or navigated away from JH */
2001
+ evictDeadPages() {
2002
+ const before = this.pages.length;
2003
+ this.pages = this.pages.filter((p) => {
2004
+ try {
2005
+ if (p.page.isClosed()) return false;
2006
+ const url = p.page.url();
2007
+ if (!url.includes("chat.ai.jh.edu")) {
2008
+ console.warn(`[PagePool] Evicting page \u2014 navigated away: ${url}`);
2009
+ p.page.close().catch(() => {
2010
+ });
2011
+ return false;
2012
+ }
2013
+ return true;
2014
+ } catch {
2015
+ return false;
2016
+ }
2017
+ });
2018
+ const evicted = before - this.pages.length;
2019
+ if (evicted > 0) {
2020
+ console.warn(`[PagePool] Evicted ${evicted} dead/stale page(s)`);
2021
+ }
2022
+ }
2023
+ /** Pre-warm a new page in the background (fire-and-forget) */
2024
+ preWarmPage() {
2025
+ this.pagesCreating++;
2026
+ this.createPage().then(() => console.log("[PagePool] Pre-warmed a new page")).catch((err) => console.warn(`[PagePool] Pre-warm failed: ${err.message}`)).finally(() => {
2027
+ this.pagesCreating--;
2028
+ });
2029
+ }
1827
2030
  /** Close all pages except the seed page */
1828
2031
  async drain() {
1829
2032
  const toClose = this.pages.slice(1);
@@ -2192,4 +2395,4 @@ export {
2192
2395
  TokenRefresher,
2193
2396
  ChromeManager
2194
2397
  };
2195
- //# sourceMappingURL=chunk-7H2RJZN3.js.map
2398
+ //# sourceMappingURL=chunk-E6JMUHPA.js.map