@h-ai/serv 0.1.0-alpha.67 → 0.1.0-alpha.68

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 CHANGED
@@ -1,8 +1,9 @@
1
- import { servM, WILDCARD_ROLE, WILDCARD_PERMISSION, implement, normalizeServLocale, buildHaiErrorBody } from './chunk-R354OMWL.js';
1
+ import { validateInputOrFail, resolveRequestLocale, localizeZodError } from './chunk-BGWE4S6Z.js';
2
+ import { servM, WILDCARD_ROLE, WILDCARD_PERMISSION, implement, buildHaiErrorBody } from './chunk-TWHCU6HT.js';
2
3
  import process from 'process';
3
4
  import { upgradeWebSocket, serve } from '@hono/node-server';
4
5
  import { apiContract, AUDIO_WS_PATH, AudioWsClientMessageSchema } from '@h-ai/api-contract';
5
- import { core, ok, err, HaiCommonError } from '@h-ai/core';
6
+ import { core, HaiCommonError, err, ok } from '@h-ai/core';
6
7
  import { OpenAPIHandler } from '@orpc/openapi/fetch';
7
8
  import { RPCHandler } from '@orpc/server/fetch';
8
9
  import { Hono } from 'hono';
@@ -14,13 +15,39 @@ import { getCookie } from 'hono/cookie';
14
15
  import { readFile } from 'fs/promises';
15
16
  import { OpenAPIGenerator } from '@orpc/openapi';
16
17
  import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4';
17
- import { TRANSPORT_PROTOCOL } from '@h-ai/crypto';
18
+ import { TRANSPORT_PROTOCOL, authenticateTransportRequest, readTransportBody, TRANSPORT_BODY_LIMITS, isValidSM2PublicKey } from '@h-ai/crypto';
18
19
 
19
20
  // src/adapters/serv-adapter-fetch.ts
20
21
  function toFetch(app) {
21
- const handler = (input, init) => {
22
- const request = input instanceof Request ? input : new Request(typeof input === "string" ? input : input.toString(), init);
23
- return Promise.resolve(app.fetch(request));
22
+ const handler = async (input, init) => {
23
+ const request = new Request(input, init);
24
+ const { signal } = request;
25
+ signal.throwIfAborted();
26
+ let onAbort;
27
+ const cancelled = new Promise((_resolve, reject) => {
28
+ onAbort = () => reject(signal.reason);
29
+ signal.addEventListener("abort", onAbort, { once: true });
30
+ });
31
+ try {
32
+ return await Promise.race([
33
+ Promise.resolve().then(async () => {
34
+ signal.throwIfAborted();
35
+ const response = await app.fetch(request);
36
+ if (signal.aborted) {
37
+ try {
38
+ await response.body?.cancel(signal.reason);
39
+ } catch {
40
+ }
41
+ signal.throwIfAborted();
42
+ }
43
+ return response;
44
+ }),
45
+ cancelled
46
+ ]);
47
+ } finally {
48
+ if (onAbort)
49
+ signal.removeEventListener("abort", onAbort);
50
+ }
24
51
  };
25
52
  return handler;
26
53
  }
@@ -585,36 +612,6 @@ function createClientIpResolver(trustedProxies) {
585
612
  return current;
586
613
  };
587
614
  }
588
- var DEFAULT_LOCALE = "zh-CN";
589
- function resolveRequestLocale(headers) {
590
- const get = (name) => {
591
- if (headers instanceof Headers)
592
- return headers.get(name) ?? void 0;
593
- return headers[name] ?? headers[name.toLowerCase()];
594
- };
595
- const explicit = get("x-hai-locale");
596
- if (explicit)
597
- return normalizeServLocale(explicit);
598
- return normalizeServLocale(get("accept-language") ?? get("Accept-Language") ?? DEFAULT_LOCALE);
599
- }
600
- function localizeZodError(zodError, locale) {
601
- const resolvedLocale = normalizeServLocale(locale);
602
- return core.zodValidation.mapZodErrorToFormErrors(
603
- zodError,
604
- core.zodValidation.createPrefixedZodMessageGetter(
605
- "serv",
606
- (messageKey, params) => servM(messageKey, { locale: resolvedLocale, params })
607
- )
608
- );
609
- }
610
- function validateInputOrFail(schema, input, locale = DEFAULT_LOCALE) {
611
- const resolvedLocale = normalizeServLocale(locale);
612
- const result = schema.safeParse(input);
613
- if (result.success)
614
- return ok(result.data);
615
- const errors = localizeZodError(result.error, resolvedLocale);
616
- return err(HaiCommonError.VALIDATION_ERROR, servM("serv_validationFailed", { locale: resolvedLocale }), errors);
617
- }
618
615
 
619
616
  // src/pipelines/serv-pipeline-require-internal-rpc.ts
620
617
  var pipelineLogger = core.logger.child({ module: "serv", scope: "pipeline" });
@@ -728,8 +725,7 @@ var TransportRuntimeConfigInputSchema = z.union([
728
725
  z.literal(false),
729
726
  z.object({
730
727
  keyExchangePath: AbsolutePathSchema.default("/_hai/key-exchange"),
731
- excludePaths: z.array(AbsolutePathSchema).default([]),
732
- maxClients: z.number().int().positive().default(1e4)
728
+ excludePaths: z.array(AbsolutePathSchema).default([])
733
729
  })
734
730
  ]);
735
731
  var ServerConfigInputSchema = z.object({
@@ -848,7 +844,7 @@ function resolveRefreshCookieConfig(apiPrefix, config, iam) {
848
844
  return {
849
845
  cookieName: config.cookieName ?? "hai_refresh_token",
850
846
  maxAge: config.maxAge ?? 30 * 24 * 3600,
851
- isSecure: config.secure ?? process.env.NODE_ENV === "production",
847
+ isSecure: config.secure ?? (process.env.NODE_ENV === "production" || process.env.HAI_CORE_ENV === "production"),
852
848
  refreshCookiePath: `${apiPrefix}${IAM_AUTH_PATHS.refresh}`,
853
849
  onRefresh,
854
850
  nativeTokenTransport: config.nativeTokenTransport
@@ -1058,7 +1054,6 @@ function readScalarBrowserEntry(packageInfo) {
1058
1054
  return typeof browser === "string" ? browser : void 0;
1059
1055
  }
1060
1056
  var logger2 = core.logger.child({ module: "serv", scope: "transport" });
1061
- var MAX_ENCRYPTED_BODY = 1048576;
1062
1057
  var ENCRYPTABLE_RESPONSE_CONTENT_TYPES = ["application/json"];
1063
1058
  function createTransportMiddleware(manager, keyExchangePath, excludePaths = []) {
1064
1059
  return async (c, next) => {
@@ -1070,55 +1065,62 @@ function createTransportMiddleware(manager, keyExchangePath, excludePaths = [])
1070
1065
  const clientId = c.req.header(TRANSPORT_PROTOCOL.CLIENT_ID_HEADER);
1071
1066
  if (!clientId)
1072
1067
  return jsonError(servM("serv_transportClientIdRequired"), 400);
1073
- const clientPublicKey = await manager.getClientPublicKey(clientId);
1074
- if (!clientPublicKey)
1075
- return jsonError(servM("serv_transportClientKeyNotFound"), 400);
1076
- if (hasBody(c.req.method) && !isEmptyBody(c.req.raw)) {
1077
- const decErr = await decryptRequestInPlace(c, manager);
1078
- if (decErr)
1079
- return decErr;
1068
+ let requestContext;
1069
+ try {
1070
+ const verified = await authenticateTransportRequest(manager, c.req.raw);
1071
+ if (!verified.success)
1072
+ return jsonError(servM("serv_transportDecryptFailed"), 400);
1073
+ requestContext = verified.data.context;
1074
+ if (c.req.method !== "GET" && c.req.method !== "HEAD")
1075
+ replaceRequestBody(c, verified.data.plaintext);
1076
+ } catch (error) {
1077
+ return jsonError(servM("serv_transportInvalidPayload"), error instanceof RangeError ? 413 : 400);
1080
1078
  }
1081
1079
  await next();
1082
1080
  const response = c.res;
1083
1081
  if (!response)
1084
1082
  return;
1085
1083
  const contentType = response.headers.get("Content-Type") ?? "";
1086
- if (isEmptyResponse(response))
1087
- return;
1088
- if (!canEncryptResponseContentType(contentType)) {
1089
- c.res = jsonError(servM("serv_transportEncryptFailed"), 500);
1090
- return;
1091
- }
1092
- const contentLength = response.headers.get("Content-Length");
1093
- if (contentLength && Number.parseInt(contentLength, 10) > MAX_ENCRYPTED_BODY) {
1084
+ const bodyless = c.req.method === "HEAD" || isEmptyResponse(response);
1085
+ if (bodyless)
1086
+ void response.body?.cancel().catch(() => {
1087
+ });
1088
+ c.header("Content-Length", void 0);
1089
+ c.header("Content-Encoding", void 0);
1090
+ c.header(TRANSPORT_PROTOCOL.ENCRYPTED_HEADER, void 0);
1091
+ c.header(TRANSPORT_PROTOCOL.ENVELOPE_HEADER, void 0);
1092
+ if (!bodyless && !canEncryptResponseContentType(contentType)) {
1093
+ void response.body?.cancel().catch(() => {
1094
+ });
1094
1095
  c.res = jsonError(servM("serv_transportEncryptFailed"), 500);
1095
1096
  return;
1096
1097
  }
1097
1098
  let bodyText;
1098
1099
  try {
1099
- bodyText = await response.clone().text();
1100
+ bodyText = bodyless ? "" : await readTransportBody(response.body, {
1101
+ maxBytes: TRANSPORT_BODY_LIMITS.plaintextBytes,
1102
+ signal: c.req.raw.signal
1103
+ });
1100
1104
  } catch (cause) {
1101
1105
  logger2.warn("Failed to read response body for encryption", { error: cause });
1102
1106
  c.res = jsonError(servM("serv_transportEncryptFailed"), 500);
1103
1107
  return;
1104
1108
  }
1105
- if (!bodyText)
1106
- return;
1107
- if (bodyText.length > MAX_ENCRYPTED_BODY) {
1108
- c.res = jsonError(servM("serv_transportEncryptFailed"), 500);
1109
- return;
1110
- }
1111
- const encResult = await manager.encryptResponse(clientId, bodyText);
1109
+ const headers = new Headers(response.headers);
1110
+ headers.delete(TRANSPORT_PROTOCOL.ENCRYPTED_HEADER);
1111
+ headers.delete("Content-Length");
1112
+ headers.delete("Content-Encoding");
1113
+ const encResult = await manager.encryptResponse(requestContext, bodyText, response.status);
1112
1114
  if (!encResult.success) {
1113
1115
  logger2.warn("Failed to encrypt response", { error: encResult.error });
1114
1116
  c.res = jsonError(servM("serv_transportEncryptFailed"), 500);
1115
1117
  return;
1116
1118
  }
1117
- const headers = new Headers(response.headers);
1118
1119
  headers.set("Content-Type", "application/json; charset=utf-8");
1119
1120
  headers.set(TRANSPORT_PROTOCOL.ENCRYPTED_HEADER, TRANSPORT_PROTOCOL.ENCRYPTED_HEADER_VALUE);
1120
- headers.delete("Content-Length");
1121
- c.res = new Response(JSON.stringify(encResult.data), {
1121
+ if (bodyless)
1122
+ headers.set(TRANSPORT_PROTOCOL.ENVELOPE_HEADER, JSON.stringify(encResult.data));
1123
+ c.res = new Response(bodyless ? null : JSON.stringify(encResult.data), {
1122
1124
  status: response.status,
1123
1125
  statusText: response.statusText,
1124
1126
  headers
@@ -1128,16 +1130,22 @@ function createTransportMiddleware(manager, keyExchangePath, excludePaths = [])
1128
1130
  async function handleKeyExchange(request, manager) {
1129
1131
  let body;
1130
1132
  try {
1131
- body = await request.json();
1132
- } catch {
1133
- return jsonError(servM("serv_transportInvalidPayload"), 400);
1133
+ body = JSON.parse(await readTransportBody(request.body, {
1134
+ maxBytes: TRANSPORT_BODY_LIMITS.keyExchangeBytes,
1135
+ signal: request.signal
1136
+ }));
1137
+ } catch (error) {
1138
+ return jsonError(servM("serv_transportInvalidPayload"), error instanceof RangeError ? 413 : 400);
1134
1139
  }
1135
- if (typeof body.clientPublicKey !== "string" || body.clientPublicKey.length === 0)
1140
+ if (!body || typeof body !== "object" || !("clientPublicKey" in body) || !isValidSM2PublicKey(body.clientPublicKey) || new TextEncoder().encode(body.clientPublicKey).byteLength > TRANSPORT_BODY_LIMITS.publicKeyBytes) {
1136
1141
  return jsonError(servM("serv_transportInvalidPayload"), 400);
1142
+ }
1137
1143
  try {
1138
- const clientId = await manager.registerClientKey(body.clientPublicKey);
1139
1144
  const serverPublicKey = manager.getServerPublicKey();
1140
- return new Response(JSON.stringify({ serverPublicKey, clientId }), {
1145
+ if (new TextEncoder().encode(serverPublicKey).byteLength > TRANSPORT_BODY_LIMITS.publicKeyBytes)
1146
+ return jsonError(servM("serv_transportKeyExchangeFailed"), 500);
1147
+ const clientId = await manager.registerClientKey(body.clientPublicKey);
1148
+ return new Response(JSON.stringify({ serverKeyId: manager.getServerKeyId(), serverPublicKey, clientId }), {
1141
1149
  status: 200,
1142
1150
  headers: { "Content-Type": "application/json; charset=utf-8" }
1143
1151
  });
@@ -1146,37 +1154,23 @@ async function handleKeyExchange(request, manager) {
1146
1154
  return jsonError(servM("serv_transportKeyExchangeFailed"), 500);
1147
1155
  }
1148
1156
  }
1149
- async function decryptRequestInPlace(c, manager) {
1150
- const isEncrypted = c.req.header(TRANSPORT_PROTOCOL.ENCRYPTED_HEADER) === TRANSPORT_PROTOCOL.ENCRYPTED_HEADER_VALUE;
1151
- if (!isEncrypted)
1152
- return jsonError(servM("serv_transportInvalidPayload"), 400);
1153
- let payload;
1154
- try {
1155
- payload = await c.req.raw.clone().json();
1156
- } catch {
1157
- return jsonError(servM("serv_transportInvalidPayload"), 400);
1158
- }
1159
- if (!isEncryptedPayloadShape(payload))
1160
- return jsonError(servM("serv_transportInvalidPayload"), 400);
1161
- const decResult = manager.decryptRequest(payload);
1162
- if (!decResult.success) {
1163
- logger2.warn("Failed to decrypt request", { error: decResult.error });
1164
- return jsonError(servM("serv_transportDecryptFailed"), 400);
1165
- }
1157
+ function replaceRequestBody(c, body) {
1166
1158
  const original = c.req.raw;
1167
1159
  const newHeaders = new Headers(original.headers);
1168
1160
  newHeaders.delete(TRANSPORT_PROTOCOL.ENCRYPTED_HEADER);
1169
1161
  newHeaders.delete("Content-Length");
1162
+ newHeaders.delete("Content-Encoding");
1170
1163
  newHeaders.set("Content-Type", "application/json; charset=utf-8");
1171
1164
  const newRequest = new Request(original.url, {
1172
1165
  method: original.method,
1173
1166
  headers: newHeaders,
1174
- body: decResult.data,
1167
+ body,
1168
+ signal: original.signal,
1175
1169
  // @ts-expect-error Node fetch 需要 duplex 才能携带流式 body。
1176
1170
  duplex: "half"
1177
1171
  });
1178
1172
  c.req.raw = newRequest;
1179
- return void 0;
1173
+ c.req.bodyCache = {};
1180
1174
  }
1181
1175
  function shouldExclude(pathname, excludePaths, keyExchangePath) {
1182
1176
  if (pathname === keyExchangePath)
@@ -1189,18 +1183,7 @@ function canEncryptResponseContentType(contentType) {
1189
1183
  function isEmptyResponse(response) {
1190
1184
  if ([204, 304].includes(response.status))
1191
1185
  return true;
1192
- return response.headers.get("Content-Length") === "0";
1193
- }
1194
- function hasBody(method) {
1195
- return ["POST", "PUT", "PATCH", "DELETE"].includes(method.toUpperCase());
1196
- }
1197
- function isEmptyBody(req) {
1198
- if (req.body === null)
1199
- return true;
1200
- const cl = req.headers.get("Content-Length");
1201
- if (cl !== null)
1202
- return Number.parseInt(cl, 10) === 0;
1203
- return !req.headers.get("Content-Type");
1186
+ return response.body === null;
1204
1187
  }
1205
1188
  function jsonError(message, status) {
1206
1189
  return new Response(JSON.stringify({ error: message }), {
@@ -1208,12 +1191,6 @@ function jsonError(message, status) {
1208
1191
  headers: { "Content-Type": "application/json; charset=utf-8" }
1209
1192
  });
1210
1193
  }
1211
- function isEncryptedPayloadShape(value) {
1212
- if (!value || typeof value !== "object")
1213
- return false;
1214
- const p = value;
1215
- return typeof p.encryptedKey === "string" && typeof p.ciphertext === "string" && typeof p.iv === "string";
1216
- }
1217
1194
 
1218
1195
  // src/serv-app.ts
1219
1196
  var API_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
@@ -1221,6 +1198,7 @@ var audioWsServers = /* @__PURE__ */ new WeakMap();
1221
1198
  function createApp(options) {
1222
1199
  const http = resolveServHttpConfig(options.http);
1223
1200
  const app = new Hono();
1201
+ app.use("*", (c, next) => core.i18n.runWithLocale(resolveRequestLocale(c.req.raw.headers), next));
1224
1202
  app.notFound((c) => c.json(buildHaiErrorBody(
1225
1203
  HaiCommonError.NOT_FOUND,
1226
1204
  core.i18n.coreM("core_errorNotFound", { locale: resolveRequestLocale(c.req.raw.headers) })
@@ -1240,8 +1218,8 @@ function createApp(options) {
1240
1218
  mountCustomMiddlewares(app, options.middlewares);
1241
1219
  if (options.transport) {
1242
1220
  const mgr = options.transport.crypto.transport.createServer({
1243
- keyStore: options.transport.keyStore,
1244
- maxClients: options.transport.maxClients
1221
+ serverKeyRing: options.transport.serverKeyRing,
1222
+ keyStore: options.transport.keyStore
1245
1223
  });
1246
1224
  if (!mgr.success) {
1247
1225
  throw core.error.buildHaiErrorInst(