bxo 0.0.5-dev.7 → 0.0.5-dev.70
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 +87 -512
- package/example/cors-example.ts +49 -0
- package/example/index.html +5 -0
- package/example/index.ts +58 -0
- package/package.json +8 -8
- package/plugins/cors.ts +123 -73
- package/plugins/index.ts +2 -11
- package/plugins/openapi.ts +130 -0
- package/src/index.ts +644 -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 -833
- 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,644 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
type Method =
|
|
5
|
+
| "GET"
|
|
6
|
+
| "POST"
|
|
7
|
+
| "PUT"
|
|
8
|
+
| "PATCH"
|
|
9
|
+
| "DELETE"
|
|
10
|
+
| "OPTIONS"
|
|
11
|
+
| "HEAD";
|
|
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
|
+
// Lifecycle hook types
|
|
56
|
+
export type BeforeRequestHook = (req: Request, ctx?: Partial<Context<any, any>>) => Request | Response | Promise<Request | Response | void>;
|
|
57
|
+
export type AfterRequestHook = (req: Request, res: Response, ctx?: Partial<Context<any, any>>) => Response | Promise<Response | void>;
|
|
58
|
+
export type BeforeResponseHook = (res: Response, ctx?: Partial<Context<any, any>>) => Response | Promise<Response | void>;
|
|
59
|
+
export type OnErrorHook = (error: Error, req: Request, ctx?: Partial<Context<any, any>>) => Response | Promise<Response | void>;
|
|
60
|
+
|
|
61
|
+
export type Context<P extends string = string, S extends RouteSchema | undefined = undefined> = {
|
|
62
|
+
request: Request;
|
|
63
|
+
params: PathParams<P>;
|
|
64
|
+
query: S extends RouteSchema ? InferOr<S["query"], QueryObject> : QueryObject;
|
|
65
|
+
headers: S extends RouteSchema ? InferOr<S["headers"], HeaderObject> : HeaderObject;
|
|
66
|
+
cookies: S extends RouteSchema ? InferOr<S["cookies"], CookieObject> : CookieObject;
|
|
67
|
+
body: S extends RouteSchema ? InferOr<S["body"], unknown> : unknown;
|
|
68
|
+
set: { headers: Record<string, string> };
|
|
69
|
+
json: <T>(data: T, status?: number) => Response;
|
|
70
|
+
text: (data: string, status?: number) => Response;
|
|
71
|
+
status: <T extends number>(status: T, data: InferResponse<S, T>) => Response;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
type AnyHandler = (ctx: Context<any, any>, app: BXO) => Response | string | Promise<Response | string>;
|
|
75
|
+
type Handler<P extends string, S extends RouteSchema | undefined = undefined> = (
|
|
76
|
+
ctx: Context<P, S>,
|
|
77
|
+
app: BXO
|
|
78
|
+
) => Response | string | Promise<Response | string>;
|
|
79
|
+
|
|
80
|
+
type InternalRoute = {
|
|
81
|
+
method: Method | "DEFAULT";
|
|
82
|
+
path: string;
|
|
83
|
+
matcher: RegExp | null;
|
|
84
|
+
paramNames: string[];
|
|
85
|
+
schema?: RouteSchema;
|
|
86
|
+
handler: AnyHandler;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
type ServeOptions = Partial<Parameters<typeof Bun.serve>[0]>;
|
|
90
|
+
|
|
91
|
+
function toHeaderObject(headers: Headers): HeaderObject {
|
|
92
|
+
const obj: HeaderObject = {};
|
|
93
|
+
headers.forEach((value, key) => {
|
|
94
|
+
obj[key.toLowerCase()] = value;
|
|
95
|
+
});
|
|
96
|
+
return obj;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseCookies(cookieHeader: string | null): CookieObject {
|
|
100
|
+
const out: CookieObject = {};
|
|
101
|
+
if (!cookieHeader) return out;
|
|
102
|
+
const parts = cookieHeader.split(";");
|
|
103
|
+
for (const part of parts) {
|
|
104
|
+
const [k, ...rest] = part.trim().split("=");
|
|
105
|
+
if (!k) continue;
|
|
106
|
+
out[k] = decodeURIComponent(rest.join("="));
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseQuery(searchParams: URLSearchParams): QueryObject;
|
|
112
|
+
function parseQuery<T extends z.ZodTypeAny>(searchParams: URLSearchParams, schema: T): z.infer<T>;
|
|
113
|
+
function parseQuery(searchParams: URLSearchParams, schema?: z.ZodTypeAny): any {
|
|
114
|
+
const out: QueryObject = {};
|
|
115
|
+
for (const [k, v] of searchParams.entries()) {
|
|
116
|
+
if (k in out) {
|
|
117
|
+
const existing = out[k];
|
|
118
|
+
if (Array.isArray(existing)) out[k] = [...existing, v];
|
|
119
|
+
else out[k] = [existing as string, v];
|
|
120
|
+
} else out[k] = v;
|
|
121
|
+
}
|
|
122
|
+
if (schema) {
|
|
123
|
+
return (schema as any).parse ? (schema as any).parse(out) : out;
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function formDataToObject(fd: FormData): Record<string, any> {
|
|
129
|
+
const obj: Record<string, any> = {};
|
|
130
|
+
for (const [key, value] of fd.entries()) {
|
|
131
|
+
if (key in obj) {
|
|
132
|
+
const existing = obj[key];
|
|
133
|
+
if (Array.isArray(existing)) obj[key] = [...existing, value];
|
|
134
|
+
else obj[key] = [existing, value];
|
|
135
|
+
} else obj[key] = value;
|
|
136
|
+
}
|
|
137
|
+
return obj;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildMatcher(path: string): { regex: RegExp | null; names: string[] } {
|
|
141
|
+
if (!path.includes(":") && !path.includes("*")) return { regex: null, names: [] };
|
|
142
|
+
const names: string[] = [];
|
|
143
|
+
const pattern = path
|
|
144
|
+
.split("/")
|
|
145
|
+
.map((seg, idx, arr) => {
|
|
146
|
+
if (!seg) return "";
|
|
147
|
+
if (seg.startsWith(":")) {
|
|
148
|
+
names.push(seg.slice(1));
|
|
149
|
+
return "([^/]+)";
|
|
150
|
+
}
|
|
151
|
+
if (seg.startsWith("*")) {
|
|
152
|
+
const name = seg.slice(1) || "wildcard";
|
|
153
|
+
names.push(name);
|
|
154
|
+
// wildcard must be terminal
|
|
155
|
+
return idx === arr.length - 1 ? "(.*)" : "(.*)";
|
|
156
|
+
}
|
|
157
|
+
return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
158
|
+
})
|
|
159
|
+
.join("/");
|
|
160
|
+
const regex = new RegExp(`^${pattern}$`);
|
|
161
|
+
return { regex, names };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function mergeHeaders(base: HeadersInit | undefined, extra: Record<string, string>): Headers {
|
|
165
|
+
const h = new Headers(base);
|
|
166
|
+
for (const [k, v] of Object.entries(extra)) h.set(k, v);
|
|
167
|
+
return h;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function toResponse(body: unknown, init?: ResponseInit): Response {
|
|
171
|
+
if (body instanceof Response) return new Response(body.body, { headers: mergeHeaders(body.headers, {}), status: body.status, statusText: body.statusText });
|
|
172
|
+
if (
|
|
173
|
+
typeof body === "string" ||
|
|
174
|
+
body instanceof Uint8Array ||
|
|
175
|
+
body instanceof ArrayBuffer ||
|
|
176
|
+
body instanceof Blob ||
|
|
177
|
+
body instanceof ReadableStream
|
|
178
|
+
) {
|
|
179
|
+
return new Response(body as BodyInit, init);
|
|
180
|
+
}
|
|
181
|
+
// Fallback: stringify unknown values
|
|
182
|
+
return new Response(String(body), init);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export default class BXO {
|
|
186
|
+
private routes: InternalRoute[] = [];
|
|
187
|
+
private serveOptions: ServeOptions;
|
|
188
|
+
public server?: ReturnType<typeof Bun.serve>;
|
|
189
|
+
|
|
190
|
+
// Lifecycle hooks
|
|
191
|
+
protected beforeRequestHooks: BeforeRequestHook[] = [];
|
|
192
|
+
protected afterRequestHooks: AfterRequestHook[] = [];
|
|
193
|
+
protected beforeResponseHooks: BeforeResponseHook[] = [];
|
|
194
|
+
protected onErrorHooks: OnErrorHook[] = [];
|
|
195
|
+
|
|
196
|
+
constructor(options?: { serve?: ServeOptions }) {
|
|
197
|
+
this.serveOptions = options?.serve ?? {};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
getRoutes(): InternalRoute[] {
|
|
201
|
+
return this.routes;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
getBXO(): this {
|
|
205
|
+
return this;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
use(plugin: BXO): this {
|
|
209
|
+
// Merge routes from another BXO instance
|
|
210
|
+
this.routes.push(...plugin.routes);
|
|
211
|
+
|
|
212
|
+
// Merge lifecycle hooks from plugin
|
|
213
|
+
this.beforeRequestHooks.push(...plugin.beforeRequestHooks);
|
|
214
|
+
this.afterRequestHooks.push(...plugin.afterRequestHooks);
|
|
215
|
+
this.beforeResponseHooks.push(...plugin.beforeResponseHooks);
|
|
216
|
+
this.onErrorHooks.push(...plugin.onErrorHooks);
|
|
217
|
+
|
|
218
|
+
return this;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
get<P extends string>(path: P, handler: Handler<P, undefined>): this;
|
|
222
|
+
get<P extends string, S extends RouteSchema>(path: P, handler: Handler<P, S>, schema: S): this;
|
|
223
|
+
get<P extends string, S extends RouteSchema | undefined>(path: P, handler: Handler<P, S>, schema?: S): this {
|
|
224
|
+
return this.add("GET", path, handler as AnyHandler, schema as RouteSchema | undefined);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
post<P extends string>(path: P, handler: Handler<P, undefined>): this;
|
|
228
|
+
post<P extends string, S extends RouteSchema>(path: P, handler: Handler<P, S>, schema: S): this;
|
|
229
|
+
post<P extends string, S extends RouteSchema | undefined>(path: P, handler: Handler<P, S>, schema?: S): this {
|
|
230
|
+
return this.add("POST", path, handler as AnyHandler, schema as RouteSchema | undefined);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
put<P extends string>(path: P, handler: Handler<P, undefined>): this;
|
|
234
|
+
put<P extends string, S extends RouteSchema>(path: P, handler: Handler<P, S>, schema: S): this;
|
|
235
|
+
put<P extends string, S extends RouteSchema | undefined>(path: P, handler: Handler<P, S>, schema?: S): this {
|
|
236
|
+
return this.add("PUT", path, handler as AnyHandler, schema as RouteSchema | undefined);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
patch<P extends string>(path: P, handler: Handler<P, undefined>): this;
|
|
240
|
+
patch<P extends string, S extends RouteSchema>(path: P, handler: Handler<P, S>, schema: S): this;
|
|
241
|
+
patch<P extends string, S extends RouteSchema | undefined>(path: P, handler: Handler<P, S>, schema?: S): this {
|
|
242
|
+
return this.add("PATCH", path, handler as AnyHandler, schema as RouteSchema | undefined);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
delete<P extends string>(path: P, handler: Handler<P, undefined>): this;
|
|
246
|
+
delete<P extends string, S extends RouteSchema>(path: P, handler: Handler<P, S>, schema: S): this;
|
|
247
|
+
delete<P extends string, S extends RouteSchema | undefined>(path: P, handler: Handler<P, S>, schema?: S): this {
|
|
248
|
+
return this.add("DELETE", path, handler as AnyHandler, schema as RouteSchema | undefined);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// default can accept a handler OR static content (including Bun HTML bundle)
|
|
252
|
+
default<P extends string>(path: P, handler: Handler<P, undefined>): this;
|
|
253
|
+
default<P extends string, S extends RouteSchema>(path: P, handler: (req: Request) => Response, schema: S): this;
|
|
254
|
+
default<P extends string>(path: P, content: Bun.HTMLBundle): this;
|
|
255
|
+
default<P extends string, S extends RouteSchema | undefined>(path: P, arg2: unknown, schema?: S): this {
|
|
256
|
+
return this.add("DEFAULT", path, arg2 as AnyHandler, schema as RouteSchema | undefined);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
start(): void {
|
|
260
|
+
// Build a basic routes map for Bun's native routes (exact paths only)
|
|
261
|
+
const nativeRoutes: Record<string, Record<string, (req: Request) => Promise<Response> | Response>> = {};
|
|
262
|
+
|
|
263
|
+
for (const r of this.routes) {
|
|
264
|
+
switch (r.method) {
|
|
265
|
+
case "DEFAULT":
|
|
266
|
+
nativeRoutes[r.path] = r.handler as any;
|
|
267
|
+
break;
|
|
268
|
+
default:
|
|
269
|
+
nativeRoutes[r.path] ||= {} as Record<string, (req: Request) => Promise<Response> | Response>;
|
|
270
|
+
nativeRoutes[r.path][r.method] = (req: Request) => this.dispatch(r, req);
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
this.server = Bun.serve({
|
|
276
|
+
...this.serveOptions,
|
|
277
|
+
routes: nativeRoutes as any,
|
|
278
|
+
fetch: (req: Request) => this.dispatchAny(req, nativeRoutes)
|
|
279
|
+
});
|
|
280
|
+
const port = (this.server as any).port ?? this.serveOptions.port ?? 3000;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Lifecycle hook methods
|
|
284
|
+
beforeRequest(hook: BeforeRequestHook): this {
|
|
285
|
+
this.beforeRequestHooks.push(hook);
|
|
286
|
+
return this;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
afterRequest(hook: AfterRequestHook): this {
|
|
290
|
+
this.afterRequestHooks.push(hook);
|
|
291
|
+
return this;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
beforeResponse(hook: BeforeResponseHook): this {
|
|
295
|
+
this.beforeResponseHooks.push(hook);
|
|
296
|
+
return this;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
onError(hook: OnErrorHook): this {
|
|
300
|
+
this.onErrorHooks.push(hook);
|
|
301
|
+
return this;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Internal
|
|
305
|
+
private add(method: Method | "DEFAULT", path: string, handler: AnyHandler, schema?: RouteSchema): this {
|
|
306
|
+
const { regex, names } = buildMatcher(path);
|
|
307
|
+
this.routes.push({ method, path, handler, matcher: regex, paramNames: names, schema });
|
|
308
|
+
return this;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private async dispatch(route: InternalRoute, req: Request): Promise<Response> {
|
|
312
|
+
// Run beforeRequest hooks
|
|
313
|
+
for (const hook of this.beforeRequestHooks) {
|
|
314
|
+
try {
|
|
315
|
+
const result = await hook(req);
|
|
316
|
+
if (result instanceof Response) {
|
|
317
|
+
return result;
|
|
318
|
+
}
|
|
319
|
+
if (result instanceof Request) {
|
|
320
|
+
req = result;
|
|
321
|
+
}
|
|
322
|
+
} catch (error) {
|
|
323
|
+
// Run error hooks
|
|
324
|
+
for (const errorHook of this.onErrorHooks) {
|
|
325
|
+
try {
|
|
326
|
+
const errorResponse = await errorHook(error as Error, req);
|
|
327
|
+
if (errorResponse instanceof Response) {
|
|
328
|
+
return errorResponse;
|
|
329
|
+
}
|
|
330
|
+
} catch {
|
|
331
|
+
// Ignore errors in error hooks
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const url = new URL(req.url);
|
|
339
|
+
const params = this.extractParams(route, url.pathname);
|
|
340
|
+
let queryObj: any;
|
|
341
|
+
let bodyObj: any = undefined;
|
|
342
|
+
const cookieObj = parseCookies(req.headers.get("cookie"));
|
|
343
|
+
const headerObj = toHeaderObject(req.headers);
|
|
344
|
+
|
|
345
|
+
// Parse query using schema if provided
|
|
346
|
+
try {
|
|
347
|
+
queryObj = route.schema?.query ? parseQuery(url.searchParams, route.schema.query as any) : parseQuery(url.searchParams);
|
|
348
|
+
} catch (err: any) {
|
|
349
|
+
const payload = err?.issues ? { error: "Validation Error", issues: err.issues } : { error: "Validation Error" };
|
|
350
|
+
return new Response(JSON.stringify(payload), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Parse body (best-effort) and validate with schema if provided
|
|
354
|
+
try {
|
|
355
|
+
const contentType = req.headers.get("content-type") || "";
|
|
356
|
+
if (contentType.includes("application/json")) {
|
|
357
|
+
const raw = await req.json();
|
|
358
|
+
bodyObj = route.schema?.body ? (route.schema.body as any).parse(raw) : raw;
|
|
359
|
+
} else if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
360
|
+
const fd = await req.formData();
|
|
361
|
+
const raw = formDataToObject(fd);
|
|
362
|
+
bodyObj = route.schema?.body ? (route.schema.body as any).parse(raw) : raw;
|
|
363
|
+
} else if (contentType.includes("text/")) {
|
|
364
|
+
const raw = await req.text();
|
|
365
|
+
bodyObj = route.schema?.body ? (route.schema.body as any).parse(raw) : raw;
|
|
366
|
+
} else if (contentType) {
|
|
367
|
+
// Unknown content-type: provide ArrayBuffer
|
|
368
|
+
const raw = await req.arrayBuffer();
|
|
369
|
+
bodyObj = route.schema?.body ? (route.schema.body as any).parse(raw) : raw;
|
|
370
|
+
} else {
|
|
371
|
+
// No content-type: try JSON then text, otherwise undefined
|
|
372
|
+
try {
|
|
373
|
+
const raw = await req.json();
|
|
374
|
+
bodyObj = route.schema?.body ? (route.schema.body as any).parse(raw) : raw;
|
|
375
|
+
} catch {
|
|
376
|
+
try {
|
|
377
|
+
const raw = await req.text();
|
|
378
|
+
bodyObj = route.schema?.body ? (route.schema.body as any).parse(raw) : raw;
|
|
379
|
+
} catch {
|
|
380
|
+
bodyObj = undefined;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (!bodyObj && route.schema?.body) {
|
|
385
|
+
return new Response(JSON.stringify({ error: "Body is required" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
386
|
+
}
|
|
387
|
+
} catch (err: any) {
|
|
388
|
+
const payload = err?.issues ? { error: "Validation Error", issues: err.issues } : { error: "Validation Error" };
|
|
389
|
+
return new Response(JSON.stringify(payload), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
// Prepare ctx with lazy helpers and header merging
|
|
395
|
+
const ctx: Context<any, any> = {
|
|
396
|
+
request: req,
|
|
397
|
+
params,
|
|
398
|
+
query: queryObj,
|
|
399
|
+
headers: headerObj,
|
|
400
|
+
cookies: cookieObj,
|
|
401
|
+
body: bodyObj,
|
|
402
|
+
set: { headers: {} },
|
|
403
|
+
json: (data, status = 200) => {
|
|
404
|
+
// Response validation if declared
|
|
405
|
+
if (route.schema?.response?.[status]) {
|
|
406
|
+
const sch = route.schema.response[status]!;
|
|
407
|
+
const res = (sch as any).safeParse ? (sch as any).safeParse(data) : { success: true };
|
|
408
|
+
if (!res.success) {
|
|
409
|
+
return new Response(JSON.stringify({ error: "Invalid response", issues: res.error?.issues ?? [] }), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const headers = mergeHeaders({ "Content-Type": "application/json" }, ctx.set.headers);
|
|
413
|
+
return new Response(JSON.stringify(data), { status, headers });
|
|
414
|
+
},
|
|
415
|
+
text: (data, status = 200) => {
|
|
416
|
+
if (route.schema?.response?.[status]) {
|
|
417
|
+
const sch = route.schema.response[status]!;
|
|
418
|
+
const res = (sch as any).safeParse ? (sch as any).safeParse(data) : { success: true };
|
|
419
|
+
if (!res.success) {
|
|
420
|
+
return new Response(JSON.stringify({ error: "Invalid response", issues: res.error?.issues ?? [] }), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const headers = mergeHeaders({ "Content-Type": "text/plain" }, ctx.set.headers);
|
|
424
|
+
return new Response(String(data), { status, headers });
|
|
425
|
+
},
|
|
426
|
+
status: (status, data) => {
|
|
427
|
+
// Response validation if declared
|
|
428
|
+
if (route.schema?.response?.[status]) {
|
|
429
|
+
const sch = route.schema.response[status]!;
|
|
430
|
+
const res = (sch as any).safeParse ? (sch as any).safeParse(data) : { success: true };
|
|
431
|
+
if (!res.success) {
|
|
432
|
+
return new Response(JSON.stringify({ error: "Invalid response", issues: res.error?.issues ?? [] }), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const resp = toResponse(data, { status });
|
|
437
|
+
// Merge ctx.set.headers into final response
|
|
438
|
+
const merged = new Response(resp.body, {
|
|
439
|
+
status: resp.status,
|
|
440
|
+
statusText: resp.statusText,
|
|
441
|
+
headers: mergeHeaders(resp.headers, ctx.set.headers)
|
|
442
|
+
});
|
|
443
|
+
return merged;
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
// Validation
|
|
448
|
+
if (route.schema) {
|
|
449
|
+
try {
|
|
450
|
+
if (route.schema.headers) {
|
|
451
|
+
(route.schema.headers as any).parse(headerObj);
|
|
452
|
+
}
|
|
453
|
+
if (route.schema.cookies) {
|
|
454
|
+
(route.schema.cookies as any).parse(cookieObj);
|
|
455
|
+
}
|
|
456
|
+
} catch (err: any) {
|
|
457
|
+
const payload = err?.issues ? { error: "Validation Error", issues: err.issues } : { error: "Validation Error" };
|
|
458
|
+
return new Response(JSON.stringify(payload), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const result = await route.handler(ctx, this);
|
|
463
|
+
const resp = toResponse(result);
|
|
464
|
+
// Merge ctx.set.headers into final response
|
|
465
|
+
let merged = new Response(resp.body, {
|
|
466
|
+
status: resp.status,
|
|
467
|
+
statusText: resp.statusText,
|
|
468
|
+
headers: mergeHeaders(resp.headers, ctx.set.headers)
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// Run beforeResponse hooks
|
|
472
|
+
for (const hook of this.beforeResponseHooks) {
|
|
473
|
+
try {
|
|
474
|
+
const result = await hook(merged, ctx);
|
|
475
|
+
if (result instanceof Response) {
|
|
476
|
+
merged = result;
|
|
477
|
+
}
|
|
478
|
+
} catch (error) {
|
|
479
|
+
// Run error hooks
|
|
480
|
+
for (const errorHook of this.onErrorHooks) {
|
|
481
|
+
try {
|
|
482
|
+
const errorResponse = await errorHook(error as Error, req, ctx);
|
|
483
|
+
if (errorResponse instanceof Response) {
|
|
484
|
+
return errorResponse;
|
|
485
|
+
}
|
|
486
|
+
} catch {
|
|
487
|
+
// Ignore errors in error hooks
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Run afterRequest hooks
|
|
495
|
+
for (const hook of this.afterRequestHooks) {
|
|
496
|
+
try {
|
|
497
|
+
const result = await hook(req, merged, ctx);
|
|
498
|
+
if (result instanceof Response) {
|
|
499
|
+
merged = result;
|
|
500
|
+
}
|
|
501
|
+
} catch (error) {
|
|
502
|
+
// Run error hooks
|
|
503
|
+
for (const errorHook of this.onErrorHooks) {
|
|
504
|
+
try {
|
|
505
|
+
const errorResponse = await errorHook(error as Error, req, ctx);
|
|
506
|
+
if (errorResponse instanceof Response) {
|
|
507
|
+
return errorResponse;
|
|
508
|
+
}
|
|
509
|
+
} catch {
|
|
510
|
+
// Ignore errors in error hooks
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return merged;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private async dispatchAny(req: Request, nativeRoutes: Record<string, Record<string, (req: Request) => Promise<Response> | Response>>): Promise<Response> {
|
|
521
|
+
// Run beforeRequest hooks for unmatched routes
|
|
522
|
+
for (const hook of this.beforeRequestHooks) {
|
|
523
|
+
try {
|
|
524
|
+
const result = await hook(req);
|
|
525
|
+
if (result instanceof Response) {
|
|
526
|
+
return result;
|
|
527
|
+
}
|
|
528
|
+
if (result instanceof Request) {
|
|
529
|
+
req = result;
|
|
530
|
+
}
|
|
531
|
+
} catch (error) {
|
|
532
|
+
// Run error hooks
|
|
533
|
+
for (const errorHook of this.onErrorHooks) {
|
|
534
|
+
try {
|
|
535
|
+
const errorResponse = await errorHook(error as Error, req);
|
|
536
|
+
if (errorResponse instanceof Response) {
|
|
537
|
+
return errorResponse;
|
|
538
|
+
}
|
|
539
|
+
} catch {
|
|
540
|
+
// Ignore errors in error hooks
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const url = new URL(req.url);
|
|
548
|
+
const method = req.method.toUpperCase() as Method;
|
|
549
|
+
|
|
550
|
+
// 1) If native routes contain exact match, prefer that
|
|
551
|
+
const exact = nativeRoutes[url.pathname];
|
|
552
|
+
if (exact) {
|
|
553
|
+
const h = exact[method] || exact["DEFAULT"];
|
|
554
|
+
if (h) return await h(req);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// 2) Fallback to our matcher list
|
|
558
|
+
for (const r of this.routes) {
|
|
559
|
+
if (r.matcher === null) continue; // exact paths handled above
|
|
560
|
+
if (r.method !== method && r.method !== "DEFAULT") continue;
|
|
561
|
+
const m = url.pathname.match(r.matcher);
|
|
562
|
+
if (m) return this.dispatch(r, req);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Create 404 response
|
|
566
|
+
let notFoundResponse = new Response("Not Found", { status: 404 });
|
|
567
|
+
|
|
568
|
+
// Run beforeResponse hooks for 404
|
|
569
|
+
for (const hook of this.beforeResponseHooks) {
|
|
570
|
+
try {
|
|
571
|
+
const result = await hook(notFoundResponse);
|
|
572
|
+
if (result instanceof Response) {
|
|
573
|
+
notFoundResponse = result;
|
|
574
|
+
}
|
|
575
|
+
} catch (error) {
|
|
576
|
+
// Run error hooks
|
|
577
|
+
for (const errorHook of this.onErrorHooks) {
|
|
578
|
+
try {
|
|
579
|
+
const errorResponse = await errorHook(error as Error, req);
|
|
580
|
+
if (errorResponse instanceof Response) {
|
|
581
|
+
return errorResponse;
|
|
582
|
+
}
|
|
583
|
+
} catch {
|
|
584
|
+
// Ignore errors in error hooks
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Run afterRequest hooks for 404
|
|
592
|
+
for (const hook of this.afterRequestHooks) {
|
|
593
|
+
try {
|
|
594
|
+
const result = await hook(req, notFoundResponse);
|
|
595
|
+
if (result instanceof Response) {
|
|
596
|
+
notFoundResponse = result;
|
|
597
|
+
}
|
|
598
|
+
} catch (error) {
|
|
599
|
+
// Run error hooks
|
|
600
|
+
for (const errorHook of this.onErrorHooks) {
|
|
601
|
+
try {
|
|
602
|
+
const errorResponse = await errorHook(error as Error, req);
|
|
603
|
+
if (errorResponse instanceof Response) {
|
|
604
|
+
return errorResponse;
|
|
605
|
+
}
|
|
606
|
+
} catch {
|
|
607
|
+
// Ignore errors in error hooks
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return notFoundResponse;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private extractParams(route: InternalRoute, pathname: string): Record<string, string> {
|
|
618
|
+
if (!route.matcher) return {};
|
|
619
|
+
const match = pathname.match(route.matcher);
|
|
620
|
+
if (!match) return {};
|
|
621
|
+
const params: Record<string, string> = {};
|
|
622
|
+
for (let i = 0; i < route.paramNames.length; i++) {
|
|
623
|
+
const name = route.paramNames[i];
|
|
624
|
+
const value = match[i + 1] ?? "";
|
|
625
|
+
params[name] = decodeURIComponent(value);
|
|
626
|
+
}
|
|
627
|
+
return params;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export { z }
|
|
632
|
+
|
|
633
|
+
export function createRoute<P extends string, S extends RouteSchema | undefined>(
|
|
634
|
+
handler: Handler<P, S>,
|
|
635
|
+
schema?: S
|
|
636
|
+
): {
|
|
637
|
+
handler: Handler<P, S>;
|
|
638
|
+
schema: S | undefined;
|
|
639
|
+
} {
|
|
640
|
+
return {
|
|
641
|
+
handler,
|
|
642
|
+
schema
|
|
643
|
+
};
|
|
644
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compilerOptions": {
|
|
3
|
-
//
|
|
4
|
-
"lib": ["ESNext"],
|
|
3
|
+
// Enable latest features
|
|
4
|
+
"lib": ["ESNext", "DOM"],
|
|
5
5
|
"target": "ESNext",
|
|
6
|
-
"module": "
|
|
6
|
+
"module": "ESNext",
|
|
7
7
|
"moduleDetection": "force",
|
|
8
8
|
"jsx": "react-jsx",
|
|
9
9
|
"allowJs": true,
|
|
@@ -18,8 +18,6 @@
|
|
|
18
18
|
"strict": true,
|
|
19
19
|
"skipLibCheck": true,
|
|
20
20
|
"noFallthroughCasesInSwitch": true,
|
|
21
|
-
"noUncheckedIndexedAccess": true,
|
|
22
|
-
"noImplicitOverride": true,
|
|
23
21
|
|
|
24
22
|
// Some stricter flags (disabled by default)
|
|
25
23
|
"noUnusedLocals": false,
|