bxo 0.0.5-dev.8 → 0.0.5-dev.80

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/index.ts ADDED
@@ -0,0 +1,960 @@
1
+ import { z } from "zod";
2
+
3
+ type Method =
4
+ | "GET"
5
+ | "POST"
6
+ | "PUT"
7
+ | "PATCH"
8
+ | "DELETE"
9
+ | "OPTIONS"
10
+ | "HEAD"
11
+ | "WS";
12
+
13
+ type PickWildcardName<S extends string> = S extends "" ? "wildcard" : S;
14
+
15
+ type PathParams<S extends string> =
16
+ S extends `${string}*${infer W}`
17
+ ? { [K in PickWildcardName<W>]: string }
18
+ : S extends `${string}:${infer P}/${infer R}`
19
+ ? ({ [K in P]: string } & PathParams<`/${R}`>)
20
+ : S extends `${string}:${infer P}`
21
+ ? { [K in P]: string }
22
+ : {};
23
+
24
+ type InferOr<T, Fallback> = T extends z.ZodTypeAny ? z.infer<T> : Fallback;
25
+
26
+ type ResponseSchemas = Record<number, z.ZodTypeAny>;
27
+
28
+ type InferResponse<S extends RouteSchema | undefined, Status extends number> =
29
+ S extends RouteSchema
30
+ ? S["response"] extends Record<Status, z.ZodTypeAny>
31
+ ? z.infer<S["response"][Status]>
32
+ : unknown
33
+ : unknown;
34
+
35
+ export type RouteSchema = {
36
+ headers?: z.ZodTypeAny;
37
+ query?: z.ZodTypeAny;
38
+ cookies?: z.ZodTypeAny;
39
+ body?: z.ZodTypeAny;
40
+ response?: ResponseSchemas;
41
+ detail?: {
42
+ defaultContentType?: "multipart/form-data" | "application/json" | "application/x-www-form-urlencoded" | "text/plain" | "application/octet-stream";
43
+ description?: string;
44
+ hidden?: boolean;
45
+ summary?: string;
46
+ tags?: string[];
47
+ [k: string]: any;
48
+ }
49
+ };
50
+
51
+ type QueryObject = Record<string, string | string[]>;
52
+ type CookieObject = Record<string, string>;
53
+ type HeaderObject = Record<string, string>;
54
+
55
+ export type CookieOptions = {
56
+ domain?: string;
57
+ expires?: Date;
58
+ httpOnly?: boolean;
59
+ maxAge?: number;
60
+ path?: string;
61
+ sameSite?: "strict" | "lax" | "none";
62
+ secure?: boolean;
63
+ };
64
+
65
+ // Lifecycle hook types
66
+ export type BeforeRequestHook = (req: Request, ctx?: Partial<Context<any, any>>) => Request | Response | Promise<Request | Response | void>;
67
+ export type AfterRequestHook = (req: Request, res: Response, ctx?: Partial<Context<any, any>>) => Response | Promise<Response | void>;
68
+ export type BeforeResponseHook = (res: Response, ctx?: Partial<Context<any, any>>) => Response | Promise<Response | void>;
69
+ export type OnErrorHook = (error: Error, req: Request, ctx?: Partial<Context<any, any>>) => Response | Promise<Response | void>;
70
+
71
+ export type Context<P extends string = string, S extends RouteSchema | undefined = undefined> = {
72
+ request: Request;
73
+ params: PathParams<P>;
74
+ query: S extends RouteSchema ? InferOr<S["query"], QueryObject> : QueryObject;
75
+ headers: S extends RouteSchema ? InferOr<S["headers"], HeaderObject> : HeaderObject;
76
+ cookies: S extends RouteSchema ? InferOr<S["cookies"], CookieObject> : CookieObject;
77
+ body: S extends RouteSchema ? InferOr<S["body"], unknown> : unknown;
78
+ set: {
79
+ headers: Record<string, string | string[]>;
80
+ cookie: (name: string, value: string, options?: CookieOptions) => void;
81
+ };
82
+ json: <T>(data: T, status?: number) => Response;
83
+ text: (data: string, status?: number) => Response;
84
+ status: <T extends number>(status: T, data: InferResponse<S, T>) => Response;
85
+ };
86
+
87
+ type AnyHandler = (ctx: Context<any, any>, app: BXO) => Response | string | Promise<Response | string>;
88
+ type Handler<P extends string, S extends RouteSchema | undefined = undefined> = (
89
+ ctx: Context<P, S>,
90
+ app: BXO
91
+ ) => Response | string | Promise<Response | string>;
92
+
93
+ // WebSocket handler types
94
+ export type WebSocketHandler<T = any> = {
95
+ message?(ws: Bun.ServerWebSocket<T>, message: string | Buffer): void | Promise<void>;
96
+ open?(ws: Bun.ServerWebSocket<T>): void | Promise<void>;
97
+ close?(ws: Bun.ServerWebSocket<T>, code: number, reason: string): void | Promise<void>;
98
+ drain?(ws: Bun.ServerWebSocket<T>): void | Promise<void>;
99
+ ping?(ws: Bun.ServerWebSocket<T>, data: Buffer): void | Promise<void>;
100
+ pong?(ws: Bun.ServerWebSocket<T>, data: Buffer): void | Promise<void>;
101
+ };
102
+
103
+ type InternalRoute = {
104
+ method: Method | "DEFAULT";
105
+ path: string;
106
+ matcher: RegExp | null;
107
+ paramNames: string[];
108
+ schema?: RouteSchema;
109
+ handler: AnyHandler;
110
+ websocketHandler?: WebSocketHandler;
111
+ };
112
+
113
+ type ServeOptions = Partial<Parameters<typeof Bun.serve>[0]>;
114
+
115
+ function toHeaderObject(headers: Headers): HeaderObject {
116
+ const obj: HeaderObject = {};
117
+ headers.forEach((value, key) => {
118
+ obj[key.toLowerCase()] = value;
119
+ });
120
+ return obj;
121
+ }
122
+
123
+ function parseCookies(cookieHeader: string | null): CookieObject {
124
+ const out: CookieObject = {};
125
+ if (!cookieHeader) return out;
126
+ const parts = cookieHeader.split(";");
127
+ for (const part of parts) {
128
+ const [k, ...rest] = part.trim().split("=");
129
+ if (!k) continue;
130
+ out[k] = decodeURIComponent(rest.join("="));
131
+ }
132
+ return out;
133
+ }
134
+
135
+ function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
136
+ let cookie = `${name}=${encodeURIComponent(value)}`;
137
+
138
+ if (options.domain) {
139
+ cookie += `; Domain=${options.domain}`;
140
+ }
141
+
142
+ if (options.path) {
143
+ cookie += `; Path=${options.path}`;
144
+ } else {
145
+ cookie += `; Path=/`;
146
+ }
147
+
148
+ if (options.expires) {
149
+ cookie += `; Expires=${options.expires.toUTCString()}`;
150
+ }
151
+
152
+ if (options.maxAge !== undefined) {
153
+ cookie += `; Max-Age=${options.maxAge}`;
154
+ }
155
+
156
+ if (options.httpOnly) {
157
+ cookie += `; HttpOnly`;
158
+ }
159
+
160
+ if (options.secure) {
161
+ cookie += `; Secure`;
162
+ }
163
+
164
+ if (options.sameSite) {
165
+ cookie += `; SameSite=${options.sameSite}`;
166
+ }
167
+
168
+ return cookie;
169
+ }
170
+
171
+ function parseQuery(searchParams: URLSearchParams): QueryObject;
172
+ function parseQuery<T extends z.ZodTypeAny>(searchParams: URLSearchParams, schema: T): z.infer<T>;
173
+ function parseQuery(searchParams: URLSearchParams, schema?: z.ZodTypeAny): any {
174
+ const out: QueryObject = {};
175
+ for (const [k, v] of searchParams.entries()) {
176
+ if (k in out) {
177
+ const existing = out[k];
178
+ if (Array.isArray(existing)) out[k] = [...existing, v];
179
+ else out[k] = [existing as string, v];
180
+ } else out[k] = v;
181
+ }
182
+ if (schema) {
183
+ return (schema as any).parse ? (schema as any).parse(out) : out;
184
+ }
185
+ return out;
186
+ }
187
+
188
+ function formDataToObject(fd: FormData): Record<string, any> {
189
+ const obj: Record<string, any> = {};
190
+
191
+ for (const [key, value] of fd.entries()) {
192
+ setNestedValue(obj, key, value);
193
+ }
194
+
195
+ return obj;
196
+ }
197
+
198
+ function setNestedValue(obj: Record<string, any>, key: string, value: any): void {
199
+ // Handle array notation like items[0], items[1]
200
+ const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
201
+ if (arrayMatch) {
202
+ const [, arrayKey, index] = arrayMatch;
203
+ const arrayIndex = parseInt(index, 10);
204
+
205
+ if (!obj[arrayKey]) {
206
+ obj[arrayKey] = [];
207
+ }
208
+
209
+ // Ensure it's an array
210
+ if (!Array.isArray(obj[arrayKey])) {
211
+ obj[arrayKey] = [];
212
+ }
213
+
214
+ // Set the value at the specific index
215
+ obj[arrayKey][arrayIndex] = value;
216
+ return;
217
+ }
218
+
219
+ // Handle nested object notation like profile[name], profile[age]
220
+ const nestedMatch = key.match(/^(.+)\[([^\]]+)\]$/);
221
+ if (nestedMatch) {
222
+ const [, parentKey, nestedKey] = nestedMatch;
223
+
224
+ if (!obj[parentKey]) {
225
+ obj[parentKey] = {};
226
+ }
227
+
228
+ // Ensure it's an object
229
+ if (typeof obj[parentKey] !== 'object' || Array.isArray(obj[parentKey])) {
230
+ obj[parentKey] = {};
231
+ }
232
+
233
+ obj[parentKey][nestedKey] = value;
234
+ return;
235
+ }
236
+
237
+ // Handle simple keys - check for duplicates to convert to arrays
238
+ if (key in obj) {
239
+ const existing = obj[key];
240
+ if (Array.isArray(existing)) {
241
+ obj[key] = [...existing, value];
242
+ } else {
243
+ obj[key] = [existing, value];
244
+ }
245
+ } else {
246
+ obj[key] = value;
247
+ }
248
+ }
249
+
250
+ function buildMatcher(path: string): { regex: RegExp | null; names: string[] } {
251
+ if (!path.includes(":") && !path.includes("*")) return { regex: null, names: [] };
252
+ const names: string[] = [];
253
+ const pattern = path
254
+ .split("/")
255
+ .map((seg, idx, arr) => {
256
+ if (!seg) return "";
257
+ if (seg.startsWith(":")) {
258
+ names.push(seg.slice(1));
259
+ return "([^/]+)";
260
+ }
261
+ if (seg.startsWith("*")) {
262
+ const name = seg.slice(1) || "wildcard";
263
+ names.push(name);
264
+ // wildcard must be terminal
265
+ return idx === arr.length - 1 ? "(.*)" : "(.*)";
266
+ }
267
+ return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
268
+ })
269
+ .join("/");
270
+ const regex = new RegExp(`^${pattern}$`);
271
+ return { regex, names };
272
+ }
273
+
274
+ function mergeHeaders(base: HeadersInit | undefined, extra: Record<string, string | string[]>): Headers {
275
+ const h = new Headers(base);
276
+ for (const [k, v] of Object.entries(extra)) {
277
+ if (Array.isArray(v)) {
278
+ // For arrays (like Set-Cookie), append each value as a separate header
279
+ for (const value of v) {
280
+ h.append(k, value);
281
+ }
282
+ } else {
283
+ h.set(k, v);
284
+ }
285
+ }
286
+ return h;
287
+ }
288
+
289
+ function toResponse(body: unknown, init?: ResponseInit): Response {
290
+ if (body instanceof Response) return new Response(body.body, { headers: mergeHeaders(body.headers, {}), status: body.status, statusText: body.statusText });
291
+ if (
292
+ typeof body === "string" ||
293
+ body instanceof Uint8Array ||
294
+ body instanceof ArrayBuffer ||
295
+ body instanceof Blob ||
296
+ body instanceof ReadableStream
297
+ ) {
298
+ return new Response(body as BodyInit, init);
299
+ }
300
+ // Fallback: stringify unknown values
301
+ return new Response(String(body), init);
302
+ }
303
+
304
+ export default class BXO {
305
+ private routes: InternalRoute[] = [];
306
+ private serveOptions: ServeOptions;
307
+ public server?: ReturnType<typeof Bun.serve>;
308
+
309
+ // Lifecycle hooks
310
+ protected beforeRequestHooks: BeforeRequestHook[] = [];
311
+ protected afterRequestHooks: AfterRequestHook[] = [];
312
+ protected beforeResponseHooks: BeforeResponseHook[] = [];
313
+ protected onErrorHooks: OnErrorHook[] = [];
314
+
315
+ constructor(options?: { serve?: ServeOptions }) {
316
+ this.serveOptions = options?.serve ?? {};
317
+ }
318
+
319
+ getRoutes(): InternalRoute[] {
320
+ return this.routes;
321
+ }
322
+
323
+ getBXO(): this {
324
+ return this;
325
+ }
326
+
327
+ use(plugin: BXO): this {
328
+ // Merge routes from another BXO instance
329
+ this.routes.push(...plugin.routes);
330
+
331
+ // Merge lifecycle hooks from plugin
332
+ this.beforeRequestHooks.push(...plugin.beforeRequestHooks);
333
+ this.afterRequestHooks.push(...plugin.afterRequestHooks);
334
+ this.beforeResponseHooks.push(...plugin.beforeResponseHooks);
335
+ this.onErrorHooks.push(...plugin.onErrorHooks);
336
+
337
+ return this;
338
+ }
339
+
340
+ get<P extends string>(path: P, handler: Handler<P, undefined>): this;
341
+ get<P extends string, S extends RouteSchema>(path: P, handler: Handler<P, S>, schema: S): this;
342
+ get<P extends string, S extends RouteSchema | undefined>(path: P, handler: Handler<P, S>, schema?: S): this {
343
+ return this.add("GET", path, handler as AnyHandler, schema as RouteSchema | undefined);
344
+ }
345
+
346
+ post<P extends string>(path: P, handler: Handler<P, undefined>): this;
347
+ post<P extends string, S extends RouteSchema>(path: P, handler: Handler<P, S>, schema: S): this;
348
+ post<P extends string, S extends RouteSchema | undefined>(path: P, handler: Handler<P, S>, schema?: S): this {
349
+ return this.add("POST", path, handler as AnyHandler, schema as RouteSchema | undefined);
350
+ }
351
+
352
+ put<P extends string>(path: P, handler: Handler<P, undefined>): this;
353
+ put<P extends string, S extends RouteSchema>(path: P, handler: Handler<P, S>, schema: S): this;
354
+ put<P extends string, S extends RouteSchema | undefined>(path: P, handler: Handler<P, S>, schema?: S): this {
355
+ return this.add("PUT", path, handler as AnyHandler, schema as RouteSchema | undefined);
356
+ }
357
+
358
+ patch<P extends string>(path: P, handler: Handler<P, undefined>): this;
359
+ patch<P extends string, S extends RouteSchema>(path: P, handler: Handler<P, S>, schema: S): this;
360
+ patch<P extends string, S extends RouteSchema | undefined>(path: P, handler: Handler<P, S>, schema?: S): this {
361
+ return this.add("PATCH", path, handler as AnyHandler, schema as RouteSchema | undefined);
362
+ }
363
+
364
+ delete<P extends string>(path: P, handler: Handler<P, undefined>): this;
365
+ delete<P extends string, S extends RouteSchema>(path: P, handler: Handler<P, S>, schema: S): this;
366
+ delete<P extends string, S extends RouteSchema | undefined>(path: P, handler: Handler<P, S>, schema?: S): this {
367
+ return this.add("DELETE", path, handler as AnyHandler, schema as RouteSchema | undefined);
368
+ }
369
+
370
+ ws<P extends string>(path: P, handler: WebSocketHandler): this {
371
+ return this.addWebSocket("WS", path, handler);
372
+ }
373
+
374
+ // default can accept a handler OR static content (including Bun HTML bundle)
375
+ default<P extends string>(path: P, handler: Handler<P, undefined>): this;
376
+ default<P extends string, S extends RouteSchema>(path: P, handler: (req: Request) => Response, schema: S): this;
377
+ default<P extends string>(path: P, content: Bun.HTMLBundle): this;
378
+ default<P extends string, S extends RouteSchema | undefined>(path: P, arg2: unknown, schema?: S): this {
379
+ return this.add("DEFAULT", path, arg2 as AnyHandler, schema as RouteSchema | undefined);
380
+ }
381
+
382
+ start(): void {
383
+ // Check if we have any WebSocket routes
384
+ const hasWebSocketRoutes = this.routes.some(r => r.method === "WS");
385
+
386
+ // Build a basic routes map for Bun's native routes (exact paths only)
387
+ const nativeRoutes: Record<string, Record<string, (req: Request) => Promise<Response> | Response>> = {};
388
+
389
+ for (const r of this.routes) {
390
+ switch (r.method) {
391
+ case "DEFAULT":
392
+ nativeRoutes[r.path] = r.handler as any;
393
+ break;
394
+ case "WS":
395
+ // Skip WebSocket routes in native routes - they'll be handled in websocket config
396
+ break;
397
+ default:
398
+ nativeRoutes[r.path] ||= {} as Record<string, (req: Request) => Promise<Response> | Response>;
399
+ nativeRoutes[r.path][r.method] = (req: Request) => this.dispatch(r, req);
400
+ break;
401
+ }
402
+ }
403
+
404
+ this.serveOptions.port = this.serveOptions.port === undefined ? 3000 : this.serveOptions.port;
405
+
406
+ // Create WebSocket configuration if we have WebSocket routes
407
+ const websocketConfig = hasWebSocketRoutes ? {
408
+ message: (ws: Bun.ServerWebSocket<{ path: string }>, message: string | Buffer) => {
409
+ this.handleWebSocketMessage(ws, message);
410
+ },
411
+ open: (ws: Bun.ServerWebSocket<{ path: string }>) => {
412
+ this.handleWebSocketOpen(ws);
413
+ },
414
+ close: (ws: Bun.ServerWebSocket<{ path: string }>, code: number, reason: string) => {
415
+ this.handleWebSocketClose(ws, code, reason);
416
+ },
417
+ drain: (ws: Bun.ServerWebSocket<{ path: string }>) => {
418
+ this.handleWebSocketDrain(ws);
419
+ },
420
+ ping: (ws: Bun.ServerWebSocket<{ path: string }>, data: Buffer) => {
421
+ this.handleWebSocketPing(ws, data);
422
+ },
423
+ pong: (ws: Bun.ServerWebSocket<{ path: string }>, data: Buffer) => {
424
+ this.handleWebSocketPong(ws, data);
425
+ }
426
+ } : undefined;
427
+
428
+ if (hasWebSocketRoutes) {
429
+ this.server = Bun.serve({
430
+ ...this.serveOptions,
431
+ routes: nativeRoutes as any,
432
+ websocket: websocketConfig as any,
433
+ fetch: (req: Request, server: Bun.Server) => {
434
+ // Handle WebSocket upgrade requests
435
+ if (req.headers.get("upgrade") === "websocket") {
436
+ const url = new URL(req.url);
437
+ const wsRoute = this.findWebSocketRoute(url.pathname);
438
+ if (wsRoute) {
439
+ const success = server.upgrade(req, {
440
+ data: { path: url.pathname }
441
+ });
442
+ if (success) {
443
+ return; // WebSocket upgrade successful
444
+ }
445
+ }
446
+ }
447
+
448
+ // Handle regular HTTP requests
449
+ return this.dispatchAny(req, nativeRoutes);
450
+ }
451
+ } as any);
452
+ } else {
453
+ this.server = Bun.serve({
454
+ ...this.serveOptions,
455
+ routes: nativeRoutes as any,
456
+ fetch: (req: Request) => {
457
+ return this.dispatchAny(req, nativeRoutes);
458
+ }
459
+ });
460
+ }
461
+ }
462
+
463
+ // Lifecycle hook methods
464
+ beforeRequest(hook: BeforeRequestHook): this {
465
+ this.beforeRequestHooks.push(hook);
466
+ return this;
467
+ }
468
+
469
+ afterRequest(hook: AfterRequestHook): this {
470
+ this.afterRequestHooks.push(hook);
471
+ return this;
472
+ }
473
+
474
+ beforeResponse(hook: BeforeResponseHook): this {
475
+ this.beforeResponseHooks.push(hook);
476
+ return this;
477
+ }
478
+
479
+ onError(hook: OnErrorHook): this {
480
+ this.onErrorHooks.push(hook);
481
+ return this;
482
+ }
483
+
484
+ // WebSocket handler methods
485
+ private findWebSocketRoute(pathname: string): InternalRoute | null {
486
+ for (const route of this.routes) {
487
+ if (route.method === "WS") {
488
+ if (route.matcher === null) {
489
+ // Exact match
490
+ if (route.path === pathname) {
491
+ return route;
492
+ }
493
+ } else {
494
+ // Pattern match
495
+ const match = pathname.match(route.matcher);
496
+ if (match) {
497
+ return route;
498
+ }
499
+ }
500
+ }
501
+ }
502
+ return null;
503
+ }
504
+
505
+ private handleWebSocketMessage(ws: Bun.ServerWebSocket<{ path: string }>, message: string | Buffer): void {
506
+ const route = this.findWebSocketRoute(ws.data?.path || "");
507
+ if (route?.websocketHandler?.message) {
508
+ try {
509
+ (route.websocketHandler as any).message(ws, message);
510
+ } catch (error) {
511
+ console.error("WebSocket message handler error:", error);
512
+ }
513
+ }
514
+ }
515
+
516
+ private handleWebSocketOpen(ws: Bun.ServerWebSocket<{ path: string }>): void {
517
+ const route = this.findWebSocketRoute(ws.data?.path || "");
518
+ if (route?.websocketHandler?.open) {
519
+ try {
520
+ (route.websocketHandler as any).open(ws);
521
+ } catch (error) {
522
+ console.error("WebSocket open handler error:", error);
523
+ }
524
+ }
525
+ }
526
+
527
+ private handleWebSocketClose(ws: Bun.ServerWebSocket<{ path: string }>, code: number, reason: string): void {
528
+ const route = this.findWebSocketRoute(ws.data?.path || "");
529
+ if (route?.websocketHandler?.close) {
530
+ try {
531
+ (route.websocketHandler as any).close(ws, code, reason);
532
+ } catch (error) {
533
+ console.error("WebSocket close handler error:", error);
534
+ }
535
+ }
536
+ }
537
+
538
+ private handleWebSocketDrain(ws: Bun.ServerWebSocket<{ path: string }>): void {
539
+ const route = this.findWebSocketRoute(ws.data?.path || "");
540
+ if (route?.websocketHandler?.drain) {
541
+ try {
542
+ (route.websocketHandler as any).drain(ws);
543
+ } catch (error) {
544
+ console.error("WebSocket drain handler error:", error);
545
+ }
546
+ }
547
+ }
548
+
549
+ private handleWebSocketPing(ws: Bun.ServerWebSocket<{ path: string }>, data: Buffer): void {
550
+ const route = this.findWebSocketRoute(ws.data?.path || "");
551
+ if (route?.websocketHandler?.ping) {
552
+ try {
553
+ (route.websocketHandler as any).ping(ws, data);
554
+ } catch (error) {
555
+ console.error("WebSocket ping handler error:", error);
556
+ }
557
+ }
558
+ }
559
+
560
+ private handleWebSocketPong(ws: Bun.ServerWebSocket<{ path: string }>, data: Buffer): void {
561
+ const route = this.findWebSocketRoute(ws.data?.path || "");
562
+ if (route?.websocketHandler?.pong) {
563
+ try {
564
+ (route.websocketHandler as any).pong(ws, data);
565
+ } catch (error) {
566
+ console.error("WebSocket pong handler error:", error);
567
+ }
568
+ }
569
+ }
570
+
571
+ // Internal
572
+ private add(method: Method | "DEFAULT", path: string, handler: AnyHandler, schema?: RouteSchema): this {
573
+ const { regex, names } = buildMatcher(path);
574
+ this.routes.push({ method, path, handler, matcher: regex, paramNames: names, schema });
575
+ return this;
576
+ }
577
+
578
+ private addWebSocket(method: "WS", path: string, handler: WebSocketHandler): this {
579
+ const { regex, names } = buildMatcher(path);
580
+ this.routes.push({ method, path, handler: () => new Response("WebSocket route", { status: 400 }), matcher: regex, paramNames: names, websocketHandler: handler });
581
+ return this;
582
+ }
583
+
584
+ private async dispatch(route: InternalRoute, req: Request, pathname?: string): Promise<Response> {
585
+ // Run beforeRequest hooks
586
+ for (const hook of this.beforeRequestHooks) {
587
+ try {
588
+ const result = await hook(req);
589
+ if (result instanceof Response) {
590
+ return result;
591
+ }
592
+ if (result instanceof Request) {
593
+ req = result;
594
+ }
595
+ } catch (error) {
596
+ // Run error hooks
597
+ for (const errorHook of this.onErrorHooks) {
598
+ try {
599
+ const errorResponse = await errorHook(error as Error, req);
600
+ if (errorResponse instanceof Response) {
601
+ return errorResponse;
602
+ }
603
+ } catch {
604
+ // Ignore errors in error hooks
605
+ }
606
+ }
607
+ return new Response("Internal Server Error", { status: 500 });
608
+ }
609
+ }
610
+
611
+ const url = new URL(req.url);
612
+ const actualPathname = pathname || url.pathname;
613
+ const params = this.extractParams(route, actualPathname);
614
+ let queryObj: any;
615
+ let bodyObj: any = undefined;
616
+ const cookieObj = parseCookies(req.headers.get("cookie"));
617
+ const headerObj = toHeaderObject(req.headers);
618
+
619
+ // Parse query using schema if provided
620
+ try {
621
+ queryObj = route.schema?.query ? parseQuery(url.searchParams, route.schema.query as any) : parseQuery(url.searchParams);
622
+ } catch (err: any) {
623
+ const payload = err?.issues ? { error: "Validation Error", issues: err.issues } : { error: "Validation Error", issues: [], message: err.message };
624
+ return new Response(JSON.stringify(payload), { status: 400, headers: { "Content-Type": "application/json" } });
625
+ }
626
+
627
+ // Parse body (best-effort) and validate with schema if provided
628
+ try {
629
+ const contentType = req.headers.get("content-type") || "";
630
+ if (contentType.includes("application/json")) {
631
+ const raw = await req.json().catch(() => undefined);
632
+ bodyObj = route.schema?.body ? (route.schema.body as any).parse(raw) : raw;
633
+ } else if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
634
+ const fd = await req.formData().catch(() => undefined);
635
+ const raw = formDataToObject(fd || new FormData());
636
+ bodyObj = route.schema?.body ? (route.schema.body as any).parse(raw) : raw;
637
+ } else if (contentType.includes("text/")) {
638
+ const raw = await req.text().catch(() => undefined);
639
+ bodyObj = route.schema?.body ? (route.schema.body as any).parse(raw) : raw;
640
+ } else if (contentType) {
641
+ // Unknown content-type: provide ArrayBuffer
642
+ const raw = await req.arrayBuffer().catch(() => undefined);
643
+ bodyObj = route.schema?.body ? (route.schema.body as any).parse(raw) : raw;
644
+ } else {
645
+ // No content-type: try JSON then text, otherwise undefined
646
+ try {
647
+ const raw = await req.json().catch(() => undefined);
648
+ bodyObj = route.schema?.body ? (route.schema.body as any).parse(raw) : raw;
649
+ } catch {
650
+ try {
651
+ const raw = await req.text().catch(() => undefined);
652
+ bodyObj = route.schema?.body ? (route.schema.body as any).parse(raw) : raw;
653
+ } catch {
654
+ bodyObj = undefined;
655
+ }
656
+ }
657
+ }
658
+ if (!bodyObj && route.schema?.body) {
659
+ return new Response(JSON.stringify({ error: "Body is required" }), { status: 400, headers: { "Content-Type": "application/json" } });
660
+ }
661
+ } catch (err: any) {
662
+ const payload = err?.issues ? { error: "Validation Error", issues: err.issues } : { error: "Validation Error", issues: [], message: err.message };
663
+ return new Response(JSON.stringify(payload), { status: 400, headers: { "Content-Type": "application/json" } });
664
+ }
665
+
666
+
667
+
668
+ // Prepare ctx with lazy helpers and header merging
669
+ const ctx: Context<any, any> = {
670
+ request: req,
671
+ params,
672
+ query: queryObj,
673
+ headers: headerObj,
674
+ cookies: cookieObj,
675
+ body: bodyObj,
676
+ set: {
677
+ headers: {},
678
+ cookie: (name: string, value: string, options: CookieOptions = {}) => {
679
+ const cookieString = serializeCookie(name, value, options);
680
+ const existingCookies = ctx.set.headers["Set-Cookie"];
681
+ if (existingCookies) {
682
+ if (Array.isArray(existingCookies)) {
683
+ existingCookies.push(cookieString);
684
+ } else {
685
+ ctx.set.headers["Set-Cookie"] = [existingCookies, cookieString];
686
+ }
687
+ } else {
688
+ ctx.set.headers["Set-Cookie"] = [cookieString];
689
+ }
690
+ }
691
+ },
692
+ json: (data, status = 200) => {
693
+ // Response validation if declared
694
+ if (route.schema?.response?.[status]) {
695
+ const sch = route.schema.response[status]!;
696
+ const res = (sch as any).safeParse ? (sch as any).safeParse(data) : { success: true };
697
+ if (!res.success) {
698
+ return new Response(JSON.stringify({ error: "Invalid response", issues: res.error?.issues ?? [] }), { status: 500, headers: { "Content-Type": "application/json" } });
699
+ }
700
+ }
701
+ return new Response(JSON.stringify(data), {
702
+ status,
703
+ headers: { "Content-Type": "application/json" }
704
+ });
705
+ },
706
+ text: (data, status = 200) => {
707
+ if (route.schema?.response?.[status]) {
708
+ const sch = route.schema.response[status]!;
709
+ const res = (sch as any).safeParse ? (sch as any).safeParse(data) : { success: true };
710
+ if (!res.success) {
711
+ return new Response(JSON.stringify({ error: "Invalid response", issues: res.error?.issues ?? [] }), { status: 500, headers: { "Content-Type": "application/json" } });
712
+ }
713
+ }
714
+ return new Response(String(data), {
715
+ status,
716
+ headers: { "Content-Type": "text/plain" }
717
+ });
718
+ },
719
+ status: (status, data) => {
720
+ // Response validation if declared
721
+ if (route.schema?.response?.[status]) {
722
+ const sch = route.schema.response[status]!;
723
+ const res = (sch as any).safeParse ? (sch as any).safeParse(data) : { success: true };
724
+ if (!res.success) {
725
+ return new Response(JSON.stringify({ error: "Invalid response", issues: res.error?.issues ?? [] }), { status: 500, headers: { "Content-Type": "application/json" } });
726
+ }
727
+ }
728
+
729
+ return toResponse(data, { status });
730
+ }
731
+ };
732
+
733
+ // Validation
734
+ if (route.schema) {
735
+ try {
736
+ if (route.schema.headers) {
737
+ const headerSchema = route.schema.headers as any;
738
+ if (headerSchema.passthrough) {
739
+ headerSchema.passthrough().parse(headerObj);
740
+ } else {
741
+ headerSchema.parse(headerObj);
742
+ }
743
+ }
744
+ if (route.schema.cookies) {
745
+ const cookieSchema = route.schema.cookies as any;
746
+ if (cookieSchema.passthrough) {
747
+ cookieSchema.passthrough().parse(cookieObj);
748
+ } else {
749
+ cookieSchema.parse(cookieObj);
750
+ }
751
+ }
752
+ } catch (err: any) {
753
+ const payload = err?.issues ? { error: "Validation Error", issues: err.issues } : { error: "Validation Error", issues: [], message: err.message };
754
+ return new Response(JSON.stringify(payload), { status: 400, headers: { "Content-Type": "application/json" } });
755
+ }
756
+ }
757
+
758
+ const result = await route.handler(ctx, this);
759
+ const resp = toResponse(result);
760
+ // Merge ctx.set.headers into final response
761
+ let merged = new Response(resp.body, {
762
+ status: resp.status,
763
+ statusText: resp.statusText,
764
+ headers: mergeHeaders(resp.headers, ctx.set.headers)
765
+ });
766
+
767
+ // Run beforeResponse hooks
768
+ for (const hook of this.beforeResponseHooks) {
769
+ try {
770
+ const result = await hook(merged, ctx);
771
+ if (result instanceof Response) {
772
+ merged = result;
773
+ }
774
+ } catch (error) {
775
+ // Run error hooks
776
+ for (const errorHook of this.onErrorHooks) {
777
+ try {
778
+ const errorResponse = await errorHook(error as Error, req, ctx);
779
+ if (errorResponse instanceof Response) {
780
+ return errorResponse;
781
+ }
782
+ } catch {
783
+ // Ignore errors in error hooks
784
+ }
785
+ }
786
+ return new Response("Internal Server Error", { status: 500 });
787
+ }
788
+ }
789
+
790
+ // Run afterRequest hooks
791
+ for (const hook of this.afterRequestHooks) {
792
+ try {
793
+ const result = await hook(req, merged, ctx);
794
+ if (result instanceof Response) {
795
+ merged = result;
796
+ }
797
+ } catch (error) {
798
+ // Run error hooks
799
+ for (const errorHook of this.onErrorHooks) {
800
+ try {
801
+ const errorResponse = await errorHook(error as Error, req, ctx);
802
+ if (errorResponse instanceof Response) {
803
+ return errorResponse;
804
+ }
805
+ } catch {
806
+ // Ignore errors in error hooks
807
+ }
808
+ }
809
+ return new Response("Internal Server Error", { status: 500 });
810
+ }
811
+ }
812
+
813
+ return merged;
814
+ }
815
+
816
+ private async dispatchAny(req: Request, nativeRoutes: Record<string, Record<string, (req: Request) => Promise<Response> | Response>>): Promise<Response> {
817
+ // Run beforeRequest hooks for unmatched routes
818
+ for (const hook of this.beforeRequestHooks) {
819
+ try {
820
+ const result = await hook(req);
821
+ if (result instanceof Response) {
822
+ return result;
823
+ }
824
+ if (result instanceof Request) {
825
+ req = result;
826
+ }
827
+ } catch (error) {
828
+ // Run error hooks
829
+ for (const errorHook of this.onErrorHooks) {
830
+ try {
831
+ const errorResponse = await errorHook(error as Error, req);
832
+ if (errorResponse instanceof Response) {
833
+ return errorResponse;
834
+ }
835
+ } catch {
836
+ // Ignore errors in error hooks
837
+ }
838
+ }
839
+ return new Response("Internal Server Error", { status: 500 });
840
+ }
841
+ }
842
+
843
+ const url = new URL(req.url);
844
+ const method = req.method.toUpperCase() as Method;
845
+
846
+ // 1) If native routes contain exact match, prefer that
847
+ const exact = nativeRoutes[url.pathname];
848
+ if (exact) {
849
+ const h = exact[method] || exact["DEFAULT"];
850
+ if (h) return await h(req);
851
+ }
852
+
853
+ // 1.5) Try URL-decoded pathname for exact matches
854
+ const decodedPathname = decodeURIComponent(url.pathname);
855
+ if (decodedPathname !== url.pathname) {
856
+ const exactDecoded = nativeRoutes[decodedPathname];
857
+ if (exactDecoded) {
858
+ const h = exactDecoded[method] || exactDecoded["DEFAULT"];
859
+ if (h) return await h(req);
860
+ }
861
+ }
862
+
863
+ // 2) Fallback to our matcher list
864
+ for (const r of this.routes) {
865
+ if (r.matcher === null) continue; // exact paths handled above
866
+ if (r.method !== method && r.method !== "DEFAULT") continue;
867
+ const m = url.pathname.match(r.matcher);
868
+ if (m) return this.dispatch(r, req, url.pathname);
869
+ }
870
+
871
+ // 2.5) Try URL-decoded pathname for pattern matches
872
+ if (decodedPathname !== url.pathname) {
873
+ for (const r of this.routes) {
874
+ if (r.matcher === null) continue; // exact paths handled above
875
+ if (r.method !== method && r.method !== "DEFAULT") continue;
876
+ const m = decodedPathname.match(r.matcher);
877
+ if (m) return this.dispatch(r, req, decodedPathname);
878
+ }
879
+ }
880
+
881
+ // Create 404 response
882
+ let notFoundResponse = new Response("Not Found", { status: 404 });
883
+
884
+ // Run beforeResponse hooks for 404
885
+ for (const hook of this.beforeResponseHooks) {
886
+ try {
887
+ const result = await hook(notFoundResponse);
888
+ if (result instanceof Response) {
889
+ notFoundResponse = result;
890
+ }
891
+ } catch (error) {
892
+ // Run error hooks
893
+ for (const errorHook of this.onErrorHooks) {
894
+ try {
895
+ const errorResponse = await errorHook(error as Error, req);
896
+ if (errorResponse instanceof Response) {
897
+ return errorResponse;
898
+ }
899
+ } catch {
900
+ // Ignore errors in error hooks
901
+ }
902
+ }
903
+ return new Response("Internal Server Error", { status: 500 });
904
+ }
905
+ }
906
+
907
+ // Run afterRequest hooks for 404
908
+ for (const hook of this.afterRequestHooks) {
909
+ try {
910
+ const result = await hook(req, notFoundResponse);
911
+ if (result instanceof Response) {
912
+ notFoundResponse = result;
913
+ }
914
+ } catch (error) {
915
+ // Run error hooks
916
+ for (const errorHook of this.onErrorHooks) {
917
+ try {
918
+ const errorResponse = await errorHook(error as Error, req);
919
+ if (errorResponse instanceof Response) {
920
+ return errorResponse;
921
+ }
922
+ } catch {
923
+ // Ignore errors in error hooks
924
+ }
925
+ }
926
+ return new Response("Internal Server Error", { status: 500 });
927
+ }
928
+ }
929
+
930
+ return notFoundResponse;
931
+ }
932
+
933
+ private extractParams(route: InternalRoute, pathname: string): Record<string, string> {
934
+ if (!route.matcher) return {};
935
+ const match = pathname.match(route.matcher);
936
+ if (!match) return {};
937
+ const params: Record<string, string> = {};
938
+ for (let i = 0; i < route.paramNames.length; i++) {
939
+ const name = route.paramNames[i];
940
+ const value = match[i + 1] ?? "";
941
+ params[name] = decodeURIComponent(value);
942
+ }
943
+ return params;
944
+ }
945
+ }
946
+
947
+ export { z }
948
+
949
+ export function createRoute<P extends string, S extends RouteSchema | undefined>(
950
+ handler: Handler<P, S>,
951
+ schema?: S
952
+ ): {
953
+ handler: Handler<P, S>;
954
+ schema: S | undefined;
955
+ } {
956
+ return {
957
+ handler,
958
+ schema
959
+ };
960
+ }