web-access-cli 0.5.0 → 0.7.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 +104 -207
- package/README_CN.md +106 -209
- package/THIRD_PARTY_NOTICES.md +0 -13
- package/dist/config/config.js +10 -2
- package/dist/config/config.js.map +1 -1
- package/dist/core/router.js +4 -2
- package/dist/core/router.js.map +1 -1
- package/dist/core/schema.js +1 -1
- package/dist/core/types.js +1 -0
- package/dist/core/types.js.map +1 -1
- package/dist/gateway/admin.js +670 -0
- package/dist/gateway/admin.js.map +1 -0
- package/dist/gateway/panel.js +2405 -0
- package/dist/gateway/panel.js.map +1 -0
- package/dist/gateway/records.js +464 -0
- package/dist/gateway/records.js.map +1 -0
- package/dist/gateway/schema.js +115 -0
- package/dist/gateway/schema.js.map +1 -0
- package/dist/gateway/server.js +663 -0
- package/dist/gateway/server.js.map +1 -0
- package/dist/gateway/service.js +607 -0
- package/dist/gateway/service.js.map +1 -0
- package/dist/providers/anysearch.js +5 -3
- package/dist/providers/anysearch.js.map +1 -1
- package/dist/providers/deepseek.js +173 -106
- package/dist/providers/deepseek.js.map +1 -1
- package/dist/providers/registry.js +2 -0
- package/dist/providers/registry.js.map +1 -1
- package/dist/providers/search.js +4 -2
- package/dist/providers/search.js.map +1 -1
- package/dist/providers/xai.js +16 -8
- package/dist/providers/xai.js.map +1 -1
- package/dist/providers/xcrawl.js +1 -1
- package/dist/providers/xcrawl.js.map +1 -1
- package/dist/providers/yuanbao.js +214 -0
- package/dist/providers/yuanbao.js.map +1 -0
- package/dist/server.js +127 -0
- package/dist/server.js.map +1 -0
- package/dist/transport/http.js +8 -0
- package/dist/transport/http.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -2
- package/schemas/config.schema.json +4 -0
- package/schemas/gatewayError.schema.json +33 -0
- package/schemas/gatewayFetchRequest.schema.json +13 -0
- package/schemas/gatewayFetchResponse.schema.json +16 -0
- package/schemas/gatewaySearchRequest.schema.json +37 -0
- package/schemas/gatewaySearchResponse.schema.json +81 -0
- package/schemas/output.schema.json +52 -0
- package/schemas/searchRequest.schema.json +1 -1
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual, } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { createServer, } from "node:http";
|
|
4
|
+
import { VERSION } from "../version.js";
|
|
5
|
+
import { createGatewayAdminController, parseGatewayConfigUpdateRequest, } from "./admin.js";
|
|
6
|
+
import { CONFIG_PATH, CONFIG_RELOAD_PATH, OVERVIEW_PATH, PANEL_PATH, RECORDS_PATH, renderPanelHtml, STATS_RESET_PATH, } from "./panel.js";
|
|
7
|
+
import { createGatewayRecordStore, createGatewayStatsTracker, GATEWAY_RECORD_FETCH_PREVIEW_CHARS, GATEWAY_RECORD_MAX_RESULTS, GATEWAY_RECORD_SEARCH_PREVIEW_CHARS, truncatePreview, } from "./records.js";
|
|
8
|
+
import { GatewayValidationError, parseGatewayFetchRequest, parseGatewaySearchRequest, } from "./schema.js";
|
|
9
|
+
import { createGatewayService, GatewayClientAbort, GatewayHttpError, } from "./service.js";
|
|
10
|
+
/**
|
|
11
|
+
* Minimum gateway key length. Deployments should still generate a 32-byte random
|
|
12
|
+
* value (64 hex characters); this floor only rejects obviously weak keys.
|
|
13
|
+
*
|
|
14
|
+
* The gateway deliberately imposes no local admission or body limits: it is a
|
|
15
|
+
* private single-user deployment, so concurrency, body size and body read time
|
|
16
|
+
* are bounded only by the upstream providers and the process itself.
|
|
17
|
+
*/
|
|
18
|
+
export const GATEWAY_MIN_API_KEY_LENGTH = 12;
|
|
19
|
+
export function normalizeGatewayApiKey(raw) {
|
|
20
|
+
const value = raw.replace(/\r?\n$/, "");
|
|
21
|
+
if (!value.trim())
|
|
22
|
+
throw new Error("网关 API key 为空");
|
|
23
|
+
if (/\s/.test(value) || hasControlCharacters(value))
|
|
24
|
+
throw new Error("网关 API key 不能包含空白或控制字符");
|
|
25
|
+
if (value.length < GATEWAY_MIN_API_KEY_LENGTH)
|
|
26
|
+
throw new Error(`网关 API key 至少需要 ${GATEWAY_MIN_API_KEY_LENGTH} 个字符`);
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
/** Read the gateway bearer key from a file; never from argv or the environment. */
|
|
30
|
+
export function readGatewayApiKeyFile(path) {
|
|
31
|
+
return normalizeGatewayApiKey(readFileSync(path, "utf8"));
|
|
32
|
+
}
|
|
33
|
+
function digest(value) {
|
|
34
|
+
return createHash("sha256").update(value, "utf8").digest();
|
|
35
|
+
}
|
|
36
|
+
function hasControlCharacters(value) {
|
|
37
|
+
for (const character of value) {
|
|
38
|
+
const code = character.codePointAt(0) ?? 0;
|
|
39
|
+
if (code < 0x20 || code === 0x7f)
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
function sendJson(response, status, body, headers = {}) {
|
|
45
|
+
if (response.writableEnded || response.destroyed)
|
|
46
|
+
return;
|
|
47
|
+
const payload = JSON.stringify(body);
|
|
48
|
+
response.writeHead(status, {
|
|
49
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
50
|
+
"Cache-Control": "no-store",
|
|
51
|
+
"Content-Length": Buffer.byteLength(payload, "utf8").toString(),
|
|
52
|
+
...headers,
|
|
53
|
+
});
|
|
54
|
+
response.end(payload);
|
|
55
|
+
}
|
|
56
|
+
function sendError(response, error, headers = {}) {
|
|
57
|
+
sendJson(response, error.status, {
|
|
58
|
+
error: {
|
|
59
|
+
message: error.message,
|
|
60
|
+
type: error.type,
|
|
61
|
+
param: null,
|
|
62
|
+
code: error.code,
|
|
63
|
+
},
|
|
64
|
+
}, {
|
|
65
|
+
...(error.retryAfterSeconds !== undefined
|
|
66
|
+
? { "Retry-After": String(error.retryAfterSeconds) }
|
|
67
|
+
: {}),
|
|
68
|
+
...headers,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function sendPanel(response, nonce, retention) {
|
|
72
|
+
if (response.writableEnded || response.destroyed)
|
|
73
|
+
return;
|
|
74
|
+
const body = renderPanelHtml({
|
|
75
|
+
nonce,
|
|
76
|
+
retention,
|
|
77
|
+
minKeyLength: GATEWAY_MIN_API_KEY_LENGTH,
|
|
78
|
+
version: VERSION,
|
|
79
|
+
});
|
|
80
|
+
response.writeHead(200, {
|
|
81
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
82
|
+
"Cache-Control": "no-store",
|
|
83
|
+
"Content-Length": Buffer.byteLength(body, "utf8").toString(),
|
|
84
|
+
"Content-Security-Policy": [
|
|
85
|
+
"default-src 'none'",
|
|
86
|
+
`script-src 'nonce-${nonce}'`,
|
|
87
|
+
`style-src 'nonce-${nonce}'`,
|
|
88
|
+
"connect-src 'self'",
|
|
89
|
+
"img-src 'none'",
|
|
90
|
+
"base-uri 'none'",
|
|
91
|
+
"form-action 'none'",
|
|
92
|
+
"frame-ancestors 'none'",
|
|
93
|
+
].join("; "),
|
|
94
|
+
"Referrer-Policy": "no-referrer",
|
|
95
|
+
"X-Content-Type-Options": "nosniff",
|
|
96
|
+
});
|
|
97
|
+
response.end(body);
|
|
98
|
+
}
|
|
99
|
+
const invalidRequest = (message) => new GatewayHttpError({
|
|
100
|
+
status: 400,
|
|
101
|
+
type: "invalid_request_error",
|
|
102
|
+
code: "invalid_request_error",
|
|
103
|
+
message,
|
|
104
|
+
});
|
|
105
|
+
const unauthorized = () => new GatewayHttpError({
|
|
106
|
+
status: 401,
|
|
107
|
+
type: "authentication_error",
|
|
108
|
+
code: "invalid_api_key",
|
|
109
|
+
message: "网关 API key 缺失或无效。",
|
|
110
|
+
});
|
|
111
|
+
const methodNotAllowed = (allowed) => new GatewayHttpError({
|
|
112
|
+
status: 405,
|
|
113
|
+
type: "invalid_request_error",
|
|
114
|
+
code: "method_not_allowed",
|
|
115
|
+
message: `该路径只接受 ${allowed} 请求。`,
|
|
116
|
+
});
|
|
117
|
+
const routeNotFound = () => new GatewayHttpError({
|
|
118
|
+
status: 404,
|
|
119
|
+
type: "invalid_request_error",
|
|
120
|
+
code: "not_found",
|
|
121
|
+
message: "接口不存在。",
|
|
122
|
+
});
|
|
123
|
+
class BodyAborted extends Error {
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Read the JSON object request body. There is intentionally no byte cap and no
|
|
127
|
+
* read deadline: the body is bounded only by the client and by process memory.
|
|
128
|
+
*/
|
|
129
|
+
function readJsonObject(request) {
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const chunks = [];
|
|
132
|
+
let settled = false;
|
|
133
|
+
const finish = (error, value) => {
|
|
134
|
+
if (settled)
|
|
135
|
+
return;
|
|
136
|
+
settled = true;
|
|
137
|
+
request.off("data", onData);
|
|
138
|
+
request.off("end", onEnd);
|
|
139
|
+
request.off("error", onError);
|
|
140
|
+
request.off("aborted", onAborted);
|
|
141
|
+
if (error)
|
|
142
|
+
reject(error);
|
|
143
|
+
else
|
|
144
|
+
resolve(value);
|
|
145
|
+
};
|
|
146
|
+
const onData = (chunk) => {
|
|
147
|
+
chunks.push(chunk);
|
|
148
|
+
};
|
|
149
|
+
const onEnd = () => {
|
|
150
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
151
|
+
if (!text.trim()) {
|
|
152
|
+
finish(invalidRequest("请求体不能为空。"));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
let parsed;
|
|
156
|
+
try {
|
|
157
|
+
parsed = JSON.parse(text);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
finish(invalidRequest("请求体不是合法的 JSON。"));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
164
|
+
finish(invalidRequest("请求体必须是 JSON 对象。"));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
finish(undefined, parsed);
|
|
168
|
+
};
|
|
169
|
+
const onError = () => finish(new BodyAborted());
|
|
170
|
+
const onAborted = () => finish(new BodyAborted());
|
|
171
|
+
request.on("data", onData);
|
|
172
|
+
request.on("end", onEnd);
|
|
173
|
+
request.on("error", onError);
|
|
174
|
+
request.on("aborted", onAborted);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function contentTypeIsJson(header) {
|
|
178
|
+
if (!header)
|
|
179
|
+
return false;
|
|
180
|
+
const [mediaType, ...parameters] = header.split(";");
|
|
181
|
+
if (mediaType?.trim().toLowerCase() !== "application/json")
|
|
182
|
+
return false;
|
|
183
|
+
for (const parameter of parameters) {
|
|
184
|
+
const name = parameter.split("=", 1)[0]?.trim().toLowerCase();
|
|
185
|
+
if (name !== "charset")
|
|
186
|
+
continue;
|
|
187
|
+
const value = parameter
|
|
188
|
+
.slice(parameter.indexOf("=") + 1)
|
|
189
|
+
.trim()
|
|
190
|
+
.replace(/^"|"$/g, "")
|
|
191
|
+
.toLowerCase();
|
|
192
|
+
if (value !== "utf-8" && value !== "utf8")
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Exactly one `Authorization: Bearer <key>` value is required. Duplicate header
|
|
199
|
+
* lines are visible through `headersDistinct` and are rejected without touching
|
|
200
|
+
* providers; `request.headers.authorization` silently keeps the first value.
|
|
201
|
+
*/
|
|
202
|
+
function authorizationKey(request) {
|
|
203
|
+
const distinct = request.headersDistinct.authorization;
|
|
204
|
+
if (distinct?.length !== 1)
|
|
205
|
+
return "";
|
|
206
|
+
const header = distinct[0];
|
|
207
|
+
if (typeof header !== "string")
|
|
208
|
+
return "";
|
|
209
|
+
if (!header.startsWith("Bearer "))
|
|
210
|
+
return "";
|
|
211
|
+
const value = header.slice("Bearer ".length);
|
|
212
|
+
if (!value || value !== value.trim())
|
|
213
|
+
return "";
|
|
214
|
+
if (hasControlCharacters(value))
|
|
215
|
+
return "";
|
|
216
|
+
return value;
|
|
217
|
+
}
|
|
218
|
+
export function createGatewayServer(options) {
|
|
219
|
+
const service = createGatewayService({
|
|
220
|
+
loaded: options.loaded,
|
|
221
|
+
transport: options.transport,
|
|
222
|
+
now: options.now,
|
|
223
|
+
limits: options.limits,
|
|
224
|
+
});
|
|
225
|
+
const expected = digest(options.apiKey);
|
|
226
|
+
const records = createGatewayRecordStore(options.recordLimit);
|
|
227
|
+
const stats = createGatewayStatsTracker();
|
|
228
|
+
const admin = createGatewayAdminController({
|
|
229
|
+
service,
|
|
230
|
+
stats,
|
|
231
|
+
retention: records.retention,
|
|
232
|
+
});
|
|
233
|
+
const log = options.log ??
|
|
234
|
+
((event) => process.stderr.write(`${JSON.stringify(event)}\n`));
|
|
235
|
+
let shuttingDown = false;
|
|
236
|
+
const active = new Set();
|
|
237
|
+
const authenticate = (request) => {
|
|
238
|
+
const providedKey = authorizationKey(request);
|
|
239
|
+
return (providedKey.length > 0 && timingSafeEqual(digest(providedKey), expected));
|
|
240
|
+
};
|
|
241
|
+
const handle = async (request, response, route) => {
|
|
242
|
+
const started = performance.now();
|
|
243
|
+
const rawRequestId = request.headers["x-request-id"];
|
|
244
|
+
const requestId = typeof rawRequestId === "string" &&
|
|
245
|
+
/^[A-Za-z0-9._-]{1,64}$/.test(rawRequestId)
|
|
246
|
+
? rawRequestId
|
|
247
|
+
: randomUUID();
|
|
248
|
+
let status = 500;
|
|
249
|
+
let code;
|
|
250
|
+
let authenticated = false;
|
|
251
|
+
let clientAborted = false;
|
|
252
|
+
const summary = {};
|
|
253
|
+
const clientAbort = new AbortController();
|
|
254
|
+
response.once("close", () => {
|
|
255
|
+
if (!response.writableEnded)
|
|
256
|
+
clientAbort.abort();
|
|
257
|
+
});
|
|
258
|
+
try {
|
|
259
|
+
if (shuttingDown)
|
|
260
|
+
throw new GatewayHttpError({
|
|
261
|
+
status: 503,
|
|
262
|
+
type: "server_error",
|
|
263
|
+
code: "shutting_down",
|
|
264
|
+
message: "网关正在停止。",
|
|
265
|
+
});
|
|
266
|
+
if (!authenticate(request))
|
|
267
|
+
throw unauthorized();
|
|
268
|
+
authenticated = true;
|
|
269
|
+
if (!contentTypeIsJson(request.headers["content-type"]))
|
|
270
|
+
throw invalidRequest("Content-Type 必须是 application/json。");
|
|
271
|
+
if (request.headers["content-encoding"] !== undefined)
|
|
272
|
+
throw invalidRequest("不支持压缩请求体。");
|
|
273
|
+
const raw = await readJsonObject(request);
|
|
274
|
+
const parsed = route === "search"
|
|
275
|
+
? parseGatewaySearchRequest(raw)
|
|
276
|
+
: parseGatewayFetchRequest(raw);
|
|
277
|
+
if (route === "search") {
|
|
278
|
+
const search = parsed;
|
|
279
|
+
summary.query = search.query;
|
|
280
|
+
summary.count = search.count;
|
|
281
|
+
summary.needContent = search.need_content ?? false;
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
summary.url = parsed.url;
|
|
285
|
+
}
|
|
286
|
+
const observer = {
|
|
287
|
+
onSuccess: (info) => {
|
|
288
|
+
summary.provider = info.provider;
|
|
289
|
+
if (info.trace.attempts.length > 0) {
|
|
290
|
+
summary.attempts = info.trace.attempts;
|
|
291
|
+
}
|
|
292
|
+
if (info.trace.contentBackfill) {
|
|
293
|
+
summary.contentBackfill = info.trace.contentBackfill;
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
const body = route === "search"
|
|
298
|
+
? await service.search(parsed, clientAbort.signal, observer)
|
|
299
|
+
: await service.fetch(parsed, clientAbort.signal, observer);
|
|
300
|
+
if (clientAbort.signal.aborted || response.writableEnded) {
|
|
301
|
+
clientAborted = true;
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (route === "search") {
|
|
305
|
+
const searchBody = body;
|
|
306
|
+
summary.resultCount = searchBody.results.length;
|
|
307
|
+
summary.results = searchBody.results
|
|
308
|
+
.slice(0, GATEWAY_RECORD_MAX_RESULTS)
|
|
309
|
+
.map((result, idx) => {
|
|
310
|
+
const preview = truncatePreview(result.content, GATEWAY_RECORD_SEARCH_PREVIEW_CHARS);
|
|
311
|
+
return {
|
|
312
|
+
rank: idx + 1,
|
|
313
|
+
title: result.title,
|
|
314
|
+
url: result.url,
|
|
315
|
+
...(result.snippet ? { snippet: result.snippet } : {}),
|
|
316
|
+
...(preview
|
|
317
|
+
? {
|
|
318
|
+
contentPreview: preview.preview,
|
|
319
|
+
contentChars: preview.chars,
|
|
320
|
+
contentTruncated: preview.truncated,
|
|
321
|
+
}
|
|
322
|
+
: {}),
|
|
323
|
+
};
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
const fetchBody = body;
|
|
328
|
+
summary.title = fetchBody.title;
|
|
329
|
+
const preview = truncatePreview(fetchBody.content, GATEWAY_RECORD_FETCH_PREVIEW_CHARS);
|
|
330
|
+
if (preview !== undefined) {
|
|
331
|
+
summary.contentPreview = preview.preview;
|
|
332
|
+
summary.contentChars = preview.chars;
|
|
333
|
+
summary.contentTruncated = preview.truncated;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
status = 200;
|
|
337
|
+
sendJson(response, 200, body);
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
if (error instanceof GatewayClientAbort || clientAbort.signal.aborted) {
|
|
341
|
+
clientAborted = true;
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const mapped = error instanceof GatewayValidationError
|
|
345
|
+
? invalidRequest(error.message)
|
|
346
|
+
: error instanceof BodyAborted
|
|
347
|
+
? invalidRequest("请求读取中断。")
|
|
348
|
+
: error instanceof GatewayHttpError
|
|
349
|
+
? error
|
|
350
|
+
: new GatewayHttpError({
|
|
351
|
+
status: 500,
|
|
352
|
+
type: "server_error",
|
|
353
|
+
code: "internal_error",
|
|
354
|
+
message: "网关内部错误。",
|
|
355
|
+
});
|
|
356
|
+
status = mapped.status;
|
|
357
|
+
code = mapped.code;
|
|
358
|
+
summary.provider = summary.provider ?? mapped.provider;
|
|
359
|
+
if (mapped.trace) {
|
|
360
|
+
if (!summary.attempts && mapped.trace.attempts.length > 0) {
|
|
361
|
+
summary.attempts = mapped.trace.attempts;
|
|
362
|
+
}
|
|
363
|
+
if (!summary.contentBackfill && mapped.trace.contentBackfill) {
|
|
364
|
+
summary.contentBackfill = mapped.trace.contentBackfill;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// A body read failure leaves the socket unusable for a follow-up request.
|
|
368
|
+
const closeConnection = error instanceof BodyAborted;
|
|
369
|
+
sendError(response, mapped, closeConnection ? { Connection: "close" } : {});
|
|
370
|
+
if (closeConnection)
|
|
371
|
+
request.destroy();
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
const durationMs = Math.max(0, Math.round(performance.now() - started));
|
|
375
|
+
log({
|
|
376
|
+
method: request.method ?? "",
|
|
377
|
+
route,
|
|
378
|
+
status,
|
|
379
|
+
durationMs,
|
|
380
|
+
requestId,
|
|
381
|
+
...(code ? { code } : {}),
|
|
382
|
+
});
|
|
383
|
+
// The panel is an observability surface, not another log sink: only
|
|
384
|
+
// authenticated tool requests are retained, so unauthenticated probes
|
|
385
|
+
// cannot flush the ring buffer and failed keys stay in stderr logs.
|
|
386
|
+
if (authenticated && !clientAborted) {
|
|
387
|
+
const now = Date.now();
|
|
388
|
+
const record = {
|
|
389
|
+
id: requestId,
|
|
390
|
+
at: now,
|
|
391
|
+
route,
|
|
392
|
+
status,
|
|
393
|
+
durationMs,
|
|
394
|
+
...(code ? { code } : {}),
|
|
395
|
+
...summary,
|
|
396
|
+
};
|
|
397
|
+
records.add(record);
|
|
398
|
+
stats.recordRequest({
|
|
399
|
+
at: now,
|
|
400
|
+
route,
|
|
401
|
+
ok: status >= 200 && status < 300,
|
|
402
|
+
attempts: record.attempts,
|
|
403
|
+
contentBackfill: record.contentBackfill,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
const handleAdminConfigUpdate = async (request, response) => {
|
|
409
|
+
try {
|
|
410
|
+
if (shuttingDown) {
|
|
411
|
+
throw new GatewayHttpError({
|
|
412
|
+
status: 503,
|
|
413
|
+
type: "server_error",
|
|
414
|
+
code: "shutting_down",
|
|
415
|
+
message: "网关正在停止。",
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
if (!authenticate(request))
|
|
419
|
+
throw unauthorized();
|
|
420
|
+
if (!contentTypeIsJson(request.headers["content-type"])) {
|
|
421
|
+
throw invalidRequest("Content-Type 必须是 application/json。");
|
|
422
|
+
}
|
|
423
|
+
if (request.headers["content-encoding"] !== undefined) {
|
|
424
|
+
throw invalidRequest("不支持压缩请求体。");
|
|
425
|
+
}
|
|
426
|
+
const raw = await readJsonObject(request);
|
|
427
|
+
const patch = parseGatewayConfigUpdateRequest(raw);
|
|
428
|
+
const result = await admin.updateConfig(patch);
|
|
429
|
+
sendJson(response, 200, result);
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
const mapped = error instanceof GatewayValidationError
|
|
433
|
+
? invalidRequest(error.message)
|
|
434
|
+
: error instanceof BodyAborted
|
|
435
|
+
? invalidRequest("请求读取中断。")
|
|
436
|
+
: error instanceof GatewayHttpError
|
|
437
|
+
? error
|
|
438
|
+
: new GatewayHttpError({
|
|
439
|
+
status: 500,
|
|
440
|
+
type: "server_error",
|
|
441
|
+
code: "internal_error",
|
|
442
|
+
message: "网关内部错误。",
|
|
443
|
+
});
|
|
444
|
+
const closeConnection = error instanceof BodyAborted;
|
|
445
|
+
sendError(response, mapped, closeConnection ? { Connection: "close" } : {});
|
|
446
|
+
if (closeConnection)
|
|
447
|
+
request.destroy();
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
const handleAdminConfigReload = async (request, response) => {
|
|
451
|
+
try {
|
|
452
|
+
if (shuttingDown) {
|
|
453
|
+
throw new GatewayHttpError({
|
|
454
|
+
status: 503,
|
|
455
|
+
type: "server_error",
|
|
456
|
+
code: "shutting_down",
|
|
457
|
+
message: "网关正在停止。",
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
if (!authenticate(request))
|
|
461
|
+
throw unauthorized();
|
|
462
|
+
const result = await admin.reloadConfig();
|
|
463
|
+
sendJson(response, 200, result);
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
const mapped = error instanceof GatewayValidationError
|
|
467
|
+
? invalidRequest(error.message)
|
|
468
|
+
: error instanceof GatewayHttpError
|
|
469
|
+
? error
|
|
470
|
+
: new GatewayHttpError({
|
|
471
|
+
status: 500,
|
|
472
|
+
type: "server_error",
|
|
473
|
+
code: "internal_error",
|
|
474
|
+
message: "网关内部错误。",
|
|
475
|
+
});
|
|
476
|
+
sendError(response, mapped);
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
const server = createServer((request, response) => {
|
|
480
|
+
active.add(response);
|
|
481
|
+
response.once("close", () => active.delete(response));
|
|
482
|
+
const url = request.url ?? "/";
|
|
483
|
+
const path = url.split("?", 1)[0] ?? "/";
|
|
484
|
+
if (path === "/healthz" || path === "/readyz") {
|
|
485
|
+
if (request.method !== "GET") {
|
|
486
|
+
sendError(response, methodNotAllowed("GET"), { Allow: "GET" });
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (path === "/healthz") {
|
|
490
|
+
sendJson(response, 200, { status: "ok" });
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (shuttingDown) {
|
|
494
|
+
sendJson(response, 503, { status: "shutting_down" });
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const readiness = service.readiness();
|
|
498
|
+
sendJson(response, readiness.ready ? 200 : 503, readiness.ready
|
|
499
|
+
? { status: "ready" }
|
|
500
|
+
: { status: "not_ready", reason: readiness.reason });
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (path === "/" || path === PANEL_PATH) {
|
|
504
|
+
if (request.method !== "GET") {
|
|
505
|
+
sendError(response, methodNotAllowed("GET"), { Allow: "GET" });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
sendPanel(response, randomBytes(16).toString("base64url"), records.retention);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (path === OVERVIEW_PATH) {
|
|
512
|
+
if (request.method !== "GET") {
|
|
513
|
+
sendError(response, methodNotAllowed("GET"), { Allow: "GET" });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (!authenticate(request)) {
|
|
517
|
+
sendError(response, unauthorized());
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
sendJson(response, 200, admin.getOverview());
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (path === RECORDS_PATH) {
|
|
524
|
+
if (request.method !== "GET" && request.method !== "DELETE") {
|
|
525
|
+
sendError(response, methodNotAllowed("GET, DELETE"), {
|
|
526
|
+
Allow: "GET, DELETE",
|
|
527
|
+
});
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (!authenticate(request)) {
|
|
531
|
+
sendError(response, unauthorized());
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (request.method === "DELETE") {
|
|
535
|
+
const cleared = records.clear();
|
|
536
|
+
sendJson(response, 200, {
|
|
537
|
+
cleared,
|
|
538
|
+
retention: records.retention,
|
|
539
|
+
generatedAt: Date.now(),
|
|
540
|
+
});
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const queryIndex = url.indexOf("?");
|
|
544
|
+
const params = new URLSearchParams(queryIndex === -1 ? "" : url.slice(queryIndex + 1));
|
|
545
|
+
const rawLimit = params.get("limit");
|
|
546
|
+
let limit = records.retention;
|
|
547
|
+
if (rawLimit !== null) {
|
|
548
|
+
if (!/^[1-9]\d{0,3}$/.test(rawLimit) ||
|
|
549
|
+
Number(rawLimit) > records.retention) {
|
|
550
|
+
sendError(response, invalidRequest(`limit 必须是 1–${records.retention} 的整数。`));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
limit = Number(rawLimit);
|
|
554
|
+
}
|
|
555
|
+
const rawRoute = params.get("route");
|
|
556
|
+
if (rawRoute !== null &&
|
|
557
|
+
rawRoute !== "" &&
|
|
558
|
+
rawRoute !== "search" &&
|
|
559
|
+
rawRoute !== "fetch") {
|
|
560
|
+
sendError(response, invalidRequest("route 必须是 search 或 fetch。"));
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const rawStatus = params.get("status");
|
|
564
|
+
if (rawStatus !== null &&
|
|
565
|
+
rawStatus !== "" &&
|
|
566
|
+
rawStatus !== "ok" &&
|
|
567
|
+
rawStatus !== "error") {
|
|
568
|
+
sendError(response, invalidRequest("status 必须是 ok 或 error。"));
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const rawProvider = params.get("provider") ?? undefined;
|
|
572
|
+
const rawQuery = params.get("q") ?? undefined;
|
|
573
|
+
const list = records.list({
|
|
574
|
+
limit,
|
|
575
|
+
...(rawRoute === "search" || rawRoute === "fetch"
|
|
576
|
+
? { route: rawRoute }
|
|
577
|
+
: {}),
|
|
578
|
+
...(rawStatus === "ok" || rawStatus === "error"
|
|
579
|
+
? { status: rawStatus }
|
|
580
|
+
: {}),
|
|
581
|
+
...(rawProvider ? { provider: rawProvider } : {}),
|
|
582
|
+
...(rawQuery ? { q: rawQuery } : {}),
|
|
583
|
+
});
|
|
584
|
+
sendJson(response, 200, {
|
|
585
|
+
records: list,
|
|
586
|
+
count: list.length,
|
|
587
|
+
retention: records.retention,
|
|
588
|
+
generatedAt: Date.now(),
|
|
589
|
+
});
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (path === STATS_RESET_PATH) {
|
|
593
|
+
if (request.method !== "POST") {
|
|
594
|
+
sendError(response, methodNotAllowed("POST"), { Allow: "POST" });
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
if (!authenticate(request)) {
|
|
598
|
+
sendError(response, unauthorized());
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
stats.reset();
|
|
602
|
+
sendJson(response, 200, {
|
|
603
|
+
reset: true,
|
|
604
|
+
stats: stats.snapshot(),
|
|
605
|
+
generatedAt: Date.now(),
|
|
606
|
+
});
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (path === CONFIG_PATH) {
|
|
610
|
+
if (request.method !== "PUT") {
|
|
611
|
+
sendError(response, methodNotAllowed("PUT"), { Allow: "PUT" });
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
void handleAdminConfigUpdate(request, response);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (path === CONFIG_RELOAD_PATH) {
|
|
618
|
+
if (request.method !== "POST") {
|
|
619
|
+
sendError(response, methodNotAllowed("POST"), { Allow: "POST" });
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
void handleAdminConfigReload(request, response);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (path === "/v1/tools/search" || path === "/v1/tools/fetch") {
|
|
626
|
+
if (request.method !== "POST") {
|
|
627
|
+
sendError(response, methodNotAllowed("POST"), { Allow: "POST" });
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
void handle(request, response, path === "/v1/tools/search" ? "search" : "fetch");
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
sendError(response, routeNotFound());
|
|
634
|
+
});
|
|
635
|
+
return {
|
|
636
|
+
server,
|
|
637
|
+
service,
|
|
638
|
+
admin,
|
|
639
|
+
async shutdown(timeoutMs) {
|
|
640
|
+
shuttingDown = true;
|
|
641
|
+
const drained = new Promise((resolveDrained) => {
|
|
642
|
+
const poll = () => {
|
|
643
|
+
if (active.size === 0)
|
|
644
|
+
resolveDrained();
|
|
645
|
+
else
|
|
646
|
+
setTimeout(poll, 20).unref();
|
|
647
|
+
};
|
|
648
|
+
poll();
|
|
649
|
+
});
|
|
650
|
+
let timer;
|
|
651
|
+
const expired = new Promise((resolveExpired) => {
|
|
652
|
+
timer = setTimeout(resolveExpired, Math.max(0, timeoutMs));
|
|
653
|
+
});
|
|
654
|
+
await Promise.race([drained, expired]);
|
|
655
|
+
if (timer)
|
|
656
|
+
clearTimeout(timer);
|
|
657
|
+
for (const response of active)
|
|
658
|
+
response.destroy();
|
|
659
|
+
await new Promise((resolveClosed) => server.close(() => resolveClosed()));
|
|
660
|
+
},
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
//# sourceMappingURL=server.js.map
|