@h-ai/serv 0.1.0-alpha.15

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/dist/index.js ADDED
@@ -0,0 +1,809 @@
1
+ import { WILDCARD_PERMISSION, requirePermission } from './chunk-YQV2OMQK.js';
2
+ import { servM, mapHaiError, requireAuth, normalizeServLocale, buildHaiErrorBody } from './chunk-4QBF4PUG.js';
3
+ import { implement } from '@orpc/server';
4
+ import process from 'process';
5
+ import { serve } from '@hono/node-server';
6
+ import { core, err, HaiCommonError, ok } from '@h-ai/core';
7
+ import { OpenAPIHandler } from '@orpc/openapi/fetch';
8
+ import { RPCHandler } from '@orpc/server/fetch';
9
+ import { Hono } from 'hono';
10
+ import { z } from 'zod';
11
+ import { IAM_AUTH_ROUTES } from '@h-ai/api-contract';
12
+ import { getCookie } from 'hono/cookie';
13
+ import { readFile } from 'fs/promises';
14
+ import { OpenAPIGenerator } from '@orpc/openapi';
15
+ import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4';
16
+ import { TRANSPORT_PROTOCOL } from '@h-ai/crypto';
17
+
18
+ // src/adapters/serv-adapter-fetch.ts
19
+ function toFetch(app) {
20
+ const handler = (input, init) => {
21
+ const request = input instanceof Request ? input : new Request(typeof input === "string" ? input : input.toString(), init);
22
+ return Promise.resolve(app.fetch(request));
23
+ };
24
+ return handler;
25
+ }
26
+ var DEFAULT_SERV_HOST = "127.0.0.1";
27
+ var DEFAULT_SERV_PORT = 3e3;
28
+ function listen(app, options = {}) {
29
+ const host = options.host ?? process.env.HOST ?? DEFAULT_SERV_HOST;
30
+ const port = options.port ?? (process.env.PORT ? Number(process.env.PORT) : DEFAULT_SERV_PORT);
31
+ const server = serve(
32
+ { fetch: app.fetch, hostname: host, port },
33
+ options.onListening
34
+ );
35
+ const handle = {
36
+ server,
37
+ address: { host, port },
38
+ close: () => new Promise((resolve, reject) => {
39
+ server.close((error) => {
40
+ if (error)
41
+ reject(error);
42
+ else resolve();
43
+ });
44
+ })
45
+ };
46
+ if (options.onClose) {
47
+ const onClose = options.onClose;
48
+ let shuttingDown = false;
49
+ const shutdown = async () => {
50
+ if (shuttingDown)
51
+ return;
52
+ shuttingDown = true;
53
+ try {
54
+ await handle.close();
55
+ } catch {
56
+ }
57
+ try {
58
+ await onClose();
59
+ } catch {
60
+ }
61
+ };
62
+ process.once("SIGINT", () => void shutdown());
63
+ process.once("SIGTERM", () => void shutdown());
64
+ }
65
+ return handle;
66
+ }
67
+ var WILDCARD_ROLE = "*";
68
+ function requireRole(role, handler) {
69
+ return requireAuth(async (options) => {
70
+ const roles = options.context.session?.roles ?? [];
71
+ if (!roles.includes(role) && !roles.includes(WILDCARD_ROLE))
72
+ return err(HaiCommonError.FORBIDDEN, servM("serv_errorForbidden", { locale: options.context.locale }));
73
+ return handler(options);
74
+ });
75
+ }
76
+ var DEFAULT_LOCALE = "zh-CN";
77
+ function resolveRequestLocale(headers) {
78
+ const get = (name) => {
79
+ if (headers instanceof Headers)
80
+ return headers.get(name) ?? void 0;
81
+ return headers[name] ?? headers[name.toLowerCase()];
82
+ };
83
+ const explicit = get("x-hai-locale");
84
+ if (explicit)
85
+ return normalizeServLocale(explicit);
86
+ return normalizeServLocale(get("accept-language") ?? get("Accept-Language") ?? DEFAULT_LOCALE);
87
+ }
88
+ function localizeZodError(zodError, locale) {
89
+ const resolvedLocale = normalizeServLocale(locale);
90
+ return core.zodValidation.mapZodErrorToFormErrors(
91
+ zodError,
92
+ core.zodValidation.createPrefixedZodMessageGetter(
93
+ "serv",
94
+ (messageKey, params) => servM(messageKey, { locale: resolvedLocale, params })
95
+ )
96
+ );
97
+ }
98
+ function validateInputOrFail(schema, input, locale = DEFAULT_LOCALE) {
99
+ const resolvedLocale = normalizeServLocale(locale);
100
+ const result = schema.safeParse(input);
101
+ if (result.success)
102
+ return ok(result.data);
103
+ const errors = localizeZodError(result.error, resolvedLocale);
104
+ return err(HaiCommonError.VALIDATION_ERROR, servM("serv_validationFailed", { locale: resolvedLocale }), errors);
105
+ }
106
+
107
+ // src/pipelines/serv-pipeline-require-internal-rpc.ts
108
+ var pipelineLogger = core.logger.child({ module: "serv", scope: "pipeline" });
109
+ var cachedGetConnInfo;
110
+ async function resolveGetConnInfo() {
111
+ if (cachedGetConnInfo !== void 0)
112
+ return cachedGetConnInfo;
113
+ try {
114
+ const mod = await import('@hono/node-server/conninfo');
115
+ cachedGetConnInfo = mod.getConnInfo;
116
+ } catch (cause) {
117
+ pipelineLogger.warn("getConnInfo unavailable in this runtime; loopback/private-network access modes will fail closed", { error: cause });
118
+ cachedGetConnInfo = null;
119
+ }
120
+ return cachedGetConnInfo;
121
+ }
122
+ var LOOPBACK_IPS = /* @__PURE__ */ new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1", "localhost"]);
123
+ function requireInternalRPC(config) {
124
+ return async (c, next) => {
125
+ const locale = resolveRequestLocale(c.req.raw.headers);
126
+ if (config.access === "gateway-only") {
127
+ const headerName = config.gatewayHeader ?? "x-hai-internal-rpc";
128
+ const received = c.req.header(headerName);
129
+ if (!config.gatewaySecret || !received || !core.string.constantTimeEqual(received, config.gatewaySecret))
130
+ return c.json(buildHaiErrorBody(HaiCommonError.FORBIDDEN, servM("serv_errorForbidden", { locale })), 403);
131
+ return next();
132
+ }
133
+ const ip = await getRequestRemoteAddress(c) ?? "";
134
+ if (config.access === "loopback" && !isLoopbackIP(ip))
135
+ return c.json(buildHaiErrorBody(HaiCommonError.FORBIDDEN, servM("serv_errorForbidden", { locale })), 403);
136
+ if (config.access === "private-network" && !isPrivateNetworkIP(ip))
137
+ return c.json(buildHaiErrorBody(HaiCommonError.FORBIDDEN, servM("serv_errorForbidden", { locale })), 403);
138
+ return next();
139
+ };
140
+ }
141
+ async function getRequestRemoteAddress(c) {
142
+ const getConnInfo = await resolveGetConnInfo();
143
+ if (!getConnInfo)
144
+ return void 0;
145
+ try {
146
+ return getConnInfo(c).remote.address;
147
+ } catch {
148
+ return void 0;
149
+ }
150
+ }
151
+ function normalizeIpAddress(ip) {
152
+ return ip.startsWith("::ffff:") ? ip.slice("::ffff:".length) : ip;
153
+ }
154
+ function isLoopbackIP(ip) {
155
+ const normalized = normalizeIpAddress(ip);
156
+ return LOOPBACK_IPS.has(ip) || LOOPBACK_IPS.has(normalized);
157
+ }
158
+ function isPrivateNetworkIP(ip) {
159
+ const normalized = normalizeIpAddress(ip);
160
+ if (isLoopbackIP(ip))
161
+ return true;
162
+ if (normalized.startsWith("10.") || normalized.startsWith("192.168."))
163
+ return true;
164
+ const second = Number(normalized.split(".")[1]);
165
+ return normalized.startsWith("172.") && Number.isInteger(second) && second >= 16 && second <= 31;
166
+ }
167
+
168
+ // src/pipelines/serv-pipeline-security-headers.ts
169
+ function securityHeaders() {
170
+ return async (c, next) => {
171
+ c.header("X-Content-Type-Options", "nosniff");
172
+ c.header("X-Frame-Options", "DENY");
173
+ c.header("Referrer-Policy", "no-referrer");
174
+ await next();
175
+ };
176
+ }
177
+ var ApiPrefixSchema = z.custom(
178
+ (value) => typeof value === "string" && value.startsWith("/api/"),
179
+ "apiPrefix must start with /api/"
180
+ );
181
+ var AbsolutePathSchema = z.custom(
182
+ (value) => typeof value === "string" && value.startsWith("/"),
183
+ "path must start with /"
184
+ );
185
+ var OpenAPIHttpConfigInputSchema = z.union([
186
+ z.literal(false),
187
+ z.object({ path: AbsolutePathSchema.default("/openapi.json") })
188
+ ]);
189
+ var DocsHttpConfigInputSchema = z.union([
190
+ z.literal(false),
191
+ z.object({
192
+ path: AbsolutePathSchema.default("/docs"),
193
+ requireAuth: z.boolean().optional()
194
+ })
195
+ ]);
196
+ var HealthHttpConfigInputSchema = z.union([
197
+ z.literal(false),
198
+ z.object({
199
+ path: AbsolutePathSchema.default("/health"),
200
+ readyPath: AbsolutePathSchema.optional().default("/ready")
201
+ })
202
+ ]);
203
+ var RpcHttpConfigInputSchema = z.union([
204
+ z.literal(false),
205
+ z.object({
206
+ prefix: AbsolutePathSchema.default("/rpc"),
207
+ access: z.enum(["loopback", "private-network", "gateway-only"]).default("loopback"),
208
+ gatewayHeader: z.string().optional(),
209
+ gatewaySecret: z.string().optional()
210
+ })
211
+ ]);
212
+ var TransportRuntimeConfigInputSchema = z.union([
213
+ z.literal(false),
214
+ z.object({
215
+ keyExchangePath: AbsolutePathSchema.default("/_hai/key-exchange"),
216
+ excludePaths: z.array(AbsolutePathSchema).default([]),
217
+ maxClients: z.number().int().positive().default(1e4)
218
+ })
219
+ ]);
220
+ var ServHttpConfigInputSchema = z.object({
221
+ apiPrefix: ApiPrefixSchema.default("/api/v1"),
222
+ openapi: OpenAPIHttpConfigInputSchema.default(false),
223
+ docs: DocsHttpConfigInputSchema.default(false),
224
+ health: HealthHttpConfigInputSchema.default({ path: "/health", readyPath: "/ready" }),
225
+ rpc: RpcHttpConfigInputSchema.default(false)
226
+ });
227
+ var ServConfigSchema = z.object({
228
+ http: ServHttpConfigInputSchema.optional(),
229
+ transport: TransportRuntimeConfigInputSchema.optional()
230
+ }).transform(({ http, transport }) => ({
231
+ http: resolveServHttpConfig(http ?? {}),
232
+ transport: resolveServTransportRuntimeConfig(transport ?? false)
233
+ }));
234
+ function resolveServHttpConfig(input = {}) {
235
+ return ServHttpConfigInputSchema.parse(input);
236
+ }
237
+ function resolveServTransportRuntimeConfig(input = false) {
238
+ return TransportRuntimeConfigInputSchema.parse(input);
239
+ }
240
+ var HEADER_REQUEST_ID = "x-request-id";
241
+ var HEADER_FORWARDED_FOR = "x-forwarded-for";
242
+ var HEADER_REAL_IP = "x-real-ip";
243
+ var HEADER_AUTHORIZATION = "authorization";
244
+ var HEADER_USER_AGENT = "user-agent";
245
+ function extractBearerToken(value) {
246
+ if (!value)
247
+ return void 0;
248
+ const [scheme, token] = value.split(" ");
249
+ if (scheme?.toLowerCase() !== "bearer" || !token)
250
+ return void 0;
251
+ return token;
252
+ }
253
+ function parseRequestContext(input) {
254
+ const headers = input.request.headers;
255
+ const requestId = headers.get(HEADER_REQUEST_ID) ?? core.id.uuid();
256
+ const forwardedFor = headers.get(HEADER_FORWARDED_FOR);
257
+ const ip = headers.get(HEADER_REAL_IP) ?? forwardedFor?.split(",")[0]?.trim();
258
+ const locale = resolveRequestLocale(headers);
259
+ const accessToken = extractBearerToken(headers.get(HEADER_AUTHORIZATION));
260
+ return {
261
+ requestId,
262
+ locale,
263
+ ip,
264
+ userAgent: headers.get(HEADER_USER_AGENT) ?? void 0,
265
+ accessToken,
266
+ request: input.request,
267
+ logger: core.logger.child({ module: "serv", requestId })
268
+ };
269
+ }
270
+ function buildAuthContextFactory(verifyToken) {
271
+ return async (input) => {
272
+ const base = parseRequestContext(input);
273
+ if (!base.accessToken)
274
+ return base;
275
+ try {
276
+ const result = await verifyToken(base.accessToken);
277
+ if (!result.success)
278
+ return base;
279
+ return { ...base, session: result.data };
280
+ } catch {
281
+ return base;
282
+ }
283
+ };
284
+ }
285
+ function resolveRefreshCookieConfig(apiPrefix, config, iam) {
286
+ const onRefresh = config.onRefresh ?? (iam ? (refreshToken) => iam.session.refresh(refreshToken) : void 0);
287
+ if (!onRefresh) {
288
+ throw core.error.buildHaiErrorInst(
289
+ HaiCommonError.INTERNAL_ERROR,
290
+ "[serv] refreshCookie requires either top-level `iam` (recommended) or `refreshCookie.onRefresh` callback"
291
+ );
292
+ }
293
+ return {
294
+ cookieName: config.cookieName ?? "hai_refresh_token",
295
+ maxAge: config.maxAge ?? 30 * 24 * 3600,
296
+ isSecure: config.secure ?? process.env.NODE_ENV === "production",
297
+ refreshCookiePath: `${apiPrefix}${IAM_AUTH_ROUTES.refresh}`,
298
+ onRefresh
299
+ };
300
+ }
301
+ function buildSetCookieHeader(name, value, path, maxAge, secure) {
302
+ const parts = [
303
+ `${name}=${encodeURIComponent(value)}`,
304
+ `Path=${path}`,
305
+ `Max-Age=${maxAge}`,
306
+ "HttpOnly",
307
+ "SameSite=Strict"
308
+ ];
309
+ if (secure)
310
+ parts.push("Secure");
311
+ return parts.join("; ");
312
+ }
313
+ function buildClearCookieHeader(name, path) {
314
+ return `${name}=; Path=${path}; Max-Age=0; HttpOnly; SameSite=Strict`;
315
+ }
316
+ function extractRefreshTokenFromData(data) {
317
+ if (!data || typeof data !== "object")
318
+ return void 0;
319
+ const d = data;
320
+ const tokens = d.tokens;
321
+ if (!tokens || typeof tokens !== "object")
322
+ return void 0;
323
+ const t = tokens;
324
+ return typeof t.refreshToken === "string" && t.refreshToken ? t.refreshToken : void 0;
325
+ }
326
+ function stripRefreshTokenFromData(data) {
327
+ if (!data || typeof data !== "object" || Array.isArray(data))
328
+ return data;
329
+ const d = data;
330
+ const tokens = d.tokens;
331
+ if (!tokens || typeof tokens !== "object" || Array.isArray(tokens))
332
+ return data;
333
+ const safeTokens = {};
334
+ for (const [key, value] of Object.entries(tokens)) {
335
+ if (key !== "refreshToken")
336
+ safeTokens[key] = value;
337
+ }
338
+ return { ...d, tokens: safeTokens };
339
+ }
340
+ function stripRefreshTokenFromPair(tokens) {
341
+ return {
342
+ accessToken: tokens.accessToken,
343
+ ...tokens.expiresIn === void 0 ? {} : { expiresIn: tokens.expiresIn },
344
+ ...tokens.tokenType === void 0 ? {} : { tokenType: tokens.tokenType }
345
+ };
346
+ }
347
+ function mountRefreshCookieRoutes(app, apiPrefix, config, iam) {
348
+ const { cookieName, maxAge, isSecure, refreshCookiePath, onRefresh } = resolveRefreshCookieConfig(apiPrefix, config, iam);
349
+ const loginPaths = [
350
+ `${apiPrefix}${IAM_AUTH_ROUTES.login}`,
351
+ `${apiPrefix}${IAM_AUTH_ROUTES.loginWithOtp}`,
352
+ `${apiPrefix}${IAM_AUTH_ROUTES.register}`
353
+ ];
354
+ for (const path of loginPaths) {
355
+ app.post(path, async (c, next) => {
356
+ await next();
357
+ if (c.res.status !== 200)
358
+ return;
359
+ const bodyText = await c.res.text();
360
+ const headers = new Headers(c.res.headers);
361
+ let responseBody = bodyText;
362
+ try {
363
+ const body = JSON.parse(bodyText);
364
+ if (body.success) {
365
+ const refreshToken = extractRefreshTokenFromData(body.data);
366
+ if (refreshToken)
367
+ headers.append("Set-Cookie", buildSetCookieHeader(cookieName, refreshToken, refreshCookiePath, maxAge, isSecure));
368
+ body.data = stripRefreshTokenFromData(body.data);
369
+ responseBody = JSON.stringify(body);
370
+ headers.delete("Content-Length");
371
+ }
372
+ } catch {
373
+ }
374
+ c.res = new Response(responseBody, { status: c.res.status, statusText: c.res.statusText, headers });
375
+ });
376
+ }
377
+ app.post(`${apiPrefix}${IAM_AUTH_ROUTES.logout}`, async (c, next) => {
378
+ await next();
379
+ if (c.res.status !== 200)
380
+ return;
381
+ const bodyText = await c.res.text();
382
+ const headers = new Headers(c.res.headers);
383
+ try {
384
+ const body = JSON.parse(bodyText);
385
+ if (body.success)
386
+ headers.append("Set-Cookie", buildClearCookieHeader(cookieName, refreshCookiePath));
387
+ } catch {
388
+ }
389
+ c.res = new Response(bodyText, { status: c.res.status, statusText: c.res.statusText, headers });
390
+ });
391
+ app.post(`${apiPrefix}${IAM_AUTH_ROUTES.refresh}`, async (c) => {
392
+ const refreshToken = getCookie(c, cookieName);
393
+ if (!refreshToken) {
394
+ const locale = resolveRequestLocale(c.req.raw.headers);
395
+ return c.json(buildHaiErrorBody(HaiCommonError.UNAUTHORIZED, servM("serv_errorUnauthorized", { locale })), 401);
396
+ }
397
+ const result = await onRefresh(refreshToken);
398
+ if (!result.success) {
399
+ const status = result.error.httpStatus ?? 401;
400
+ const clearCookie = buildClearCookieHeader(cookieName, refreshCookiePath);
401
+ return new Response(JSON.stringify({ success: false, error: result.error }), {
402
+ status,
403
+ headers: {
404
+ "Content-Type": "application/json",
405
+ "Set-Cookie": clearCookie
406
+ }
407
+ });
408
+ }
409
+ const tokens = result.data;
410
+ const setCookie = buildSetCookieHeader(cookieName, tokens.refreshToken, refreshCookiePath, maxAge, isSecure);
411
+ return new Response(JSON.stringify({ success: true, data: { tokens: stripRefreshTokenFromPair(tokens) } }), {
412
+ status: 200,
413
+ headers: {
414
+ "Content-Type": "application/json",
415
+ "Set-Cookie": setCookie
416
+ }
417
+ });
418
+ });
419
+ }
420
+ var SCALAR_ROUTE = "/_hai/scalar.js";
421
+ var SCALAR_BROWSER_ENTRY_FALLBACK = "./dist/browser/standalone.js";
422
+ var scalarScriptCache;
423
+ async function generateSpec(contract, options = {}) {
424
+ const generator = new OpenAPIGenerator({
425
+ schemaConverters: [new ZodToJsonSchemaConverter()]
426
+ });
427
+ return generator.generate(contract, {
428
+ info: {
429
+ title: options.title ?? "hai-framework API",
430
+ version: options.version ?? "0.1.0",
431
+ description: options.description
432
+ },
433
+ servers: options.apiPrefix ? [{ url: options.apiPrefix }] : void 0,
434
+ security: [{ bearerAuth: [] }],
435
+ components: {
436
+ securitySchemes: {
437
+ bearerAuth: { type: "http", scheme: "bearer" }
438
+ }
439
+ }
440
+ });
441
+ }
442
+ async function getScalarScript() {
443
+ if (scalarScriptCache !== void 0)
444
+ return scalarScriptCache || void 0;
445
+ try {
446
+ const fileUrl = await resolveScalarBrowserScriptUrl();
447
+ scalarScriptCache = await readFile(fileUrl, "utf8");
448
+ } catch {
449
+ scalarScriptCache = "";
450
+ }
451
+ return scalarScriptCache || void 0;
452
+ }
453
+ function createDocsPage(spec, options = {}) {
454
+ const title = escapeHtml(options.title ?? spec.info.title);
455
+ const specUrl = options.specUrl;
456
+ const scriptUrl = escapeHtml(options.scriptUrl ?? SCALAR_ROUTE);
457
+ if (specUrl) {
458
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title></head><body><script id="api-reference" data-url="${escapeHtml(specUrl)}"></script><script src="${scriptUrl}"></script></body></html>`;
459
+ }
460
+ const encodedSpec = escapeScript(JSON.stringify(spec));
461
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title></head><body><script id="api-reference" type="application/json">${encodedSpec}</script><script src="${scriptUrl}"></script></body></html>`;
462
+ }
463
+ function escapeHtml(value) {
464
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll(String.fromCharCode(39), "&#39;");
465
+ }
466
+ function escapeScript(value) {
467
+ return value.replaceAll("</script", "<\\/script");
468
+ }
469
+ async function resolveScalarBrowserScriptUrl() {
470
+ const packageEntryUrl = import.meta.resolve("@scalar/api-reference");
471
+ const packageJsonUrl = new URL("../package.json", packageEntryUrl);
472
+ const packageInfo = JSON.parse(await readFile(packageJsonUrl, "utf8"));
473
+ const browserEntry = readScalarBrowserEntry(packageInfo) ?? SCALAR_BROWSER_ENTRY_FALLBACK;
474
+ return new URL(browserEntry, packageJsonUrl);
475
+ }
476
+ function readScalarBrowserEntry(packageInfo) {
477
+ if (typeof packageInfo !== "object" || packageInfo === null || Array.isArray(packageInfo))
478
+ return void 0;
479
+ const browser = packageInfo.browser;
480
+ return typeof browser === "string" ? browser : void 0;
481
+ }
482
+ var logger = core.logger.child({ module: "serv", scope: "transport" });
483
+ var MAX_ENCRYPTED_BODY = 1048576;
484
+ var ENCRYPTABLE_RESPONSE_CONTENT_TYPES = ["application/json"];
485
+ function createTransportMiddleware(manager, keyExchangePath, excludePaths = []) {
486
+ return async (c, next) => {
487
+ const pathname = new URL(c.req.url).pathname;
488
+ if (pathname === keyExchangePath && c.req.method === "POST")
489
+ return handleKeyExchange(c.req.raw, manager);
490
+ if (shouldExclude(pathname, excludePaths, keyExchangePath))
491
+ return next();
492
+ const clientId = c.req.header(TRANSPORT_PROTOCOL.CLIENT_ID_HEADER);
493
+ if (!clientId)
494
+ return jsonError(servM("serv_transportClientIdRequired"), 400);
495
+ const clientPublicKey = await manager.getClientPublicKey(clientId);
496
+ if (!clientPublicKey)
497
+ return jsonError(servM("serv_transportClientKeyNotFound"), 400);
498
+ if (hasBody(c.req.method) && !isEmptyBody(c.req.raw)) {
499
+ const decErr = await decryptRequestInPlace(c, manager);
500
+ if (decErr)
501
+ return decErr;
502
+ }
503
+ await next();
504
+ const response = c.res;
505
+ if (!response)
506
+ return;
507
+ const contentType = response.headers.get("Content-Type") ?? "";
508
+ if (isEmptyResponse(response))
509
+ return;
510
+ if (!canEncryptResponseContentType(contentType)) {
511
+ c.res = jsonError(servM("serv_transportEncryptFailed"), 500);
512
+ return;
513
+ }
514
+ const contentLength = response.headers.get("Content-Length");
515
+ if (contentLength && Number.parseInt(contentLength, 10) > MAX_ENCRYPTED_BODY) {
516
+ c.res = jsonError(servM("serv_transportEncryptFailed"), 500);
517
+ return;
518
+ }
519
+ let bodyText;
520
+ try {
521
+ bodyText = await response.clone().text();
522
+ } catch (cause) {
523
+ logger.warn("Failed to read response body for encryption", { error: cause });
524
+ c.res = jsonError(servM("serv_transportEncryptFailed"), 500);
525
+ return;
526
+ }
527
+ if (!bodyText)
528
+ return;
529
+ if (bodyText.length > MAX_ENCRYPTED_BODY) {
530
+ c.res = jsonError(servM("serv_transportEncryptFailed"), 500);
531
+ return;
532
+ }
533
+ const encResult = await manager.encryptResponse(clientId, bodyText);
534
+ if (!encResult.success) {
535
+ logger.warn("Failed to encrypt response", { error: encResult.error });
536
+ c.res = jsonError(servM("serv_transportEncryptFailed"), 500);
537
+ return;
538
+ }
539
+ const headers = new Headers(response.headers);
540
+ headers.set("Content-Type", "application/json; charset=utf-8");
541
+ headers.set(TRANSPORT_PROTOCOL.ENCRYPTED_HEADER, TRANSPORT_PROTOCOL.ENCRYPTED_HEADER_VALUE);
542
+ headers.delete("Content-Length");
543
+ c.res = new Response(JSON.stringify(encResult.data), {
544
+ status: response.status,
545
+ statusText: response.statusText,
546
+ headers
547
+ });
548
+ };
549
+ }
550
+ async function handleKeyExchange(request, manager) {
551
+ let body;
552
+ try {
553
+ body = await request.json();
554
+ } catch {
555
+ return jsonError(servM("serv_transportInvalidPayload"), 400);
556
+ }
557
+ if (typeof body.clientPublicKey !== "string" || body.clientPublicKey.length === 0)
558
+ return jsonError(servM("serv_transportInvalidPayload"), 400);
559
+ try {
560
+ const clientId = await manager.registerClientKey(body.clientPublicKey);
561
+ const serverPublicKey = manager.getServerPublicKey();
562
+ return new Response(JSON.stringify({ serverPublicKey, clientId }), {
563
+ status: 200,
564
+ headers: { "Content-Type": "application/json; charset=utf-8" }
565
+ });
566
+ } catch (cause) {
567
+ logger.error("Key exchange failed", { error: cause });
568
+ return jsonError(servM("serv_transportKeyExchangeFailed"), 500);
569
+ }
570
+ }
571
+ async function decryptRequestInPlace(c, manager) {
572
+ const isEncrypted = c.req.header(TRANSPORT_PROTOCOL.ENCRYPTED_HEADER) === TRANSPORT_PROTOCOL.ENCRYPTED_HEADER_VALUE;
573
+ if (!isEncrypted)
574
+ return jsonError(servM("serv_transportInvalidPayload"), 400);
575
+ let payload;
576
+ try {
577
+ payload = await c.req.raw.clone().json();
578
+ } catch {
579
+ return jsonError(servM("serv_transportInvalidPayload"), 400);
580
+ }
581
+ if (!isEncryptedPayloadShape(payload))
582
+ return jsonError(servM("serv_transportInvalidPayload"), 400);
583
+ const decResult = manager.decryptRequest(payload);
584
+ if (!decResult.success) {
585
+ logger.warn("Failed to decrypt request", { error: decResult.error });
586
+ return jsonError(servM("serv_transportDecryptFailed"), 400);
587
+ }
588
+ const original = c.req.raw;
589
+ const newHeaders = new Headers(original.headers);
590
+ newHeaders.delete(TRANSPORT_PROTOCOL.ENCRYPTED_HEADER);
591
+ newHeaders.delete("Content-Length");
592
+ newHeaders.set("Content-Type", "application/json; charset=utf-8");
593
+ const newRequest = new Request(original.url, {
594
+ method: original.method,
595
+ headers: newHeaders,
596
+ body: decResult.data,
597
+ // @ts-expect-error Node fetch 需要 duplex 才能携带流式 body。
598
+ duplex: "half"
599
+ });
600
+ c.req.raw = newRequest;
601
+ return void 0;
602
+ }
603
+ function shouldExclude(pathname, excludePaths, keyExchangePath) {
604
+ if (pathname === keyExchangePath)
605
+ return true;
606
+ return excludePaths.some((p) => pathname === p || pathname.startsWith(`${p}/`));
607
+ }
608
+ function canEncryptResponseContentType(contentType) {
609
+ return ENCRYPTABLE_RESPONSE_CONTENT_TYPES.some((type) => contentType.includes(type));
610
+ }
611
+ function isEmptyResponse(response) {
612
+ if ([204, 304].includes(response.status))
613
+ return true;
614
+ return response.headers.get("Content-Length") === "0";
615
+ }
616
+ function hasBody(method) {
617
+ return ["POST", "PUT", "PATCH", "DELETE"].includes(method.toUpperCase());
618
+ }
619
+ function isEmptyBody(req) {
620
+ if (req.body === null)
621
+ return true;
622
+ const cl = req.headers.get("Content-Length");
623
+ if (cl !== null)
624
+ return Number.parseInt(cl, 10) === 0;
625
+ return !req.headers.get("Content-Type");
626
+ }
627
+ function jsonError(message, status) {
628
+ return new Response(JSON.stringify({ error: message }), {
629
+ status,
630
+ headers: { "Content-Type": "application/json; charset=utf-8" }
631
+ });
632
+ }
633
+ function isEncryptedPayloadShape(value) {
634
+ if (!value || typeof value !== "object")
635
+ return false;
636
+ const p = value;
637
+ return typeof p.encryptedKey === "string" && typeof p.ciphertext === "string" && typeof p.iv === "string";
638
+ }
639
+
640
+ // src/serv-app.ts
641
+ var API_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
642
+ function createApp(options) {
643
+ const http = resolveServHttpConfig(options.http);
644
+ const app = new Hono();
645
+ const iamRef = options.iam;
646
+ const verifyToken = options.verifyToken ?? (iamRef ? (token) => iamRef.session.verifyToken(token) : void 0);
647
+ const createContext = options.createContext ?? (verifyToken ? buildAuthContextFactory(verifyToken) : parseRequestContext);
648
+ let cachedSpec;
649
+ const getSpec = () => {
650
+ cachedSpec ??= generateSpec(options.contract, { apiPrefix: http.apiPrefix });
651
+ return cachedSpec;
652
+ };
653
+ app.use("*", securityHeaders());
654
+ if (options.transport) {
655
+ const mgr = options.transport.crypto.transport.createServer({ maxClients: options.transport.maxClients });
656
+ if (!mgr.success) {
657
+ throw core.error.buildHaiErrorInst(
658
+ HaiCommonError.INTERNAL_ERROR,
659
+ mgr.error.message,
660
+ mgr.error
661
+ );
662
+ }
663
+ const keyExchangePath = `${http.apiPrefix}${options.transport.keyExchangePath ?? "/_hai/key-exchange"}`;
664
+ app.use("*", createTransportMiddleware(mgr.data, keyExchangePath, options.transport.excludePaths));
665
+ }
666
+ if (options.middlewares)
667
+ mountCustomMiddlewares(app, options.middlewares);
668
+ if (http.health !== false)
669
+ mountHealthRoutes(app, http.health);
670
+ if (options.refreshCookie)
671
+ mountRefreshCookieRoutes(app, http.apiPrefix, options.refreshCookie, options.iam);
672
+ mountOpenAPIRoutes(app, options.procedures, http.apiPrefix, createContext);
673
+ if (http.rpc !== false)
674
+ mountRPCRoutes(app, options.procedures, http.rpc, createContext);
675
+ if (http.openapi !== false) {
676
+ app.get(http.openapi.path, async (c) => c.json(await getSpec()));
677
+ }
678
+ if (http.docs !== false) {
679
+ const docs = http.docs;
680
+ app.get(SCALAR_ROUTE, async (c) => {
681
+ const content = await getScalarScript();
682
+ if (!content)
683
+ return c.notFound();
684
+ c.header("Content-Type", "application/javascript; charset=utf-8");
685
+ c.header("Cache-Control", "public, max-age=86400, immutable");
686
+ return c.body(content);
687
+ });
688
+ app.get(docs.path, async (c) => {
689
+ if (docs.requireAuth) {
690
+ const context = await createContext({ request: c.req.raw });
691
+ if (!context.session)
692
+ return c.json(buildHaiErrorBody(HaiCommonError.UNAUTHORIZED, servM("serv_errorUnauthorized", { locale: resolveRequestLocale(c.req.raw.headers) })), 401);
693
+ }
694
+ const specUrl = http.openapi === false ? void 0 : http.openapi.path;
695
+ return c.html(createDocsPage(await getSpec(), { specUrl }));
696
+ });
697
+ }
698
+ return app;
699
+ }
700
+ function mountCustomMiddlewares(app, middlewares) {
701
+ for (const entry of middlewares)
702
+ app.use(entry.path ?? "*", entry.middleware);
703
+ }
704
+ function mountHealthRoutes(app, config) {
705
+ app.get(config.path, (c) => c.json({ status: "ok" }));
706
+ if (config.readyPath)
707
+ app.get(config.readyPath, (c) => c.json({ status: "ready" }));
708
+ }
709
+ function mountOpenAPIRoutes(app, procedures, apiPrefix, createContext) {
710
+ const handler = new OpenAPIHandler(procedures);
711
+ app.on([...API_METHODS], `${apiPrefix}/*`, (c, next) => handleORPC(c, next, apiPrefix, handler, createContext));
712
+ }
713
+ function mountRPCRoutes(app, procedures, rpcConfig, createContext) {
714
+ const handler = new RPCHandler(procedures);
715
+ const prefix = rpcConfig.prefix;
716
+ app.use(`${prefix}/*`, requireInternalRPC(rpcConfig));
717
+ app.on([...API_METHODS], `${prefix}/*`, (c, next) => handleORPC(c, next, prefix, handler, createContext));
718
+ }
719
+ async function handleORPC(c, next, prefix, handler, createContext) {
720
+ const context = await createContext({ request: c.req.raw });
721
+ const { matched, response } = await handler.handle(c.req.raw, { prefix, context });
722
+ if (!matched) {
723
+ await next();
724
+ return;
725
+ }
726
+ return localizeValidationResponse(response, c.req.raw.headers);
727
+ }
728
+ async function localizeValidationResponse(response, requestHeaders) {
729
+ if (response.status !== 400)
730
+ return response;
731
+ const contentType = response.headers.get("content-type") ?? "";
732
+ if (!contentType.includes("application/json"))
733
+ return response;
734
+ let payload;
735
+ try {
736
+ payload = await response.clone().json();
737
+ } catch {
738
+ return response;
739
+ }
740
+ if (!isOrpcValidationErrorPayload(payload))
741
+ return response;
742
+ const issues = payload.data.issues;
743
+ const locale = resolveRequestLocale(requestHeaders);
744
+ const errors = localizeZodError({ issues }, locale);
745
+ const body = {
746
+ success: false,
747
+ error: {
748
+ code: HaiCommonError.VALIDATION_ERROR.code,
749
+ message: servM("serv_validationFailed", { locale }),
750
+ httpStatus: HaiCommonError.VALIDATION_ERROR.httpStatus,
751
+ system: HaiCommonError.VALIDATION_ERROR.system,
752
+ module: HaiCommonError.VALIDATION_ERROR.module,
753
+ cause: errors
754
+ },
755
+ errors
756
+ };
757
+ const headers = new Headers(response.headers);
758
+ headers.set("content-type", "application/json; charset=utf-8");
759
+ return new Response(JSON.stringify(body), {
760
+ status: HaiCommonError.VALIDATION_ERROR.httpStatus,
761
+ headers
762
+ });
763
+ }
764
+ function isOrpcValidationErrorPayload(value) {
765
+ if (!value || typeof value !== "object")
766
+ return false;
767
+ const obj = value;
768
+ if (obj.code !== "BAD_REQUEST")
769
+ return false;
770
+ if (typeof obj.status !== "number" || obj.status !== 400)
771
+ return false;
772
+ const data = obj.data;
773
+ if (!data || typeof data !== "object")
774
+ return false;
775
+ const issues = data.issues;
776
+ return Array.isArray(issues);
777
+ }
778
+
779
+ // src/serv-main.ts
780
+ var serv = {
781
+ // 应用与上下文
782
+ createApp,
783
+ parseRequestContext,
784
+ buildAuthContextFactory,
785
+ resolveRequestLocale,
786
+ // 运行时适配器
787
+ listen,
788
+ toFetch,
789
+ // OpenAPI / 导出
790
+ generateSpec,
791
+ // Procedure 装配(contract 路由定义统一使用 `apiContract.route`)。
792
+ /** 基于 contract 创建可注入 context 的 procedure builder(封装 `@orpc/server` 的 `implement`)。 */
793
+ implement,
794
+ // oRPC procedure 包装器
795
+ requireAuth,
796
+ requirePermission,
797
+ requireRole,
798
+ mapHaiError,
799
+ validateInputOrFail,
800
+ // 通配符常量
801
+ WILDCARD_PERMISSION,
802
+ WILDCARD_ROLE,
803
+ // Serv 模块 i18n 消息获取器(支持 `options.locale` 单次调用本地化)。
804
+ m: servM
805
+ };
806
+
807
+ export { ServConfigSchema, serv };
808
+ //# sourceMappingURL=index.js.map
809
+ //# sourceMappingURL=index.js.map