@faapi/faapi 0.0.0-canary.2a7b6a3 → 0.0.0-canary.9a79cdf

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.d.ts CHANGED
@@ -307,6 +307,8 @@ interface LoggerOptions {
307
307
  * before/after 一体,闭包变量共享开始时间,无需污染 ctx。
308
308
  * 错误用 try/catch 捕获,记录后重新抛出(让上层处理)。
309
309
  * 成功时从 next() 返回的 Response 读取状态码。
310
+ *
311
+ * log 函数每次请求时读取(options.log ?? console.log),运行时替换 console.log 会生效。
310
312
  */
311
313
  declare function logger(options?: LoggerOptions): FaapiMiddleware;
312
314
 
@@ -810,6 +812,13 @@ type RuntimeType = {
810
812
  kind: 'record';
811
813
  key: RuntimeType;
812
814
  value: RuntimeType;
815
+ } | {
816
+ kind: 'map';
817
+ key: RuntimeType;
818
+ value: RuntimeType;
819
+ } | {
820
+ kind: 'set';
821
+ element: RuntimeType;
813
822
  } | {
814
823
  kind: 'ref';
815
824
  name: string;
@@ -949,6 +958,18 @@ interface RouteSchemaSource {
949
958
  filePath: string;
950
959
  schemaName: string;
951
960
  typeInfo: HandlerTypeInfo | null;
961
+ /**
962
+ * 是否对 number/boolean 字段生成 z.preprocess 字符串转换(coerce)。
963
+ *
964
+ * - query/params:始终 coerce=true(URL 来源均为 string)
965
+ * - body:始终 coerce=false(JSON 解析已是天然 JS 类型)
966
+ * - form:coerce=true(form-urlencoded 来源均为 string),由本函数在提取时
967
+ * 检测到 handler 声明 `form` 参数时显式设置。schema 名仍为 `POSTBody`
968
+ * (与 body 共享运行时 schema key),运行时 validateInput 无需感知 form/body 差异。
969
+ *
970
+ * 未设置时由 generateSchemaFileSource 回退到 schemaName 后缀正则推断(Query/Params → true)。
971
+ */
972
+ coerce?: boolean;
952
973
  }
953
974
  /**
954
975
  * 从路由清单收集 schema 提取所需的原始数据
@@ -1061,6 +1082,8 @@ interface CreateAppOptions {
1061
1082
  rootDir?: string;
1062
1083
  /** 源码目录前缀,覆盖环境变量 FAAPI_APP_DIR,默认 'src' */
1063
1084
  appDir?: string;
1085
+ /** 产物输出目录,覆盖环境变量 FAAPI_OUT_DIR,默认 'dist' */
1086
+ outDir?: string;
1064
1087
  /** 端口号,也可在 listen() 时传入;默认环境变量 PORT 或 3000 */
1065
1088
  port?: number;
1066
1089
  }
package/dist/index.js CHANGED
@@ -290,10 +290,35 @@ function resolveTypeReference(typeNode, checker, visited = /* @__PURE__ */ new S
290
290
  const properties = typeName === "Pick" ? innerType.properties.filter((p) => keySet.has(p.name)) : innerType.properties.filter((p) => !keySet.has(p.name));
291
291
  return { kind: "object", properties };
292
292
  }
293
- if (typeName === "Map" || typeName === "Set" || typeName === "WeakMap" || typeName === "WeakSet") {
293
+ if (typeName === "Map") {
294
+ if (!typeNode.typeArguments || typeNode.typeArguments.length !== 2) {
295
+ throw new SchemaExtractionError(
296
+ typeNode.getText(),
297
+ "Map \u5FC5\u987B\u5E26 2 \u4E2A\u7C7B\u578B\u53C2\u6570\uFF0C\u5982 Map<K, V>\uFF0C\u88F8 Map \u4E0D\u652F\u6301"
298
+ );
299
+ }
300
+ return {
301
+ kind: "map",
302
+ key: resolveTypeNode(typeNode.typeArguments[0], checker, visited),
303
+ value: resolveTypeNode(typeNode.typeArguments[1], checker, visited)
304
+ };
305
+ }
306
+ if (typeName === "Set") {
307
+ if (!typeNode.typeArguments || typeNode.typeArguments.length !== 1) {
308
+ throw new SchemaExtractionError(
309
+ typeNode.getText(),
310
+ "Set \u5FC5\u987B\u5E26 1 \u4E2A\u7C7B\u578B\u53C2\u6570\uFF0C\u5982 Set<T>\uFF0C\u88F8 Set \u4E0D\u652F\u6301"
311
+ );
312
+ }
313
+ return {
314
+ kind: "set",
315
+ element: resolveTypeNode(typeNode.typeArguments[0], checker, visited)
316
+ };
317
+ }
318
+ if (typeName === "WeakMap" || typeName === "WeakSet") {
294
319
  throw new SchemaExtractionError(
295
320
  typeNode.getText(),
296
- `${typeName} \u8FD0\u884C\u65F6\u65E0\u6CD5\u6821\u9A8C\uFF0C\u8BF7\u6539\u7528\u5BF9\u8C61\u6216\u6570\u7EC4`
321
+ `${typeName} \u8FD0\u884C\u65F6\u65E0\u6CD5\u679A\u4E3E\u6821\u9A8C\uFF0C\u8BF7\u6539\u7528 Map / Set \u6216\u5BF9\u8C61`
297
322
  );
298
323
  }
299
324
  if (typeName === "Promise") {
@@ -650,6 +675,7 @@ import ts4 from "typescript";
650
675
  var PARAM_TYPE_MAP = {
651
676
  query: "query",
652
677
  body: "body",
678
+ form: "form",
653
679
  headers: "headers",
654
680
  params: "params",
655
681
  context: "context",
@@ -807,9 +833,16 @@ function collectRouteSchemaSources(routes, rootDir) {
807
833
  const inputType = getInputTypeForMethod(method);
808
834
  const schemaName = getSchemaName(method, inputType);
809
835
  const meta = analyzeInjection(code, method);
810
- const param = meta.params.find((p) => p.type === inputType);
836
+ const param = meta.params.find((p) => p.type === inputType) ?? (inputType === "body" ? meta.params.find((p) => p.type === "form") : void 0);
837
+ const isForm = param?.type === "form";
811
838
  const typeInfo = param?.typeName ? extractTypeInfo(program, filePath, param.typeName) : null;
812
- sources.push({ urlPath: entry.urlPath, filePath, schemaName, typeInfo });
839
+ sources.push({
840
+ urlPath: entry.urlPath,
841
+ filePath,
842
+ schemaName,
843
+ typeInfo,
844
+ coerce: isForm || void 0
845
+ });
813
846
  }
814
847
  }
815
848
  return { sources, allTypesByFile, mergedAllTypes };
@@ -882,8 +915,8 @@ function cors(options = {}) {
882
915
 
883
916
  // src/middleware/logger.ts
884
917
  function logger(options = {}) {
885
- const { log = console.log } = options;
886
918
  return async (ctx, next) => {
919
+ const log = options.log ?? console.log;
887
920
  const start = Date.now();
888
921
  try {
889
922
  const response = await next();
@@ -1649,6 +1682,10 @@ function getBuiltinInjectionValue(type, ctx, body) {
1649
1682
  return ctx.ip;
1650
1683
  case "body":
1651
1684
  return body;
1685
+ // form 与 body 共享解析结果(resolveInput 已按 Content-Type 解析 form-urlencoded)
1686
+ // 差异仅在 schema 校验(form coerce=true,由 collectRouteSchemaSources 标记)
1687
+ case "form":
1688
+ return body;
1652
1689
  case "files":
1653
1690
  if (body && typeof body === "object" && "files" in body) {
1654
1691
  return body.files;
@@ -2175,6 +2212,13 @@ function collectNamedTypes(type, ctx) {
2175
2212
  collectNamedTypes(type.key, ctx);
2176
2213
  collectNamedTypes(type.value, ctx);
2177
2214
  return;
2215
+ case "map":
2216
+ collectNamedTypes(type.key, ctx);
2217
+ collectNamedTypes(type.value, ctx);
2218
+ return;
2219
+ case "set":
2220
+ collectNamedTypes(type.element, ctx);
2221
+ return;
2178
2222
  case "ref": {
2179
2223
  if (ctx.namedTypes.has(type.name)) return;
2180
2224
  ctx.namedTypes.set(type.name, { kind: "any" });
@@ -2264,6 +2308,10 @@ function baseExpression(type, ctx) {
2264
2308
  return 'z.preprocess((v) => (typeof v === "string" ? new Date(v) : v), z.date())';
2265
2309
  case "record":
2266
2310
  return `z.record(${runtimeTypeToZodExpression(type.key, ctx)}, ${runtimeTypeToZodExpression(type.value, ctx)})`;
2311
+ case "map":
2312
+ return `z.preprocess(coerceMap, z.map(${runtimeTypeToZodExpression(type.key, ctx)}, ${runtimeTypeToZodExpression(type.value, ctx)}))`;
2313
+ case "set":
2314
+ return `z.preprocess(coerceSet, z.set(${runtimeTypeToZodExpression(type.element, ctx)}))`;
2267
2315
  case "ref":
2268
2316
  if (type.name === ctx.entryTypeName) {
2269
2317
  return `${ctx.entryExportName}Schema`;
@@ -2273,17 +2321,21 @@ function baseExpression(type, ctx) {
2273
2321
  }
2274
2322
  var COERCE_NUMBER_HELPER = 'export const coerceNumber = (v) => typeof v === "string" && v.trim() !== "" && !isNaN(Number(v)) ? Number(v) : v;';
2275
2323
  var COERCE_BOOLEAN_HELPER = 'export const coerceBoolean = (v) => v === "true" || v === "1" ? true : v === "false" || v === "0" ? false : v;';
2324
+ var COERCE_MAP_HELPER = 'export const coerceMap = (v) => Array.isArray(v) ? new Map(v) : v instanceof Map ? v : (v && typeof v === "object" ? new Map(Object.entries(v)) : v);';
2325
+ var COERCE_SET_HELPER = "export const coerceSet = (v) => v instanceof Set ? v : (Array.isArray(v) ? new Set(v) : v);";
2276
2326
  var HELPERS_FILENAME = "faapi-helpers.js";
2277
2327
  function generateHelpersFileSource() {
2278
2328
  return [
2279
2329
  "// faapi-helpers.js \u2014 faapi \u81EA\u52A8\u751F\u6210\u7684\u516C\u7528\u51FD\u6570\uFF08\u8BF7\u52FF\u624B\u52A8\u7F16\u8F91\uFF09",
2280
2330
  COERCE_NUMBER_HELPER,
2281
2331
  COERCE_BOOLEAN_HELPER,
2332
+ COERCE_MAP_HELPER,
2333
+ COERCE_SET_HELPER,
2282
2334
  ""
2283
2335
  ].join("\n");
2284
2336
  }
2285
2337
  function usesCoerceHelpers(code) {
2286
- return code.includes("coerceNumber") || code.includes("coerceBoolean");
2338
+ return code.includes("coerceNumber") || code.includes("coerceBoolean") || code.includes("coerceMap") || code.includes("coerceSet");
2287
2339
  }
2288
2340
  function wrapCoercePreprocess(kind, inner) {
2289
2341
  if (kind === "number") {
@@ -2370,6 +2422,10 @@ function containsRef(type, visited) {
2370
2422
  return type.members.some((m) => containsRef(m, visited));
2371
2423
  case "record":
2372
2424
  return containsRef(type.key, visited) || containsRef(type.value, visited);
2425
+ case "map":
2426
+ return containsRef(type.key, visited) || containsRef(type.value, visited);
2427
+ case "set":
2428
+ return containsRef(type.element, visited);
2373
2429
  default:
2374
2430
  return false;
2375
2431
  }
@@ -2434,7 +2490,7 @@ function generateSchemaFileSource(sources, allTypes, helpersImportPath) {
2434
2490
  if (!typeInfo) {
2435
2491
  continue;
2436
2492
  }
2437
- const coerce = /(?:Query|Params)$/.test(schemaName);
2493
+ const coerce = source.coerce ?? /(?:Query|Params)$/.test(schemaName);
2438
2494
  const block = [`// ${schemaName}`];
2439
2495
  const schemaCode = generateZodSchemaSource(typeInfo, resolveType, schemaName, coerce).replace(
2440
2496
  /^import \{ z \} from 'zod';\s*\n\s*\n/,
@@ -2446,7 +2502,9 @@ function generateSchemaFileSource(sources, allTypes, helpersImportPath) {
2446
2502
  }
2447
2503
  const allSchemaCode = schemaBlocks.join("\n");
2448
2504
  if (helpersImportPath && usesCoerceHelpers(allSchemaCode)) {
2449
- lines.push(`import { coerceNumber, coerceBoolean } from '${helpersImportPath}';`);
2505
+ lines.push(
2506
+ `import { coerceNumber, coerceBoolean, coerceMap, coerceSet } from '${helpersImportPath}';`
2507
+ );
2450
2508
  }
2451
2509
  lines.push("");
2452
2510
  lines.push(...schemaBlocks);
@@ -2567,6 +2625,7 @@ function createServer(options) {
2567
2625
  middlewares: globalMiddlewares,
2568
2626
  injectors: globalInjectors,
2569
2627
  helmet: helmetOption,
2628
+ logger: loggerOption,
2570
2629
  bodyLimit = DEFAULT_BODY_LIMIT,
2571
2630
  http2: http2Option
2572
2631
  } = options;
@@ -2578,6 +2637,8 @@ function createServer(options) {
2578
2637
  const helmOpts = typeof helmetOption === "object" ? helmetOption : {};
2579
2638
  configMiddlewares.push(helmet(helmOpts));
2580
2639
  }
2640
+ const loggerMiddlewareInst = loggerOption === false ? null : loggerOption === true || loggerOption === void 0 ? logger() : logger(loggerOption);
2641
+ if (loggerMiddlewareInst) configMiddlewares.push(loggerMiddlewareInst);
2581
2642
  const server = (() => {
2582
2643
  if (http2Option) {
2583
2644
  const h2Opts = typeof http2Option === "object" ? http2Option : {};
@@ -2883,7 +2944,7 @@ function isFaapiConfigKey(key) {
2883
2944
  }
2884
2945
  async function createAppBase(options) {
2885
2946
  const rootDir = options?.rootDir ?? process.cwd();
2886
- const outDir = process.env.FAAPI_OUT_DIR ?? DEFAULT_OUT_DIR;
2947
+ const outDir = options?.outDir ?? process.env.FAAPI_OUT_DIR ?? DEFAULT_OUT_DIR;
2887
2948
  const routesPath = path7.resolve(rootDir, outDir, ROUTES_FILE);
2888
2949
  if (!fs4.existsSync(routesPath)) {
2889
2950
  throw new Error(
@@ -2919,6 +2980,7 @@ async function createAppBase(options) {
2919
2980
  middlewares: config?.middlewares,
2920
2981
  injectors: config?.injectors,
2921
2982
  helmet: config?.helmet,
2983
+ logger: config?.logger,
2922
2984
  bodyLimit: config?.bodyLimit,
2923
2985
  http2: config?.http2
2924
2986
  });