@bunary/http 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +166 -3
  2. package/dist/app.d.ts +7 -25
  3. package/dist/app.d.ts.map +1 -1
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +342 -41
  7. package/dist/pathUtils.d.ts +34 -0
  8. package/dist/pathUtils.d.ts.map +1 -0
  9. package/dist/response.d.ts +26 -0
  10. package/dist/response.d.ts.map +1 -0
  11. package/dist/router.d.ts +49 -0
  12. package/dist/router.d.ts.map +1 -0
  13. package/dist/routes/builder.d.ts +17 -0
  14. package/dist/routes/builder.d.ts.map +1 -0
  15. package/dist/routes/find.d.ts +27 -0
  16. package/dist/routes/find.d.ts.map +1 -0
  17. package/dist/routes/group.d.ts +7 -0
  18. package/dist/routes/group.d.ts.map +1 -0
  19. package/dist/routes/index.d.ts +4 -0
  20. package/dist/routes/index.d.ts.map +1 -0
  21. package/dist/types/appOptions.d.ts +8 -0
  22. package/dist/types/appOptions.d.ts.map +1 -0
  23. package/dist/types/bunaryApp.d.ts +97 -0
  24. package/dist/types/bunaryApp.d.ts.map +1 -0
  25. package/dist/types/bunaryServer.d.ts +14 -0
  26. package/dist/types/bunaryServer.d.ts.map +1 -0
  27. package/dist/types/groupOptions.d.ts +13 -0
  28. package/dist/types/groupOptions.d.ts.map +1 -0
  29. package/dist/types/groupRouter.d.ts +26 -0
  30. package/dist/types/groupRouter.d.ts.map +1 -0
  31. package/dist/types/handlerResponse.d.ts +8 -0
  32. package/dist/types/handlerResponse.d.ts.map +1 -0
  33. package/dist/types/httpMethod.d.ts +5 -0
  34. package/dist/types/httpMethod.d.ts.map +1 -0
  35. package/dist/types/index.d.ts +19 -0
  36. package/dist/types/index.d.ts.map +1 -0
  37. package/dist/types/middleware.d.ts +21 -0
  38. package/dist/types/middleware.d.ts.map +1 -0
  39. package/dist/types/pathParams.d.ts +6 -0
  40. package/dist/types/pathParams.d.ts.map +1 -0
  41. package/dist/types/queryParams.d.ts +5 -0
  42. package/dist/types/queryParams.d.ts.map +1 -0
  43. package/dist/types/requestContext.d.ts +22 -0
  44. package/dist/types/requestContext.d.ts.map +1 -0
  45. package/dist/types/route.d.ts +27 -0
  46. package/dist/types/route.d.ts.map +1 -0
  47. package/dist/types/routeBuilder.d.ts +24 -0
  48. package/dist/types/routeBuilder.d.ts.map +1 -0
  49. package/dist/types/routeHandler.d.ts +17 -0
  50. package/dist/types/routeHandler.d.ts.map +1 -0
  51. package/dist/types/routeInfo.d.ts +13 -0
  52. package/dist/types/routeInfo.d.ts.map +1 -0
  53. package/package.json +1 -1
package/README.md CHANGED
@@ -11,6 +11,10 @@ Part of the [Bunary](https://github.com/bunary-dev) ecosystem - a Bun-first back
11
11
  - 🔒 **Type-safe** - Full TypeScript support with strict types
12
12
  - ⚡ **Fast** - Minimal overhead, direct routing
13
13
  - 🧩 **Simple API** - Chainable route registration with automatic JSON serialization
14
+ - 📂 **Route Groups** - Organize routes with shared prefixes, middleware, and name prefixes
15
+ - 🏷️ **Named Routes** - URL generation with route names
16
+ - ✅ **Route Constraints** - Validate parameters with regex patterns
17
+ - ❓ **Optional Parameters** - Flexible routes with optional path segments
14
18
 
15
19
  ## Installation
16
20
 
@@ -215,9 +219,163 @@ app.use(async (ctx, next) => {
215
219
  });
216
220
  ```
217
221
 
222
+ ## Route Groups
223
+
224
+ Group routes together with shared prefixes, middleware, and name prefixes.
225
+
226
+ ### Basic Groups
227
+
228
+ ```typescript
229
+ // Simple prefix
230
+ app.group('/api', (router) => {
231
+ router.get('/users', () => ({ users: [] })); // /api/users
232
+ router.get('/posts', () => ({ posts: [] })); // /api/posts
233
+ });
234
+ ```
235
+
236
+ ### Groups with Options
237
+
238
+ ```typescript
239
+ // Auth middleware for protected routes
240
+ const authMiddleware = async (ctx, next) => {
241
+ const token = ctx.request.headers.get('Authorization');
242
+ if (!token) return new Response('Unauthorized', { status: 401 });
243
+ return await next();
244
+ };
245
+
246
+ app.group({
247
+ prefix: '/admin',
248
+ middleware: [authMiddleware],
249
+ name: 'admin.'
250
+ }, (router) => {
251
+ router.get('/dashboard', () => ({})).name('dashboard'); // name: admin.dashboard
252
+ router.get('/users', () => ({})).name('users'); // name: admin.users
253
+ });
254
+ ```
255
+
256
+ ### Nested Groups
257
+
258
+ ```typescript
259
+ app.group('/api', (api) => {
260
+ api.group('/v1', (v1) => {
261
+ v1.get('/users', () => ({})); // /api/v1/users
262
+ });
263
+ api.group('/v2', (v2) => {
264
+ v2.get('/users', () => ({})); // /api/v2/users
265
+ });
266
+ });
267
+ ```
268
+
269
+ ## Named Routes
270
+
271
+ Assign names to routes for URL generation.
272
+
273
+ ### Naming Routes
274
+
275
+ ```typescript
276
+ app.get('/users/:id', (ctx) => ({})).name('users.show');
277
+ app.get('/posts/:slug', (ctx) => ({})).name('posts.show');
278
+ ```
279
+
280
+ ### Generating URLs
281
+
282
+ ```typescript
283
+ // Basic URL generation
284
+ const url = app.route('users.show', { id: 42 });
285
+ // "/users/42"
286
+
287
+ // With query string
288
+ const searchUrl = app.route('users.show', { id: 42, tab: 'profile' });
289
+ // "/users/42?tab=profile"
290
+
291
+ // Check if route exists
292
+ if (app.hasRoute('users.show')) {
293
+ // ...
294
+ }
295
+
296
+ // List all routes
297
+ const routes = app.getRoutes();
298
+ // [{ name: 'users.show', method: 'GET', path: '/users/:id' }, ...]
299
+ ```
300
+
301
+ ## Route Constraints
302
+
303
+ Add regex constraints to validate route parameters.
304
+
305
+ ### Basic Constraints
306
+
307
+ ```typescript
308
+ // Only match if :id is numeric
309
+ app.get('/users/:id', (ctx) => ({}))
310
+ .where('id', /^\d+$/);
311
+
312
+ // Using string pattern
313
+ app.get('/posts/:slug', (ctx) => ({}))
314
+ .where('slug', '^[a-z0-9-]+$');
315
+
316
+ // Multiple constraints
317
+ app.get('/users/:id/posts/:postId', (ctx) => ({}))
318
+ .where({ id: /^\d+$/, postId: /^\d+$/ });
319
+ ```
320
+
321
+ ### Helper Methods
322
+
323
+ ```typescript
324
+ // whereNumber - digits only
325
+ app.get('/users/:id', () => ({})).whereNumber('id');
326
+
327
+ // whereAlpha - letters only (a-zA-Z)
328
+ app.get('/categories/:name', () => ({})).whereAlpha('name');
329
+
330
+ // whereAlphaNumeric - letters and digits
331
+ app.get('/codes/:code', () => ({})).whereAlphaNumeric('code');
332
+
333
+ // whereUuid - UUID format
334
+ app.get('/items/:uuid', () => ({})).whereUuid('uuid');
335
+
336
+ // whereUlid - ULID format
337
+ app.get('/records/:ulid', () => ({})).whereUlid('ulid');
338
+
339
+ // whereIn - specific allowed values
340
+ app.get('/status/:status', () => ({})).whereIn('status', ['active', 'pending', 'archived']);
341
+ ```
342
+
343
+ ### Chaining Constraints
344
+
345
+ ```typescript
346
+ app.get('/users/:id/posts/:slug', (ctx) => ({}))
347
+ .whereNumber('id')
348
+ .whereAlpha('slug')
349
+ .name('users.posts');
350
+ ```
351
+
352
+ ## Optional Parameters
353
+
354
+ Use `?` to mark route parameters as optional.
355
+
356
+ ```typescript
357
+ // :id is optional
358
+ app.get('/users/:id?', (ctx) => {
359
+ if (ctx.params.id) {
360
+ return { user: ctx.params.id };
361
+ }
362
+ return { users: [] };
363
+ });
364
+
365
+ // Multiple optional params
366
+ app.get('/archive/:year?/:month?', (ctx) => {
367
+ const { year, month } = ctx.params;
368
+ // year and month may be undefined
369
+ return { year, month };
370
+ });
371
+
372
+ // Constraints work with optional params
373
+ app.get('/posts/:id?', (ctx) => ({})).whereNumber('id');
374
+ ```
375
+
218
376
  ## Error Handling
219
377
 
220
- Uncaught errors in handlers return a 500 response:
378
+ Uncaught errors in handlers return a 500 response with the error message:
221
379
 
222
380
  ```typescript
223
381
  app.get('/error', () => {
@@ -225,7 +383,7 @@ app.get('/error', () => {
225
383
  });
226
384
 
227
385
  // Returns: 500 Internal Server Error
228
- // Body: { error: "Internal Server Error" }
386
+ // Body: { error: "Something went wrong" }
229
387
  ```
230
388
 
231
389
  ## Types
@@ -238,7 +396,12 @@ import type {
238
396
  BunaryServer,
239
397
  RequestContext,
240
398
  RouteHandler,
241
- Middleware
399
+ Middleware,
400
+ RouteBuilder,
401
+ GroupOptions,
402
+ GroupRouter,
403
+ GroupCallback,
404
+ RouteInfo
242
405
  } from '@bunary/http';
243
406
  ```
244
407
 
package/dist/app.d.ts CHANGED
@@ -1,21 +1,4 @@
1
- /**
2
- * Create a new Bunary HTTP application instance.
3
- *
4
- * @returns BunaryApp instance with routing and middleware support
5
- *
6
- * @example
7
- * ```ts
8
- * import { createApp } from "@bunary/http";
9
- *
10
- * const app = createApp();
11
- *
12
- * app.get("/", () => ({ message: "Hello!" }));
13
- * app.get("/users/:id", (ctx) => ({ id: ctx.params.id }));
14
- *
15
- * app.listen(3000);
16
- * ```
17
- */
18
- import type { BunaryApp } from "./types.js";
1
+ import type { BunaryApp } from "./types/index.js";
19
2
  /**
20
3
  * Create a new Bunary HTTP application instance.
21
4
  *
@@ -38,15 +21,14 @@ import type { BunaryApp } from "./types.js";
38
21
  * return { id: ctx.params.id };
39
22
  * });
40
23
  *
41
- * // Query parameters
42
- * app.get("/search", (ctx) => {
43
- * return { query: ctx.query.get("q") };
24
+ * // Route groups
25
+ * app.group("/api", (router) => {
26
+ * router.get("/users", () => ({ users: [] }));
44
27
  * });
45
28
  *
46
- * // Custom Response
47
- * app.get("/custom", () => {
48
- * return new Response("Custom", { status: 201 });
49
- * });
29
+ * // Named routes
30
+ * app.get("/users/:id", (ctx) => ({ id: ctx.params.id })).name("users.show");
31
+ * const url = app.route("users.show", { id: 123 });
50
32
  *
51
33
  * app.listen(3000);
52
34
  * ```
package/dist/app.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,KAAK,EACX,SAAS,EAQT,MAAM,YAAY,CAAC;AA0FpB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,wBAAgB,SAAS,IAAI,SAAS,CAuOrC"}
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EACX,SAAS,EAYT,MAAM,kBAAkB,CAAC;AAE1B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,SAAS,IAAI,SAAS,CAmOrC"}
package/dist/index.d.ts CHANGED
@@ -18,6 +18,6 @@
18
18
  *
19
19
  * @packageDocumentation
20
20
  */
21
- export type { AppOptions, BunaryApp, BunaryServer, HandlerResponse, HttpMethod, Middleware, PathParams, QueryParams, RequestContext, Route, RouteHandler, } from "./types.js";
21
+ export type { AppOptions, BunaryApp, BunaryServer, GroupCallback, GroupOptions, GroupRouter, HandlerResponse, HttpMethod, Middleware, PathParams, QueryParams, RequestContext, Route, RouteBuilder, RouteHandler, RouteInfo, } from "./types/index.js";
22
22
  export { createApp } from "./app.js";
23
23
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAGH,YAAY,EACX,UAAU,EACV,SAAS,EACT,YAAY,EACZ,eAAe,EACf,UAAU,EACV,UAAU,EACV,UAAU,EACV,WAAW,EACX,cAAc,EACd,KAAK,EACL,YAAY,GACZ,MAAM,YAAY,CAAC;AAGpB,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAGH,YAAY,EACX,UAAU,EACV,SAAS,EACT,YAAY,EACZ,aAAa,EACb,YAAY,EACZ,WAAW,EACX,eAAe,EACf,UAAU,EACV,UAAU,EACV,UAAU,EACV,WAAW,EACX,cAAc,EACd,KAAK,EACL,YAAY,EACZ,YAAY,EACZ,SAAS,GACT,MAAM,kBAAkB,CAAC;AAG1B,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC"}
package/dist/index.js CHANGED
@@ -1,26 +1,5 @@
1
1
  // @bun
2
- // src/app.ts
3
- function compilePath(path) {
4
- const paramNames = [];
5
- const regexString = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_match, paramName) => {
6
- paramNames.push(paramName);
7
- return "([^/]+)";
8
- });
9
- return {
10
- pattern: new RegExp(`^${regexString}$`),
11
- paramNames
12
- };
13
- }
14
- function extractParams(path, route) {
15
- const match = path.match(route.pattern);
16
- if (!match)
17
- return {};
18
- const params = {};
19
- for (let i = 0;i < route.paramNames.length; i++) {
20
- params[route.paramNames[i]] = match[i + 1];
21
- }
22
- return params;
23
- }
2
+ // src/response.ts
24
3
  function toResponse(result) {
25
4
  if (result instanceof Response) {
26
5
  return result;
@@ -45,34 +24,296 @@ function toResponse(result) {
45
24
  headers: { "Content-Type": "application/json" }
46
25
  });
47
26
  }
27
+
28
+ // src/router.ts
29
+ function compilePath(path) {
30
+ const paramNames = [];
31
+ const optionalParams = [];
32
+ let regexString = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
33
+ regexString = regexString.replace(/\/:([a-zA-Z_][a-zA-Z0-9_]*)(\\\?)?/g, (_match, paramName, isOptional) => {
34
+ if (paramNames.includes(paramName)) {
35
+ throw new Error(`Duplicate parameter name ":${paramName}" in route pattern "${path}". Each parameter name must be unique within a route.`);
36
+ }
37
+ paramNames.push(paramName);
38
+ if (isOptional) {
39
+ optionalParams.push(paramName);
40
+ return "(?:/([^/]+))?";
41
+ }
42
+ return "/([^/]+)";
43
+ });
44
+ regexString += "/?";
45
+ return {
46
+ pattern: new RegExp(`^${regexString}$`),
47
+ paramNames,
48
+ optionalParams
49
+ };
50
+ }
51
+ function extractParams(path, route) {
52
+ const match = path.match(route.pattern);
53
+ if (!match)
54
+ return {};
55
+ const params = {};
56
+ for (let i = 0;i < route.paramNames.length; i++) {
57
+ const value = match[i + 1];
58
+ if (value !== undefined && value !== "") {
59
+ params[route.paramNames[i]] = value;
60
+ }
61
+ }
62
+ return params;
63
+ }
64
+ function checkConstraints(params, constraints) {
65
+ if (!constraints)
66
+ return true;
67
+ for (const [param, pattern] of Object.entries(constraints)) {
68
+ const value = params[param];
69
+ if (value === undefined)
70
+ continue;
71
+ if (!pattern.test(value))
72
+ return false;
73
+ }
74
+ return true;
75
+ }
76
+
77
+ // src/routes/builder.ts
78
+ function compilePattern(pattern, param) {
79
+ try {
80
+ return new RegExp(pattern);
81
+ } catch (error) {
82
+ const message = error instanceof Error ? error.message : "Invalid pattern";
83
+ throw new Error(`Invalid regex pattern for parameter "${param}": ${message}`);
84
+ }
85
+ }
86
+ function createRouteBuilder(route, namedRoutes, app) {
87
+ function addConstraint(param, pattern) {
88
+ if (!route.constraints) {
89
+ route.constraints = {};
90
+ }
91
+ route.constraints[param] = pattern;
92
+ }
93
+ const builder = {
94
+ get get() {
95
+ return app.get;
96
+ },
97
+ get post() {
98
+ return app.post;
99
+ },
100
+ get put() {
101
+ return app.put;
102
+ },
103
+ get delete() {
104
+ return app.delete;
105
+ },
106
+ get patch() {
107
+ return app.patch;
108
+ },
109
+ get use() {
110
+ return app.use;
111
+ },
112
+ get group() {
113
+ return app.group;
114
+ },
115
+ get route() {
116
+ return app.route;
117
+ },
118
+ get hasRoute() {
119
+ return app.hasRoute;
120
+ },
121
+ get getRoutes() {
122
+ return app.getRoutes;
123
+ },
124
+ get listen() {
125
+ return app.listen;
126
+ },
127
+ get fetch() {
128
+ return app.fetch;
129
+ },
130
+ name: (name) => {
131
+ if (namedRoutes.has(name)) {
132
+ throw new Error(`Route name "${name}" is already defined`);
133
+ }
134
+ route.name = name;
135
+ namedRoutes.set(name, route);
136
+ return builder;
137
+ },
138
+ where: (paramOrConstraints, pattern) => {
139
+ if (typeof paramOrConstraints === "string") {
140
+ if (!pattern) {
141
+ throw new Error(`Pattern is required for constraint on "${paramOrConstraints}"`);
142
+ }
143
+ const regex = typeof pattern === "string" ? compilePattern(pattern, paramOrConstraints) : pattern;
144
+ addConstraint(paramOrConstraints, regex);
145
+ } else {
146
+ for (const [param, pat] of Object.entries(paramOrConstraints)) {
147
+ const regex = typeof pat === "string" ? compilePattern(pat, param) : pat;
148
+ addConstraint(param, regex);
149
+ }
150
+ }
151
+ return builder;
152
+ },
153
+ whereNumber: (param) => {
154
+ addConstraint(param, /^\d+$/);
155
+ return builder;
156
+ },
157
+ whereAlpha: (param) => {
158
+ addConstraint(param, /^[a-zA-Z]+$/);
159
+ return builder;
160
+ },
161
+ whereAlphaNumeric: (param) => {
162
+ addConstraint(param, /^[a-zA-Z0-9]+$/);
163
+ return builder;
164
+ },
165
+ whereUuid: (param) => {
166
+ addConstraint(param, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
167
+ return builder;
168
+ },
169
+ whereUlid: (param) => {
170
+ addConstraint(param, /^[0-9A-HJKMNP-TV-Z]{26}$/);
171
+ return builder;
172
+ },
173
+ whereIn: (param, values) => {
174
+ if (values.length === 0) {
175
+ throw new Error(`whereIn requires at least one value for parameter "${param}"`);
176
+ }
177
+ const escaped = values.map((v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
178
+ addConstraint(param, new RegExp(`^(${escaped.join("|")})$`));
179
+ return builder;
180
+ }
181
+ };
182
+ return builder;
183
+ }
184
+ function wrapBuilderWithNamePrefix(builder, namePrefix) {
185
+ if (!namePrefix)
186
+ return builder;
187
+ return new Proxy(builder, {
188
+ get(target, prop) {
189
+ if (prop === "name") {
190
+ return (name) => {
191
+ return target.name(namePrefix + name);
192
+ };
193
+ }
194
+ return target[prop];
195
+ }
196
+ });
197
+ }
198
+ // src/routes/find.ts
199
+ function findRoute(routes, method, path) {
200
+ for (const route of routes) {
201
+ if (route.pattern.test(path)) {
202
+ if (route.method === method) {
203
+ const params = extractParams(path, route);
204
+ if (!checkConstraints(params, route.constraints)) {
205
+ continue;
206
+ }
207
+ return { route, params };
208
+ }
209
+ }
210
+ }
211
+ return null;
212
+ }
213
+ function hasMatchingPath(routes, path) {
214
+ return routes.some((route) => {
215
+ if (!route.pattern.test(path))
216
+ return false;
217
+ const params = extractParams(path, route);
218
+ return checkConstraints(params, route.constraints);
219
+ });
220
+ }
221
+ // src/pathUtils.ts
222
+ function normalizePrefix(prefix) {
223
+ let normalized = prefix;
224
+ if (!normalized.startsWith("/")) {
225
+ normalized = `/${normalized}`;
226
+ }
227
+ if (normalized.endsWith("/") && normalized.length > 1) {
228
+ normalized = normalized.slice(0, -1);
229
+ }
230
+ return normalized;
231
+ }
232
+ function joinPaths(prefix, path) {
233
+ const normalizedPrefix = normalizePrefix(prefix);
234
+ let normalizedPath = path;
235
+ if (!normalizedPath.startsWith("/") && normalizedPath !== "") {
236
+ normalizedPath = `/${normalizedPath}`;
237
+ }
238
+ if (normalizedPath === "/") {
239
+ return normalizedPrefix;
240
+ }
241
+ return normalizedPrefix + normalizedPath;
242
+ }
243
+
244
+ // src/routes/group.ts
245
+ function createGroupRouter(prefix, groupMiddleware, namePrefix, addRoute) {
246
+ const router = {
247
+ get: (path, handler) => {
248
+ const fullPath = joinPaths(prefix, path);
249
+ const builder = addRoute("GET", fullPath, handler, groupMiddleware);
250
+ return wrapBuilderWithNamePrefix(builder, namePrefix);
251
+ },
252
+ post: (path, handler) => {
253
+ const fullPath = joinPaths(prefix, path);
254
+ return wrapBuilderWithNamePrefix(addRoute("POST", fullPath, handler, groupMiddleware), namePrefix);
255
+ },
256
+ put: (path, handler) => {
257
+ const fullPath = joinPaths(prefix, path);
258
+ return wrapBuilderWithNamePrefix(addRoute("PUT", fullPath, handler, groupMiddleware), namePrefix);
259
+ },
260
+ delete: (path, handler) => {
261
+ const fullPath = joinPaths(prefix, path);
262
+ return wrapBuilderWithNamePrefix(addRoute("DELETE", fullPath, handler, groupMiddleware), namePrefix);
263
+ },
264
+ patch: (path, handler) => {
265
+ const fullPath = joinPaths(prefix, path);
266
+ return wrapBuilderWithNamePrefix(addRoute("PATCH", fullPath, handler, groupMiddleware), namePrefix);
267
+ },
268
+ group: (prefixOrOptions, callback) => {
269
+ const opts = typeof prefixOrOptions === "string" ? { prefix: prefixOrOptions } : prefixOrOptions;
270
+ const nestedPrefix = joinPaths(prefix, opts.prefix);
271
+ const nestedMiddleware = [...groupMiddleware, ...opts.middleware ?? []];
272
+ const nestedNamePrefix = namePrefix + (opts.name ?? "");
273
+ const nestedRouter = createGroupRouter(nestedPrefix, nestedMiddleware, nestedNamePrefix, addRoute);
274
+ callback(nestedRouter);
275
+ return router;
276
+ }
277
+ };
278
+ return router;
279
+ }
280
+ // src/app.ts
48
281
  function createApp() {
49
282
  const routes = [];
50
283
  const middlewares = [];
51
- function addRoute(method, path, handler) {
52
- const { pattern, paramNames } = compilePath(path);
53
- routes.push({ method, path, pattern, paramNames, handler });
54
- return app;
55
- }
56
- function findRoute(method, path) {
57
- for (const route of routes) {
58
- if (route.pattern.test(path)) {
59
- if (route.method === method) {
60
- return { route, params: extractParams(path, route) };
61
- }
62
- }
284
+ const namedRoutes = new Map;
285
+ let globalMiddlewareVersion = 0;
286
+ const middlewareCache = new WeakMap;
287
+ function getMiddlewareChain(route) {
288
+ const cached = middlewareCache.get(route);
289
+ if (cached && cached.version === globalMiddlewareVersion) {
290
+ return cached.chain;
63
291
  }
64
- return null;
292
+ const chain = route.middleware ? [...middlewares, ...route.middleware] : middlewares.length > 0 ? [...middlewares] : [];
293
+ middlewareCache.set(route, { version: globalMiddlewareVersion, chain });
294
+ return chain;
65
295
  }
66
- function hasMatchingPath(path) {
67
- return routes.some((route) => route.pattern.test(path));
296
+ function addRoute(method, path, handler, groupMiddleware = []) {
297
+ const { pattern, paramNames, optionalParams } = compilePath(path);
298
+ const route = {
299
+ method,
300
+ path,
301
+ pattern,
302
+ paramNames,
303
+ handler,
304
+ optionalParams: optionalParams.length > 0 ? optionalParams : undefined,
305
+ middleware: groupMiddleware.length > 0 ? [...groupMiddleware] : undefined
306
+ };
307
+ routes.push(route);
308
+ return createRouteBuilder(route, namedRoutes, app);
68
309
  }
69
310
  async function handleRequest(request) {
70
311
  const url = new URL(request.url);
71
312
  const path = url.pathname;
72
313
  const method = request.method;
73
- const match = findRoute(method, path);
314
+ const match = findRoute(routes, method, path);
74
315
  if (!match) {
75
- if (hasMatchingPath(path)) {
316
+ if (hasMatchingPath(routes, path)) {
76
317
  return new Response(JSON.stringify({ error: "Method not allowed" }), {
77
318
  status: 405,
78
319
  headers: { "Content-Type": "application/json" }
@@ -89,10 +330,11 @@ function createApp() {
89
330
  query: url.searchParams
90
331
  };
91
332
  try {
333
+ const allMiddleware = getMiddlewareChain(match.route);
92
334
  let index = 0;
93
335
  const next = async () => {
94
- if (index < middlewares.length) {
95
- const middleware = middlewares[index++];
336
+ if (index < allMiddleware.length) {
337
+ const middleware = allMiddleware[index++];
96
338
  return await middleware(ctx, next);
97
339
  }
98
340
  return await match.route.handler(ctx);
@@ -115,8 +357,67 @@ function createApp() {
115
357
  patch: (path, handler) => addRoute("PATCH", path, handler),
116
358
  use: (middleware) => {
117
359
  middlewares.push(middleware);
360
+ globalMiddlewareVersion++;
361
+ return app;
362
+ },
363
+ group: (prefixOrOptions, callback) => {
364
+ const opts = typeof prefixOrOptions === "string" ? { prefix: prefixOrOptions } : prefixOrOptions;
365
+ const groupRouter = createGroupRouter(opts.prefix, opts.middleware ?? [], opts.name ?? "", addRoute);
366
+ callback(groupRouter);
118
367
  return app;
119
368
  },
369
+ route: (name, params) => {
370
+ const route = namedRoutes.get(name);
371
+ if (!route) {
372
+ throw new Error(`Route "${name}" not found`);
373
+ }
374
+ if (params) {
375
+ for (const [key, value] of Object.entries(params)) {
376
+ const strValue = String(value);
377
+ if (strValue.includes("\r") || strValue.includes(`
378
+ `) || strValue.includes("\x00")) {
379
+ throw new Error(`Invalid character in parameter "${key}": control characters are not allowed`);
380
+ }
381
+ }
382
+ }
383
+ let url = route.path;
384
+ const queryParams = {};
385
+ const usedParams = new Set;
386
+ for (const paramName of route.paramNames) {
387
+ const isOptional = route.optionalParams?.includes(paramName);
388
+ const value = params?.[paramName];
389
+ if (value !== undefined) {
390
+ url = url.replace(new RegExp(`:${paramName}\\??`), encodeURIComponent(String(value)));
391
+ usedParams.add(paramName);
392
+ } else if (isOptional) {
393
+ url = url.replace(new RegExp(`/:${paramName}\\?`), "");
394
+ } else {
395
+ throw new Error(`Missing required param "${paramName}" for route "${name}"`);
396
+ }
397
+ }
398
+ if (params) {
399
+ for (const [key, value] of Object.entries(params)) {
400
+ if (!usedParams.has(key)) {
401
+ queryParams[key] = String(value);
402
+ }
403
+ }
404
+ }
405
+ if (Object.keys(queryParams).length > 0) {
406
+ const qs = new URLSearchParams(queryParams).toString();
407
+ url += `?${qs}`;
408
+ }
409
+ return url;
410
+ },
411
+ hasRoute: (name) => {
412
+ return namedRoutes.has(name);
413
+ },
414
+ getRoutes: () => {
415
+ return routes.map((route) => ({
416
+ name: route.name ?? null,
417
+ method: route.method,
418
+ path: route.path
419
+ }));
420
+ },
120
421
  listen: (port = 3000, hostname = "localhost") => {
121
422
  const server = Bun.serve({
122
423
  port,