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 +1 -1
- package/dist/{chunk-TNKXXCTQ.js → chunk-Y2NMKJOG.js} +273 -126
- package/dist/chunk-Y2NMKJOG.js.map +1 -0
- package/dist/cli.js +7 -7
- package/dist/cli.js.map +1 -1
- package/dist/{tui-YGSNFLO7.js → tui-GRDJWXQL.js} +5 -29
- package/dist/tui-GRDJWXQL.js.map +1 -0
- package/package.json +1 -1
- package/dist/chunk-TNKXXCTQ.js.map +0 -1
- package/dist/tui-YGSNFLO7.js.map +0 -1
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:
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
688
|
+
},
|
|
689
|
+
{
|
|
690
|
+
apiBase: JH_API_BASE,
|
|
691
|
+
bearerToken: credentials.bearerToken,
|
|
692
|
+
endpointPath: endpoint,
|
|
693
|
+
requestBody: body
|
|
685
694
|
}
|
|
686
|
-
|
|
687
|
-
{
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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: "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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,
|
|
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 ??
|
|
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: "
|
|
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-
|
|
2326
|
+
//# sourceMappingURL=chunk-Y2NMKJOG.js.map
|