@lovrozagar/honey 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +51 -0
  2. package/docs/api-ref/go.md +399 -0
  3. package/docs/api-ref/python.md +399 -0
  4. package/docs/api-ref/rust.md +399 -0
  5. package/docs/api-ref/typescript.md +399 -0
  6. package/docs/invalidate-spec.md +303 -0
  7. package/docs/sdk.md +722 -0
  8. package/package.json +120 -0
  9. package/src/accepts.ts +78 -0
  10. package/src/body-limit.ts +100 -0
  11. package/src/build/index.ts +174 -0
  12. package/src/cli-go/auth.go +15 -0
  13. package/src/cli-go/config.go +165 -0
  14. package/src/cli-go/errors.go +57 -0
  15. package/src/cli-go/multipart.go +55 -0
  16. package/src/cli-go/output.go +109 -0
  17. package/src/cli-go/root.go +30 -0
  18. package/src/cli-go/stream.go +88 -0
  19. package/src/cli-go/version.go +5 -0
  20. package/src/cli.ts +204 -0
  21. package/src/client/error.ts +27 -0
  22. package/src/client/http.ts +502 -0
  23. package/src/client/index.ts +127 -0
  24. package/src/client/sdk.ts +265 -0
  25. package/src/client/sse.ts +102 -0
  26. package/src/client/types.ts +211 -0
  27. package/src/client/ws.ts +115 -0
  28. package/src/client-go/errors.go +207 -0
  29. package/src/client-go/invalidation.go +373 -0
  30. package/src/client-go/realtime.go +317 -0
  31. package/src/client-go/result.go +20 -0
  32. package/src/client-go/runtime.go +413 -0
  33. package/src/client-go/sse.go +149 -0
  34. package/src/client-go/transport.go +452 -0
  35. package/src/client-go/ws.go +104 -0
  36. package/src/client-mcp/README.md +10 -0
  37. package/src/client-mcp/server.ts +94 -0
  38. package/src/client-mcp/types.ts +19 -0
  39. package/src/client-python/_errors.py +115 -0
  40. package/src/client-python/_invalidation.py +578 -0
  41. package/src/client-python/_realtime.py +156 -0
  42. package/src/client-python/_runtime.py +469 -0
  43. package/src/client-python/_sse.py +99 -0
  44. package/src/client-python/_transport.py +335 -0
  45. package/src/client-python/_ws.py +131 -0
  46. package/src/client-python/py.typed +0 -0
  47. package/src/client-rust/errors.rs +200 -0
  48. package/src/client-rust/invalidation.rs +441 -0
  49. package/src/client-rust/invalidation_sync.rs +306 -0
  50. package/src/client-rust/realtime.rs +695 -0
  51. package/src/client-rust/result.rs +22 -0
  52. package/src/client-rust/runtime.rs +547 -0
  53. package/src/client-rust/runtime_sync.rs +452 -0
  54. package/src/client-rust/sse.rs +167 -0
  55. package/src/client-rust/ws.rs +143 -0
  56. package/src/cloudflare/realtime.ts +236 -0
  57. package/src/codegen-go-cli.ts +1180 -0
  58. package/src/codegen-go.ts +1010 -0
  59. package/src/codegen-hash.ts +8 -0
  60. package/src/codegen-ir.ts +517 -0
  61. package/src/codegen-loaders.ts +53 -0
  62. package/src/codegen-mcp.ts +364 -0
  63. package/src/codegen-python.ts +1028 -0
  64. package/src/codegen-rust.ts +1652 -0
  65. package/src/codegen-sanitize.ts +67 -0
  66. package/src/codegen-schema-naming.ts +146 -0
  67. package/src/codegen.ts +5015 -0
  68. package/src/context.ts +210 -0
  69. package/src/cookie-sign.ts +58 -0
  70. package/src/cookie.ts +89 -0
  71. package/src/cors.ts +96 -0
  72. package/src/crypto.ts +19 -0
  73. package/src/csrf.ts +54 -0
  74. package/src/curl-logger.ts +235 -0
  75. package/src/detect-runtime.ts +19 -0
  76. package/src/error.ts +52 -0
  77. package/src/errors.ts +87 -0
  78. package/src/etag.ts +74 -0
  79. package/src/feature-load.ts +23 -0
  80. package/src/go-type-emitter.ts +404 -0
  81. package/src/honey-response.ts +354 -0
  82. package/src/i18n-slot.ts +20 -0
  83. package/src/i18n.ts +234 -0
  84. package/src/index.ts +3843 -0
  85. package/src/init.ts +149 -0
  86. package/src/input.ts +8 -0
  87. package/src/ip-restrict.ts +192 -0
  88. package/src/logger.ts +149 -0
  89. package/src/middleware.ts +199 -0
  90. package/src/node-request.ts +240 -0
  91. package/src/node.ts +212 -0
  92. package/src/openapi/register.ts +19 -0
  93. package/src/openapi/scalar.ts +33 -0
  94. package/src/openapi/spec-factory.ts +46 -0
  95. package/src/openapi/spec.ts +31 -0
  96. package/src/openapi/swagger.ts +34 -0
  97. package/src/plugin.ts +771 -0
  98. package/src/powered-by.ts +17 -0
  99. package/src/pretty-json.ts +39 -0
  100. package/src/proxy.ts +155 -0
  101. package/src/python-type-emitter.ts +144 -0
  102. package/src/realtime/buffer.ts +101 -0
  103. package/src/realtime/bus.ts +93 -0
  104. package/src/realtime/longpoll.ts +148 -0
  105. package/src/realtime/protocol.ts +128 -0
  106. package/src/realtime/route.ts +104 -0
  107. package/src/realtime/runtime.ts +430 -0
  108. package/src/request-id.ts +23 -0
  109. package/src/request-to-curl.ts +34 -0
  110. package/src/response.ts +414 -0
  111. package/src/rust-type-emitter.ts +412 -0
  112. package/src/secure-headers.ts +69 -0
  113. package/src/serve-register.ts +8 -0
  114. package/src/serve-slot.ts +20 -0
  115. package/src/serve.ts +141 -0
  116. package/src/server-timing.ts +66 -0
  117. package/src/static.ts +60 -0
  118. package/src/telemetry/otel.ts +139 -0
  119. package/src/testing.ts +114 -0
  120. package/src/timeout.ts +36 -0
  121. package/src/tree.ts +464 -0
  122. package/src/ts-type-emitter.ts +106 -0
  123. package/src/type-emitter.ts +524 -0
  124. package/src/type-extractor.ts +749 -0
  125. package/src/types.ts +638 -0
  126. package/src/validation.ts +300 -0
  127. package/src/ws/bun.ts +89 -0
  128. package/src/ws/cloudflare.ts +133 -0
  129. package/src/ws/deno.ts +109 -0
  130. package/src/ws/node.ts +161 -0
  131. package/src/yaml.ts +80 -0
package/src/tree.ts ADDED
@@ -0,0 +1,464 @@
1
+ import type { HttpMethod, InputSchemasDef, OutputSchemaDef } from "./types.ts"
2
+ import type { WSHandler } from "./ws/cloudflare.ts"
3
+
4
+ export type { HttpMethod }
5
+
6
+ export type OutputValidator = (statusKey: string, data: unknown) => Promise<void>
7
+
8
+ export type RouteHandler = {
9
+ /** cached compiled middleware chain (set lazily at first request) */
10
+ _compiled?: (ctx: object) => Response | Promise<Response>
11
+ /** internal route — excluded from codegen output */
12
+ _skip?: boolean
13
+ /** boundary error key — wraps undeclared/unexpected errors (null = use default or internal_server_error) */
14
+ bek: string | null
15
+ /** pre-computed error factory subset (null = use global factory) */
16
+ ef: Record<string, (...args: never[]) => unknown> | null
17
+ /** declared error keys for this route — used for runtime enforcement */
18
+ ek: Set<string>
19
+ /** handler function */
20
+ fn: (ctx: unknown) => Response | Promise<Response>
21
+ /** input validation schemas (null = no input validation) */
22
+ iv: InputSchemasDef | null
23
+ /** route metadata — frozen object, accessible via ctx.meta */
24
+ mt: Record<string, unknown> | null
25
+ /** middleware chain — same as RuntimeMiddleware, inlined to avoid circular dep */
26
+ mw: Array<
27
+ (
28
+ ctx: Record<string, unknown>,
29
+ next: (additions?: Record<string, unknown>) => Promise<Response>,
30
+ ) => Promise<Response>
31
+ >
32
+ /** output schemas by content-type (null = no output validation) */
33
+ os: OutputSchemaDef | null
34
+ /** output validator function — validates response body against schema */
35
+ ov: OutputValidator | null
36
+ /** registered path pattern (e.g. "/users/:id") */
37
+ rp: string
38
+ }
39
+
40
+ export type { OutputSchemaDef }
41
+
42
+ export type WSRouteHandler = {
43
+ bek: string | null
44
+ ek: Set<string>
45
+ fn: WSHandler<unknown>
46
+ iv: InputSchemasDef | null
47
+ mt: Record<string, unknown> | null
48
+ mw: Array<
49
+ (
50
+ ctx: Record<string, unknown>,
51
+ next: (additions?: Record<string, unknown>) => Promise<Response>,
52
+ ) => Promise<Response>
53
+ >
54
+ /** registered ws path pattern — used by scoped-mw prefix filter */
55
+ rp: string
56
+ }
57
+
58
+ export type TreeNode = {
59
+ /** dynamic param child — { n: param name, c: child subtree } */
60
+ d: { c: TreeNode; n: string } | null
61
+ /** route-graph generation — bumped on insert/merge so served specs can invalidate */
62
+ g?: number
63
+ /** method handlers — maps HTTP method (or ALL) to route handler */
64
+ m: Record<HttpMethod | "ALL", RouteHandler> | null
65
+ /** static children — maps literal path segment to child subtree */
66
+ s: Record<string, TreeNode>
67
+ /** wildcard handler — { n: wildcard name, m: method→handler map } */
68
+ w: { m: Record<HttpMethod | "ALL", RouteHandler>; n: string } | null
69
+ /** websocket handler for this path */
70
+ ws: WSRouteHandler | null
71
+ }
72
+
73
+ export function bumpGeneration(root: TreeNode): number {
74
+ const next = (root.g ?? 0) + 1
75
+ root.g = next
76
+ return next
77
+ }
78
+
79
+ export type MatchResult =
80
+ | { allowed: HttpMethod[]; matched: false }
81
+ | { handler: RouteHandler; matched: true; params: Record<string, string> }
82
+ | null
83
+
84
+ export interface RouteMeta {}
85
+
86
+ export type RouteTree = {
87
+ handlers?: Record<string, RouteHandler>
88
+ meta: Record<string, RouteMeta>
89
+ root: TreeNode
90
+ }
91
+
92
+ function decode(s: string): string {
93
+ if (s.indexOf("%") === -1) return s
94
+ try {
95
+ return decodeURIComponent(s)
96
+ } catch {
97
+ return s
98
+ }
99
+ }
100
+
101
+ export function createNode(): TreeNode {
102
+ return {
103
+ d: null,
104
+ m: null,
105
+ s: Object.create(null) as Record<string, TreeNode>,
106
+ w: null,
107
+ ws: null,
108
+ }
109
+ }
110
+
111
+ function splitSegments(path: string): string[] {
112
+ return path.split("/").filter((s) => s.length > 0)
113
+ }
114
+
115
+ export function insertRoute(
116
+ root: TreeNode,
117
+ method: HttpMethod | "ALL",
118
+ path: string,
119
+ handler: RouteHandler,
120
+ ): void {
121
+ bumpGeneration(root)
122
+ const segments = splitSegments(path)
123
+ let node = root
124
+
125
+ for (const seg of segments) {
126
+
127
+ if (seg.startsWith("*")) {
128
+ const name = seg.length > 1 ? seg.slice(1) : "*"
129
+ if (node.w !== null && node.w.n !== name) {
130
+ throw new Error(`Wildcard name conflict: "${node.w.n}" vs "${name}"`)
131
+ }
132
+ if (node.w === null) {
133
+ node.w = {
134
+ m: Object.create(null) as Record<HttpMethod | "ALL", RouteHandler>,
135
+ n: name,
136
+ }
137
+ }
138
+ if (method in node.w.m) {
139
+ throw new Error(`Duplicate route: ${method} ${path}`)
140
+ }
141
+ node.w.m[method] = handler
142
+ return
143
+ }
144
+
145
+ if (seg.startsWith(":")) {
146
+ const name = seg.endsWith("?") ? seg.slice(1, -1) : seg.slice(1)
147
+ if (node.d !== null && node.d.n !== name) {
148
+ throw new Error(
149
+ `Route "${path}": param name conflict — expected ":${node.d.n}" but got ":${name}"`,
150
+ )
151
+ }
152
+ if (node.d === null) {
153
+ node.d = { c: createNode(), n: name }
154
+ }
155
+
156
+ if (seg.endsWith("?")) {
157
+ /* optional param — register handler on parent too */
158
+ if (node.m === null) {
159
+ node.m = Object.create(null) as Record<HttpMethod | "ALL", RouteHandler>
160
+ }
161
+ if (method in node.m) {
162
+ throw new Error(`Duplicate route: ${method} ${path}`)
163
+ }
164
+ node.m[method] = handler
165
+ }
166
+
167
+ node = node.d.c
168
+ continue
169
+ }
170
+
171
+ /* static segment */
172
+ const staticChild = node.s[seg]
173
+ if (staticChild !== undefined) {
174
+ node = staticChild
175
+ continue
176
+ }
177
+ const nextNode = createNode()
178
+ node.s[seg] = nextNode
179
+ node = nextNode
180
+ }
181
+
182
+ /* terminal node */
183
+ if (node.m === null) {
184
+ node.m = Object.create(null) as Record<HttpMethod | "ALL", RouteHandler>
185
+ }
186
+ if (method in node.m) {
187
+ throw new Error(`Duplicate route: ${method} ${path}`)
188
+ }
189
+ node.m[method] = handler
190
+ }
191
+
192
+ function resolveMethod(
193
+ handlers: Record<HttpMethod | "ALL", RouteHandler>,
194
+ method: HttpMethod,
195
+ params: Record<string, string>,
196
+ ): MatchResult {
197
+ if (method in handlers) {
198
+ return { handler: handlers[method], matched: true, params }
199
+ }
200
+ /* HEAD falls back to GET handler */
201
+ if (method === "HEAD" && "GET" in handlers) {
202
+ return { handler: handlers["GET"], matched: true, params }
203
+ }
204
+ if ("ALL" in handlers) {
205
+ return { handler: handlers["ALL"], matched: true, params }
206
+ }
207
+ const allowed = Object.keys(handlers).filter((k) => k !== "ALL") as HttpMethod[]
208
+ return { allowed, matched: false }
209
+ }
210
+
211
+ export function matchRoute(root: TreeNode, method: HttpMethod, path: string): MatchResult {
212
+ let node = root
213
+ const params: Record<string, string> = Object.create(null)
214
+ const len = path.length
215
+
216
+ /* skip leading slash */
217
+ let pos = path.charCodeAt(0) === 47 ? 1 : 0
218
+
219
+ while (pos < len) {
220
+ /* find next slash or end */
221
+ let end = pos
222
+ while (end < len && path.charCodeAt(end) !== 47) end++
223
+ if (end === pos) {
224
+ pos++
225
+ continue
226
+ }
227
+ const seg = path.substring(pos, end)
228
+ pos = end + 1
229
+
230
+ /* 1. static child */
231
+ const staticChild = node.s[seg]
232
+ if (staticChild !== undefined) {
233
+ node = staticChild
234
+ continue
235
+ }
236
+
237
+ /* 2. dynamic param */
238
+ if (node.d !== null) {
239
+ params[node.d.n] = decode(seg)
240
+ node = node.d.c
241
+ continue
242
+ }
243
+
244
+ /* 3. wildcard — consumes remaining */
245
+ if (node.w !== null) {
246
+ params[node.w.n] = decode(pos <= len ? `${seg}/${path.substring(pos)}` : seg)
247
+ return resolveMethod(node.w.m, method, params)
248
+ }
249
+
250
+ return null
251
+ }
252
+
253
+ /* terminal node */
254
+ if (node.m !== null) {
255
+ return resolveMethod(node.m, method, params)
256
+ }
257
+
258
+ /* check wildcard for empty remainder */
259
+ if (node.w !== null) {
260
+ params[node.w.n] = ""
261
+ return resolveMethod(node.w.m, method, params)
262
+ }
263
+
264
+ return null
265
+ }
266
+
267
+ export function insertWsRoute(root: TreeNode, path: string, handler: WSRouteHandler): void {
268
+ bumpGeneration(root)
269
+ const segments = splitSegments(path)
270
+ let node = root
271
+
272
+ for (const seg of segments) {
273
+
274
+ if (seg.startsWith("*")) {
275
+ throw new Error("Wildcard segments not supported for WebSocket routes")
276
+ }
277
+
278
+ if (seg.startsWith(":")) {
279
+ const name = seg.slice(1)
280
+ if (node.d !== null && node.d.n !== name) {
281
+ throw new Error(
282
+ `Route "${path}": param name conflict — expected ":${node.d.n}" but got ":${name}"`,
283
+ )
284
+ }
285
+ if (node.d === null) {
286
+ node.d = { c: createNode(), n: name }
287
+ }
288
+ node = node.d.c
289
+ continue
290
+ }
291
+
292
+ const staticChild = node.s[seg]
293
+ if (staticChild !== undefined) {
294
+ node = staticChild
295
+ continue
296
+ }
297
+ const nextNode = createNode()
298
+ node.s[seg] = nextNode
299
+ node = nextNode
300
+ }
301
+
302
+ if (node.ws !== null) {
303
+ throw new Error(`Duplicate WebSocket route: ${path}`)
304
+ }
305
+ node.ws = handler
306
+ }
307
+
308
+ export function matchWsRoute(
309
+ root: TreeNode,
310
+ path: string,
311
+ ): { handler: WSRouteHandler; params: Record<string, string> } | null {
312
+ let node = root
313
+ const params: Record<string, string> = Object.create(null)
314
+ const len = path.length
315
+
316
+ let pos = path.charCodeAt(0) === 47 ? 1 : 0
317
+
318
+ while (pos < len) {
319
+ let end = pos
320
+ while (end < len && path.charCodeAt(end) !== 47) end++
321
+ if (end === pos) {
322
+ pos++
323
+ continue
324
+ }
325
+ const seg = path.substring(pos, end)
326
+ pos = end + 1
327
+
328
+ const staticChild = node.s[seg]
329
+ if (staticChild !== undefined) {
330
+ node = staticChild
331
+ continue
332
+ }
333
+
334
+ if (node.d !== null) {
335
+ params[node.d.n] = decode(seg)
336
+ node = node.d.c
337
+ continue
338
+ }
339
+
340
+ return null
341
+ }
342
+
343
+ if (node.ws !== null) {
344
+ return { handler: node.ws, params }
345
+ }
346
+
347
+ return null
348
+ }
349
+
350
+ function mergeNodes(target: TreeNode, source: TreeNode, path: string): void {
351
+ /* merge method handlers */
352
+ if (source.m !== null && source.m !== undefined) {
353
+ if (target.m === null || target.m === undefined) {
354
+ target.m = Object.create(null) as Record<HttpMethod | "ALL", RouteHandler>
355
+ }
356
+ for (const [method, handler] of Object.entries(source.m)) {
357
+ const prev = target.m[method as HttpMethod | "ALL"]
358
+ if (prev !== undefined) {
359
+ if (prev._skip && handler._skip) continue
360
+ throw new Error(`Merge conflict: duplicate ${method} ${path}`)
361
+ }
362
+ target.m[method as HttpMethod | "ALL"] = handler
363
+ }
364
+ }
365
+
366
+ /* merge static children — shallow-copy s to avoid mutating shared empty objects */
367
+ if (source.s && Object.keys(source.s).length > 0) {
368
+ let detached = false
369
+ for (const [seg, child] of Object.entries(source.s)) {
370
+ const targetChild = target.s[seg]
371
+ if (targetChild !== undefined) {
372
+ mergeNodes(targetChild, child, `${path}/${seg}`)
373
+ } else {
374
+ if (!detached) {
375
+ target.s = Object.assign(Object.create(null) as Record<string, TreeNode>, target.s)
376
+ detached = true
377
+ }
378
+ target.s[seg] = child
379
+ }
380
+ }
381
+ }
382
+
383
+ /* merge dynamic param */
384
+ if (source.d !== null && source.d !== undefined) {
385
+ if (target.d !== null && target.d !== undefined) {
386
+ if (target.d.n !== source.d.n) {
387
+ throw new Error(
388
+ `Merge conflict: param name mismatch at ${path}: "${target.d.n}" vs "${source.d.n}"`,
389
+ )
390
+ }
391
+ mergeNodes(target.d.c, source.d.c, `${path}/:${target.d.n}`)
392
+ } else {
393
+ target.d = source.d
394
+ }
395
+ }
396
+
397
+ /* merge wildcard */
398
+ if (source.w !== null && source.w !== undefined) {
399
+ if (target.w !== null && target.w !== undefined) {
400
+ if (target.w.n !== source.w.n) {
401
+ throw new Error(
402
+ `Merge conflict: wildcard name mismatch at ${path}: "${target.w.n}" vs "${source.w.n}"`,
403
+ )
404
+ }
405
+ for (const [method, handler] of Object.entries(source.w.m)) {
406
+ if (method in target.w.m) {
407
+ throw new Error(`Merge conflict: duplicate ${method} ${path}/*${target.w.n}`)
408
+ }
409
+ target.w.m[method as HttpMethod | "ALL"] = handler
410
+ }
411
+ } else {
412
+ target.w = source.w
413
+ }
414
+ }
415
+
416
+ /* merge websocket */
417
+ if (source.ws !== null && source.ws !== undefined) {
418
+ if (target.ws !== null && target.ws !== undefined) {
419
+ throw new Error(`Merge conflict: duplicate WebSocket handler at ${path}`)
420
+ }
421
+ target.ws = source.ws
422
+ }
423
+ }
424
+
425
+ /** Merge source tree nodes into target tree (mutates target) */
426
+ export function mergeInto(target: TreeNode, source: TreeNode): void {
427
+ bumpGeneration(target)
428
+ mergeNodes(target, source, "")
429
+ }
430
+
431
+ type TreeInput = RouteTree | [RouteTree, Record<string, unknown>]
432
+
433
+ function tagHandlerMeta(node: TreeNode, extra: Record<string, unknown>): void {
434
+ if (node.m !== null) {
435
+ for (const handler of Object.values(node.m) as RouteHandler[]) {
436
+ handler.mt = handler.mt ? { ...handler.mt, ...extra } : { ...extra }
437
+ }
438
+ }
439
+ for (const child of Object.values(node.s)) {
440
+ tagHandlerMeta(child, extra)
441
+ }
442
+ if (node.d) tagHandlerMeta(node.d.c, extra)
443
+ if (node.w !== null) {
444
+ for (const handler of Object.values(node.w.m) as RouteHandler[]) {
445
+ handler.mt = handler.mt ? { ...handler.mt, ...extra } : { ...extra }
446
+ }
447
+ }
448
+ }
449
+
450
+ export function mergeTree(...trees: TreeInput[]): RouteTree {
451
+ const merged: RouteTree = {
452
+ meta: {},
453
+ root: createNode(),
454
+ }
455
+
456
+ for (const entry of trees) {
457
+ const [tree, extra] = Array.isArray(entry) ? entry : [entry, undefined]
458
+ if (extra) tagHandlerMeta(tree.root, extra)
459
+ mergeNodes(merged.root, tree.root, "")
460
+ Object.assign(merged.meta, tree.meta)
461
+ }
462
+
463
+ return merged
464
+ }
@@ -0,0 +1,106 @@
1
+ /** JSON Schema IR → TypeScript type string emitter. Mirrors jsonSchemaToTS logic case-for-case. */
2
+
3
+ import type { IRSchema } from "./codegen-ir.ts"
4
+
5
+ /* TS keywords that are valid identifiers but require quoting as object property keys */
6
+ const TS_RESERVED = new Set([
7
+ "break", "case", "catch", "class", "const", "continue", "debugger",
8
+ "default", "delete", "do", "else", "enum", "export", "extends", "false",
9
+ "finally", "for", "function", "if", "import", "in", "instanceof", "new",
10
+ "null", "return", "super", "switch", "this", "throw", "true", "try",
11
+ "typeof", "var", "void", "while", "with", "yield", "let", "static",
12
+ "implements", "interface", "package", "private", "protected", "public",
13
+ "abstract", "as", "async", "await", "constructor", "declare", "from",
14
+ "get", "infer", "is", "keyof", "module", "namespace", "never", "of",
15
+ "readonly", "require", "set", "satisfies", "symbol", "type", "unique",
16
+ "unknown", "override",
17
+ ])
18
+
19
+ export function irToTs(ir: IRSchema, depth = 0): string {
20
+ if (depth > 8) return "unknown"
21
+
22
+ switch (ir.kind) {
23
+ case "scalar":
24
+ return emitScalar(ir)
25
+
26
+ case "const":
27
+ return typeof ir.value === "string" ? JSON.stringify(ir.value) : String(ir.value)
28
+
29
+ case "object":
30
+ return emitObject(ir, depth)
31
+
32
+ case "array": {
33
+ const el = irToTs(ir.items, depth + 1)
34
+ return el.includes("|") || el.includes("&") ? `(${el})[]` : `${el}[]`
35
+ }
36
+
37
+ case "tuple":
38
+ return `[${ir.items.map((i) => irToTs(i, depth + 1)).join(", ")}]`
39
+
40
+ case "union":
41
+ return ir.variants.map((v) => irToTs(v, depth + 1)).join(" | ")
42
+
43
+ case "allOf":
44
+ return ir.parts.map((p) => irToTs(p, depth + 1)).join(" & ")
45
+
46
+ case "ref":
47
+ return ir.name
48
+
49
+ case "nullable": {
50
+ const inner = irToTs(ir.inner, depth + 1)
51
+ return `${inner} | null`
52
+ }
53
+
54
+ /*
55
+ * binary kind arises from {type: "string", format: "binary"}.
56
+ * jsonSchemaToTS falls through to effectiveType "string" → returns "string".
57
+ * We must match that behavior for byte-equivalence.
58
+ */
59
+ case "binary":
60
+ return "string"
61
+
62
+ case "unknown":
63
+ return "unknown"
64
+ }
65
+ }
66
+
67
+ function emitScalar(ir: Extract<IRSchema, { kind: "scalar" }>): string {
68
+ if (ir.enum) {
69
+ return ir.enum
70
+ .map((v) => (typeof v === "string" ? JSON.stringify(v) : String(v)))
71
+ .join(" | ")
72
+ }
73
+
74
+ if (ir.type === "string") return "string"
75
+ if (ir.type === "number" || ir.type === "integer") return "number"
76
+ if (ir.type === "boolean") return "boolean"
77
+ if (ir.type === "null") return "null"
78
+ return "unknown"
79
+ }
80
+
81
+ function emitObject(ir: Extract<IRSchema, { kind: "object" }>, depth: number): string {
82
+ const { fields, additional } = ir
83
+
84
+ if (fields.length === 0) {
85
+ if (additional) {
86
+ return `Record<string, ${irToTs(additional, depth + 1)}>`
87
+ }
88
+ return "Record<string, unknown>"
89
+ }
90
+
91
+ const sortedFields = fields.slice().sort((a, b) => a.name.localeCompare(b.name))
92
+
93
+ const entries = sortedFields.map((field) => {
94
+ const opt = field.required ? "" : "?"
95
+ const needsQuote =
96
+ !/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(field.name) || TS_RESERVED.has(field.name)
97
+ const key = needsQuote ? JSON.stringify(field.name) : field.name
98
+ return `${key}${opt}: ${irToTs(field.schema, depth + 1)}`
99
+ })
100
+
101
+ if (additional) {
102
+ entries.push(`[k: string]: ${irToTs(additional, depth + 1)}`)
103
+ }
104
+
105
+ return `{ ${entries.join("; ")} }`
106
+ }