@lovrozagar/honey 0.5.1 → 0.5.3

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/src/codegen.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { InternPool } from "./codegen-route-tree-intern.ts"
1
2
  import { deriveErrorEnvelopeName, deriveSchemaName, isErrorEnvelope, shortHash } from "./codegen-schema-naming.ts"
2
3
  import type { SchemaNameContext } from "./codegen-schema-naming.ts"
3
4
  import { effectJsonSchemaFn, loadEffectJsonSchema, loadToJSONSchema, toJSONSchemaFn } from "./codegen-loaders.ts"
@@ -5,47 +6,29 @@ import { sanitizeZodJsonSchema } from "./codegen-sanitize.ts"
5
6
  import { methodsOf, namespacesOf, schemaToIR, toIR } from "./codegen-ir.ts"
6
7
  import type { IRNamespace } from "./codegen-ir.ts"
7
8
  import type { HoneyError } from "./error.ts"
8
- import {
9
- type InvalidateCheckConfig,
10
- type InvalidateCheckOperation,
11
- reportMissingInvalidate,
12
- } from "./invalidate-check.ts"
13
- import {
14
- applyMetaSpec,
15
- compileMetaSpec,
16
- type CompiledMetaSpec,
17
- MetaSpecCollector,
18
- resolveProfile,
19
- type SchemaMetaHit,
20
- type SchemaMetaLookup,
21
- } from "./meta-spec.ts"
22
9
  import { ERROR_META } from "./errors.ts"
23
10
  import type { ErrorMetaEntry } from "./errors.ts"
11
+ import type { InvalidateCheckConfig } from "./invalidate-check.ts"
24
12
  import type { Honey } from "./index.ts"
25
13
  import {
26
- type PackedColumn,
27
- type PackedRouteTable,
28
- packInputSchemas,
29
- packOutputSchemas,
30
- type RouteHandler,
31
- type TreeNode,
32
- } from "./tree.ts"
14
+ generateOpenApiFromTree,
15
+ type OpenApiInfo,
16
+ type OpenApiRouteInfo,
17
+ type OpenApiSpec,
18
+ } from "./openapi/document.ts"
19
+ import { getJsonSchemaConverter, setJsonSchemaConverter } from "./openapi/json-schema-slot.ts"
20
+ import type { RouteHandler, TreeNode } from "./tree.ts"
33
21
  import { irToTs } from "./ts-type-emitter.ts"
34
22
  import { createTypeEmitState, emitSchemaType } from "./type-emitter.ts"
35
23
  import type { TypeEmitState } from "./type-emitter.ts"
36
- import type {
37
- InputSchemaEntry,
38
- InputSchemasDef,
39
- MetaSpecConfig,
40
- MetaSpecSchemaSource,
41
- OutputSchemaDef,
42
- StandardSchemaLike,
43
- } from "./types.ts"
24
+ import type { InputSchemaEntry, InputSchemasDef, OutputSchemaDef, StandardSchemaLike } from "./types.ts"
44
25
  import { EMPTY_OBJ, statusKeyToCode } from "./types.ts"
45
26
 
46
27
  export { prepareCodegen } from "./codegen-loaders.ts"
47
28
  export type { InvalidateCheckConfig, InvalidateCheckLevel } from "./invalidate-check.ts"
48
29
  export { toYaml, yamlSiblingPath } from "./yaml.ts"
30
+ export { DEFAULT_ERROR_JSON_SCHEMA } from "./openapi/document.ts"
31
+ export type { OpenApiInfo, OpenApiRouteInfo, OpenApiSpec }
49
32
 
50
33
  /* Status → typed error subclass. Single source of truth shared between the
51
34
  * emitted client (class declarations + status map + footer re-exports) and
@@ -289,22 +272,6 @@ type RouteManifest = {
289
272
  routes: RouteManifestEntry[]
290
273
  }
291
274
 
292
- type OpenApiInfo = {
293
- description?: string
294
- title: string
295
- version: string
296
- }
297
-
298
- export type OpenApiSpec = {
299
- components?: {
300
- schemas?: Record<string, Record<string, unknown>>
301
- securitySchemes?: Record<string, unknown>
302
- }
303
- info: OpenApiInfo
304
- openapi: string
305
- paths: Record<string, Record<string, Record<string, unknown>>>
306
- }
307
-
308
275
  function extractParams(path: string): string[] {
309
276
  const params: string[] = []
310
277
  for (const seg of path.split("/")) {
@@ -315,10 +282,6 @@ function extractParams(path: string): string[] {
315
282
  return params
316
283
  }
317
284
 
318
- function toOpenApiPath(path: string): string {
319
- return path.replace(/:(\w+)\??/g, "{$1}")
320
- }
321
-
322
285
  type CollectedRoute = {
323
286
  handler: RouteHandler
324
287
  method: string
@@ -353,23 +316,6 @@ function walkTree(node: TreeNode, currentPath: string, routes: CollectedRoute[],
353
316
  }
354
317
  }
355
318
 
356
- type CollectedWSRoute = {
357
- handler: import("./tree.ts").WSRouteHandler
358
- path: string
359
- }
360
-
361
- function walkWSRoutes(node: TreeNode, currentPath: string, routes: CollectedWSRoute[]): void {
362
- if (node.ws !== null) {
363
- routes.push({ handler: node.ws, path: currentPath || "/" })
364
- }
365
- for (const [seg, child] of Object.entries(node.s)) {
366
- walkWSRoutes(child, `${currentPath}/${seg}`, routes)
367
- }
368
- if (node.d !== null) {
369
- walkWSRoutes(node.d.c, `${currentPath}/:${node.d.n}`, routes)
370
- }
371
- }
372
-
373
319
  type ErrorInfo = { errorKey: string; status: number; statusKey: string }
374
320
 
375
321
  /* Longest suffix first so `*_not_found` wins over `*_found` (302). */
@@ -415,45 +361,6 @@ function getErrorFactory<TEnv, TCtx>(
415
361
  return (app as unknown as { _errorFactory: Record<string, () => HoneyError> | null })._errorFactory
416
362
  }
417
363
 
418
- export const DEFAULT_ERROR_JSON_SCHEMA: Record<string, unknown> = {
419
- properties: {
420
- error_key: { type: "string" },
421
- fields: {
422
- additionalProperties: {
423
- items: {
424
- properties: {
425
- error_key: { type: "string" },
426
- message: { type: "string" },
427
- path: { type: "string" },
428
- },
429
- required: ["error_key", "message", "path"],
430
- type: "object",
431
- },
432
- type: "array",
433
- },
434
- type: "object",
435
- },
436
- message: { type: "string" },
437
- status: { type: "integer" },
438
- status_key: { type: "string" },
439
- success: { const: false },
440
- },
441
- required: ["error_key", "fields", "message", "status", "status_key", "success"],
442
- type: "object",
443
- }
444
-
445
- function getErrorSchema<TEnv, TCtx>(
446
- app: Honey<TEnv, TCtx, unknown, unknown, unknown, string, string>,
447
- ): StandardSchemaLike | null {
448
- return (app as unknown as { _errorSchema: StandardSchemaLike | null })._errorSchema
449
- }
450
-
451
- function getCustomErrorSchema<TEnv, TCtx>(
452
- app: Honey<TEnv, TCtx, unknown, unknown, unknown, string, string>,
453
- ): StandardSchemaLike | null {
454
- return (app as unknown as { _customErrorSchema: StandardSchemaLike | null })._customErrorSchema
455
- }
456
-
457
364
  function getErrorMeta(factory: Record<string, () => HoneyError> | null): Record<string, ErrorMetaEntry> | null {
458
365
  if (!factory) return null
459
366
  return (factory as Record<symbol, Record<string, ErrorMetaEntry>>)[ERROR_META] ?? null
@@ -477,49 +384,6 @@ function introspectSchema(schema: StandardSchemaLike): unknown {
477
384
 
478
385
  export { normalizeSecurity } from "./meta-spec.ts"
479
386
 
480
- /* Strip properties marked with x-internal from an object JSON schema */
481
- function stripInternalProps(schema: Record<string, unknown>): Record<string, unknown> {
482
- const props = schema.properties as Record<string, Record<string, unknown>> | undefined
483
- if (!props) return schema
484
- const filtered: Record<string, unknown> = {}
485
- for (const [key, val] of Object.entries(props)) {
486
- if (val?.["x-internal"] === true) continue
487
- filtered[key] = val
488
- }
489
- const required = schema.required as string[] | undefined
490
- const result: Record<string, unknown> = { ...schema, properties: filtered }
491
- if (required) {
492
- result.required = required.filter((k) => filtered[k] !== undefined)
493
- }
494
- return result
495
- }
496
-
497
- const BINARY_FORM_FORMATS = new Set(["binary", "byte", "file"])
498
-
499
- /** File parts cannot ride application/x-www-form-urlencoded. */
500
- function isBinaryFormPart(schema: Record<string, unknown> | undefined): boolean {
501
- if (!schema) return false
502
- const format = schema.format
503
- if (typeof format === "string" && BINARY_FORM_FORMATS.has(format)) return true
504
- if (schema.type === "file") return true
505
- if (schema.type === "array") {
506
- const items = schema.items
507
- if (items !== null && typeof items === "object" && !Array.isArray(items)) {
508
- return isBinaryFormPart(items as Record<string, unknown>)
509
- }
510
- }
511
- return false
512
- }
513
-
514
- function formSchemaHasBinaryPart(schema: Record<string, unknown>): boolean {
515
- const props = schema.properties as Record<string, Record<string, unknown>> | undefined
516
- if (!props) return false
517
- for (const prop of Object.values(props)) {
518
- if (isBinaryFormPart(prop)) return true
519
- }
520
- return false
521
- }
522
-
523
387
  export function canonicalizeSchema(schema: Record<string, unknown>): string {
524
388
  return JSON.stringify(schema, (_, value) => {
525
389
  if (value && typeof value === "object" && !Array.isArray(value)) {
@@ -1313,16 +1177,6 @@ function schemaToJsonSchema(schema: StandardSchemaLike, io: "input" | "output" =
1313
1177
  return introspectSchema(schema)
1314
1178
  }
1315
1179
 
1316
- function asJsonSchema(
1317
- entry: StandardSchemaLike | Record<string, unknown>,
1318
- io: "input" | "output" = "output",
1319
- ): Record<string, unknown> {
1320
- if (entry !== null && entry !== undefined && "~standard" in (entry as object)) {
1321
- return schemaToJsonSchema(entry as StandardSchemaLike, io) as Record<string, unknown>
1322
- }
1323
- return entry as Record<string, unknown>
1324
- }
1325
-
1326
1180
  /* ---- Manifest generation ---- */
1327
1181
 
1328
1182
  export function generateManifest<TEnv, TCtx>(
@@ -1388,129 +1242,6 @@ export function generateManifest<TEnv, TCtx>(
1388
1242
 
1389
1243
  /* ---- OpenAPI generation ---- */
1390
1244
 
1391
- export type OpenApiRouteInfo<TMeta = unknown> = {
1392
- meta: TMeta
1393
- method: string
1394
- path: string
1395
- }
1396
-
1397
- function getMetaSpecConfig(app: unknown): MetaSpecConfig | null {
1398
- return (app as { _metaSpec?: MetaSpecConfig | null })._metaSpec ?? null
1399
- }
1400
-
1401
- /** Lowest declared 2xx JSON output schema — the "output" schema-meta source */
1402
- function primaryOutputSchema(handler: RouteHandler): StandardSchemaLike | null {
1403
- const json = handler.os?.["application/json"] as Record<string, unknown> | undefined
1404
- if (!json) return null
1405
- let best: { code: number; schema: StandardSchemaLike } | null = null
1406
- for (const [statusKey, schema] of Object.entries(json)) {
1407
- if (schema === undefined) continue
1408
- const code = statusKeyToCode[statusKey as keyof typeof statusKeyToCode]
1409
- if (!code || code < 200 || code > 299) continue
1410
- if (!best || code < best.code) best = { code, schema: schema as StandardSchemaLike }
1411
- }
1412
- return best?.schema ?? null
1413
- }
1414
-
1415
- /**
1416
- * Reads app keys off the *root* of a route schema's JSON Schema. Vendor-neutral: Zod's
1417
- * `.meta({ … })` lands on the JSON Schema root, and so does every other validator honey
1418
- * supports that carries schema metadata.
1419
- */
1420
- /** Child schema nodes a deep search descends into, in visit order */
1421
- function schemaChildren(node: Record<string, unknown>): Record<string, unknown>[] {
1422
- const out: Record<string, unknown>[] = []
1423
- const push = (value: unknown): void => {
1424
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1425
- out.push(value as Record<string, unknown>)
1426
- }
1427
- }
1428
- const props = node.properties
1429
- if (props !== null && typeof props === "object" && !Array.isArray(props)) {
1430
- for (const child of Object.values(props as Record<string, unknown>)) push(child)
1431
- }
1432
- push(node.items)
1433
- for (const key of ["allOf", "anyOf", "oneOf", "prefixItems"]) {
1434
- const members = node[key]
1435
- if (Array.isArray(members)) {
1436
- for (const member of members) push(member)
1437
- }
1438
- }
1439
- return out
1440
- }
1441
-
1442
- /** Bounded BFS — shallowest match wins; several distinct values at that depth is ambiguous */
1443
- function searchSchemaKey(root: Record<string, unknown>, key: string, maxDepth: number): SchemaMetaHit {
1444
- let level = [root]
1445
- for (let depth = 0; depth <= maxDepth && level.length > 0; depth++) {
1446
- const matches: unknown[] = []
1447
- const seen = new Set<string>()
1448
- for (const node of level) {
1449
- if (!Object.hasOwn(node, key) || node[key] === undefined) continue
1450
- const fingerprint = JSON.stringify(node[key]) ?? "undefined"
1451
- if (seen.has(fingerprint)) continue
1452
- seen.add(fingerprint)
1453
- matches.push(node[key])
1454
- }
1455
- if (matches.length === 1) return { found: true, value: matches[0] }
1456
- if (matches.length > 1) return { found: "ambiguous", values: matches }
1457
- const next: Record<string, unknown>[] = []
1458
- for (const node of level) next.push(...schemaChildren(node))
1459
- level = next
1460
- }
1461
- return { found: false }
1462
- }
1463
-
1464
- const DEEP_SEARCH_MAX_DEPTH = 6
1465
-
1466
- /**
1467
- * Resolves one schema-metadata key against one schema source. `"root"` reads the schema root,
1468
- * seeing through one level of `items` so a bare `array<Entity>` list output still finds the
1469
- * descriptor stamped on the item. `"deep"` walks the schema for envelope shapes.
1470
- */
1471
- function makeSchemaMetaLookup(handler: RouteHandler): SchemaMetaLookup {
1472
- const cache = new Map<string, Record<string, unknown> | undefined>()
1473
-
1474
- const rootOf = (source: MetaSpecSchemaSource): Record<string, unknown> | undefined => {
1475
- if (cache.has(source)) return cache.get(source)
1476
- let schema: StandardSchemaLike | null = null
1477
- let io: "input" | "output" = "output"
1478
- if (source === "output") {
1479
- schema = primaryOutputSchema(handler)
1480
- } else {
1481
- io = "input"
1482
- const key = source.slice("input.".length) as keyof InputSchemasDef
1483
- const entry = handler.iv?.[key]
1484
- schema = entry ? unwrapEntry(entry as InputSchemaEntry) : null
1485
- }
1486
- let root: Record<string, unknown> | undefined
1487
- if (schema) {
1488
- const converted = asJsonSchema(schema, io)
1489
- root = converted && typeof converted === "object" ? converted : undefined
1490
- }
1491
- cache.set(source, root)
1492
- return root
1493
- }
1494
-
1495
- return (source, key, search) => {
1496
- const root = rootOf(source)
1497
- if (!root) return { found: false }
1498
- if (search === "deep") return searchSchemaKey(root, key, DEEP_SEARCH_MAX_DEPTH)
1499
- if (Object.hasOwn(root, key) && root[key] !== undefined) return { found: true, value: root[key] }
1500
- /* a list output is `array<Entity>` — the descriptor sits on the item */
1501
- if (root.type === "array") {
1502
- const items = root.items
1503
- if (items !== null && typeof items === "object" && !Array.isArray(items)) {
1504
- const itemRoot = items as Record<string, unknown>
1505
- if (Object.hasOwn(itemRoot, key) && itemRoot[key] !== undefined) {
1506
- return { found: true, value: itemRoot[key] }
1507
- }
1508
- }
1509
- }
1510
- return { found: false }
1511
- }
1512
- }
1513
-
1514
1245
  export async function generateOpenApi<TEnv, TCtx, TMeta = unknown>(
1515
1246
  app: Honey<TEnv, TCtx, unknown, TMeta, unknown, string, string>,
1516
1247
  options: {
@@ -1528,347 +1259,14 @@ export async function generateOpenApi<TEnv, TCtx, TMeta = unknown>(
1528
1259
  },
1529
1260
  ): Promise<OpenApiSpec> {
1530
1261
  await Promise.all([loadToJSONSchema(), loadEffectJsonSchema()])
1531
- const collector = new MetaSpecCollector()
1532
- const metaSpec: CompiledMetaSpec = compileMetaSpec(getMetaSpecConfig(app), collector)
1533
- const { filter: profileFilter, name: profileName } = resolveProfile(metaSpec, options.profile, collector)
1534
- const factory = getErrorFactory(app)
1535
- const errorMeta = getErrorMeta(factory)
1536
-
1537
- /* resolve error response schema — custom StandardSchema → JSON Schema, or default */
1538
- const rawErrorSchema = getErrorSchema(app)
1539
- const baseErrorJsonSchema = rawErrorSchema
1540
- ? (schemaToJsonSchema(rawErrorSchema) as Record<string, unknown>)
1541
- : DEFAULT_ERROR_JSON_SCHEMA
1542
-
1543
- /* resolve custom error formatter schema (for merging into custom schema errors) */
1544
- const rawCustomErrorSchema = getCustomErrorSchema(app)
1545
- const customErrorAddsSchema = rawCustomErrorSchema
1546
- ? (schemaToJsonSchema(rawCustomErrorSchema) as Record<string, unknown>)
1547
- : null
1548
-
1549
- const tree = (app as unknown as { _tree: TreeNode })._tree
1550
- const collected: CollectedRoute[] = []
1551
- walkTree(tree, "", collected)
1552
-
1553
- const routeFilter = options.filterRoutes
1554
- const paths: Record<string, Record<string, Record<string, unknown>>> = {}
1555
- /* operations as emitted, for whole-document checks that need to see siblings */
1556
- const emitted: InvalidateCheckOperation[] = []
1557
-
1558
- for (const { handler, method, path } of collected) {
1559
- if (routeFilter) {
1560
- const meta = handler.mt as TMeta
1561
- if (!routeFilter({ meta, method, path })) continue
1562
- }
1563
- const oaPath = toOpenApiPath(path)
1564
- if (paths[oaPath] === undefined) {
1565
- paths[oaPath] = {}
1566
- }
1567
-
1568
- const methodKey = method.toLowerCase()
1569
- const operation: Record<string, unknown> = {}
1570
- const responses: Record<string, unknown> = {}
1571
- const parameters: Array<Record<string, unknown>> = []
1572
-
1573
- /* meta + schema meta → openApi operation fields, per the declared policy */
1574
- applyMetaSpec({
1575
- collector,
1576
- filter: profileFilter,
1577
- kind: "http",
1578
- meta: handler.mt as Record<string, unknown> | null,
1579
- method,
1580
- operation,
1581
- path,
1582
- profile: profileName,
1583
- schemaMeta: metaSpec.needsSchemas ? makeSchemaMetaLookup(handler) : undefined,
1584
- spec: metaSpec,
1585
- })
1586
-
1587
- /* path params */
1588
- const params = extractParams(path)
1589
- for (const name of params) {
1590
- const isOptional = path.includes(`:${name}?`)
1591
- parameters.push({
1592
- in: "path",
1593
- name,
1594
- required: !isOptional,
1595
- schema: { type: "string" },
1596
- })
1597
- }
1598
-
1599
- /* input → requestBody / parameters */
1600
- if (handler.iv) {
1601
- for (const [source, entry] of Object.entries(handler.iv)) {
1602
- if (entry === undefined) continue
1603
- /* readableStream() is a runtime tag; the document describes the inner schema */
1604
- const unwrapped = unwrapEntry(entry as InputSchemaEntry)
1605
- const jsonSchema = asJsonSchema(unwrapped, "input")
1606
-
1607
- if (source === "json" || source === "form") {
1608
- const schema = stripInternalProps(jsonSchema)
1609
- const content: Record<string, { schema: Record<string, unknown> }> = {}
1610
- if (source === "form") {
1611
- /* z.file() is format:binary — illegal on urlencoded; oat only builds FormData for multipart/* */
1612
- if (formSchemaHasBinaryPart(schema)) {
1613
- content["multipart/form-data"] = { schema }
1614
- }
1615
- content["application/x-www-form-urlencoded"] = { schema }
1616
- } else {
1617
- content["application/json"] = { schema }
1618
- }
1619
- operation.requestBody = {
1620
- content,
1621
- required: true,
1622
- }
1623
- } else if (source === "search" || source === "headers" || source === "cookies") {
1624
- let location: "cookie" | "header" | "query" = "cookie"
1625
- if (source === "search") location = "query"
1626
- else if (source === "headers") location = "header"
1627
- /* decompose object schema properties into individual parameters */
1628
- const props = jsonSchema.properties as Record<string, unknown> | undefined
1629
- const required = (jsonSchema.required as string[]) ?? []
1630
- if (props) {
1631
- for (const [propName, propSchema] of Object.entries(props)) {
1632
- if ((propSchema as Record<string, unknown>)?.["x-internal"] === true) continue
1633
- parameters.push({
1634
- in: location,
1635
- name: propName,
1636
- required: required.includes(propName),
1637
- schema: propSchema,
1638
- })
1639
- }
1640
- }
1641
- } else if (source === "params") {
1642
- /* override path param schemas with actual schema types */
1643
- const props = jsonSchema.properties as Record<string, unknown> | undefined
1644
- if (props) {
1645
- for (const param of parameters) {
1646
- const propSchema = props[param.name as string]
1647
- if (propSchema && param.in === "path") {
1648
- param.schema = propSchema
1649
- }
1650
- }
1651
- }
1652
- }
1653
- }
1654
- }
1655
-
1656
- if (parameters.length > 0) {
1657
- operation.parameters = parameters
1658
- }
1659
-
1660
- /* output → responses */
1661
- if (handler.os) {
1662
- for (const [contentType, schemas] of Object.entries(handler.os)) {
1663
- if (schemas === undefined) continue
1664
- if (contentType === "redirect") {
1665
- for (const statusKey of Object.keys(schemas)) {
1666
- const statusCode = statusKeyToCode[statusKey as keyof typeof statusKeyToCode]
1667
- if (statusCode) {
1668
- responses[String(statusCode)] = {
1669
- description: statusKey.replace(/_/g, " "),
1670
- headers: {
1671
- Location: {
1672
- description: "Redirect target URL",
1673
- schema: { format: "uri", type: "string" },
1674
- },
1675
- },
1676
- }
1677
- }
1678
- }
1679
- continue
1680
- }
1681
- for (const [statusKey, schema] of Object.entries(schemas)) {
1682
- if (schema === undefined) continue
1683
- const statusCode = statusKeyToCode[statusKey as keyof typeof statusKeyToCode]
1684
- if (statusCode) {
1685
- responses[String(statusCode)] = {
1686
- content: {
1687
- [contentType]: {
1688
- schema: asJsonSchema(schema as StandardSchemaLike | Record<string, unknown>),
1689
- },
1690
- },
1691
- description: statusKey.replace(/_/g, " "),
1692
- }
1693
- }
1694
- }
1695
- }
1696
- }
1697
-
1698
- /* error responses from declared error keys */
1699
- if (handler.ek.size > 0) {
1700
- type ErrorEntry = { key: string; schema: Record<string, unknown> | null }
1701
- const byStatus = new Map<number, ErrorEntry[]>()
1702
- for (const ek of handler.ek) {
1703
- const info = resolveErrorInfo(ek, factory)
1704
- if (info.status > 0) {
1705
- let entries = byStatus.get(info.status)
1706
- if (!entries) {
1707
- entries = []
1708
- byStatus.set(info.status, entries)
1709
- }
1710
- /* check if this error has a custom schema */
1711
- const meta = errorMeta?.[ek]
1712
- let customSchema: Record<string, unknown> | null = null
1713
- if (meta?.schema) {
1714
- const converted = schemaToJsonSchema(meta.schema as StandardSchemaLike)
1715
- if (converted) {
1716
- customSchema = customErrorAddsSchema
1717
- ? { allOf: [converted, customErrorAddsSchema] }
1718
- : (converted as Record<string, unknown>)
1719
- }
1720
- }
1721
- entries.push({ key: ek, schema: customSchema })
1722
- }
1723
- }
1724
- for (const [status, entries] of byStatus) {
1725
- if (responses[String(status)] === undefined) {
1726
- const standardKeys = entries.filter((e) => !e.schema).map((e) => e.key)
1727
- const customSchemas = entries.filter((e) => e.schema).map((e) => e.schema as Record<string, unknown>)
1728
-
1729
- let schema: Record<string, unknown>
1730
-
1731
- if (customSchemas.length === 0) {
1732
- /* all standard errors at this status — use base error schema with constrained enums */
1733
- schema = cloneJson(baseErrorJsonSchema)
1734
- const props = schema.properties as Record<string, unknown> | undefined
1735
- if (props?.error_key) {
1736
- props.error_key = { enum: standardKeys.sort(), type: "string" }
1737
- }
1738
- if (props?.status) {
1739
- props.status = { enum: [status], type: "integer" }
1740
- }
1741
- } else if (standardKeys.length === 0 && customSchemas.length === 1) {
1742
- /* single custom schema error — use it directly */
1743
- schema = customSchemas[0]
1744
- } else {
1745
- /* mixed standard + custom, or multiple custom — use oneOf */
1746
- const schemas: Record<string, unknown>[] = []
1747
- if (standardKeys.length > 0) {
1748
- const stdSchema = cloneJson(baseErrorJsonSchema)
1749
- const props = stdSchema.properties as Record<string, unknown> | undefined
1750
- if (props?.error_key) {
1751
- props.error_key = { enum: standardKeys.sort(), type: "string" }
1752
- }
1753
- if (props?.status) {
1754
- props.status = { enum: [status], type: "integer" }
1755
- }
1756
- schemas.push(stdSchema)
1757
- }
1758
- schemas.push(...customSchemas)
1759
- schema = { oneOf: schemas }
1760
- }
1761
-
1762
- responses[String(status)] = {
1763
- content: {
1764
- "application/json": { schema },
1765
- },
1766
- description: entries
1767
- .map((e) => e.key)
1768
- .sort()
1769
- .join(", "),
1770
- }
1771
- }
1772
- }
1773
- }
1774
-
1775
- if (Object.keys(responses).length === 0) {
1776
- responses["200"] = { description: "Success" }
1777
- }
1778
- operation.responses = responses
1779
-
1780
- paths[oaPath][methodKey] = operation
1781
- emitted.push({
1782
- meta: handler.mt as Record<string, unknown> | null,
1783
- method: methodKey,
1784
- operation,
1785
- path: oaPath,
1786
- })
1787
- }
1788
-
1789
- /* WS routes */
1790
- const wsRoutes: CollectedWSRoute[] = []
1791
- walkWSRoutes(tree, "", wsRoutes)
1792
-
1793
- for (const { handler, path } of wsRoutes) {
1794
- const oaPath = toOpenApiPath(path)
1795
- if (paths[oaPath] === undefined) {
1796
- paths[oaPath] = {}
1797
- }
1798
-
1799
- const operation: Record<string, unknown> = { "x-websocket": true }
1800
- const parameters: Array<Record<string, unknown>> = []
1801
-
1802
- applyMetaSpec({
1803
- collector,
1804
- filter: profileFilter,
1805
- kind: "ws",
1806
- meta: handler.mt as Record<string, unknown> | null,
1807
- method: "WS",
1808
- operation,
1809
- path,
1810
- profile: profileName,
1811
- schemaMeta: metaSpec.needsSchemas ? makeSchemaMetaLookup(handler as unknown as RouteHandler) : undefined,
1812
- spec: metaSpec,
1813
- })
1814
-
1815
- /* path params */
1816
- const wsParams = extractParams(path)
1817
- for (const name of wsParams) {
1818
- parameters.push({
1819
- in: "path",
1820
- name,
1821
- required: true,
1822
- schema: { type: "string" },
1823
- })
1824
- }
1825
-
1826
- /* query params from input schemas — use same conversion as HTTP routes */
1827
- if (handler.iv?.search) {
1828
- const searchEntry = handler.iv.search
1829
- const searchSchema =
1830
- "_tag" in searchEntry
1831
- ? (searchEntry as { schema: StandardSchemaLike }).schema
1832
- : (searchEntry as StandardSchemaLike)
1833
- const jsonSchema = asJsonSchema(searchSchema as StandardSchemaLike | Record<string, unknown>, "input")
1834
- if (jsonSchema && typeof jsonSchema === "object") {
1835
- const props = (jsonSchema as Record<string, unknown>).properties as Record<string, unknown> | undefined
1836
- const required = new Set(((jsonSchema as Record<string, unknown>).required ?? []) as string[])
1837
- if (props) {
1838
- for (const [name, schema] of Object.entries(props)) {
1839
- parameters.push({
1840
- in: "query",
1841
- name,
1842
- required: required.has(name),
1843
- schema,
1844
- })
1845
- }
1846
- }
1847
- }
1848
- }
1849
-
1850
- if (parameters.length > 0) operation.parameters = parameters
1851
- operation.responses = { "101": { description: "WebSocket upgrade" } }
1852
-
1853
- paths[oaPath].get = operation
1854
- }
1855
-
1856
- collector.flush()
1857
- /* after the policy is known good — a broken policy makes this report meaningless */
1858
- reportMissingInvalidate(emitted, options.invalidate)
1859
-
1860
- const result: OpenApiSpec = {
1861
- info: options.info,
1862
- openapi: "3.1.0",
1863
- paths,
1864
- }
1865
- if (options.securitySchemes) {
1866
- result.components = {
1867
- ...result.components,
1868
- securitySchemes: options.securitySchemes,
1869
- }
1262
+ const prev = getJsonSchemaConverter()
1263
+ setJsonSchemaConverter((schema, io) => schemaToJsonSchema(schema, io) as Record<string, unknown>)
1264
+ try {
1265
+ const spec = generateOpenApiFromTree(app, options)
1266
+ return deduplicateSchemas(spec)
1267
+ } finally {
1268
+ setJsonSchemaConverter(prev)
1870
1269
  }
1871
- return deduplicateSchemas(result)
1872
1270
  }
1873
1271
 
1874
1272
  export function extractSchemas<TEnv, TCtx>(
@@ -1998,98 +1396,129 @@ function collectRoutesForTree(node: TreeNode, currentPath: string, routes: Route
1998
1396
  }
1999
1397
  }
2000
1398
 
2001
- function selectorOf(route: RouteConfig): string {
2002
- return `${route.method} ${route.path}`
1399
+ type TreeBuild = {
1400
+ children: Record<string, TreeBuild>
1401
+ dynamic: { child: TreeBuild; name: string } | null
1402
+ handlers: Array<{ handler: string; method: string }>
1403
+ wildcard: {
1404
+ handlers: Array<{ handler: string; method: string }>
1405
+ name: string
1406
+ } | null
1407
+ }
1408
+
1409
+ function createTreeBuild(): TreeBuild {
1410
+ return { children: {}, dynamic: null, handlers: [], wildcard: null }
2003
1411
  }
2004
1412
 
2005
- function encodePackedColumn<T>(values: T[]): PackedColumn<T> {
2006
- const table: T[] = []
2007
- const index: number[] = []
2008
- const seen = new Map<string, number>()
2009
- for (const value of values) {
2010
- const key = JSON.stringify(value) ?? "null"
2011
- let id = seen.get(key)
2012
- if (id === undefined) {
2013
- id = table.length
2014
- seen.set(key, id)
2015
- table.push(value)
1413
+ function serializeNode(node: TreeBuild): string {
1414
+ const staticEntries = Object.entries(node.children)
1415
+ const sExpr =
1416
+ staticEntries.length > 0
1417
+ ? `S({ ${staticEntries.map(([k, v]) => `${JSON.stringify(k)}: ${serializeNode(v)}`).join(", ")} })`
1418
+ : "E"
1419
+
1420
+ const dExpr = node.dynamic
1421
+ ? `{ n: ${JSON.stringify(node.dynamic.name)}, c: ${serializeNode(node.dynamic.child)} }`
1422
+ : "null"
1423
+
1424
+ const wExpr = node.wildcard
1425
+ ? `{ n: ${JSON.stringify(node.wildcard.name)}, m: { ${node.wildcard.handlers.map((h) => `${h.method}: ${h.handler}`).join(", ")} } }`
1426
+ : "null"
1427
+
1428
+ const mExpr =
1429
+ node.handlers.length > 0 ? `{ ${node.handlers.map((h) => `${h.method}: ${h.handler}`).join(", ")} }` : "null"
1430
+
1431
+ const args = [sExpr]
1432
+ if (mExpr !== "null" || dExpr !== "null" || wExpr !== "null") {
1433
+ args.push(mExpr)
1434
+ if (dExpr !== "null" || wExpr !== "null") {
1435
+ args.push(dExpr)
1436
+ if (wExpr !== "null") args.push(wExpr)
2016
1437
  }
2017
- index.push(id)
2018
1438
  }
2019
- const dict: PackedColumn<T> = { t: table, x: index }
2020
- return JSON.stringify(dict).length < JSON.stringify(values).length ? dict : values
2021
- }
2022
-
2023
- function pickSmallerJson<T extends object>(candidate: T, fallback: T): T {
2024
- return JSON.stringify(candidate).length < JSON.stringify(fallback).length ? candidate : fallback
2025
- }
2026
-
2027
- function sharedErrorKeys(rows: string[][]): string[] {
2028
- if (rows.length === 0) return []
2029
- const sets = rows.map((row) => new Set(row))
2030
- return rows[0].filter((key) => sets.every((set) => set.has(key)))
2031
- }
2032
-
2033
- function encodeErrorKeys(rows: string[][]): Pick<PackedRouteTable, "e" | "k"> {
2034
- if (!rows.some((row) => row.length > 0)) return {}
2035
- const full: Pick<PackedRouteTable, "e" | "k"> = { e: encodePackedColumn(rows) }
2036
- const shared = sharedErrorKeys(rows)
2037
- if (shared.length === 0) return full
2038
- const sharedSet = new Set(shared)
2039
- const extras = rows.map((row) => row.filter((key) => !sharedSet.has(key)))
2040
- const withBase: Pick<PackedRouteTable, "e" | "k"> = { k: shared }
2041
- if (extras.some((row) => row.length > 0)) withBase.e = encodePackedColumn(extras)
2042
- return pickSmallerJson(withBase, full)
2043
- }
2044
-
2045
- function encodeMetaColumn(rows: Array<Record<string, unknown> | null>): Pick<PackedRouteTable, "m" | "u"> {
2046
- if (!rows.some((row) => row !== null)) return {}
2047
- const names: string[] = []
2048
- const seen = new Set<string>()
2049
- for (const row of rows) {
2050
- if (row === null) continue
2051
- for (const key of Object.keys(row)) {
2052
- if (seen.has(key)) continue
2053
- if (/^\d+$/.test(key)) return { m: encodePackedColumn(rows) }
2054
- seen.add(key)
2055
- names.push(key)
2056
- }
1439
+ return `N(${args.join(", ")})`
1440
+ }
1441
+
1442
+ function nodeHasStatic(node: TreeBuild): boolean {
1443
+ if (Object.keys(node.children).length > 0) return true
1444
+ if (node.dynamic !== null) return nodeHasStatic(node.dynamic.child)
1445
+ return false
1446
+ }
1447
+
1448
+ function selectorOf(route: RouteConfig): string {
1449
+ return `${route.method} ${route.path}`
1450
+ }
1451
+
1452
+ function mapKeyExpr(intern: InternPool, selector: string): string {
1453
+ const id = intern.id(selector)
1454
+ return id !== undefined ? `[${id}]` : JSON.stringify(selector)
1455
+ }
1456
+
1457
+ function buildInternPool(routes: RouteConfig[]): InternPool {
1458
+ const intern = new InternPool()
1459
+ for (const route of routes) {
1460
+ intern.count(route.boundaryErrorKey)
1461
+ intern.count(route.errorKeys)
1462
+ intern.count(route.inputSchemas)
1463
+ intern.count(route.outputSchemas)
1464
+ intern.count(route.meta)
1465
+ intern.count(selectorOf(route))
2057
1466
  }
2058
- if (names.length === 0) return { m: encodePackedColumn(rows) }
2059
- const packed = rows.map((row) => {
2060
- if (row === null) return null
2061
- const out: Record<string, unknown> = Object.create(null)
2062
- for (const [key, value] of Object.entries(row)) out[String(names.indexOf(key))] = value
2063
- return out
2064
- })
2065
- return pickSmallerJson({ m: encodePackedColumn(packed), u: names }, { m: encodePackedColumn(rows) })
2066
- }
2067
-
2068
- function packRouteTable(routes: RouteConfig[]): PackedRouteTable {
2069
- const table: PackedRouteTable = { s: routes.map(selectorOf) }
2070
- const b = routes.map((r) => r.boundaryErrorKey)
2071
- const e = routes.map((r) => r.errorKeys)
2072
- const i = routes.map((r) => packInputSchemas(r.inputSchemas))
2073
- const o = routes.map((r) => packOutputSchemas(r.outputSchemas))
2074
- const m = routes.map((r) => r.meta)
2075
- if (b.some((x) => x !== null)) {
2076
- const first = b[0]
2077
- if (first !== null && b.every((x) => x === first)) table.b = first
2078
- else table.b = b
1467
+ for (const route of routes) {
1468
+ intern.force(route.inputSchemas, "I")
1469
+ intern.force(route.outputSchemas, "O")
1470
+ intern.force(route.meta, "M")
1471
+ intern.force(selectorOf(route), "P")
2079
1472
  }
2080
- Object.assign(table, encodeErrorKeys(e))
2081
- if (i.some((x) => x !== null)) table.i = encodePackedColumn(i)
2082
- if (o.some((x) => x !== null)) table.o = encodePackedColumn(o)
2083
- Object.assign(table, encodeMetaColumn(m))
2084
- return table
1473
+ intern.seal()
1474
+ return intern
2085
1475
  }
2086
1476
 
2087
- function emitPackedLiteral(value: unknown): string {
2088
- const json = JSON.stringify(value)
2089
- if (json.includes('"__proto__"')) {
2090
- return `JSON.parse(${JSON.stringify(json)})`
1477
+ function insertHandler(rootBuild: TreeBuild, route: RouteConfig, hName: string): void {
1478
+ const segments = route.path.split("/").filter((s) => s.length > 0)
1479
+ let node = rootBuild
1480
+
1481
+ for (let i = 0; i < segments.length; i++) {
1482
+ const seg = segments[i]
1483
+ if (seg.startsWith("*")) {
1484
+ const name = seg.length > 1 ? seg.slice(1) : "*"
1485
+ if (node.wildcard === null) {
1486
+ node.wildcard = { handlers: [], name }
1487
+ }
1488
+ node.wildcard.handlers.push({ handler: hName, method: route.method })
1489
+ return
1490
+ }
1491
+ if (seg.startsWith(":")) {
1492
+ const name = seg.endsWith("?") ? seg.slice(1, -1) : seg.slice(1)
1493
+ if (node.dynamic === null) {
1494
+ node.dynamic = { child: createTreeBuild(), name }
1495
+ }
1496
+ if (i === segments.length - 1) {
1497
+ node.dynamic.child.handlers.push({
1498
+ handler: hName,
1499
+ method: route.method,
1500
+ })
1501
+ } else {
1502
+ node = node.dynamic.child
1503
+ }
1504
+ continue
1505
+ }
1506
+ if (node.children[seg] === undefined) {
1507
+ node.children[seg] = createTreeBuild()
1508
+ }
1509
+ if (i === segments.length - 1) {
1510
+ node.children[seg].handlers.push({
1511
+ handler: hName,
1512
+ method: route.method,
1513
+ })
1514
+ } else {
1515
+ node = node.children[seg]
1516
+ }
1517
+ }
1518
+
1519
+ if (segments.length === 0) {
1520
+ node.handlers.push({ handler: hName, method: route.method })
2091
1521
  }
2092
- return json
2093
1522
  }
2094
1523
 
2095
1524
  function collectMetaShape(collected: CollectedRoute[]): {
@@ -2125,50 +1554,103 @@ function emitMetaShapeType(shape: ReturnType<typeof collectMetaShape>): string {
2125
1554
  }
2126
1555
 
2127
1556
  function emitRouteTree(routes: RouteConfig[], collected: CollectedRoute[] | null): string {
1557
+ const intern = buildInternPool(routes)
2128
1558
  const metaShape = collected !== null ? collectMetaShape(collected) : null
2129
1559
  const hasMetaShape = metaShape !== null && metaShape.allKeys.size > 0
2130
- const selectors = routes.map(selectorOf)
2131
- const table = packRouteTable(routes)
2132
1560
  const lines: string[] = []
1561
+ lines.push('import type { TreeNode, RouteHandler, RouteTree } from "@lovrozagar/honey/tree"')
1562
+ lines.push("")
1563
+ lines.push("const E = Object.create(null) as Record<string, TreeNode>")
2133
1564
  lines.push(
2134
- 'import { assembleRouteTree, type PackedRouteTable, type RouteHandler, type RouteTree, type TreeNode } from "@lovrozagar/honey/tree"',
1565
+ 'function N(s: Record<string, TreeNode>, m: Record<string, RouteHandler> | null = null, d: TreeNode["d"] = null, w: { n: string; m: Record<string, RouteHandler> } | null = null): TreeNode {',
2135
1566
  )
1567
+ lines.push('\treturn { s, d, w: w as TreeNode["w"], m: m as TreeNode["m"], ws: null }')
1568
+ lines.push("}")
1569
+
1570
+ const rootBuild = createTreeBuild()
1571
+ for (let i = 0; i < routes.length; i++) {
1572
+ insertHandler(rootBuild, routes[i], `H${i}`)
1573
+ }
1574
+
1575
+ if (nodeHasStatic(rootBuild)) {
1576
+ lines.push("function S(o: Record<string, TreeNode>): Record<string, TreeNode> {")
1577
+ lines.push("\treturn Object.assign(Object.create(null), o) as Record<string, TreeNode>")
1578
+ lines.push("}")
1579
+ }
1580
+
1581
+ if (routes.length > 0) {
1582
+ lines.push('const FN = null as unknown as RouteHandler["fn"]')
1583
+ }
1584
+
1585
+ const consts = intern.emitConstLines()
1586
+ if (consts.length > 0) {
1587
+ lines.push("")
1588
+ for (const line of consts) {
1589
+ if (hasMetaShape && /^const M\d+ = /.test(line)) lines.push(`${line} as unknown as MetaShape`)
1590
+ else lines.push(line)
1591
+ }
1592
+ }
1593
+
1594
+ if (routes.length > 0) {
1595
+ lines.push("")
1596
+ /* unique handler per route — sharing H objects would cross-wire .routeTree() patches */
1597
+ for (let i = 0; i < routes.length; i++) {
1598
+ const route = routes[i]
1599
+ const bekExpr = route.boundaryErrorKey !== null ? intern.expr(route.boundaryErrorKey) : "null"
1600
+ const ekSet = route.errorKeys.length > 0 ? `new Set(${intern.expr(route.errorKeys)})` : "new Set()"
1601
+ const ivExpr = route.inputSchemas !== null ? intern.expr(route.inputSchemas) : "null"
1602
+ const osExpr = route.outputSchemas !== null ? intern.expr(route.outputSchemas) : "null"
1603
+ const mtExpr = route.meta !== null ? intern.expr(route.meta) : "null"
1604
+ lines.push(
1605
+ `const H${i}: RouteHandler = { bek: ${bekExpr}, fn: FN, mw: [], ek: ${ekSet}, iv: ${ivExpr}, os: ${osExpr}, mt: ${mtExpr} }`,
1606
+ )
1607
+ }
1608
+ }
1609
+
2136
1610
  lines.push("")
2137
- lines.push(`declare const P: readonly ${JSON.stringify(selectors)}`)
2138
- lines.push(`const D: PackedRouteTable = ${emitPackedLiteral(table)}`)
2139
- lines.push("export const routeTree: RouteTree = assembleRouteTree(D)")
2140
- lines.push("export const tree: TreeNode = routeTree.root")
2141
- lines.push("export const handlers: Record<string, RouteHandler> = routeTree.handlers ?? {}")
2142
- let code = `${lines.join("\n")}\n`
1611
+ lines.push(`export const tree: TreeNode = ${serializeNode(rootBuild)}`)
1612
+ lines.push("")
1613
+
1614
+ const handlerMapEntries = routes.map((route, index) => `\t${mapKeyExpr(intern, selectorOf(route))}: H${index}`)
1615
+ lines.push(`export const handlers: Record<string, RouteHandler> = {\n${handlerMapEntries.join(",\n")}\n}`)
1616
+ lines.push("")
1617
+
1618
+ let code = lines.join("\n")
2143
1619
  if (collected === null) return code
2144
1620
 
1621
+ const metaEntries: string[] = []
1622
+ for (const { handler, method, path } of collected) {
1623
+ if (handler.mt === null || Object.keys(handler.mt).length === 0) continue
1624
+ const selector = `${method} ${path}`
1625
+ metaEntries.push(`\t${mapKeyExpr(intern, selector)}: ${intern.expr(handler.mt)}`)
1626
+ }
1627
+
2145
1628
  if (hasMetaShape && metaShape !== null) {
2146
1629
  code += `\n${emitMetaShapeType(metaShape)}\n`
2147
1630
  }
2148
1631
 
2149
- const publicSelectors: string[] = []
1632
+ const selectors = new Set<string>()
2150
1633
  for (const { handler, method, path } of collected) {
2151
1634
  if (handler._skip || isMetaInternal(handler)) continue
2152
- publicSelectors.push(`${method} ${path}`)
1635
+ selectors.add(`${method} ${path}`)
2153
1636
  }
2154
- if (publicSelectors.length > 0) {
2155
- if (publicSelectors.length === selectors.length) {
2156
- code += "export type RouteSelector = typeof P[number]\n"
2157
- } else {
2158
- const parts = publicSelectors.map((s) => {
2159
- const idx = selectors.indexOf(s)
2160
- return idx >= 0 ? `typeof P[${idx}]` : JSON.stringify(s)
2161
- })
2162
- code += `export type RouteSelector = ${parts.join(" | ")}\n`
2163
- }
1637
+ if (selectors.size > 0) {
1638
+ const sorted = [...selectors].sort()
1639
+ const parts = sorted.map((s) => {
1640
+ const id = intern.id(s)
1641
+ return id !== undefined ? `typeof ${id}` : JSON.stringify(s)
1642
+ })
1643
+ code += `\nexport type RouteSelector = ${parts.join(" | ")}\n`
2164
1644
  }
2165
1645
 
2166
- const hasMeta = routes.some((r) => r.meta !== null && Object.keys(r.meta).length > 0)
2167
- if (hasMeta) {
1646
+ if (metaEntries.length > 0) {
2168
1647
  const metaType = hasMetaShape ? "Record<string, MetaShape>" : "Record<string, Record<string, unknown>>"
2169
- code += `export const meta: ${metaType} = routeTree.meta as ${metaType}\n`
1648
+ code += `export const meta: ${metaType} = {\n${metaEntries.join(",\n")}\n}\n`
2170
1649
  }
2171
1650
 
1651
+ const hasMetaExport = metaEntries.length > 0
1652
+ code += `export const routeTree: RouteTree = { root: tree, ${hasMetaExport ? "meta, " : "meta: {}, "}handlers }\n`
1653
+
2172
1654
  return code
2173
1655
  }
2174
1656