jh-web-gateway 1.0.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 +182 -0
- package/dist/cli.js +2469 -0
- package/dist/cli.js.map +1 -0
- package/package.json +43 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2469 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/setup.ts
|
|
4
|
+
import * as p from "@clack/prompts";
|
|
5
|
+
|
|
6
|
+
// src/infra/chrome-cdp.ts
|
|
7
|
+
import { chromium } from "playwright-core";
|
|
8
|
+
var DEFAULT_TIMEOUT_MS = 5e3;
|
|
9
|
+
var JH_URL = "https://chat.ai.jh.edu";
|
|
10
|
+
async function getChromeWebSocketUrl(cdpUrl, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
11
|
+
const controller = new AbortController();
|
|
12
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
const res = await fetch(`${cdpUrl}/json/version`, {
|
|
16
|
+
signal: controller.signal
|
|
17
|
+
});
|
|
18
|
+
raw = await res.text();
|
|
19
|
+
} catch {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`Chrome is not running or remote debugging is not enabled at ${cdpUrl}`
|
|
22
|
+
);
|
|
23
|
+
} finally {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
}
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(raw);
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error(`Unexpected CDP response format: ${raw}`);
|
|
31
|
+
}
|
|
32
|
+
const wsUrl = parsed !== null && typeof parsed === "object" && "webSocketDebuggerUrl" in parsed ? parsed.webSocketDebuggerUrl : void 0;
|
|
33
|
+
if (typeof wsUrl !== "string" || wsUrl.length === 0) {
|
|
34
|
+
throw new Error(`Unexpected CDP response format: ${raw}`);
|
|
35
|
+
}
|
|
36
|
+
return wsUrl;
|
|
37
|
+
}
|
|
38
|
+
async function connectToChrome(cdpUrl) {
|
|
39
|
+
const wsUrl = await getChromeWebSocketUrl(cdpUrl);
|
|
40
|
+
const browser = await chromium.connectOverCDP(wsUrl);
|
|
41
|
+
const contexts = browser.contexts();
|
|
42
|
+
const existingPage = contexts.length > 0 ? contexts[0].pages()[0] : void 0;
|
|
43
|
+
const page = existingPage ?? await (contexts.length > 0 ? contexts[0].newPage() : (await browser.newContext()).newPage());
|
|
44
|
+
return { browser, page };
|
|
45
|
+
}
|
|
46
|
+
async function findOrOpenJhPage(browser) {
|
|
47
|
+
for (const context2 of browser.contexts()) {
|
|
48
|
+
for (const page2 of context2.pages()) {
|
|
49
|
+
if (page2.url().includes("chat.ai.jh.edu")) {
|
|
50
|
+
return page2;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const contexts = browser.contexts();
|
|
55
|
+
const context = contexts.length > 0 ? contexts[0] : await browser.newContext();
|
|
56
|
+
const page = await context.newPage();
|
|
57
|
+
await page.goto(JH_URL);
|
|
58
|
+
return page;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/infra/config.ts
|
|
62
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
63
|
+
import { homedir } from "os";
|
|
64
|
+
import { join } from "path";
|
|
65
|
+
function getConfigPath() {
|
|
66
|
+
return join(homedir(), ".jh-gateway", "config.json");
|
|
67
|
+
}
|
|
68
|
+
function getDefaultConfig() {
|
|
69
|
+
return {
|
|
70
|
+
cdpUrl: "http://127.0.0.1:9222",
|
|
71
|
+
port: 8741,
|
|
72
|
+
defaultModel: "claude-opus-4.5",
|
|
73
|
+
defaultEndpoint: "AnthropicClaude",
|
|
74
|
+
credentials: null,
|
|
75
|
+
auth: { mode: "none", token: null },
|
|
76
|
+
maxQueueWaitMs: 12e4
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function validateConfig(raw) {
|
|
80
|
+
if (typeof raw !== "object" || raw === null) {
|
|
81
|
+
throw new Error("Config validation error: config must be a JSON object");
|
|
82
|
+
}
|
|
83
|
+
const c = raw;
|
|
84
|
+
if (typeof c.cdpUrl !== "string" || !/^https?:\/\//.test(c.cdpUrl)) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
"Config validation error: cdpUrl must be a valid URL starting with http:// or https://"
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (typeof c.port !== "number" || !Number.isInteger(c.port) || c.port < 1 || c.port > 65535) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
"Config validation error: port must be between 1 and 65535"
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (typeof c.defaultModel !== "string" || c.defaultModel.trim() === "") {
|
|
95
|
+
throw new Error(
|
|
96
|
+
"Config validation error: defaultModel must be a non-empty string"
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (typeof c.defaultEndpoint !== "string" || c.defaultEndpoint.trim() === "") {
|
|
100
|
+
throw new Error(
|
|
101
|
+
"Config validation error: defaultEndpoint must be a non-empty string"
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (c.credentials !== null && c.credentials !== void 0) {
|
|
105
|
+
if (typeof c.credentials !== "object" || Array.isArray(c.credentials)) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
"Config validation error: credentials must be null or an object with bearerToken, cookie, and userAgent strings"
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const creds = c.credentials;
|
|
111
|
+
for (const field of ["bearerToken", "cookie", "userAgent"]) {
|
|
112
|
+
if (typeof creds[field] !== "string") {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`Config validation error: credentials.${field} must be a string`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (typeof c.auth !== "object" || c.auth === null || Array.isArray(c.auth)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
"Config validation error: auth must be an object with mode and token fields"
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const auth = c.auth;
|
|
125
|
+
if (auth.mode !== "none" && auth.mode !== "bearer" && auth.mode !== "basic") {
|
|
126
|
+
throw new Error(
|
|
127
|
+
'Config validation error: auth.mode must be "none", "bearer", or "basic"'
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (auth.token !== null && typeof auth.token !== "string") {
|
|
131
|
+
throw new Error(
|
|
132
|
+
"Config validation error: auth.token must be null or a string"
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (typeof c.maxQueueWaitMs !== "number" || c.maxQueueWaitMs <= 0) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
"Config validation error: maxQueueWaitMs must be a positive number"
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
cdpUrl: c.cdpUrl,
|
|
142
|
+
port: c.port,
|
|
143
|
+
defaultModel: c.defaultModel,
|
|
144
|
+
defaultEndpoint: c.defaultEndpoint,
|
|
145
|
+
credentials: c.credentials != null ? {
|
|
146
|
+
bearerToken: c.credentials.bearerToken,
|
|
147
|
+
cookie: c.credentials.cookie,
|
|
148
|
+
userAgent: c.credentials.userAgent,
|
|
149
|
+
expiresAt: typeof c.credentials.expiresAt === "number" ? c.credentials.expiresAt : 0
|
|
150
|
+
} : null,
|
|
151
|
+
auth: {
|
|
152
|
+
mode: auth.mode,
|
|
153
|
+
token: auth.token ?? null
|
|
154
|
+
},
|
|
155
|
+
maxQueueWaitMs: c.maxQueueWaitMs
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
async function loadConfig() {
|
|
159
|
+
const configPath = getConfigPath();
|
|
160
|
+
const configDir = join(homedir(), ".jh-gateway");
|
|
161
|
+
let raw;
|
|
162
|
+
try {
|
|
163
|
+
raw = await readFile(configPath, "utf8");
|
|
164
|
+
} catch (err) {
|
|
165
|
+
if (isNodeError(err) && (err.code === "ENOENT" || err.code === "ENOTDIR")) {
|
|
166
|
+
const defaults = getDefaultConfig();
|
|
167
|
+
await mkdir(configDir, { recursive: true });
|
|
168
|
+
await writeFile(configPath, JSON.stringify(defaults, null, 2), "utf8");
|
|
169
|
+
return defaults;
|
|
170
|
+
}
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
let parsed;
|
|
174
|
+
try {
|
|
175
|
+
parsed = JSON.parse(raw);
|
|
176
|
+
} catch {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Config validation error: config file at ${configPath} contains malformed JSON`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return validateConfig(parsed);
|
|
182
|
+
}
|
|
183
|
+
async function saveConfig(config) {
|
|
184
|
+
const configPath = getConfigPath();
|
|
185
|
+
const configDir = join(homedir(), ".jh-gateway");
|
|
186
|
+
await mkdir(configDir, { recursive: true });
|
|
187
|
+
await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
|
|
188
|
+
}
|
|
189
|
+
async function updateConfig(partial) {
|
|
190
|
+
const current = await loadConfig();
|
|
191
|
+
const updated = { ...current, ...partial };
|
|
192
|
+
if (partial.auth !== void 0) {
|
|
193
|
+
updated.auth = { ...current.auth, ...partial.auth };
|
|
194
|
+
}
|
|
195
|
+
await saveConfig(updated);
|
|
196
|
+
}
|
|
197
|
+
function isNodeError(err) {
|
|
198
|
+
return err instanceof Error && "code" in err;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/core/auth-capture.ts
|
|
202
|
+
var JH_HOST = "chat.ai.jh.edu";
|
|
203
|
+
function getTokenExpiry(token) {
|
|
204
|
+
try {
|
|
205
|
+
const parts = token.split(".");
|
|
206
|
+
if (parts.length < 2) return 0;
|
|
207
|
+
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
208
|
+
const json = Buffer.from(payload, "base64").toString("utf8");
|
|
209
|
+
const parsed = JSON.parse(json);
|
|
210
|
+
const exp = parsed["exp"];
|
|
211
|
+
if (typeof exp !== "number") return 0;
|
|
212
|
+
return exp;
|
|
213
|
+
} catch {
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async function captureCredentials(cdpUrl, timeoutMs = 12e4) {
|
|
218
|
+
const { browser, page: _initialPage } = await connectToChrome(cdpUrl);
|
|
219
|
+
const page = await findOrOpenJhPage(browser);
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
let settled = false;
|
|
222
|
+
const timer = setTimeout(() => {
|
|
223
|
+
if (settled) return;
|
|
224
|
+
settled = true;
|
|
225
|
+
reject(
|
|
226
|
+
new Error(
|
|
227
|
+
`Credential capture timed out after ${timeoutMs / 1e3}s. Please log in to chat.ai.jh.edu and send a message to trigger authentication.`
|
|
228
|
+
)
|
|
229
|
+
);
|
|
230
|
+
}, timeoutMs);
|
|
231
|
+
page.route("**/*", async (route) => {
|
|
232
|
+
const request = route.request();
|
|
233
|
+
const headers = await request.headers();
|
|
234
|
+
const authHeader = headers["authorization"] ?? headers["Authorization"] ?? "";
|
|
235
|
+
if (!settled && authHeader.startsWith("Bearer ") && request.url().includes(JH_HOST)) {
|
|
236
|
+
settled = true;
|
|
237
|
+
clearTimeout(timer);
|
|
238
|
+
try {
|
|
239
|
+
const bearerToken = authHeader.slice("Bearer ".length).trim();
|
|
240
|
+
const rawCookies = await page.context().cookies();
|
|
241
|
+
const cookie = rawCookies.map((c) => `${c.name}=${c.value}`).join("; ");
|
|
242
|
+
const userAgent = await page.evaluate(
|
|
243
|
+
() => navigator.userAgent
|
|
244
|
+
);
|
|
245
|
+
const expiresAt = getTokenExpiry(bearerToken);
|
|
246
|
+
const captured = {
|
|
247
|
+
bearerToken,
|
|
248
|
+
cookie,
|
|
249
|
+
userAgent,
|
|
250
|
+
expiresAt
|
|
251
|
+
};
|
|
252
|
+
await updateConfig({ credentials: captured });
|
|
253
|
+
resolve(captured);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
reject(err);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
await route.continue();
|
|
259
|
+
}).catch((err) => {
|
|
260
|
+
if (!settled) {
|
|
261
|
+
settled = true;
|
|
262
|
+
clearTimeout(timer);
|
|
263
|
+
reject(err);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/infra/gateway-auth.ts
|
|
270
|
+
import { randomBytes } from "crypto";
|
|
271
|
+
function generateApiKey() {
|
|
272
|
+
return `jh-local-${randomBytes(16).toString("hex")}`;
|
|
273
|
+
}
|
|
274
|
+
function authMiddleware(config) {
|
|
275
|
+
return async (c, next) => {
|
|
276
|
+
const { mode, token } = config.auth;
|
|
277
|
+
if (mode === "none") {
|
|
278
|
+
return next();
|
|
279
|
+
}
|
|
280
|
+
const authHeader = c.req.header("authorization");
|
|
281
|
+
if (!authHeader) {
|
|
282
|
+
return c.json(
|
|
283
|
+
{
|
|
284
|
+
error: {
|
|
285
|
+
message: "Missing Authorization header",
|
|
286
|
+
type: "authentication_error",
|
|
287
|
+
code: "missing_auth",
|
|
288
|
+
param: null
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
401
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (mode === "bearer") {
|
|
295
|
+
const expected = `Bearer ${token}`;
|
|
296
|
+
if (authHeader !== expected) {
|
|
297
|
+
return c.json(
|
|
298
|
+
{
|
|
299
|
+
error: {
|
|
300
|
+
message: "Invalid bearer token",
|
|
301
|
+
type: "authentication_error",
|
|
302
|
+
code: "invalid_auth",
|
|
303
|
+
param: null
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
401
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
} else if (mode === "basic") {
|
|
310
|
+
const expected = `Basic ${Buffer.from(`gateway:${token}`).toString("base64")}`;
|
|
311
|
+
if (authHeader !== expected) {
|
|
312
|
+
return c.json(
|
|
313
|
+
{
|
|
314
|
+
error: {
|
|
315
|
+
message: "Invalid basic credentials",
|
|
316
|
+
type: "authentication_error",
|
|
317
|
+
code: "invalid_auth",
|
|
318
|
+
param: null
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
401
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return next();
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// src/infra/types.ts
|
|
330
|
+
var MODEL_ENDPOINT_MAP = {
|
|
331
|
+
"claude-opus-4.5": "AnthropicClaude",
|
|
332
|
+
"claude-sonnet-4.5": "AnthropicClaude",
|
|
333
|
+
"claude-haiku-4.5": "AnthropicClaude",
|
|
334
|
+
"gpt-4.1": "OpenAI",
|
|
335
|
+
"o3": "OpenAI",
|
|
336
|
+
"o3-mini": "OpenAI",
|
|
337
|
+
"gpt-5": "OpenAI",
|
|
338
|
+
"gpt-5.1": "OpenAI",
|
|
339
|
+
"gpt-5.2": "OpenAI",
|
|
340
|
+
"llama3-3-70b-instruct": "Meta"
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// src/cli/setup.ts
|
|
344
|
+
var COMMON_CDP_PORTS = [9222, 9223];
|
|
345
|
+
function formatExpiry(exp) {
|
|
346
|
+
if (exp === 0) {
|
|
347
|
+
return "unknown";
|
|
348
|
+
}
|
|
349
|
+
return new Date(exp * 1e3).toLocaleString();
|
|
350
|
+
}
|
|
351
|
+
async function detectChrome() {
|
|
352
|
+
p.log.step("Step 1: Chrome detection");
|
|
353
|
+
for (const port of COMMON_CDP_PORTS) {
|
|
354
|
+
const url = `http://127.0.0.1:${port}`;
|
|
355
|
+
const spinner4 = p.spinner();
|
|
356
|
+
spinner4.start(`Checking ${url}\u2026`);
|
|
357
|
+
try {
|
|
358
|
+
await getChromeWebSocketUrl(url, 2e3);
|
|
359
|
+
spinner4.stop(`Chrome found at ${url}`);
|
|
360
|
+
return url;
|
|
361
|
+
} catch {
|
|
362
|
+
spinner4.stop(`Not found at ${url}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const custom = await p.text({
|
|
366
|
+
message: "Enter your Chrome CDP URL (e.g. http://127.0.0.1:9224):",
|
|
367
|
+
placeholder: "http://127.0.0.1:9222",
|
|
368
|
+
validate(value) {
|
|
369
|
+
if (!value.startsWith("http://") && !value.startsWith("https://")) {
|
|
370
|
+
return "Must be a valid URL starting with http:// or https://";
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
if (p.isCancel(custom)) {
|
|
375
|
+
p.cancel("Setup cancelled.");
|
|
376
|
+
process.exit(0);
|
|
377
|
+
}
|
|
378
|
+
const spinner3 = p.spinner();
|
|
379
|
+
spinner3.start(`Checking ${custom}\u2026`);
|
|
380
|
+
try {
|
|
381
|
+
await getChromeWebSocketUrl(custom, 5e3);
|
|
382
|
+
spinner3.stop(`Chrome found at ${custom}`);
|
|
383
|
+
return custom;
|
|
384
|
+
} catch (err) {
|
|
385
|
+
spinner3.stop(`Failed to connect to ${custom}`);
|
|
386
|
+
throw err;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
async function captureAuth(cdpUrl) {
|
|
390
|
+
p.log.step("Step 2: JH authentication");
|
|
391
|
+
p.log.info(
|
|
392
|
+
"Opening chat.ai.jh.edu in your browser. Send any message to trigger auth capture\u2026"
|
|
393
|
+
);
|
|
394
|
+
const spinner3 = p.spinner();
|
|
395
|
+
spinner3.start("Waiting for credentials (up to 120s)\u2026");
|
|
396
|
+
const creds = await captureCredentials(cdpUrl, 12e4);
|
|
397
|
+
const expiry = getTokenExpiry(creds.bearerToken);
|
|
398
|
+
spinner3.stop(`Credentials captured! Token expires: ${formatExpiry(expiry)}`);
|
|
399
|
+
return expiry;
|
|
400
|
+
}
|
|
401
|
+
async function selectPort() {
|
|
402
|
+
p.log.step("Step 3: Port selection");
|
|
403
|
+
const input = await p.text({
|
|
404
|
+
message: "Gateway port:",
|
|
405
|
+
placeholder: "8741",
|
|
406
|
+
defaultValue: "8741",
|
|
407
|
+
validate(value) {
|
|
408
|
+
const n = Number(value);
|
|
409
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
410
|
+
return "Must be a valid port number (1\u201365535)";
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
if (p.isCancel(input)) {
|
|
415
|
+
p.cancel("Setup cancelled.");
|
|
416
|
+
process.exit(0);
|
|
417
|
+
}
|
|
418
|
+
return Number(input);
|
|
419
|
+
}
|
|
420
|
+
async function verifyConnection() {
|
|
421
|
+
p.log.step("Step 4: Verification");
|
|
422
|
+
const models = Object.keys(MODEL_ENDPOINT_MAP);
|
|
423
|
+
p.log.success(`Available models: ${models.join(", ")}`);
|
|
424
|
+
}
|
|
425
|
+
async function runSetup() {
|
|
426
|
+
p.intro("JH Web Gateway \u2014 Setup Wizard");
|
|
427
|
+
let cdpUrl;
|
|
428
|
+
let port = 8741;
|
|
429
|
+
while (true) {
|
|
430
|
+
try {
|
|
431
|
+
cdpUrl = await detectChrome();
|
|
432
|
+
break;
|
|
433
|
+
} catch (err) {
|
|
434
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
435
|
+
p.log.error(`Chrome detection failed: ${msg}`);
|
|
436
|
+
const retry = await p.confirm({ message: "Retry Chrome detection?" });
|
|
437
|
+
if (p.isCancel(retry) || !retry) {
|
|
438
|
+
p.cancel("Setup cancelled.");
|
|
439
|
+
process.exit(1);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
await updateConfig({ cdpUrl });
|
|
444
|
+
while (true) {
|
|
445
|
+
try {
|
|
446
|
+
await captureAuth(cdpUrl);
|
|
447
|
+
break;
|
|
448
|
+
} catch (err) {
|
|
449
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
450
|
+
p.log.error(`Auth capture failed: ${msg}`);
|
|
451
|
+
const retry = await p.confirm({ message: "Retry authentication?" });
|
|
452
|
+
if (p.isCancel(retry) || !retry) {
|
|
453
|
+
p.cancel("Setup cancelled.");
|
|
454
|
+
process.exit(1);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
port = await selectPort();
|
|
460
|
+
} catch (err) {
|
|
461
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
462
|
+
p.log.error(`Port selection failed: ${msg}`);
|
|
463
|
+
p.cancel("Setup cancelled.");
|
|
464
|
+
process.exit(1);
|
|
465
|
+
}
|
|
466
|
+
const apiKey = generateApiKey();
|
|
467
|
+
await updateConfig({
|
|
468
|
+
port,
|
|
469
|
+
auth: { mode: "bearer", token: apiKey }
|
|
470
|
+
});
|
|
471
|
+
await verifyConnection();
|
|
472
|
+
const config = await loadConfig();
|
|
473
|
+
const baseUrl = `http://127.0.0.1:${config.port}`;
|
|
474
|
+
p.outro("Setup complete!");
|
|
475
|
+
console.log("\n Base URL: " + baseUrl);
|
|
476
|
+
console.log(" API Key: " + apiKey);
|
|
477
|
+
console.log("\n Test with curl:");
|
|
478
|
+
console.log(
|
|
479
|
+
` curl ${baseUrl}/v1/models -H "Authorization: Bearer ${apiKey}"
|
|
480
|
+
`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// src/server.ts
|
|
485
|
+
import { Hono as Hono4 } from "hono";
|
|
486
|
+
import { serve } from "@hono/node-server";
|
|
487
|
+
|
|
488
|
+
// src/routes/models.ts
|
|
489
|
+
import { Hono } from "hono";
|
|
490
|
+
var MODEL_LIST = Object.keys(MODEL_ENDPOINT_MAP).map((id) => ({
|
|
491
|
+
id,
|
|
492
|
+
object: "model",
|
|
493
|
+
created: 17e8,
|
|
494
|
+
owned_by: "jh-web"
|
|
495
|
+
}));
|
|
496
|
+
var MODEL_SET = new Set(Object.keys(MODEL_ENDPOINT_MAP));
|
|
497
|
+
function modelsRouter(_config) {
|
|
498
|
+
const app = new Hono();
|
|
499
|
+
app.get("/", (c) => {
|
|
500
|
+
return c.json({ object: "list", data: MODEL_LIST });
|
|
501
|
+
});
|
|
502
|
+
app.get("/:id", (c) => {
|
|
503
|
+
const id = c.req.param("id");
|
|
504
|
+
if (!MODEL_SET.has(id)) {
|
|
505
|
+
return c.json(
|
|
506
|
+
{
|
|
507
|
+
error: {
|
|
508
|
+
message: `Model '${id}' not found`,
|
|
509
|
+
type: "invalid_request_error",
|
|
510
|
+
code: "model_not_found",
|
|
511
|
+
param: "id"
|
|
512
|
+
}
|
|
513
|
+
},
|
|
514
|
+
404
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
return c.json({ id, object: "model", created: 17e8, owned_by: "jh-web" });
|
|
518
|
+
});
|
|
519
|
+
return app;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// src/routes/health.ts
|
|
523
|
+
import { Hono as Hono2 } from "hono";
|
|
524
|
+
function healthRouter(config, startTime) {
|
|
525
|
+
const app = new Hono2();
|
|
526
|
+
app.get("/", (c) => {
|
|
527
|
+
const uptime = (Date.now() - startTime) / 1e3;
|
|
528
|
+
const tokenExpiry = config.credentials?.bearerToken ? getTokenExpiry(config.credentials.bearerToken) || null : null;
|
|
529
|
+
const tokenExpired = tokenExpiry !== null && Date.now() / 1e3 > tokenExpiry;
|
|
530
|
+
return c.json({
|
|
531
|
+
status: "ok",
|
|
532
|
+
uptime,
|
|
533
|
+
tokenExpiry,
|
|
534
|
+
tokenExpired,
|
|
535
|
+
cdpUrl: config.cdpUrl
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
return app;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// src/routes/chat-completions.ts
|
|
542
|
+
import { Hono as Hono3 } from "hono";
|
|
543
|
+
import { stream as honoStream } from "hono/streaming";
|
|
544
|
+
|
|
545
|
+
// src/core/message-builder.ts
|
|
546
|
+
function formatToolDefinitionsXml(tools) {
|
|
547
|
+
const defs = tools.map((t) => {
|
|
548
|
+
const fn = t.function;
|
|
549
|
+
let xml = `<tool name="${fn.name}"`;
|
|
550
|
+
if (fn.description) {
|
|
551
|
+
xml += ` description="${escapeXmlAttr(fn.description)}"`;
|
|
552
|
+
}
|
|
553
|
+
xml += ">";
|
|
554
|
+
if (fn.parameters) {
|
|
555
|
+
xml += `
|
|
556
|
+
<parameters>${JSON.stringify(fn.parameters)}</parameters>`;
|
|
557
|
+
}
|
|
558
|
+
xml += "\n</tool>";
|
|
559
|
+
return xml;
|
|
560
|
+
});
|
|
561
|
+
return `<tools>
|
|
562
|
+
${defs.join("\n")}
|
|
563
|
+
</tools>`;
|
|
564
|
+
}
|
|
565
|
+
function formatToolResponse(toolCallId, content) {
|
|
566
|
+
return `<tool_response id="${toolCallId}">${content}</tool_response>`;
|
|
567
|
+
}
|
|
568
|
+
function buildPrompt(messages, tools, toolChoice) {
|
|
569
|
+
let systemPrompt;
|
|
570
|
+
const parts = [];
|
|
571
|
+
for (const msg of messages) {
|
|
572
|
+
switch (msg.role) {
|
|
573
|
+
case "system":
|
|
574
|
+
systemPrompt = systemPrompt ? `${systemPrompt}
|
|
575
|
+
${msg.content ?? ""}` : msg.content ?? "";
|
|
576
|
+
break;
|
|
577
|
+
case "user":
|
|
578
|
+
parts.push(`${msg.content ?? ""}`);
|
|
579
|
+
break;
|
|
580
|
+
case "assistant": {
|
|
581
|
+
let content = msg.content ?? "";
|
|
582
|
+
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
|
583
|
+
const toolXml = msg.tool_calls.map(
|
|
584
|
+
(tc) => `<tool_call id="${tc.id}" name="${tc.function.name}">${tc.function.arguments}</tool_call>`
|
|
585
|
+
).join("");
|
|
586
|
+
content = content ? `${content}
|
|
587
|
+
${toolXml}` : toolXml;
|
|
588
|
+
}
|
|
589
|
+
parts.push(`Assistant: ${content}`);
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
case "tool":
|
|
593
|
+
parts.push(formatToolResponse(msg.tool_call_id ?? "unknown", msg.content ?? ""));
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (tools && tools.length > 0) {
|
|
598
|
+
const toolsXml = formatToolDefinitionsXml(tools);
|
|
599
|
+
const toolInstructions = 'You have access to the following tools. To use a tool, respond with <tool_call id="call_ID" name="TOOL_NAME">ARGUMENTS_JSON</tool_call>';
|
|
600
|
+
const injection = `${toolInstructions}
|
|
601
|
+
|
|
602
|
+
${toolsXml}`;
|
|
603
|
+
systemPrompt = systemPrompt ? `${systemPrompt}
|
|
604
|
+
|
|
605
|
+
${injection}` : injection;
|
|
606
|
+
}
|
|
607
|
+
if (toolChoice) {
|
|
608
|
+
let instruction = "";
|
|
609
|
+
if (toolChoice === "required") {
|
|
610
|
+
instruction = "You MUST use a tool in your response.";
|
|
611
|
+
} else if (typeof toolChoice === "object" && toolChoice.function?.name) {
|
|
612
|
+
instruction = `You MUST use the tool "${toolChoice.function.name}" in your response.`;
|
|
613
|
+
}
|
|
614
|
+
if (instruction) {
|
|
615
|
+
parts.push(instruction);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return {
|
|
619
|
+
prompt: parts.join("\n\n"),
|
|
620
|
+
systemPrompt
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
function escapeXmlAttr(s) {
|
|
624
|
+
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// src/core/client.ts
|
|
628
|
+
var JH_API_BASE = "https://chat.ai.jh.edu/api";
|
|
629
|
+
var JH_DEFAULT_GREETING = "Hello! How can I help you today?";
|
|
630
|
+
var NULL_UUID = "00000000-0000-0000-0000-000000000000";
|
|
631
|
+
function isTokenExpired(token) {
|
|
632
|
+
try {
|
|
633
|
+
const parts = token.split(".");
|
|
634
|
+
if (parts.length < 2) return true;
|
|
635
|
+
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
636
|
+
const json = Buffer.from(payload, "base64").toString("utf8");
|
|
637
|
+
const parsed = JSON.parse(json);
|
|
638
|
+
const exp = parsed["exp"];
|
|
639
|
+
if (typeof exp !== "number") return true;
|
|
640
|
+
return Date.now() / 1e3 > exp;
|
|
641
|
+
} catch {
|
|
642
|
+
return true;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
async function sendChatRequest(page, credentials, request, options) {
|
|
646
|
+
const MAX_ATTEMPTS = 3;
|
|
647
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
648
|
+
try {
|
|
649
|
+
return await sendChatRequestInner(page, credentials, request, options, false);
|
|
650
|
+
} catch (err) {
|
|
651
|
+
const error = err;
|
|
652
|
+
if (error.statusCode === 404 && attempt < MAX_ATTEMPTS) {
|
|
653
|
+
console.log(`[gateway] Stream not found, retrying (attempt ${attempt + 1}/${MAX_ATTEMPTS})...`);
|
|
654
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
throw err;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
throw new Error("Unexpected: all retry attempts exhausted");
|
|
661
|
+
}
|
|
662
|
+
async function sendChatRequestInner(page, credentials, request, options, isRetry) {
|
|
663
|
+
if (!isRetry && isTokenExpired(credentials.bearerToken)) {
|
|
664
|
+
throw Object.assign(
|
|
665
|
+
new Error("Bearer token has expired. Run `jh-gateway auth` to capture fresh credentials."),
|
|
666
|
+
{ statusCode: 401 }
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
const endpoint = MODEL_ENDPOINT_MAP[request.model];
|
|
670
|
+
if (!endpoint) {
|
|
671
|
+
throw Object.assign(new Error(`Model '${request.model}' is not supported`), { statusCode: 400 });
|
|
672
|
+
}
|
|
673
|
+
const conversationId = request.conversationId ?? crypto.randomUUID();
|
|
674
|
+
const parentMessageId = request.parentMessageId ?? NULL_UUID;
|
|
675
|
+
const messageId = crypto.randomUUID();
|
|
676
|
+
const body = {
|
|
677
|
+
text: request.prompt,
|
|
678
|
+
sender: "User",
|
|
679
|
+
clientTimestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
680
|
+
isCreatedByUser: true,
|
|
681
|
+
parentMessageId,
|
|
682
|
+
conversationId,
|
|
683
|
+
messageId,
|
|
684
|
+
error: false,
|
|
685
|
+
endpoint,
|
|
686
|
+
endpointType: "custom",
|
|
687
|
+
model: request.model,
|
|
688
|
+
resendFiles: true,
|
|
689
|
+
greeting: JH_DEFAULT_GREETING,
|
|
690
|
+
key: "never",
|
|
691
|
+
modelDisplayLabel: "Claude",
|
|
692
|
+
isTemporary: true,
|
|
693
|
+
isRegenerate: false,
|
|
694
|
+
isContinued: false,
|
|
695
|
+
ephemeralAgent: {
|
|
696
|
+
execute_code: false,
|
|
697
|
+
web_search: false,
|
|
698
|
+
file_search: false,
|
|
699
|
+
artifacts: false,
|
|
700
|
+
mcp: []
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
let sseResolve;
|
|
704
|
+
const ssePromise = new Promise((res) => {
|
|
705
|
+
sseResolve = res;
|
|
706
|
+
});
|
|
707
|
+
let sseResolved = false;
|
|
708
|
+
const streamPattern = "**/api/agents/chat/stream/*";
|
|
709
|
+
const routeHandler = async (route) => {
|
|
710
|
+
console.log(`[gateway] Route handler intercepted: ${route.request().url()}`);
|
|
711
|
+
const url = route.request().url();
|
|
712
|
+
const delays = [0, 50, 100, 150, 250, 400, 700, 1200, 2e3, 3500];
|
|
713
|
+
let lastStatus = 0;
|
|
714
|
+
let lastBody = "";
|
|
715
|
+
let lastHeaders = {};
|
|
716
|
+
const reqHeaders = route.request().headers();
|
|
717
|
+
for (let i = 0; i < delays.length; i++) {
|
|
718
|
+
if (delays[i] > 0) {
|
|
719
|
+
await new Promise((r) => setTimeout(r, delays[i]));
|
|
720
|
+
}
|
|
721
|
+
try {
|
|
722
|
+
const response = await fetch(url, {
|
|
723
|
+
method: "GET",
|
|
724
|
+
headers: {
|
|
725
|
+
Accept: reqHeaders["accept"] ?? "text/event-stream",
|
|
726
|
+
Authorization: reqHeaders["authorization"] ?? "",
|
|
727
|
+
Cookie: reqHeaders["cookie"] ?? "",
|
|
728
|
+
Referer: reqHeaders["referer"] ?? "",
|
|
729
|
+
"User-Agent": reqHeaders["user-agent"] ?? ""
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
lastStatus = response.status;
|
|
733
|
+
lastBody = await response.text();
|
|
734
|
+
lastHeaders = {};
|
|
735
|
+
response.headers.forEach((v, k) => {
|
|
736
|
+
lastHeaders[k] = v;
|
|
737
|
+
});
|
|
738
|
+
if (lastStatus === 200) {
|
|
739
|
+
console.log(`[gateway] Stream ready on attempt ${i + 1}, body length: ${lastBody.length}`);
|
|
740
|
+
if (!sseResolved) {
|
|
741
|
+
sseResolved = true;
|
|
742
|
+
sseResolve({ error: false, status: 200, statusText: "OK", body: lastBody });
|
|
743
|
+
}
|
|
744
|
+
await route.fulfill({ status: 200, headers: lastHeaders, body: lastBody });
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
if (lastStatus !== 404) {
|
|
748
|
+
console.log(`[gateway] Stream non-404 error: ${lastStatus}, body: ${lastBody.slice(0, 200)}`);
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
} catch (err) {
|
|
752
|
+
lastBody = String(err);
|
|
753
|
+
lastStatus = 500;
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
console.log(`[gateway] Stream fetch failed after ${delays.length} attempts, last status: ${lastStatus}`);
|
|
758
|
+
if (!sseResolved) {
|
|
759
|
+
sseResolved = true;
|
|
760
|
+
sseResolve({ error: true, status: lastStatus, statusText: "stream fetch failed", body: lastBody });
|
|
761
|
+
}
|
|
762
|
+
try {
|
|
763
|
+
await route.fulfill({ status: lastStatus, headers: lastHeaders, body: lastBody });
|
|
764
|
+
} catch {
|
|
765
|
+
try {
|
|
766
|
+
await route.continue();
|
|
767
|
+
} catch {
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
await page.route(streamPattern, routeHandler);
|
|
772
|
+
const postResult = await page.evaluate(
|
|
773
|
+
async ({
|
|
774
|
+
apiBase,
|
|
775
|
+
bearerToken,
|
|
776
|
+
endpointPath,
|
|
777
|
+
requestBody
|
|
778
|
+
}) => {
|
|
779
|
+
try {
|
|
780
|
+
const res = await fetch(`${apiBase}/agents/chat/${endpointPath}`, {
|
|
781
|
+
method: "POST",
|
|
782
|
+
headers: {
|
|
783
|
+
"Content-Type": "application/json",
|
|
784
|
+
Accept: "text/event-stream",
|
|
785
|
+
Authorization: `Bearer ${bearerToken}`
|
|
786
|
+
},
|
|
787
|
+
body: JSON.stringify(requestBody)
|
|
788
|
+
});
|
|
789
|
+
if (!res.ok) {
|
|
790
|
+
return {
|
|
791
|
+
error: true,
|
|
792
|
+
status: res.status,
|
|
793
|
+
statusText: res.statusText,
|
|
794
|
+
body: (await res.text()).slice(0, 2e3),
|
|
795
|
+
contentType: ""
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
799
|
+
if (ct.includes("text/event-stream")) {
|
|
800
|
+
const reader = res.body?.getReader();
|
|
801
|
+
if (!reader) return { error: true, status: 500, statusText: "No body", body: "No SSE body", contentType: ct };
|
|
802
|
+
const decoder = new TextDecoder();
|
|
803
|
+
let text2 = "";
|
|
804
|
+
while (true) {
|
|
805
|
+
const { done, value } = await reader.read();
|
|
806
|
+
if (done) break;
|
|
807
|
+
text2 += decoder.decode(value, { stream: true });
|
|
808
|
+
}
|
|
809
|
+
return { error: false, status: 200, statusText: "OK", body: text2, contentType: ct };
|
|
810
|
+
}
|
|
811
|
+
const bodyText = await res.text();
|
|
812
|
+
console.log(`[gateway] POST response content-type: ${ct}, body: ${bodyText.slice(0, 500)}`);
|
|
813
|
+
return { error: false, status: 200, statusText: "OK", body: bodyText, contentType: ct };
|
|
814
|
+
} catch (err) {
|
|
815
|
+
return { error: true, status: 500, statusText: "fetch error", body: String(err), contentType: "" };
|
|
816
|
+
}
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
apiBase: JH_API_BASE,
|
|
820
|
+
bearerToken: credentials.bearerToken,
|
|
821
|
+
endpointPath: endpoint,
|
|
822
|
+
requestBody: body
|
|
823
|
+
}
|
|
824
|
+
);
|
|
825
|
+
let result;
|
|
826
|
+
if (postResult.error) {
|
|
827
|
+
result = { error: true, status: postResult.status, statusText: postResult.statusText ?? "", body: postResult.body };
|
|
828
|
+
await page.unroute(streamPattern, routeHandler);
|
|
829
|
+
} else if (postResult.contentType.includes("text/event-stream")) {
|
|
830
|
+
result = { error: false, status: 200, statusText: "OK", body: postResult.body };
|
|
831
|
+
await page.unroute(streamPattern, routeHandler);
|
|
832
|
+
} else {
|
|
833
|
+
let streamId;
|
|
834
|
+
try {
|
|
835
|
+
streamId = JSON.parse(postResult.body).streamId;
|
|
836
|
+
} catch {
|
|
837
|
+
}
|
|
838
|
+
if (!streamId) {
|
|
839
|
+
result = { error: false, status: 200, statusText: "OK", body: postResult.body };
|
|
840
|
+
await page.unroute(streamPattern, routeHandler);
|
|
841
|
+
} else {
|
|
842
|
+
const streamUrl = `${JH_API_BASE}/agents/chat/stream/${streamId}`;
|
|
843
|
+
page.evaluate(
|
|
844
|
+
async ({ url, token }) => {
|
|
845
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
846
|
+
try {
|
|
847
|
+
await fetch(url, {
|
|
848
|
+
method: "GET",
|
|
849
|
+
headers: { Accept: "text/event-stream", Authorization: `Bearer ${token}` }
|
|
850
|
+
});
|
|
851
|
+
} catch {
|
|
852
|
+
}
|
|
853
|
+
},
|
|
854
|
+
{ url: streamUrl, token: credentials.bearerToken }
|
|
855
|
+
).catch(() => {
|
|
856
|
+
});
|
|
857
|
+
const timeout = new Promise(
|
|
858
|
+
(res) => setTimeout(() => {
|
|
859
|
+
if (!sseResolved) {
|
|
860
|
+
sseResolved = true;
|
|
861
|
+
res({ error: true, status: 408, statusText: "timeout", body: "Stream capture timed out after 120s" });
|
|
862
|
+
}
|
|
863
|
+
}, 12e4)
|
|
864
|
+
);
|
|
865
|
+
result = await Promise.race([ssePromise, timeout]);
|
|
866
|
+
await page.unroute(streamPattern, routeHandler);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
if (result.error) {
|
|
870
|
+
const status = result.status;
|
|
871
|
+
const responseBody = result.body;
|
|
872
|
+
if (status === 401 && !isRetry) {
|
|
873
|
+
const cdpUrl = options?.cdpUrl ?? "http://127.0.0.1:9222";
|
|
874
|
+
try {
|
|
875
|
+
await page.reload({ waitUntil: "networkidle" });
|
|
876
|
+
const fresh = await captureCredentials(cdpUrl, 3e4);
|
|
877
|
+
const newCreds = {
|
|
878
|
+
bearerToken: fresh.bearerToken,
|
|
879
|
+
cookie: fresh.cookie,
|
|
880
|
+
userAgent: fresh.userAgent
|
|
881
|
+
};
|
|
882
|
+
options?.onCredentialsRefreshed?.(newCreds);
|
|
883
|
+
return sendChatRequestInner(page, newCreds, request, options, true);
|
|
884
|
+
} catch {
|
|
885
|
+
throw Object.assign(
|
|
886
|
+
new Error("JH platform returned 401 and automatic re-authentication failed. Run `jh-gateway auth` to capture fresh credentials."),
|
|
887
|
+
{ statusCode: 401 }
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
if (status === 401) {
|
|
892
|
+
throw Object.assign(
|
|
893
|
+
new Error("JH platform returned 401 after re-authentication attempt. Run `jh-gateway auth` to capture fresh credentials."),
|
|
894
|
+
{ statusCode: 401 }
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
if (status === 403) {
|
|
898
|
+
throw Object.assign(
|
|
899
|
+
new Error("JH platform returned 403 \u2014 Cloudflare session has expired. Please open chat.ai.jh.edu in your browser, complete any challenge, then run `jh-gateway auth`."),
|
|
900
|
+
{ statusCode: 403 }
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
throw Object.assign(new Error(`JH platform returned ${status}: ${responseBody}`), { statusCode: status });
|
|
904
|
+
}
|
|
905
|
+
console.log(`[gateway] Final result: error=${result.error}, status=${result.status}, body length=${result.body.length}`);
|
|
906
|
+
const rawSseText = result.body;
|
|
907
|
+
const { newConversationId, newParentMessageId } = extractConversationState(rawSseText, conversationId, messageId);
|
|
908
|
+
return { rawSseText, conversationId: newConversationId, parentMessageId: newParentMessageId };
|
|
909
|
+
}
|
|
910
|
+
function extractConversationState(rawSse, fallbackConversationId, fallbackParentMessageId) {
|
|
911
|
+
let newConversationId = fallbackConversationId ?? "";
|
|
912
|
+
let newParentMessageId = fallbackParentMessageId;
|
|
913
|
+
const blocks = rawSse.split("\n\n");
|
|
914
|
+
for (const block of blocks) {
|
|
915
|
+
const trimmed = block.trim();
|
|
916
|
+
if (!trimmed) continue;
|
|
917
|
+
let event = "";
|
|
918
|
+
let data = "";
|
|
919
|
+
for (const line of trimmed.split("\n")) {
|
|
920
|
+
if (line.startsWith("event: ")) event = line.slice(7).trim();
|
|
921
|
+
else if (line.startsWith("data: ")) data = line.slice(6);
|
|
922
|
+
else if (line.startsWith("data:")) data = line.slice(5);
|
|
923
|
+
}
|
|
924
|
+
if (event === "message" && data) {
|
|
925
|
+
try {
|
|
926
|
+
const parsed = JSON.parse(data);
|
|
927
|
+
if (parsed?.isCreatedByUser === false) {
|
|
928
|
+
if (parsed.conversationId) newConversationId = parsed.conversationId;
|
|
929
|
+
if (parsed.messageId) newParentMessageId = parsed.messageId;
|
|
930
|
+
}
|
|
931
|
+
const msg = parsed?.message;
|
|
932
|
+
if (msg?.isCreatedByUser === false) {
|
|
933
|
+
if (msg.conversationId) newConversationId = msg.conversationId;
|
|
934
|
+
if (msg.messageId) newParentMessageId = msg.messageId;
|
|
935
|
+
}
|
|
936
|
+
} catch {
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return { newConversationId, newParentMessageId };
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// src/core/stream-translator.ts
|
|
944
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
945
|
+
|
|
946
|
+
// src/core/tool-parser.ts
|
|
947
|
+
var TOOL_CALL_RE = /<tool_call\s+id="([^"]*)"\s+name="([^"]*)">([\s\S]*?)<\/tool_call>/g;
|
|
948
|
+
var THINK_RE = /<think>([\s\S]*?)<\/think>/g;
|
|
949
|
+
function parseToolsAndThinking(text2) {
|
|
950
|
+
const toolCalls = [];
|
|
951
|
+
let thinking = null;
|
|
952
|
+
const thinkMatches = [...text2.matchAll(THINK_RE)];
|
|
953
|
+
if (thinkMatches.length > 0) {
|
|
954
|
+
thinking = thinkMatches.map((m) => m[1]).join("\n");
|
|
955
|
+
}
|
|
956
|
+
let remaining = text2.replace(THINK_RE, "");
|
|
957
|
+
const toolMatches = [...remaining.matchAll(TOOL_CALL_RE)];
|
|
958
|
+
for (const match of toolMatches) {
|
|
959
|
+
const id = match[1];
|
|
960
|
+
const name = match[2];
|
|
961
|
+
const rawArgs = match[3].trim();
|
|
962
|
+
let args2;
|
|
963
|
+
try {
|
|
964
|
+
JSON.parse(rawArgs);
|
|
965
|
+
args2 = rawArgs;
|
|
966
|
+
} catch {
|
|
967
|
+
args2 = JSON.stringify(rawArgs);
|
|
968
|
+
}
|
|
969
|
+
toolCalls.push({ id, name, arguments: args2 });
|
|
970
|
+
}
|
|
971
|
+
remaining = remaining.replace(TOOL_CALL_RE, "");
|
|
972
|
+
const cleanedText = remaining.trim();
|
|
973
|
+
return {
|
|
974
|
+
text: cleanedText,
|
|
975
|
+
toolCalls,
|
|
976
|
+
thinking
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
function toOpenAIToolCalls(calls) {
|
|
980
|
+
return calls.map((call, index) => ({
|
|
981
|
+
id: call.id,
|
|
982
|
+
type: "function",
|
|
983
|
+
index,
|
|
984
|
+
function: {
|
|
985
|
+
name: call.name,
|
|
986
|
+
arguments: call.arguments
|
|
987
|
+
}
|
|
988
|
+
}));
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// src/core/stream-translator.ts
|
|
992
|
+
function parseSseEvents(rawSse) {
|
|
993
|
+
const events = [];
|
|
994
|
+
const blocks = rawSse.split("\n\n");
|
|
995
|
+
for (const block of blocks) {
|
|
996
|
+
const trimmed = block.trim();
|
|
997
|
+
if (!trimmed) continue;
|
|
998
|
+
let event = "";
|
|
999
|
+
let data = "";
|
|
1000
|
+
for (const line of trimmed.split("\n")) {
|
|
1001
|
+
if (line.startsWith("event: ")) {
|
|
1002
|
+
event = line.slice(7).trim();
|
|
1003
|
+
} else if (line.startsWith("data: ")) {
|
|
1004
|
+
data = line.slice(6);
|
|
1005
|
+
} else if (line.startsWith("data:")) {
|
|
1006
|
+
data = line.slice(5);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
if (event || data) {
|
|
1010
|
+
events.push({ event, data });
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
return events;
|
|
1014
|
+
}
|
|
1015
|
+
function resolveEventType(ev) {
|
|
1016
|
+
if (ev.event && ev.event !== "message") {
|
|
1017
|
+
try {
|
|
1018
|
+
return { type: ev.event, parsed: JSON.parse(ev.data) };
|
|
1019
|
+
} catch {
|
|
1020
|
+
return { type: ev.event, parsed: null };
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
if (ev.data) {
|
|
1024
|
+
try {
|
|
1025
|
+
const parsed = JSON.parse(ev.data);
|
|
1026
|
+
const jsonEvent = typeof parsed.event === "string" ? parsed.event : null;
|
|
1027
|
+
return { type: jsonEvent ?? ev.event, parsed };
|
|
1028
|
+
} catch {
|
|
1029
|
+
return { type: ev.event, parsed: null };
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return { type: ev.event, parsed: null };
|
|
1033
|
+
}
|
|
1034
|
+
function isUserEcho(parsed) {
|
|
1035
|
+
if (!parsed) return false;
|
|
1036
|
+
if (parsed.isCreatedByUser === true) return true;
|
|
1037
|
+
const msg = parsed.message;
|
|
1038
|
+
if (msg?.isCreatedByUser === true || msg?.sender === "User") return true;
|
|
1039
|
+
return false;
|
|
1040
|
+
}
|
|
1041
|
+
function extractDeltaText(parsed) {
|
|
1042
|
+
if (!parsed) return null;
|
|
1043
|
+
let content = parsed.delta?.content;
|
|
1044
|
+
if (!content) {
|
|
1045
|
+
const dataObj = parsed.data;
|
|
1046
|
+
content = dataObj?.delta?.content;
|
|
1047
|
+
}
|
|
1048
|
+
if (!Array.isArray(content)) return null;
|
|
1049
|
+
const texts = [];
|
|
1050
|
+
for (const item of content) {
|
|
1051
|
+
if (item?.type === "text" && typeof item.text === "string") {
|
|
1052
|
+
texts.push(item.text);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
return texts.length > 0 ? texts.join("") : null;
|
|
1056
|
+
}
|
|
1057
|
+
function extractMessageText(parsed) {
|
|
1058
|
+
if (!parsed) return null;
|
|
1059
|
+
const msg = parsed.message;
|
|
1060
|
+
if (!msg) return null;
|
|
1061
|
+
if (msg.isCreatedByUser === true || msg.sender === "User") return null;
|
|
1062
|
+
if (typeof msg.text === "string" && msg.text) return msg.text;
|
|
1063
|
+
return null;
|
|
1064
|
+
}
|
|
1065
|
+
function generateCompletionId() {
|
|
1066
|
+
return `chatcmpl-${randomBytes2(12).toString("hex")}`;
|
|
1067
|
+
}
|
|
1068
|
+
function extractContentFromJhSse(rawSse) {
|
|
1069
|
+
const events = parseSseEvents(rawSse);
|
|
1070
|
+
const parts = [];
|
|
1071
|
+
let lastMessageText = "";
|
|
1072
|
+
for (const ev of events) {
|
|
1073
|
+
const { type, parsed } = resolveEventType(ev);
|
|
1074
|
+
if (isUserEcho(parsed)) continue;
|
|
1075
|
+
if (type === "on_run_step") continue;
|
|
1076
|
+
if (type === "on_message_delta") {
|
|
1077
|
+
const delta = extractDeltaText(parsed);
|
|
1078
|
+
if (delta !== null) {
|
|
1079
|
+
parts.push(delta);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
if (type === "message" || !type) {
|
|
1083
|
+
const msgText = extractMessageText(parsed);
|
|
1084
|
+
if (msgText && msgText.length > lastMessageText.length) {
|
|
1085
|
+
lastMessageText = msgText;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
const deltaText = parts.join("");
|
|
1090
|
+
return deltaText || lastMessageText;
|
|
1091
|
+
}
|
|
1092
|
+
function translateToStream(rawSse, model, completionId) {
|
|
1093
|
+
const id = completionId ?? generateCompletionId();
|
|
1094
|
+
const created = Math.floor(Date.now() / 1e3);
|
|
1095
|
+
const events = parseSseEvents(rawSse);
|
|
1096
|
+
const chunks = [];
|
|
1097
|
+
let lastMessageText = "";
|
|
1098
|
+
chunks.push({
|
|
1099
|
+
id,
|
|
1100
|
+
object: "chat.completion.chunk",
|
|
1101
|
+
created,
|
|
1102
|
+
model,
|
|
1103
|
+
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }]
|
|
1104
|
+
});
|
|
1105
|
+
let gotDeltas = false;
|
|
1106
|
+
for (const ev of events) {
|
|
1107
|
+
const { type, parsed } = resolveEventType(ev);
|
|
1108
|
+
if (isUserEcho(parsed)) continue;
|
|
1109
|
+
if (type === "on_run_step") continue;
|
|
1110
|
+
if (type === "on_message_delta") {
|
|
1111
|
+
const delta = extractDeltaText(parsed);
|
|
1112
|
+
if (delta !== null) {
|
|
1113
|
+
gotDeltas = true;
|
|
1114
|
+
chunks.push({
|
|
1115
|
+
id,
|
|
1116
|
+
object: "chat.completion.chunk",
|
|
1117
|
+
created,
|
|
1118
|
+
model,
|
|
1119
|
+
choices: [{ index: 0, delta: { content: delta }, finish_reason: null }]
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
if (type === "message" || !type) {
|
|
1124
|
+
const msgText = extractMessageText(parsed);
|
|
1125
|
+
if (msgText && msgText.length > lastMessageText.length) {
|
|
1126
|
+
const delta = msgText.slice(lastMessageText.length);
|
|
1127
|
+
lastMessageText = msgText;
|
|
1128
|
+
if (!gotDeltas && delta) {
|
|
1129
|
+
chunks.push({
|
|
1130
|
+
id,
|
|
1131
|
+
object: "chat.completion.chunk",
|
|
1132
|
+
created,
|
|
1133
|
+
model,
|
|
1134
|
+
choices: [{ index: 0, delta: { content: delta }, finish_reason: null }]
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
chunks.push({
|
|
1141
|
+
id,
|
|
1142
|
+
object: "chat.completion.chunk",
|
|
1143
|
+
created,
|
|
1144
|
+
model,
|
|
1145
|
+
choices: [{ index: 0, delta: {}, finish_reason: "stop" }]
|
|
1146
|
+
});
|
|
1147
|
+
return chunks;
|
|
1148
|
+
}
|
|
1149
|
+
function translateToCompletion(rawSse, model, completionId) {
|
|
1150
|
+
const id = completionId ?? generateCompletionId();
|
|
1151
|
+
const created = Math.floor(Date.now() / 1e3);
|
|
1152
|
+
const fullText = extractContentFromJhSse(rawSse);
|
|
1153
|
+
const parsed = parseToolsAndThinking(fullText);
|
|
1154
|
+
const promptTokens = 0;
|
|
1155
|
+
const completionTokens = Math.max(1, Math.ceil(fullText.length / 4));
|
|
1156
|
+
const message = {
|
|
1157
|
+
role: "assistant",
|
|
1158
|
+
content: parsed.text || null
|
|
1159
|
+
};
|
|
1160
|
+
if (parsed.toolCalls.length > 0) {
|
|
1161
|
+
message.tool_calls = toOpenAIToolCalls(parsed.toolCalls);
|
|
1162
|
+
}
|
|
1163
|
+
return {
|
|
1164
|
+
id,
|
|
1165
|
+
object: "chat.completion",
|
|
1166
|
+
created,
|
|
1167
|
+
model,
|
|
1168
|
+
choices: [{ index: 0, message, finish_reason: "stop" }],
|
|
1169
|
+
usage: {
|
|
1170
|
+
prompt_tokens: promptTokens,
|
|
1171
|
+
completion_tokens: completionTokens,
|
|
1172
|
+
total_tokens: promptTokens + completionTokens
|
|
1173
|
+
}
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// src/routes/chat-completions.ts
|
|
1178
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
1179
|
+
var MODEL_SET2 = new Set(Object.keys(MODEL_ENDPOINT_MAP));
|
|
1180
|
+
function chatCompletionsRouter(_config, deps) {
|
|
1181
|
+
const app = new Hono3();
|
|
1182
|
+
app.post("/", async (c) => {
|
|
1183
|
+
let body;
|
|
1184
|
+
try {
|
|
1185
|
+
body = await c.req.json();
|
|
1186
|
+
} catch {
|
|
1187
|
+
return c.json(
|
|
1188
|
+
{
|
|
1189
|
+
error: {
|
|
1190
|
+
message: "Request body must be valid JSON",
|
|
1191
|
+
type: "invalid_request_error",
|
|
1192
|
+
code: "invalid_json",
|
|
1193
|
+
param: null
|
|
1194
|
+
}
|
|
1195
|
+
},
|
|
1196
|
+
400
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
const model = body.model;
|
|
1200
|
+
if (typeof model !== "string" || !model) {
|
|
1201
|
+
return c.json(
|
|
1202
|
+
{
|
|
1203
|
+
error: {
|
|
1204
|
+
message: "Missing required field: model",
|
|
1205
|
+
type: "invalid_request_error",
|
|
1206
|
+
code: "missing_field",
|
|
1207
|
+
param: "model"
|
|
1208
|
+
}
|
|
1209
|
+
},
|
|
1210
|
+
400
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
if (!MODEL_SET2.has(model)) {
|
|
1214
|
+
return c.json(
|
|
1215
|
+
{
|
|
1216
|
+
error: {
|
|
1217
|
+
message: `Model '${model}' is not supported. Available models: ${[...MODEL_SET2].join(", ")}`,
|
|
1218
|
+
type: "invalid_request_error",
|
|
1219
|
+
code: "model_not_found",
|
|
1220
|
+
param: "model"
|
|
1221
|
+
}
|
|
1222
|
+
},
|
|
1223
|
+
400
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
const messages = body.messages;
|
|
1227
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
1228
|
+
return c.json(
|
|
1229
|
+
{
|
|
1230
|
+
error: {
|
|
1231
|
+
message: "Missing or empty required field: messages",
|
|
1232
|
+
type: "invalid_request_error",
|
|
1233
|
+
code: "missing_field",
|
|
1234
|
+
param: "messages"
|
|
1235
|
+
}
|
|
1236
|
+
},
|
|
1237
|
+
400
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
const shouldStream = body.stream === true;
|
|
1241
|
+
const tools = body.tools ?? void 0;
|
|
1242
|
+
const toolChoice = body.tool_choice;
|
|
1243
|
+
const pool = deps.getPool();
|
|
1244
|
+
if (!pool) {
|
|
1245
|
+
return c.json(
|
|
1246
|
+
{
|
|
1247
|
+
error: {
|
|
1248
|
+
message: "Chrome browser is not connected. Run `jh-gateway setup` or `jh-gateway auth`.",
|
|
1249
|
+
type: "service_unavailable",
|
|
1250
|
+
code: "chrome_disconnected",
|
|
1251
|
+
param: null
|
|
1252
|
+
}
|
|
1253
|
+
},
|
|
1254
|
+
503
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
const credentials = deps.getCredentials();
|
|
1258
|
+
if (!credentials) {
|
|
1259
|
+
return c.json(
|
|
1260
|
+
{
|
|
1261
|
+
error: {
|
|
1262
|
+
message: "No credentials available. Run `jh-gateway auth` to capture credentials.",
|
|
1263
|
+
type: "authentication_error",
|
|
1264
|
+
code: "no_credentials",
|
|
1265
|
+
param: null
|
|
1266
|
+
}
|
|
1267
|
+
},
|
|
1268
|
+
401
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
const completionId = `chatcmpl-${randomBytes3(12).toString("hex")}`;
|
|
1272
|
+
const [built, acquired] = await Promise.all([
|
|
1273
|
+
Promise.resolve(buildPrompt(messages, tools, toolChoice)),
|
|
1274
|
+
pool.acquire()
|
|
1275
|
+
]);
|
|
1276
|
+
const { page, queue, release } = acquired;
|
|
1277
|
+
const stats = pool.stats;
|
|
1278
|
+
console.log(`[chat] Acquired page (pool: ${stats.busy}/${stats.total} busy)`);
|
|
1279
|
+
try {
|
|
1280
|
+
const response = await queue.enqueue(
|
|
1281
|
+
() => sendChatRequest(page, credentials, {
|
|
1282
|
+
model,
|
|
1283
|
+
prompt: built.prompt
|
|
1284
|
+
})
|
|
1285
|
+
);
|
|
1286
|
+
if (shouldStream) {
|
|
1287
|
+
const chunks = translateToStream(response.rawSseText, model, completionId);
|
|
1288
|
+
return honoStream(c, async (stream) => {
|
|
1289
|
+
c.header("Content-Type", "text/event-stream");
|
|
1290
|
+
c.header("Cache-Control", "no-cache");
|
|
1291
|
+
c.header("Connection", "keep-alive");
|
|
1292
|
+
try {
|
|
1293
|
+
for (const chunk of chunks) {
|
|
1294
|
+
await stream.write(`data: ${JSON.stringify(chunk)}
|
|
1295
|
+
|
|
1296
|
+
`);
|
|
1297
|
+
}
|
|
1298
|
+
} catch (streamErr) {
|
|
1299
|
+
const sErr = streamErr;
|
|
1300
|
+
const errorEvent = {
|
|
1301
|
+
error: {
|
|
1302
|
+
message: sErr.message || "An error occurred during streaming",
|
|
1303
|
+
type: "server_error",
|
|
1304
|
+
code: "stream_error",
|
|
1305
|
+
param: null
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
await stream.write(`data: ${JSON.stringify(errorEvent)}
|
|
1309
|
+
|
|
1310
|
+
`);
|
|
1311
|
+
} finally {
|
|
1312
|
+
release();
|
|
1313
|
+
}
|
|
1314
|
+
await stream.write("data: [DONE]\n\n");
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
release();
|
|
1318
|
+
const completion = translateToCompletion(response.rawSseText, model, completionId);
|
|
1319
|
+
return c.json(completion);
|
|
1320
|
+
} catch (err) {
|
|
1321
|
+
release();
|
|
1322
|
+
const error = err;
|
|
1323
|
+
const statusCode = error.statusCode ?? 500;
|
|
1324
|
+
if (statusCode === 401 && deps.reauthLock) {
|
|
1325
|
+
try {
|
|
1326
|
+
const freshCreds = await deps.reauthLock.acquire(async () => {
|
|
1327
|
+
const captured = await captureCredentials(_config.cdpUrl);
|
|
1328
|
+
return {
|
|
1329
|
+
bearerToken: captured.bearerToken,
|
|
1330
|
+
cookie: captured.cookie,
|
|
1331
|
+
userAgent: captured.userAgent
|
|
1332
|
+
};
|
|
1333
|
+
});
|
|
1334
|
+
deps.setCredentials?.(freshCreds);
|
|
1335
|
+
const retry = await pool.acquire();
|
|
1336
|
+
try {
|
|
1337
|
+
const retryResponse = await retry.queue.enqueue(
|
|
1338
|
+
() => sendChatRequest(retry.page, freshCreds, {
|
|
1339
|
+
model,
|
|
1340
|
+
prompt: built.prompt
|
|
1341
|
+
})
|
|
1342
|
+
);
|
|
1343
|
+
if (shouldStream) {
|
|
1344
|
+
const chunks = translateToStream(retryResponse.rawSseText, model, completionId);
|
|
1345
|
+
return honoStream(c, async (stream) => {
|
|
1346
|
+
c.header("Content-Type", "text/event-stream");
|
|
1347
|
+
c.header("Cache-Control", "no-cache");
|
|
1348
|
+
c.header("Connection", "keep-alive");
|
|
1349
|
+
try {
|
|
1350
|
+
for (const chunk of chunks) {
|
|
1351
|
+
await stream.write(`data: ${JSON.stringify(chunk)}
|
|
1352
|
+
|
|
1353
|
+
`);
|
|
1354
|
+
}
|
|
1355
|
+
} catch (streamErr) {
|
|
1356
|
+
const sErr = streamErr;
|
|
1357
|
+
const errorEvent = {
|
|
1358
|
+
error: {
|
|
1359
|
+
message: sErr.message || "An error occurred during streaming",
|
|
1360
|
+
type: "server_error",
|
|
1361
|
+
code: "stream_error",
|
|
1362
|
+
param: null
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
await stream.write(`data: ${JSON.stringify(errorEvent)}
|
|
1366
|
+
|
|
1367
|
+
`);
|
|
1368
|
+
} finally {
|
|
1369
|
+
retry.release();
|
|
1370
|
+
}
|
|
1371
|
+
await stream.write("data: [DONE]\n\n");
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
retry.release();
|
|
1375
|
+
const completion = translateToCompletion(retryResponse.rawSseText, model, completionId);
|
|
1376
|
+
return c.json(completion);
|
|
1377
|
+
} catch {
|
|
1378
|
+
retry.release();
|
|
1379
|
+
}
|
|
1380
|
+
} catch {
|
|
1381
|
+
}
|
|
1382
|
+
const reauthErrorBody = {
|
|
1383
|
+
error: {
|
|
1384
|
+
message: "Authentication failed after automatic re-capture attempt.",
|
|
1385
|
+
type: "authentication_error",
|
|
1386
|
+
code: "upstream_error",
|
|
1387
|
+
param: null
|
|
1388
|
+
}
|
|
1389
|
+
};
|
|
1390
|
+
if (shouldStream) {
|
|
1391
|
+
return honoStream(c, async (stream) => {
|
|
1392
|
+
c.header("Content-Type", "text/event-stream");
|
|
1393
|
+
c.header("Cache-Control", "no-cache");
|
|
1394
|
+
c.header("Connection", "keep-alive");
|
|
1395
|
+
await stream.write(`data: ${JSON.stringify(reauthErrorBody)}
|
|
1396
|
+
|
|
1397
|
+
`);
|
|
1398
|
+
await stream.write("data: [DONE]\n\n");
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
return c.json(reauthErrorBody, 401);
|
|
1402
|
+
}
|
|
1403
|
+
const typeMap = {
|
|
1404
|
+
400: "invalid_request_error",
|
|
1405
|
+
401: "authentication_error",
|
|
1406
|
+
403: "permission_error",
|
|
1407
|
+
429: "rate_limit_error",
|
|
1408
|
+
503: "service_unavailable"
|
|
1409
|
+
};
|
|
1410
|
+
const errorBody = {
|
|
1411
|
+
error: {
|
|
1412
|
+
message: error.message || "Internal server error",
|
|
1413
|
+
type: typeMap[statusCode] ?? "server_error",
|
|
1414
|
+
code: statusCode === 429 ? "queue_overflow" : "upstream_error",
|
|
1415
|
+
param: null
|
|
1416
|
+
}
|
|
1417
|
+
};
|
|
1418
|
+
if (shouldStream) {
|
|
1419
|
+
return honoStream(c, async (stream) => {
|
|
1420
|
+
c.header("Content-Type", "text/event-stream");
|
|
1421
|
+
c.header("Cache-Control", "no-cache");
|
|
1422
|
+
c.header("Connection", "keep-alive");
|
|
1423
|
+
await stream.write(`data: ${JSON.stringify(errorBody)}
|
|
1424
|
+
|
|
1425
|
+
`);
|
|
1426
|
+
await stream.write("data: [DONE]\n\n");
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
return c.json(
|
|
1430
|
+
errorBody,
|
|
1431
|
+
statusCode
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
});
|
|
1435
|
+
return app;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// src/infra/logger.ts
|
|
1439
|
+
import { appendFile, readFile as readFile2, readdir, mkdir as mkdir2 } from "fs/promises";
|
|
1440
|
+
import { homedir as homedir2 } from "os";
|
|
1441
|
+
import { join as join2 } from "path";
|
|
1442
|
+
var DEFAULT_LOG_DIR = join2(homedir2(), ".jh-gateway", "logs");
|
|
1443
|
+
function dateStamp(d = /* @__PURE__ */ new Date()) {
|
|
1444
|
+
return d.toISOString().slice(0, 10);
|
|
1445
|
+
}
|
|
1446
|
+
var Logger = class {
|
|
1447
|
+
logDir;
|
|
1448
|
+
dirCreated = false;
|
|
1449
|
+
constructor(logDir) {
|
|
1450
|
+
this.logDir = logDir ?? DEFAULT_LOG_DIR;
|
|
1451
|
+
}
|
|
1452
|
+
/** Ensure the log directory exists (lazy, once per instance). */
|
|
1453
|
+
async ensureDir() {
|
|
1454
|
+
if (this.dirCreated) {
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
await mkdir2(this.logDir, { recursive: true });
|
|
1458
|
+
this.dirCreated = true;
|
|
1459
|
+
}
|
|
1460
|
+
/** Append a log entry to today's JSONL file. */
|
|
1461
|
+
async log(entry) {
|
|
1462
|
+
await this.ensureDir();
|
|
1463
|
+
const file = join2(this.logDir, `${dateStamp()}.jsonl`);
|
|
1464
|
+
await appendFile(file, JSON.stringify(entry) + "\n", "utf8");
|
|
1465
|
+
}
|
|
1466
|
+
/** Query log entries. Reads from a specific date or the most recent file. */
|
|
1467
|
+
async query(options = {}) {
|
|
1468
|
+
await this.ensureDir();
|
|
1469
|
+
const limit = options.limit ?? 100;
|
|
1470
|
+
if (options.date) {
|
|
1471
|
+
return this.readLogFile(join2(this.logDir, `${options.date}.jsonl`), limit);
|
|
1472
|
+
}
|
|
1473
|
+
let names;
|
|
1474
|
+
try {
|
|
1475
|
+
names = await readdir(this.logDir);
|
|
1476
|
+
} catch {
|
|
1477
|
+
return [];
|
|
1478
|
+
}
|
|
1479
|
+
const jsonlFiles = names.filter((n) => n.endsWith(".jsonl")).toSorted().toReversed();
|
|
1480
|
+
const entries = [];
|
|
1481
|
+
for (const name of jsonlFiles) {
|
|
1482
|
+
if (entries.length >= limit) {
|
|
1483
|
+
break;
|
|
1484
|
+
}
|
|
1485
|
+
const batch = await this.readLogFile(join2(this.logDir, name), limit - entries.length);
|
|
1486
|
+
entries.push(...batch);
|
|
1487
|
+
}
|
|
1488
|
+
return entries.slice(0, limit);
|
|
1489
|
+
}
|
|
1490
|
+
async readLogFile(filePath, limit) {
|
|
1491
|
+
let raw;
|
|
1492
|
+
try {
|
|
1493
|
+
raw = await readFile2(filePath, "utf8");
|
|
1494
|
+
} catch {
|
|
1495
|
+
return [];
|
|
1496
|
+
}
|
|
1497
|
+
const entries = [];
|
|
1498
|
+
const lines = raw.split("\n");
|
|
1499
|
+
for (let i = lines.length - 1; i >= 0 && entries.length < limit; i--) {
|
|
1500
|
+
const line = lines[i].trim();
|
|
1501
|
+
if (!line) {
|
|
1502
|
+
continue;
|
|
1503
|
+
}
|
|
1504
|
+
try {
|
|
1505
|
+
entries.push(JSON.parse(line));
|
|
1506
|
+
} catch {
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
return entries;
|
|
1510
|
+
}
|
|
1511
|
+
};
|
|
1512
|
+
|
|
1513
|
+
// src/server.ts
|
|
1514
|
+
function createServer(config, deps) {
|
|
1515
|
+
const app = new Hono4();
|
|
1516
|
+
const startTime = Date.now();
|
|
1517
|
+
const logger = new Logger();
|
|
1518
|
+
app.use("/v1/*", authMiddleware(config));
|
|
1519
|
+
app.use("*", async (c, next) => {
|
|
1520
|
+
const start = Date.now();
|
|
1521
|
+
await next();
|
|
1522
|
+
const latencyMs = Date.now() - start;
|
|
1523
|
+
let model = null;
|
|
1524
|
+
if (c.req.method === "POST" && c.req.path.includes("chat/completions")) {
|
|
1525
|
+
try {
|
|
1526
|
+
const bodyText = await c.req.raw.clone().text();
|
|
1527
|
+
const parsed = JSON.parse(bodyText);
|
|
1528
|
+
model = typeof parsed?.model === "string" ? parsed.model : null;
|
|
1529
|
+
} catch {
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
const resBody = c.res?.headers?.get("content-length");
|
|
1533
|
+
const resSize = resBody ? parseInt(resBody, 10) : 0;
|
|
1534
|
+
const entry = {
|
|
1535
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1536
|
+
method: c.req.method,
|
|
1537
|
+
path: c.req.path,
|
|
1538
|
+
model,
|
|
1539
|
+
statusCode: c.res.status,
|
|
1540
|
+
latencyMs,
|
|
1541
|
+
estimatedTokens: {
|
|
1542
|
+
prompt: 0,
|
|
1543
|
+
completion: Math.max(0, Math.ceil(resSize / 4))
|
|
1544
|
+
}
|
|
1545
|
+
};
|
|
1546
|
+
logger.log(entry).catch(() => {
|
|
1547
|
+
});
|
|
1548
|
+
});
|
|
1549
|
+
app.route("/v1/models", modelsRouter(config));
|
|
1550
|
+
app.route("/health", healthRouter(config, startTime));
|
|
1551
|
+
if (deps) {
|
|
1552
|
+
app.route(
|
|
1553
|
+
"/v1/chat/completions",
|
|
1554
|
+
chatCompletionsRouter(config, {
|
|
1555
|
+
getPool: deps.getPool,
|
|
1556
|
+
getCredentials: deps.getCredentials,
|
|
1557
|
+
reauthLock: deps.reauthLock,
|
|
1558
|
+
setCredentials: deps.setCredentials
|
|
1559
|
+
})
|
|
1560
|
+
);
|
|
1561
|
+
}
|
|
1562
|
+
app.onError((err, c) => {
|
|
1563
|
+
return c.json(
|
|
1564
|
+
{
|
|
1565
|
+
error: {
|
|
1566
|
+
message: err.message || "Internal server error",
|
|
1567
|
+
type: "server_error",
|
|
1568
|
+
code: "internal_error",
|
|
1569
|
+
param: null
|
|
1570
|
+
}
|
|
1571
|
+
},
|
|
1572
|
+
500
|
|
1573
|
+
);
|
|
1574
|
+
});
|
|
1575
|
+
app.notFound((c) => {
|
|
1576
|
+
return c.json(
|
|
1577
|
+
{
|
|
1578
|
+
error: {
|
|
1579
|
+
message: `Route ${c.req.method} ${c.req.path} not found`,
|
|
1580
|
+
type: "invalid_request_error",
|
|
1581
|
+
code: "route_not_found",
|
|
1582
|
+
param: null
|
|
1583
|
+
}
|
|
1584
|
+
},
|
|
1585
|
+
404
|
|
1586
|
+
);
|
|
1587
|
+
});
|
|
1588
|
+
return app;
|
|
1589
|
+
}
|
|
1590
|
+
async function startServer(config, deps) {
|
|
1591
|
+
const hostname = "127.0.0.1";
|
|
1592
|
+
const app = createServer(config, deps);
|
|
1593
|
+
const port = config.port;
|
|
1594
|
+
const server = serve({
|
|
1595
|
+
fetch: app.fetch,
|
|
1596
|
+
port,
|
|
1597
|
+
hostname
|
|
1598
|
+
});
|
|
1599
|
+
console.log(`JH Web Gateway listening on http://${hostname}:${port}`);
|
|
1600
|
+
let shuttingDown = false;
|
|
1601
|
+
const DRAIN_TIMEOUT_MS = 1e4;
|
|
1602
|
+
const shutdown = async () => {
|
|
1603
|
+
if (shuttingDown) {
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
shuttingDown = true;
|
|
1607
|
+
console.log("\nShutting down gracefully...");
|
|
1608
|
+
await new Promise((resolve) => {
|
|
1609
|
+
const timer = setTimeout(() => {
|
|
1610
|
+
console.log("Drain timeout reached, forcing close.");
|
|
1611
|
+
resolve();
|
|
1612
|
+
}, DRAIN_TIMEOUT_MS);
|
|
1613
|
+
server.close(() => {
|
|
1614
|
+
clearTimeout(timer);
|
|
1615
|
+
resolve();
|
|
1616
|
+
});
|
|
1617
|
+
});
|
|
1618
|
+
if (deps.browser) {
|
|
1619
|
+
try {
|
|
1620
|
+
await deps.browser.close();
|
|
1621
|
+
} catch {
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
console.log("Shutdown complete.");
|
|
1625
|
+
};
|
|
1626
|
+
process.on("SIGINT", async () => {
|
|
1627
|
+
await shutdown();
|
|
1628
|
+
process.exit(0);
|
|
1629
|
+
});
|
|
1630
|
+
process.on("SIGTERM", async () => {
|
|
1631
|
+
await shutdown();
|
|
1632
|
+
process.exit(0);
|
|
1633
|
+
});
|
|
1634
|
+
return { close: shutdown };
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// src/core/request-queue.ts
|
|
1638
|
+
var RequestQueue = class {
|
|
1639
|
+
queue = [];
|
|
1640
|
+
running = false;
|
|
1641
|
+
maxWaitMs;
|
|
1642
|
+
constructor(maxWaitMs = 12e4) {
|
|
1643
|
+
this.maxWaitMs = maxWaitMs;
|
|
1644
|
+
}
|
|
1645
|
+
/** Current number of tasks waiting in the queue (not including the active one). */
|
|
1646
|
+
get pending() {
|
|
1647
|
+
return this.queue.length;
|
|
1648
|
+
}
|
|
1649
|
+
/** Enqueue a task. Resolves when the task completes. Rejects on timeout or task error. */
|
|
1650
|
+
async enqueue(task) {
|
|
1651
|
+
await this.waitForTurn();
|
|
1652
|
+
try {
|
|
1653
|
+
return await task();
|
|
1654
|
+
} finally {
|
|
1655
|
+
if (this.queue.length > 0) {
|
|
1656
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1657
|
+
}
|
|
1658
|
+
this.release();
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
waitForTurn() {
|
|
1662
|
+
if (!this.running) {
|
|
1663
|
+
this.running = true;
|
|
1664
|
+
return Promise.resolve();
|
|
1665
|
+
}
|
|
1666
|
+
return new Promise((resolve, reject) => {
|
|
1667
|
+
const timer = setTimeout(() => {
|
|
1668
|
+
const idx = this.queue.indexOf(release);
|
|
1669
|
+
if (idx !== -1) this.queue.splice(idx, 1);
|
|
1670
|
+
reject(
|
|
1671
|
+
Object.assign(
|
|
1672
|
+
new Error(
|
|
1673
|
+
`Request queue wait exceeded ${this.maxWaitMs}ms \u2014 server is overloaded`
|
|
1674
|
+
),
|
|
1675
|
+
{ statusCode: 429 }
|
|
1676
|
+
)
|
|
1677
|
+
);
|
|
1678
|
+
}, this.maxWaitMs);
|
|
1679
|
+
const release = () => {
|
|
1680
|
+
clearTimeout(timer);
|
|
1681
|
+
resolve();
|
|
1682
|
+
};
|
|
1683
|
+
this.queue.push(release);
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
release() {
|
|
1687
|
+
const next = this.queue.shift();
|
|
1688
|
+
if (next) {
|
|
1689
|
+
next();
|
|
1690
|
+
} else {
|
|
1691
|
+
this.running = false;
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
};
|
|
1695
|
+
|
|
1696
|
+
// src/core/page-pool.ts
|
|
1697
|
+
var PagePool = class {
|
|
1698
|
+
pages = [];
|
|
1699
|
+
browser = null;
|
|
1700
|
+
targetUrl;
|
|
1701
|
+
maxPages;
|
|
1702
|
+
maxWaitMs;
|
|
1703
|
+
initPromise = null;
|
|
1704
|
+
constructor(options = {}) {
|
|
1705
|
+
this.targetUrl = options.targetUrl ?? "https://chat.ai.jh.edu";
|
|
1706
|
+
this.maxPages = options.maxPages ?? 3;
|
|
1707
|
+
this.maxWaitMs = options.maxWaitMs ?? 12e4;
|
|
1708
|
+
}
|
|
1709
|
+
/** Initialize the pool with an existing browser connection and seed page */
|
|
1710
|
+
async init(browser, seedPage) {
|
|
1711
|
+
if (this.initPromise) return this.initPromise;
|
|
1712
|
+
this.initPromise = this._doInit(browser, seedPage);
|
|
1713
|
+
return this.initPromise;
|
|
1714
|
+
}
|
|
1715
|
+
async _doInit(browser, seedPage) {
|
|
1716
|
+
this.browser = browser;
|
|
1717
|
+
this.pages.push({
|
|
1718
|
+
page: seedPage,
|
|
1719
|
+
queue: new RequestQueue(this.maxWaitMs),
|
|
1720
|
+
inUse: false
|
|
1721
|
+
});
|
|
1722
|
+
console.log(`[PagePool] Initialized with 1 page, will scale up to ${this.maxPages}`);
|
|
1723
|
+
}
|
|
1724
|
+
/** Get pool statistics */
|
|
1725
|
+
get stats() {
|
|
1726
|
+
const busy = this.pages.filter((p3) => p3.inUse).length;
|
|
1727
|
+
return {
|
|
1728
|
+
total: this.pages.length,
|
|
1729
|
+
busy,
|
|
1730
|
+
available: this.pages.length - busy
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
/**
|
|
1734
|
+
* Acquire a page for use. Creates new pages on-demand up to maxPages.
|
|
1735
|
+
* Note: We intentionally don't lock here — allowing multiple requests to
|
|
1736
|
+
* grab the same page and queue on it is actually faster than creating new pages.
|
|
1737
|
+
*/
|
|
1738
|
+
async acquire() {
|
|
1739
|
+
let pooled = this.pages.find((p4) => !p4.inUse);
|
|
1740
|
+
if (!pooled && this.pages.length < this.maxPages && this.browser) {
|
|
1741
|
+
pooled = await this.createPage();
|
|
1742
|
+
}
|
|
1743
|
+
if (!pooled) {
|
|
1744
|
+
pooled = this.pages.reduce(
|
|
1745
|
+
(a, b) => a.queue.pending <= b.queue.pending ? a : b
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
pooled.inUse = true;
|
|
1749
|
+
const p3 = pooled;
|
|
1750
|
+
return {
|
|
1751
|
+
page: p3.page,
|
|
1752
|
+
queue: p3.queue,
|
|
1753
|
+
release: () => {
|
|
1754
|
+
p3.inUse = false;
|
|
1755
|
+
}
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
async createPage() {
|
|
1759
|
+
if (!this.browser) {
|
|
1760
|
+
throw new Error("PagePool not initialized");
|
|
1761
|
+
}
|
|
1762
|
+
console.log(`[PagePool] Creating new page (${this.pages.length + 1}/${this.maxPages})...`);
|
|
1763
|
+
const context = this.browser.contexts()[0];
|
|
1764
|
+
if (!context) {
|
|
1765
|
+
throw new Error("No browser context available");
|
|
1766
|
+
}
|
|
1767
|
+
const page = await context.newPage();
|
|
1768
|
+
await page.goto(this.targetUrl, { waitUntil: "networkidle", timeout: 3e4 });
|
|
1769
|
+
console.log(`[PagePool] New page ready: ${page.url()}`);
|
|
1770
|
+
const pooled = {
|
|
1771
|
+
page,
|
|
1772
|
+
queue: new RequestQueue(this.maxWaitMs),
|
|
1773
|
+
inUse: false
|
|
1774
|
+
};
|
|
1775
|
+
this.pages.push(pooled);
|
|
1776
|
+
return pooled;
|
|
1777
|
+
}
|
|
1778
|
+
/** Close all pages except the seed page */
|
|
1779
|
+
async drain() {
|
|
1780
|
+
const toClose = this.pages.slice(1);
|
|
1781
|
+
this.pages = this.pages.slice(0, 1);
|
|
1782
|
+
for (const p3 of toClose) {
|
|
1783
|
+
try {
|
|
1784
|
+
await p3.page.close();
|
|
1785
|
+
} catch {
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
console.log(`[PagePool] Drained ${toClose.length} pages`);
|
|
1789
|
+
}
|
|
1790
|
+
};
|
|
1791
|
+
|
|
1792
|
+
// src/cli/serve.ts
|
|
1793
|
+
async function runServe(options) {
|
|
1794
|
+
const config = await loadConfig();
|
|
1795
|
+
if (options.port !== void 0) {
|
|
1796
|
+
await updateConfig({ port: options.port });
|
|
1797
|
+
config.port = options.port;
|
|
1798
|
+
}
|
|
1799
|
+
const maxPages = options.pages ?? 3;
|
|
1800
|
+
let pool = null;
|
|
1801
|
+
let browser;
|
|
1802
|
+
try {
|
|
1803
|
+
const conn = await connectToChrome(config.cdpUrl);
|
|
1804
|
+
const seedPage = await findOrOpenJhPage(conn.browser);
|
|
1805
|
+
browser = conn.browser;
|
|
1806
|
+
console.log(`Connected to Chrome at ${config.cdpUrl}`);
|
|
1807
|
+
const currentUrl = seedPage.url();
|
|
1808
|
+
if (!currentUrl.includes("chat.ai.jh.edu")) {
|
|
1809
|
+
console.log("Navigating to chat.ai.jh.edu...");
|
|
1810
|
+
await seedPage.goto("https://chat.ai.jh.edu", { waitUntil: "networkidle" });
|
|
1811
|
+
}
|
|
1812
|
+
console.log(`Browser page: ${seedPage.url()}`);
|
|
1813
|
+
pool = new PagePool({
|
|
1814
|
+
maxPages,
|
|
1815
|
+
maxWaitMs: config.maxQueueWaitMs
|
|
1816
|
+
});
|
|
1817
|
+
await pool.init(conn.browser, seedPage);
|
|
1818
|
+
console.log(`Page pool initialized (max ${maxPages} concurrent pages)`);
|
|
1819
|
+
} catch (err) {
|
|
1820
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1821
|
+
console.warn(`Warning: Could not connect to Chrome: ${msg}`);
|
|
1822
|
+
console.warn("Chat completions will fail until Chrome is available.");
|
|
1823
|
+
}
|
|
1824
|
+
await startServer(config, {
|
|
1825
|
+
getPool: () => pool,
|
|
1826
|
+
getCredentials: () => config.credentials,
|
|
1827
|
+
browser
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// src/cli/auth.ts
|
|
1832
|
+
function formatExpiry2(exp) {
|
|
1833
|
+
if (exp === 0) {
|
|
1834
|
+
return "unknown";
|
|
1835
|
+
}
|
|
1836
|
+
return new Date(exp * 1e3).toLocaleString();
|
|
1837
|
+
}
|
|
1838
|
+
async function runAuth() {
|
|
1839
|
+
const config = await loadConfig();
|
|
1840
|
+
console.log(`Connecting to Chrome at ${config.cdpUrl}\u2026`);
|
|
1841
|
+
console.log(
|
|
1842
|
+
"Opening chat.ai.jh.edu. Send any message to trigger auth capture (timeout: 120s)\u2026"
|
|
1843
|
+
);
|
|
1844
|
+
const creds = await captureCredentials(config.cdpUrl, 12e4);
|
|
1845
|
+
const expiry = getTokenExpiry(creds.bearerToken);
|
|
1846
|
+
console.log("Credentials captured successfully.");
|
|
1847
|
+
console.log(`Token expires: ${formatExpiry2(expiry)}`);
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
// src/cli/config.ts
|
|
1851
|
+
function redactConfig(config) {
|
|
1852
|
+
return {
|
|
1853
|
+
cdpUrl: config.cdpUrl,
|
|
1854
|
+
port: config.port,
|
|
1855
|
+
defaultModel: config.defaultModel,
|
|
1856
|
+
defaultEndpoint: config.defaultEndpoint,
|
|
1857
|
+
credentials: config.credentials !== null ? {
|
|
1858
|
+
bearerToken: "[REDACTED]",
|
|
1859
|
+
cookie: "[REDACTED]",
|
|
1860
|
+
userAgent: config.credentials.userAgent
|
|
1861
|
+
} : null,
|
|
1862
|
+
auth: {
|
|
1863
|
+
mode: config.auth.mode,
|
|
1864
|
+
token: config.auth.token !== null ? "[REDACTED]" : null
|
|
1865
|
+
},
|
|
1866
|
+
maxQueueWaitMs: config.maxQueueWaitMs
|
|
1867
|
+
};
|
|
1868
|
+
}
|
|
1869
|
+
async function runConfig() {
|
|
1870
|
+
const config = await loadConfig();
|
|
1871
|
+
const redacted = redactConfig(config);
|
|
1872
|
+
console.log(JSON.stringify(redacted, null, 2));
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
// src/cli/status.ts
|
|
1876
|
+
function formatExpiry3(exp) {
|
|
1877
|
+
if (exp === 0) {
|
|
1878
|
+
return "unknown";
|
|
1879
|
+
}
|
|
1880
|
+
return new Date(exp * 1e3).toLocaleString();
|
|
1881
|
+
}
|
|
1882
|
+
async function runStatus() {
|
|
1883
|
+
const config = await loadConfig();
|
|
1884
|
+
let chromeStatus;
|
|
1885
|
+
try {
|
|
1886
|
+
await getChromeWebSocketUrl(config.cdpUrl, 3e3);
|
|
1887
|
+
chromeStatus = `connected (${config.cdpUrl})`;
|
|
1888
|
+
} catch {
|
|
1889
|
+
chromeStatus = `disconnected (${config.cdpUrl})`;
|
|
1890
|
+
}
|
|
1891
|
+
let tokenStatus;
|
|
1892
|
+
if (!config.credentials) {
|
|
1893
|
+
tokenStatus = "no credentials stored";
|
|
1894
|
+
} else {
|
|
1895
|
+
const exp = getTokenExpiry(config.credentials.bearerToken);
|
|
1896
|
+
const expired = isTokenExpired(config.credentials.bearerToken);
|
|
1897
|
+
tokenStatus = expired ? `expired at ${formatExpiry3(exp)}` : `valid until ${formatExpiry3(exp)}`;
|
|
1898
|
+
}
|
|
1899
|
+
let gatewayStatus;
|
|
1900
|
+
try {
|
|
1901
|
+
const res = await fetch(`http://127.0.0.1:${config.port}/health`, {
|
|
1902
|
+
signal: AbortSignal.timeout(1e3)
|
|
1903
|
+
});
|
|
1904
|
+
if (res.ok) {
|
|
1905
|
+
gatewayStatus = `running on port ${config.port}`;
|
|
1906
|
+
} else {
|
|
1907
|
+
gatewayStatus = `port ${config.port} responded with ${res.status}`;
|
|
1908
|
+
}
|
|
1909
|
+
} catch {
|
|
1910
|
+
gatewayStatus = `not running (port ${config.port})`;
|
|
1911
|
+
}
|
|
1912
|
+
console.log(`Chrome: ${chromeStatus}`);
|
|
1913
|
+
console.log(`Token: ${tokenStatus}`);
|
|
1914
|
+
console.log(`Gateway: ${gatewayStatus}`);
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
// src/cli/logs.ts
|
|
1918
|
+
import { readFile as readFile3, readdir as readdir2 } from "fs/promises";
|
|
1919
|
+
import { homedir as homedir3 } from "os";
|
|
1920
|
+
import { join as join3 } from "path";
|
|
1921
|
+
var LOG_DIR = join3(homedir3(), ".jh-gateway", "logs");
|
|
1922
|
+
async function readLogFile(filePath) {
|
|
1923
|
+
const raw = await readFile3(filePath, "utf8");
|
|
1924
|
+
const entries = [];
|
|
1925
|
+
for (const line of raw.split("\n")) {
|
|
1926
|
+
const trimmed = line.trim();
|
|
1927
|
+
if (!trimmed) {
|
|
1928
|
+
continue;
|
|
1929
|
+
}
|
|
1930
|
+
try {
|
|
1931
|
+
entries.push(JSON.parse(trimmed));
|
|
1932
|
+
} catch {
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
return entries;
|
|
1936
|
+
}
|
|
1937
|
+
async function runLogs(options) {
|
|
1938
|
+
const limit = options.limit ?? 50;
|
|
1939
|
+
let files;
|
|
1940
|
+
try {
|
|
1941
|
+
const names = await readdir2(LOG_DIR);
|
|
1942
|
+
files = names.filter((n) => n.endsWith(".jsonl")).toSorted().toReversed().map((n) => join3(LOG_DIR, n));
|
|
1943
|
+
} catch {
|
|
1944
|
+
console.log("No log files found. Logs are written once the server handles requests.");
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
const entries = [];
|
|
1948
|
+
for (const file of files) {
|
|
1949
|
+
if (entries.length >= limit) {
|
|
1950
|
+
break;
|
|
1951
|
+
}
|
|
1952
|
+
try {
|
|
1953
|
+
const fileEntries = await readLogFile(file);
|
|
1954
|
+
entries.push(...fileEntries);
|
|
1955
|
+
} catch {
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
const recent = entries.slice(-limit).toReversed();
|
|
1959
|
+
if (recent.length === 0) {
|
|
1960
|
+
console.log("No log entries found.");
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
for (const entry of recent) {
|
|
1964
|
+
const tokens = `${entry.estimatedTokens.prompt}p/${entry.estimatedTokens.completion}c`;
|
|
1965
|
+
console.log(
|
|
1966
|
+
`${entry.timestamp} ${entry.method.padEnd(4)} ${entry.path.padEnd(30)} ${String(entry.statusCode).padEnd(4)} ${String(entry.latencyMs).padStart(6)}ms ${(entry.model ?? "-").padEnd(20)} tokens:${tokens}`
|
|
1967
|
+
);
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// src/cli/start.ts
|
|
1972
|
+
import * as p2 from "@clack/prompts";
|
|
1973
|
+
|
|
1974
|
+
// src/infra/chrome-manager.ts
|
|
1975
|
+
import { existsSync } from "fs";
|
|
1976
|
+
import { execSync, spawn } from "child_process";
|
|
1977
|
+
import { homedir as homedir4 } from "os";
|
|
1978
|
+
import { join as join4 } from "path";
|
|
1979
|
+
import { chromium as chromium2 } from "playwright-core";
|
|
1980
|
+
var MACOS_CHROME_PATH = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
1981
|
+
var WINDOWS_CHROME_PATHS = [
|
|
1982
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
1983
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
|
|
1984
|
+
];
|
|
1985
|
+
var LINUX_CHROME_CANDIDATES = [
|
|
1986
|
+
"google-chrome",
|
|
1987
|
+
"google-chrome-stable",
|
|
1988
|
+
"chromium-browser",
|
|
1989
|
+
"chromium"
|
|
1990
|
+
];
|
|
1991
|
+
var ChromeManager = class _ChromeManager {
|
|
1992
|
+
cdpPort;
|
|
1993
|
+
headless;
|
|
1994
|
+
userDataDir;
|
|
1995
|
+
constructor(options) {
|
|
1996
|
+
this.cdpPort = options?.cdpPort ?? 9222;
|
|
1997
|
+
this.headless = options?.headless ?? false;
|
|
1998
|
+
this.userDataDir = options?.userDataDir ?? join4(homedir4(), ".jh-gateway", "chrome-profile");
|
|
1999
|
+
}
|
|
2000
|
+
/**
|
|
2001
|
+
* Try connecting to an existing Chrome instance at the configured CDP port.
|
|
2002
|
+
* If no instance is running, launch a new Chrome process and connect to it.
|
|
2003
|
+
*/
|
|
2004
|
+
async connect() {
|
|
2005
|
+
const cdpUrl = `http://127.0.0.1:${this.cdpPort}`;
|
|
2006
|
+
try {
|
|
2007
|
+
const wsUrl2 = await getChromeWebSocketUrl(cdpUrl);
|
|
2008
|
+
const browser2 = await chromium2.connectOverCDP(wsUrl2);
|
|
2009
|
+
return { browser: browser2, selfLaunched: false };
|
|
2010
|
+
} catch {
|
|
2011
|
+
}
|
|
2012
|
+
const chromePath = _ChromeManager.findChromePath();
|
|
2013
|
+
if (!chromePath) {
|
|
2014
|
+
throw new Error(
|
|
2015
|
+
`Chrome executable not found. Please install Google Chrome or Chromium.
|
|
2016
|
+
Expected locations for ${process.platform}:
|
|
2017
|
+
` + (process.platform === "darwin" ? ` - ${MACOS_CHROME_PATH}
|
|
2018
|
+
` : process.platform === "linux" ? LINUX_CHROME_CANDIDATES.map((c) => ` - ${c} (via PATH)`).join("\n") + "\n" : WINDOWS_CHROME_PATHS.map((p3) => ` - ${p3}`).join("\n") + "\n")
|
|
2019
|
+
);
|
|
2020
|
+
}
|
|
2021
|
+
const args2 = [
|
|
2022
|
+
`--remote-debugging-port=${this.cdpPort}`,
|
|
2023
|
+
`--user-data-dir=${this.userDataDir}`,
|
|
2024
|
+
"--no-first-run",
|
|
2025
|
+
"--no-default-browser-check"
|
|
2026
|
+
];
|
|
2027
|
+
if (this.headless) {
|
|
2028
|
+
args2.push("--headless=new");
|
|
2029
|
+
}
|
|
2030
|
+
const child = spawn(chromePath, args2, {
|
|
2031
|
+
detached: false,
|
|
2032
|
+
stdio: "ignore"
|
|
2033
|
+
});
|
|
2034
|
+
await this.waitForCdp(cdpUrl);
|
|
2035
|
+
const wsUrl = await getChromeWebSocketUrl(cdpUrl);
|
|
2036
|
+
const browser = await chromium2.connectOverCDP(wsUrl);
|
|
2037
|
+
return { browser, selfLaunched: true, process: child };
|
|
2038
|
+
}
|
|
2039
|
+
/**
|
|
2040
|
+
* Poll the CDP /json/version endpoint until it responds, or timeout.
|
|
2041
|
+
*/
|
|
2042
|
+
async waitForCdp(cdpUrl, timeoutMs = 15e3, intervalMs = 250) {
|
|
2043
|
+
const deadline = Date.now() + timeoutMs;
|
|
2044
|
+
while (Date.now() < deadline) {
|
|
2045
|
+
try {
|
|
2046
|
+
const res = await fetch(`${cdpUrl}/json/version`);
|
|
2047
|
+
if (res.ok) return;
|
|
2048
|
+
} catch {
|
|
2049
|
+
}
|
|
2050
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
2051
|
+
}
|
|
2052
|
+
throw new Error(
|
|
2053
|
+
`Chrome did not become available at ${cdpUrl} within ${timeoutMs / 1e3}s`
|
|
2054
|
+
);
|
|
2055
|
+
}
|
|
2056
|
+
/**
|
|
2057
|
+
* Terminate managed Chrome (only if selfLaunched).
|
|
2058
|
+
* Tries to close the browser connection gracefully first, then kills the process.
|
|
2059
|
+
*/
|
|
2060
|
+
async shutdown(state) {
|
|
2061
|
+
if (!state.selfLaunched || !state.process) {
|
|
2062
|
+
return;
|
|
2063
|
+
}
|
|
2064
|
+
try {
|
|
2065
|
+
await state.browser.close();
|
|
2066
|
+
} catch {
|
|
2067
|
+
}
|
|
2068
|
+
try {
|
|
2069
|
+
state.process.kill("SIGTERM");
|
|
2070
|
+
} catch {
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
/**
|
|
2074
|
+
* Attempt to relaunch and reconnect to Chrome within 30s.
|
|
2075
|
+
* Returns a fresh ChromeManagerState.
|
|
2076
|
+
*/
|
|
2077
|
+
async reconnect() {
|
|
2078
|
+
const RECONNECT_TIMEOUT_MS = 3e4;
|
|
2079
|
+
const deadline = Date.now() + RECONNECT_TIMEOUT_MS;
|
|
2080
|
+
while (Date.now() < deadline) {
|
|
2081
|
+
try {
|
|
2082
|
+
return await this.connect();
|
|
2083
|
+
} catch {
|
|
2084
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
throw new Error(
|
|
2088
|
+
`Failed to reconnect to Chrome within ${RECONNECT_TIMEOUT_MS / 1e3}s`
|
|
2089
|
+
);
|
|
2090
|
+
}
|
|
2091
|
+
/**
|
|
2092
|
+
* Minimize the Chrome window via CDP Browser.setWindowBounds.
|
|
2093
|
+
* This is best-effort — errors are silently caught.
|
|
2094
|
+
*/
|
|
2095
|
+
async minimizeWindow(state) {
|
|
2096
|
+
try {
|
|
2097
|
+
const page = state.browser.contexts()[0]?.pages()[0];
|
|
2098
|
+
if (!page) return;
|
|
2099
|
+
const cdpSession = await page.context().newCDPSession(page);
|
|
2100
|
+
const { windowId } = await cdpSession.send(
|
|
2101
|
+
"Browser.getWindowForTarget"
|
|
2102
|
+
);
|
|
2103
|
+
await cdpSession.send("Browser.setWindowBounds", {
|
|
2104
|
+
windowId,
|
|
2105
|
+
bounds: { windowState: "minimized" }
|
|
2106
|
+
});
|
|
2107
|
+
} catch {
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
/**
|
|
2111
|
+
* Detect the Chrome/Chromium executable path for the current OS.
|
|
2112
|
+
* Returns the absolute path string, or `null` if no installation is found.
|
|
2113
|
+
*/
|
|
2114
|
+
static findChromePath() {
|
|
2115
|
+
const platform = process.platform;
|
|
2116
|
+
if (platform === "darwin") {
|
|
2117
|
+
return existsSync(MACOS_CHROME_PATH) ? MACOS_CHROME_PATH : null;
|
|
2118
|
+
}
|
|
2119
|
+
if (platform === "linux") {
|
|
2120
|
+
for (const candidate of LINUX_CHROME_CANDIDATES) {
|
|
2121
|
+
try {
|
|
2122
|
+
const resolved = execSync(`which ${candidate}`, {
|
|
2123
|
+
encoding: "utf8",
|
|
2124
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
2125
|
+
}).trim();
|
|
2126
|
+
if (resolved) {
|
|
2127
|
+
return resolved;
|
|
2128
|
+
}
|
|
2129
|
+
} catch {
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
return null;
|
|
2133
|
+
}
|
|
2134
|
+
if (platform === "win32") {
|
|
2135
|
+
for (const winPath of WINDOWS_CHROME_PATHS) {
|
|
2136
|
+
if (existsSync(winPath)) {
|
|
2137
|
+
return winPath;
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
return null;
|
|
2141
|
+
}
|
|
2142
|
+
return null;
|
|
2143
|
+
}
|
|
2144
|
+
};
|
|
2145
|
+
|
|
2146
|
+
// src/core/token-refresher.ts
|
|
2147
|
+
var CredentialHolder = class {
|
|
2148
|
+
creds = null;
|
|
2149
|
+
/** Returns the current credentials, or `null` if none have been set yet. */
|
|
2150
|
+
get() {
|
|
2151
|
+
return this.creds;
|
|
2152
|
+
}
|
|
2153
|
+
/** Atomically replaces the stored credentials. */
|
|
2154
|
+
set(creds) {
|
|
2155
|
+
this.creds = creds;
|
|
2156
|
+
}
|
|
2157
|
+
};
|
|
2158
|
+
function shouldRefresh(nowMs, expiresAt, thresholdMs) {
|
|
2159
|
+
return expiresAt * 1e3 - nowMs < thresholdMs;
|
|
2160
|
+
}
|
|
2161
|
+
var BACKOFF_DELAYS = [5e3, 15e3, 3e4];
|
|
2162
|
+
var TokenRefresher = class {
|
|
2163
|
+
credentialHolder;
|
|
2164
|
+
cdpUrl;
|
|
2165
|
+
checkIntervalMs;
|
|
2166
|
+
refreshBeforeExpiryMs;
|
|
2167
|
+
maxRetries;
|
|
2168
|
+
intervalId = null;
|
|
2169
|
+
constructor(credentialHolder, cdpUrl, options) {
|
|
2170
|
+
this.credentialHolder = credentialHolder;
|
|
2171
|
+
this.cdpUrl = cdpUrl;
|
|
2172
|
+
this.checkIntervalMs = options?.checkIntervalMs ?? 6e4;
|
|
2173
|
+
this.refreshBeforeExpiryMs = options?.refreshBeforeExpiryMs ?? 3e5;
|
|
2174
|
+
this.maxRetries = options?.maxRetries ?? 3;
|
|
2175
|
+
}
|
|
2176
|
+
/** Start the background check interval. */
|
|
2177
|
+
start() {
|
|
2178
|
+
if (this.intervalId !== null) return;
|
|
2179
|
+
this.intervalId = setInterval(() => {
|
|
2180
|
+
void this.checkAndRefresh();
|
|
2181
|
+
}, this.checkIntervalMs);
|
|
2182
|
+
}
|
|
2183
|
+
/** Stop the background check interval. */
|
|
2184
|
+
stop() {
|
|
2185
|
+
if (this.intervalId !== null) {
|
|
2186
|
+
clearInterval(this.intervalId);
|
|
2187
|
+
this.intervalId = null;
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
/** Check if a refresh is needed and perform it. Returns true if refreshed. */
|
|
2191
|
+
async checkAndRefresh() {
|
|
2192
|
+
const creds = this.credentialHolder.get();
|
|
2193
|
+
if (!creds || !creds.expiresAt) {
|
|
2194
|
+
return false;
|
|
2195
|
+
}
|
|
2196
|
+
if (!shouldRefresh(Date.now(), creds.expiresAt, this.refreshBeforeExpiryMs)) {
|
|
2197
|
+
return false;
|
|
2198
|
+
}
|
|
2199
|
+
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
|
2200
|
+
try {
|
|
2201
|
+
const newCreds = await captureCredentials(this.cdpUrl);
|
|
2202
|
+
const gatewayCreds = {
|
|
2203
|
+
bearerToken: newCreds.bearerToken,
|
|
2204
|
+
cookie: newCreds.cookie,
|
|
2205
|
+
userAgent: newCreds.userAgent,
|
|
2206
|
+
expiresAt: newCreds.expiresAt
|
|
2207
|
+
};
|
|
2208
|
+
this.credentialHolder.set(gatewayCreds);
|
|
2209
|
+
await updateConfig({ credentials: gatewayCreds });
|
|
2210
|
+
const expiryDate = new Date(newCreds.expiresAt * 1e3).toISOString();
|
|
2211
|
+
console.log(`[TokenRefresher] Credentials refreshed successfully. New expiry: ${expiryDate}`);
|
|
2212
|
+
return true;
|
|
2213
|
+
} catch (err) {
|
|
2214
|
+
const delay = BACKOFF_DELAYS[attempt] ?? BACKOFF_DELAYS[BACKOFF_DELAYS.length - 1];
|
|
2215
|
+
if (attempt < this.maxRetries - 1) {
|
|
2216
|
+
console.warn(
|
|
2217
|
+
`[TokenRefresher] Refresh attempt ${attempt + 1}/${this.maxRetries} failed, retrying in ${delay / 1e3}s...`
|
|
2218
|
+
);
|
|
2219
|
+
await this.sleep(delay);
|
|
2220
|
+
} else {
|
|
2221
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2222
|
+
console.warn(
|
|
2223
|
+
`
|
|
2224
|
+
\u26A0\uFE0F [TokenRefresher] All ${this.maxRetries} refresh attempts failed. ${msg}
|
|
2225
|
+
Continuing with current credentials. If requests start failing with 401,
|
|
2226
|
+
restart with \`jh-gateway start\` (without --headless) to re-login.
|
|
2227
|
+
`
|
|
2228
|
+
);
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
return false;
|
|
2233
|
+
}
|
|
2234
|
+
sleep(ms) {
|
|
2235
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2236
|
+
}
|
|
2237
|
+
};
|
|
2238
|
+
|
|
2239
|
+
// src/core/reauth-lock.ts
|
|
2240
|
+
var ReauthLock = class {
|
|
2241
|
+
inflight = null;
|
|
2242
|
+
async acquire(recaptureFn) {
|
|
2243
|
+
if (this.inflight) {
|
|
2244
|
+
return this.inflight;
|
|
2245
|
+
}
|
|
2246
|
+
this.inflight = recaptureFn().finally(() => {
|
|
2247
|
+
this.inflight = null;
|
|
2248
|
+
});
|
|
2249
|
+
return this.inflight;
|
|
2250
|
+
}
|
|
2251
|
+
};
|
|
2252
|
+
|
|
2253
|
+
// src/cli/start.ts
|
|
2254
|
+
async function runStart(options) {
|
|
2255
|
+
const config = await loadConfig();
|
|
2256
|
+
if (options.port !== void 0) {
|
|
2257
|
+
await updateConfig({ port: options.port });
|
|
2258
|
+
config.port = options.port;
|
|
2259
|
+
}
|
|
2260
|
+
const maxPages = options.pages ?? 3;
|
|
2261
|
+
const cdpPort = parseInt(new URL(config.cdpUrl).port, 10) || 9222;
|
|
2262
|
+
const chromeManager = new ChromeManager({
|
|
2263
|
+
cdpPort,
|
|
2264
|
+
headless: options.headless
|
|
2265
|
+
});
|
|
2266
|
+
const s = p2.spinner();
|
|
2267
|
+
s.start("Connecting to Chrome...");
|
|
2268
|
+
let state;
|
|
2269
|
+
try {
|
|
2270
|
+
state = await chromeManager.connect();
|
|
2271
|
+
s.stop(
|
|
2272
|
+
state.selfLaunched ? "Chrome launched and connected." : "Connected to existing Chrome instance."
|
|
2273
|
+
);
|
|
2274
|
+
} catch (err) {
|
|
2275
|
+
s.stop("Failed to connect to Chrome.");
|
|
2276
|
+
throw err;
|
|
2277
|
+
}
|
|
2278
|
+
const needsAuth = !config.credentials || !config.credentials.expiresAt || shouldRefresh(Date.now(), config.credentials.expiresAt, 0);
|
|
2279
|
+
if (needsAuth && options.headless) {
|
|
2280
|
+
await chromeManager.shutdown(state);
|
|
2281
|
+
throw new Error(
|
|
2282
|
+
"Cannot authenticate in headless mode \u2014 no browser window to log in.\nRun `jh-gateway start` (without --headless) first to log in, then use --headless on subsequent runs."
|
|
2283
|
+
);
|
|
2284
|
+
}
|
|
2285
|
+
if (needsAuth) {
|
|
2286
|
+
s.start("Waiting for login (timeout: 300s)...");
|
|
2287
|
+
try {
|
|
2288
|
+
await findOrOpenJhPage(state.browser);
|
|
2289
|
+
const creds = await captureCredentials(config.cdpUrl, 3e5);
|
|
2290
|
+
config.credentials = {
|
|
2291
|
+
bearerToken: creds.bearerToken,
|
|
2292
|
+
cookie: creds.cookie,
|
|
2293
|
+
userAgent: creds.userAgent,
|
|
2294
|
+
expiresAt: creds.expiresAt
|
|
2295
|
+
};
|
|
2296
|
+
await chromeManager.minimizeWindow(state);
|
|
2297
|
+
const expiryStr = creds.expiresAt ? new Date(creds.expiresAt * 1e3).toLocaleString() : "unknown";
|
|
2298
|
+
s.stop(`Credentials captured. Token expires: ${expiryStr}`);
|
|
2299
|
+
} catch (err) {
|
|
2300
|
+
s.stop("Authentication failed.");
|
|
2301
|
+
await chromeManager.shutdown(state);
|
|
2302
|
+
throw err;
|
|
2303
|
+
}
|
|
2304
|
+
} else {
|
|
2305
|
+
p2.log.info("Valid credentials found, skipping login.");
|
|
2306
|
+
}
|
|
2307
|
+
s.start(`Starting server on port ${config.port}...`);
|
|
2308
|
+
const seedPage = await findOrOpenJhPage(state.browser);
|
|
2309
|
+
const pool = new PagePool({
|
|
2310
|
+
maxPages,
|
|
2311
|
+
maxWaitMs: config.maxQueueWaitMs
|
|
2312
|
+
});
|
|
2313
|
+
await pool.init(state.browser, seedPage);
|
|
2314
|
+
const credentialHolder = new CredentialHolder();
|
|
2315
|
+
if (config.credentials) {
|
|
2316
|
+
credentialHolder.set(config.credentials);
|
|
2317
|
+
}
|
|
2318
|
+
const _reauthLock = new ReauthLock();
|
|
2319
|
+
const serverHandle = await startServer(config, {
|
|
2320
|
+
getPool: () => pool,
|
|
2321
|
+
getCredentials: () => credentialHolder.get(),
|
|
2322
|
+
browser: state.browser
|
|
2323
|
+
});
|
|
2324
|
+
s.stop(`Server running on port ${config.port}.`);
|
|
2325
|
+
const tokenRefresher = new TokenRefresher(credentialHolder, config.cdpUrl);
|
|
2326
|
+
tokenRefresher.start();
|
|
2327
|
+
const baseUrl = `http://127.0.0.1:${config.port}`;
|
|
2328
|
+
const apiKey = config.auth.token ?? "(no auth required)";
|
|
2329
|
+
p2.log.success(`Gateway ready!`);
|
|
2330
|
+
p2.log.info(`Base URL: ${baseUrl}`);
|
|
2331
|
+
p2.log.info(`API Key: ${apiKey}`);
|
|
2332
|
+
p2.log.info("Press Ctrl+C to stop.");
|
|
2333
|
+
const shutdown = async () => {
|
|
2334
|
+
tokenRefresher.stop();
|
|
2335
|
+
await serverHandle.close();
|
|
2336
|
+
await chromeManager.shutdown(state);
|
|
2337
|
+
};
|
|
2338
|
+
process.on("SIGINT", async () => {
|
|
2339
|
+
await shutdown();
|
|
2340
|
+
process.exit(0);
|
|
2341
|
+
});
|
|
2342
|
+
process.on("SIGTERM", async () => {
|
|
2343
|
+
await shutdown();
|
|
2344
|
+
process.exit(0);
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
// src/cli.ts
|
|
2349
|
+
var args = process.argv.slice(2);
|
|
2350
|
+
var command = args[0];
|
|
2351
|
+
function parseFlags(argv) {
|
|
2352
|
+
const flags = {};
|
|
2353
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2354
|
+
const arg = argv[i];
|
|
2355
|
+
if (arg.startsWith("--")) {
|
|
2356
|
+
const key = arg.slice(2);
|
|
2357
|
+
const next = argv[i + 1];
|
|
2358
|
+
if (next !== void 0 && !next.startsWith("--")) {
|
|
2359
|
+
flags[key] = next;
|
|
2360
|
+
i++;
|
|
2361
|
+
} else {
|
|
2362
|
+
flags[key] = true;
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
return flags;
|
|
2367
|
+
}
|
|
2368
|
+
function printHelp() {
|
|
2369
|
+
console.log(`
|
|
2370
|
+
jh-gateway \u2014 JH Web Gateway CLI
|
|
2371
|
+
|
|
2372
|
+
Usage:
|
|
2373
|
+
jh-gateway <command> [options]
|
|
2374
|
+
|
|
2375
|
+
Commands:
|
|
2376
|
+
start Launch Chrome, authenticate, and start the gateway server
|
|
2377
|
+
setup Interactive setup wizard (Chrome detection, auth, port)
|
|
2378
|
+
serve Start the HTTP server
|
|
2379
|
+
auth Re-capture JH credentials
|
|
2380
|
+
config Print current configuration (credentials redacted)
|
|
2381
|
+
status Show Chrome connection, token expiry, and gateway state
|
|
2382
|
+
logs Display recent request logs
|
|
2383
|
+
|
|
2384
|
+
Options:
|
|
2385
|
+
start --headless Launch Chrome in headless mode
|
|
2386
|
+
start --port <n> Override the configured port
|
|
2387
|
+
start --pages <n> Max concurrent browser pages (default: 3)
|
|
2388
|
+
serve --port <n> Override the configured port
|
|
2389
|
+
serve --pages <n> Max concurrent browser pages (default: 3)
|
|
2390
|
+
logs --limit <n> Number of log entries to show (default: 50)
|
|
2391
|
+
--help Show this help message
|
|
2392
|
+
`);
|
|
2393
|
+
}
|
|
2394
|
+
async function main() {
|
|
2395
|
+
if (!command || command === "--help" || command === "-h") {
|
|
2396
|
+
printHelp();
|
|
2397
|
+
process.exit(0);
|
|
2398
|
+
}
|
|
2399
|
+
const flags = parseFlags(args.slice(1));
|
|
2400
|
+
try {
|
|
2401
|
+
switch (command) {
|
|
2402
|
+
case "start": {
|
|
2403
|
+
const headless = flags["headless"] === true;
|
|
2404
|
+
const startPortFlag = flags["port"];
|
|
2405
|
+
const startPort = startPortFlag !== void 0 && startPortFlag !== true ? Number(startPortFlag) : void 0;
|
|
2406
|
+
if (startPort !== void 0 && (isNaN(startPort) || startPort < 1 || startPort > 65535)) {
|
|
2407
|
+
console.error("Error: --port must be a valid port number (1\u201365535)");
|
|
2408
|
+
process.exit(1);
|
|
2409
|
+
}
|
|
2410
|
+
const startPagesFlag = flags["pages"];
|
|
2411
|
+
const startPages = startPagesFlag !== void 0 && startPagesFlag !== true ? Number(startPagesFlag) : void 0;
|
|
2412
|
+
if (startPages !== void 0 && (isNaN(startPages) || startPages < 1 || startPages > 10)) {
|
|
2413
|
+
console.error("Error: --pages must be between 1 and 10");
|
|
2414
|
+
process.exit(1);
|
|
2415
|
+
}
|
|
2416
|
+
await runStart({ headless, port: startPort, pages: startPages });
|
|
2417
|
+
break;
|
|
2418
|
+
}
|
|
2419
|
+
case "setup":
|
|
2420
|
+
await runSetup();
|
|
2421
|
+
break;
|
|
2422
|
+
case "serve": {
|
|
2423
|
+
const portFlag = flags["port"];
|
|
2424
|
+
const port = portFlag !== void 0 && portFlag !== true ? Number(portFlag) : void 0;
|
|
2425
|
+
if (port !== void 0 && (isNaN(port) || port < 1 || port > 65535)) {
|
|
2426
|
+
console.error("Error: --port must be a valid port number (1\u201365535)");
|
|
2427
|
+
process.exit(1);
|
|
2428
|
+
}
|
|
2429
|
+
const pagesFlag = flags["pages"];
|
|
2430
|
+
const pages = pagesFlag !== void 0 && pagesFlag !== true ? Number(pagesFlag) : void 0;
|
|
2431
|
+
if (pages !== void 0 && (isNaN(pages) || pages < 1 || pages > 10)) {
|
|
2432
|
+
console.error("Error: --pages must be between 1 and 10");
|
|
2433
|
+
process.exit(1);
|
|
2434
|
+
}
|
|
2435
|
+
await runServe({ port, pages });
|
|
2436
|
+
break;
|
|
2437
|
+
}
|
|
2438
|
+
case "auth":
|
|
2439
|
+
await runAuth();
|
|
2440
|
+
break;
|
|
2441
|
+
case "config":
|
|
2442
|
+
await runConfig();
|
|
2443
|
+
break;
|
|
2444
|
+
case "status":
|
|
2445
|
+
await runStatus();
|
|
2446
|
+
break;
|
|
2447
|
+
case "logs": {
|
|
2448
|
+
const limitFlag = flags["limit"];
|
|
2449
|
+
const limit = limitFlag !== void 0 && limitFlag !== true ? Number(limitFlag) : void 0;
|
|
2450
|
+
if (limit !== void 0 && (isNaN(limit) || limit < 1)) {
|
|
2451
|
+
console.error("Error: --limit must be a positive number");
|
|
2452
|
+
process.exit(1);
|
|
2453
|
+
}
|
|
2454
|
+
await runLogs({ limit });
|
|
2455
|
+
break;
|
|
2456
|
+
}
|
|
2457
|
+
default:
|
|
2458
|
+
console.error(`Unknown command: ${command}`);
|
|
2459
|
+
printHelp();
|
|
2460
|
+
process.exit(1);
|
|
2461
|
+
}
|
|
2462
|
+
} catch (err) {
|
|
2463
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2464
|
+
console.error(`Error: ${msg}`);
|
|
2465
|
+
process.exit(1);
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
main();
|
|
2469
|
+
//# sourceMappingURL=cli.js.map
|