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/README.md +258 -483
- package/example/cookie-example.ts +151 -0
- package/example/cors-example.ts +49 -0
- package/example/index.html +5 -0
- package/example/index.ts +191 -0
- package/example/multipart-example.ts +203 -0
- package/example/openapi-example.ts +132 -0
- package/example/passthrough-validation-example.ts +115 -0
- package/example/url-encoding-example.ts +93 -0
- package/example/websocket-example.ts +132 -0
- package/package.json +8 -8
- package/plugins/cors.ts +123 -73
- package/plugins/index.ts +2 -11
- package/plugins/openapi.ts +204 -0
- package/src/index.ts +960 -0
- package/test-url-encoding.ts +20 -0
- package/tsconfig.json +3 -5
- package/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc +0 -111
- package/example.ts +0 -183
- package/index.ts +0 -835
- package/plugins/auth.ts +0 -119
- package/plugins/logger.ts +0 -109
- package/plugins/ratelimit.ts +0 -140
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
|
+
}
|