@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.
- package/README.md +51 -0
- package/docs/api-ref/go.md +399 -0
- package/docs/api-ref/python.md +399 -0
- package/docs/api-ref/rust.md +399 -0
- package/docs/api-ref/typescript.md +399 -0
- package/docs/invalidate-spec.md +303 -0
- package/docs/sdk.md +722 -0
- package/package.json +120 -0
- package/src/accepts.ts +78 -0
- package/src/body-limit.ts +100 -0
- package/src/build/index.ts +174 -0
- package/src/cli-go/auth.go +15 -0
- package/src/cli-go/config.go +165 -0
- package/src/cli-go/errors.go +57 -0
- package/src/cli-go/multipart.go +55 -0
- package/src/cli-go/output.go +109 -0
- package/src/cli-go/root.go +30 -0
- package/src/cli-go/stream.go +88 -0
- package/src/cli-go/version.go +5 -0
- package/src/cli.ts +204 -0
- package/src/client/error.ts +27 -0
- package/src/client/http.ts +502 -0
- package/src/client/index.ts +127 -0
- package/src/client/sdk.ts +265 -0
- package/src/client/sse.ts +102 -0
- package/src/client/types.ts +211 -0
- package/src/client/ws.ts +115 -0
- package/src/client-go/errors.go +207 -0
- package/src/client-go/invalidation.go +373 -0
- package/src/client-go/realtime.go +317 -0
- package/src/client-go/result.go +20 -0
- package/src/client-go/runtime.go +413 -0
- package/src/client-go/sse.go +149 -0
- package/src/client-go/transport.go +452 -0
- package/src/client-go/ws.go +104 -0
- package/src/client-mcp/README.md +10 -0
- package/src/client-mcp/server.ts +94 -0
- package/src/client-mcp/types.ts +19 -0
- package/src/client-python/_errors.py +115 -0
- package/src/client-python/_invalidation.py +578 -0
- package/src/client-python/_realtime.py +156 -0
- package/src/client-python/_runtime.py +469 -0
- package/src/client-python/_sse.py +99 -0
- package/src/client-python/_transport.py +335 -0
- package/src/client-python/_ws.py +131 -0
- package/src/client-python/py.typed +0 -0
- package/src/client-rust/errors.rs +200 -0
- package/src/client-rust/invalidation.rs +441 -0
- package/src/client-rust/invalidation_sync.rs +306 -0
- package/src/client-rust/realtime.rs +695 -0
- package/src/client-rust/result.rs +22 -0
- package/src/client-rust/runtime.rs +547 -0
- package/src/client-rust/runtime_sync.rs +452 -0
- package/src/client-rust/sse.rs +167 -0
- package/src/client-rust/ws.rs +143 -0
- package/src/cloudflare/realtime.ts +236 -0
- package/src/codegen-go-cli.ts +1180 -0
- package/src/codegen-go.ts +1010 -0
- package/src/codegen-hash.ts +8 -0
- package/src/codegen-ir.ts +517 -0
- package/src/codegen-loaders.ts +53 -0
- package/src/codegen-mcp.ts +364 -0
- package/src/codegen-python.ts +1028 -0
- package/src/codegen-rust.ts +1652 -0
- package/src/codegen-sanitize.ts +67 -0
- package/src/codegen-schema-naming.ts +146 -0
- package/src/codegen.ts +5015 -0
- package/src/context.ts +210 -0
- package/src/cookie-sign.ts +58 -0
- package/src/cookie.ts +89 -0
- package/src/cors.ts +96 -0
- package/src/crypto.ts +19 -0
- package/src/csrf.ts +54 -0
- package/src/curl-logger.ts +235 -0
- package/src/detect-runtime.ts +19 -0
- package/src/error.ts +52 -0
- package/src/errors.ts +87 -0
- package/src/etag.ts +74 -0
- package/src/feature-load.ts +23 -0
- package/src/go-type-emitter.ts +404 -0
- package/src/honey-response.ts +354 -0
- package/src/i18n-slot.ts +20 -0
- package/src/i18n.ts +234 -0
- package/src/index.ts +3843 -0
- package/src/init.ts +149 -0
- package/src/input.ts +8 -0
- package/src/ip-restrict.ts +192 -0
- package/src/logger.ts +149 -0
- package/src/middleware.ts +199 -0
- package/src/node-request.ts +240 -0
- package/src/node.ts +212 -0
- package/src/openapi/register.ts +19 -0
- package/src/openapi/scalar.ts +33 -0
- package/src/openapi/spec-factory.ts +46 -0
- package/src/openapi/spec.ts +31 -0
- package/src/openapi/swagger.ts +34 -0
- package/src/plugin.ts +771 -0
- package/src/powered-by.ts +17 -0
- package/src/pretty-json.ts +39 -0
- package/src/proxy.ts +155 -0
- package/src/python-type-emitter.ts +144 -0
- package/src/realtime/buffer.ts +101 -0
- package/src/realtime/bus.ts +93 -0
- package/src/realtime/longpoll.ts +148 -0
- package/src/realtime/protocol.ts +128 -0
- package/src/realtime/route.ts +104 -0
- package/src/realtime/runtime.ts +430 -0
- package/src/request-id.ts +23 -0
- package/src/request-to-curl.ts +34 -0
- package/src/response.ts +414 -0
- package/src/rust-type-emitter.ts +412 -0
- package/src/secure-headers.ts +69 -0
- package/src/serve-register.ts +8 -0
- package/src/serve-slot.ts +20 -0
- package/src/serve.ts +141 -0
- package/src/server-timing.ts +66 -0
- package/src/static.ts +60 -0
- package/src/telemetry/otel.ts +139 -0
- package/src/testing.ts +114 -0
- package/src/timeout.ts +36 -0
- package/src/tree.ts +464 -0
- package/src/ts-type-emitter.ts +106 -0
- package/src/type-emitter.ts +524 -0
- package/src/type-extractor.ts +749 -0
- package/src/types.ts +638 -0
- package/src/validation.ts +300 -0
- package/src/ws/bun.ts +89 -0
- package/src/ws/cloudflare.ts +133 -0
- package/src/ws/deno.ts +109 -0
- package/src/ws/node.ts +161 -0
- 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
|
+
}
|