@bunary/http 0.0.11 → 0.1.3

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/CHANGELOG.md CHANGED
@@ -5,6 +5,50 @@ All notable changes to `@bunary/http` will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.3] - 2026-02-15
9
+
10
+ ### Fixed
11
+
12
+ - Path parameters are now decoded with `decodeURIComponent` (#52)
13
+ - `hello%20world` → `"hello world"`, `caf%C3%A9` → `"café"`, emoji and CJK characters decoded correctly
14
+ - Malformed percent-sequences (e.g., `%ZZ`) gracefully return raw value
15
+ - Route constraints now check against decoded values
16
+ - `joinPaths("/", "/users")` no longer returns `"//users"` (#49)
17
+
18
+ ### Added
19
+
20
+ - 83 new unit tests for utility functions and URL encoding behaviour (#49, #52)
21
+ - Direct unit tests for `compilePath()`, `extractParams()`, `checkConstraints()`, `normalizePrefix()`, `joinPaths()`, `toResponse()`
22
+ - Full coverage of URL-encoded paths, unicode, double-encoding, and encoded slashes
23
+
24
+ ## [0.1.2] - 2026-02-15
25
+
26
+ ### Changed
27
+
28
+ - Single-pass route resolution replaces multiple scans of the route table (#42, #48)
29
+ - HEAD requests now resolve in one pass instead of three separate `findRoute()` calls
30
+ - 405 responses reuse allowed-methods collected during matching instead of a second full scan
31
+ - New internal `resolveRoute()` function combines matching, HEAD→GET fallback, and method collection
32
+ - No public API changes — purely internal performance improvement
33
+
34
+ ## [0.1.1] - 2026-02-15
35
+
36
+ ### Fixed
37
+
38
+ - Default error handler no longer leaks `error.message` in production (#44)
39
+ - Returns generic `"Internal Server Error"` when `NODE_ENV=production`
40
+ - Full error message still shown in development and test modes
41
+
42
+ ### Removed
43
+
44
+ - Removed internal `Route` type from public exports — use `RouteInfo` for route metadata (#45)
45
+
46
+ ## [0.1.0] - 2026-01-31
47
+
48
+ ### Added
49
+
50
+ - First minor release — API stable for development use until 1.0.0
51
+
8
52
  ## [0.0.11] - 2026-01-29
9
53
 
10
54
  ### Removed
package/README.md CHANGED
@@ -1,24 +1,6 @@
1
1
  # @bunary/http
2
2
 
3
- A lightweight, type-safe HTTP framework built exclusively for [Bun](https://bun.sh).
4
-
5
- Part of the [Bunary](https://github.com/bunary-dev) ecosystem - a Bun-first backend platform inspired by Laravel.
6
-
7
- ## Documentation
8
-
9
- Canonical documentation for this package lives in [`docs/index.md`](./docs/index.md).
10
-
11
- ## Features
12
-
13
- - 🚀 **Bun-native** - Uses `Bun.serve()` directly, no Node.js compatibility layer
14
- - 📦 **Zero dependencies** - No runtime dependencies
15
- - 🔒 **Type-safe** - Full TypeScript support with strict types
16
- - ⚡ **Fast** - Minimal overhead, direct routing
17
- - 🧩 **Simple API** - Chainable route registration with automatic JSON serialization
18
- - 📂 **Route Groups** - Organize routes with shared prefixes, middleware, and name prefixes
19
- - 🏷️ **Named Routes** - URL generation with route names
20
- - ✅ **Route Constraints** - Validate parameters with regex patterns
21
- - ❓ **Optional Parameters** - Flexible routes with optional path segments
3
+ Lightweight, type-safe HTTP framework for [Bun](https://bun.sh). Routes, middleware, groups, named routes, constraints. Full reference: [docs/index.md](./docs/index.md).
22
4
 
23
5
  ## Installation
24
6
 
@@ -32,490 +14,11 @@ bun add @bunary/http
32
14
  import { createApp } from '@bunary/http';
33
15
 
34
16
  const app = createApp();
35
-
36
17
  app.get('/hello', () => ({ message: 'Hello, Bun!' }));
37
-
38
18
  app.listen({ port: 3000 });
39
19
  ```
40
20
 
41
- ## API
42
-
43
- ### `createApp(options?)`
44
-
45
- Creates a new Bunary application instance.
46
-
47
- ```typescript
48
- import { createApp } from '@bunary/http';
49
-
50
- // Without basePath
51
- const app = createApp();
52
-
53
- // With basePath (prefixes all routes)
54
- const apiApp = createApp({ basePath: '/api' });
55
- apiApp.get('/users', () => ({})); // Matches /api/users
56
- ```
57
-
58
- **Options:**
59
- - `basePath` - Optional base path prefix for all routes (useful when mounting behind a reverse proxy)
60
- - Automatically normalized (leading slash added, trailing slash removed)
61
- - Composes with route groups: `basePath + group prefix + route path`
62
- - Included in `app.route()` URL generation
63
- - `onNotFound` - Custom handler for 404 Not Found responses
64
- - Called when no route matches the request path
65
- - Receives `RequestContext` (params empty, query available)
66
- - Can return `Response` or `HandlerResponse`
67
- - `onMethodNotAllowed` - Custom handler for 405 Method Not Allowed responses
68
- - Called when a route matches the path but not the HTTP method
69
- - Receives `RequestContext` and array of allowed methods
70
- - Can return `Response` or `HandlerResponse`
71
- - `Allow` header is automatically added if not present
72
- - `onError` - Custom handler for 500 Internal Server Error responses
73
- - Called when a route handler or middleware throws an error
74
- - Receives `RequestContext` and the error object
75
- - Can return `Response` or `HandlerResponse`
76
-
77
- **Example with custom error handlers:**
78
-
79
- ```typescript
80
- const app = createApp({
81
- basePath: '/api',
82
- onNotFound: (ctx) => {
83
- return new Response('Not Found', { status: 404 });
84
- },
85
- onMethodNotAllowed: (ctx, allowed) => {
86
- return new Response(
87
- JSON.stringify({ error: 'Method not allowed', allowed }),
88
- { status: 405, headers: { 'Content-Type': 'application/json' } }
89
- );
90
- },
91
- onError: (ctx, error) => {
92
- console.error('Request error:', error);
93
- return new Response('Internal Server Error', { status: 500 });
94
- }
95
- });
96
- ```
97
-
98
- ### Route Registration
99
-
100
- Register routes using chainable HTTP method helpers:
101
-
102
- ```typescript
103
- app
104
- .get('/users', () => ({ users: [] }))
105
- .post('/users', async (ctx) => {
106
- const body = await ctx.request.json();
107
- return { id: 1, ...body };
108
- })
109
- .put('/users/:id', (ctx) => {
110
- return { id: ctx.params.id, updated: true };
111
- })
112
- .delete('/users/:id', (ctx) => {
113
- return { deleted: ctx.params.id };
114
- })
115
- .patch('/users/:id', (ctx) => {
116
- return { patched: ctx.params.id };
117
- });
118
- ```
119
-
120
- ### Path Parameters
121
-
122
- Path parameters are extracted automatically:
123
-
124
- ```typescript
125
- app.get('/users/:id', (ctx) => {
126
- return { userId: ctx.params.id };
127
- });
128
-
129
- app.get('/posts/:postId/comments/:commentId', (ctx) => {
130
- const { postId, commentId } = ctx.params;
131
- return { postId, commentId };
132
- });
133
- ```
134
-
135
- ### Query Parameters
136
-
137
- Query parameters are accessed via `URLSearchParams` API:
138
-
139
- ```typescript
140
- app.get('/search', (ctx) => {
141
- const q = ctx.query.get('q');
142
- const page = ctx.query.get('page');
143
- const limit = ctx.query.get('limit');
144
- return { query: q, page, limit };
145
- });
146
- ```
147
-
148
- For multi-value query parameters (e.g., `?tag=a&tag=b`), use `getAll()`:
149
-
150
- ```typescript
151
- app.get('/filter', (ctx) => {
152
- const tags = ctx.query.getAll('tag');
153
- return { tags };
154
- });
155
- ```
156
-
157
- ### Request Context
158
-
159
- Route handlers receive a `RequestContext` object:
160
-
161
- ```typescript
162
- interface RequestContext {
163
- request: Request; // Original Bun Request object
164
- params: Record<string, string>; // Path parameters
165
- query: URLSearchParams; // Query parameters (use .get() and .getAll())
166
- }
167
- ```
168
-
169
- ### HTTP Method Handling
170
-
171
- #### HEAD Requests
172
-
173
- HEAD requests are automatically handled for GET routes. They return the same status code and headers as the corresponding GET request, but with an empty body:
174
-
175
- ```typescript
176
- app.get('/users', () => ({ users: [] }));
177
-
178
- // HEAD /users returns 200 with empty body
179
- // Preserves all headers from GET handler
180
- ```
181
-
182
- #### OPTIONS Requests
183
-
184
- OPTIONS requests return `204 No Content` with an `Allow` header listing all permitted methods for the path:
185
-
186
- ```typescript
187
- app.get('/users', () => ({}));
188
- app.post('/users', () => ({}));
189
- app.delete('/users', () => ({}));
190
-
191
- // OPTIONS /users returns:
192
- // Status: 204
193
- // Allow: DELETE, GET, POST
194
- ```
195
-
196
- If no route matches the path, OPTIONS returns `404`.
197
-
198
- #### Method Not Allowed (405)
199
-
200
- When a path exists but the requested method is not allowed, the response includes an `Allow` header:
201
-
202
- ```typescript
203
- app.get('/users', () => ({}));
204
- app.post('/users', () => ({}));
205
-
206
- // PUT /users returns:
207
- // Status: 405 Method Not Allowed
208
- // Allow: GET, POST
209
- ```
210
-
211
- ### Response Handling
212
-
213
- Handlers can return various types - they're automatically serialized:
214
-
215
- ```typescript
216
- // Objects/Arrays → JSON with Content-Type: application/json
217
- app.get('/json', () => ({ data: 'value' }));
218
-
219
- // Strings → text/plain
220
- app.get('/text', () => 'Hello, world!');
221
-
222
- // Response objects passed through unchanged
223
- app.get('/custom', () => new Response('Custom', { status: 201 }));
224
-
225
- // null/undefined → 204 No Content
226
- app.get('/empty', () => null);
227
- ```
228
-
229
- ### Starting the Server
230
-
231
- Both object and positional forms are supported:
232
-
233
- ```typescript
234
- // Object form (recommended)
235
- const server = app.listen({ port: 3000, hostname: 'localhost' });
236
-
237
- // Positional form
238
- const server = app.listen(3000, 'localhost');
239
-
240
- console.log(`Server running on ${server.hostname}:${server.port}`);
241
-
242
- // Stop the server when done
243
- server.stop();
244
- ```
245
-
246
- ### Testing Without Server
247
-
248
- Use `app.fetch()` to test handlers directly:
249
-
250
- ```typescript
251
- const app = createApp();
252
- app.get('/hello', () => ({ message: 'hi' }));
253
-
254
- const response = await app.fetch(new Request('http://localhost/hello'));
255
- const data = await response.json();
256
- // { message: 'hi' }
257
- ```
258
-
259
- ## Middleware
260
-
261
- Add middleware to handle cross-cutting concerns like logging, authentication, and error handling.
262
-
263
- ### Basic Middleware
264
-
265
- ```typescript
266
- // Logging middleware
267
- app.use(async (ctx, next) => {
268
- const start = Date.now();
269
- const result = await next();
270
- console.log(`${ctx.request.method} ${new URL(ctx.request.url).pathname} - ${Date.now() - start}ms`);
271
- return result;
272
- });
273
- ```
274
-
275
- ### Middleware Chain
276
-
277
- Middleware executes in registration order. Each middleware can:
278
- - Run code before calling `next()`
279
- - Call `next()` to continue the chain
280
- - Run code after `next()` returns
281
- - Return early without calling `next()`
282
-
283
- ```typescript
284
- app
285
- .use(async (ctx, next) => {
286
- console.log('First - before');
287
- const result = await next();
288
- console.log('First - after');
289
- return result;
290
- })
291
- .use(async (ctx, next) => {
292
- console.log('Second - before');
293
- const result = await next();
294
- console.log('Second - after');
295
- return result;
296
- });
297
-
298
- // Output order: First-before, Second-before, handler, Second-after, First-after
299
- ```
300
-
301
- ### Error Handling Middleware
302
-
303
- ```typescript
304
- app.use(async (ctx, next) => {
305
- try {
306
- return await next();
307
- } catch (error) {
308
- console.error('Error:', error);
309
- return new Response(JSON.stringify({ error: error.message }), {
310
- status: 500,
311
- headers: { 'Content-Type': 'application/json' }
312
- });
313
- }
314
- });
315
- ```
316
-
317
- ### Auth Middleware (Example)
318
-
319
- ```typescript
320
- app.use(async (ctx, next) => {
321
- const token = ctx.request.headers.get('Authorization');
322
- if (!token) {
323
- return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
324
- }
325
- // Validate token...
326
- return await next();
327
- });
328
- ```
329
-
330
- ## Route Groups
331
-
332
- Group routes together with shared prefixes, middleware, and name prefixes.
333
-
334
- ### Basic Groups
335
-
336
- ```typescript
337
- // Simple prefix
338
- app.group('/api', (router) => {
339
- router.get('/users', () => ({ users: [] })); // /api/users
340
- router.get('/posts', () => ({ posts: [] })); // /api/posts
341
- });
342
- ```
343
-
344
- ### Groups with Options
345
-
346
- ```typescript
347
- // Auth middleware for protected routes
348
- const authMiddleware = async (ctx, next) => {
349
- const token = ctx.request.headers.get('Authorization');
350
- if (!token) return new Response('Unauthorized', { status: 401 });
351
- return await next();
352
- };
353
-
354
- app.group({
355
- prefix: '/admin',
356
- middleware: [authMiddleware],
357
- name: 'admin.'
358
- }, (router) => {
359
- router.get('/dashboard', () => ({})).name('dashboard'); // name: admin.dashboard
360
- router.get('/users', () => ({})).name('users'); // name: admin.users
361
- });
362
- ```
363
-
364
- ### Nested Groups
365
-
366
- ```typescript
367
- app.group('/api', (api) => {
368
- api.group('/v1', (v1) => {
369
- v1.get('/users', () => ({})); // /api/v1/users
370
- });
371
- api.group('/v2', (v2) => {
372
- v2.get('/users', () => ({})); // /api/v2/users
373
- });
374
- });
375
- ```
376
-
377
- ## Named Routes
378
-
379
- Assign names to routes for URL generation.
380
-
381
- ### Naming Routes
382
-
383
- ```typescript
384
- app.get('/users/:id', (ctx) => ({})).name('users.show');
385
- app.get('/posts/:slug', (ctx) => ({})).name('posts.show');
386
- ```
387
-
388
- ### Generating URLs
389
-
390
- ```typescript
391
- // Basic URL generation
392
- const url = app.route('users.show', { id: 42 });
393
- // "/users/42"
394
-
395
- // With query string
396
- const searchUrl = app.route('users.show', { id: 42, tab: 'profile' });
397
- // "/users/42?tab=profile"
398
-
399
- // Check if route exists
400
- if (app.hasRoute('users.show')) {
401
- // ...
402
- }
403
-
404
- // List all routes
405
- const routes = app.getRoutes();
406
- // [{ name: 'users.show', method: 'GET', path: '/users/:id' }, ...]
407
- ```
408
-
409
- ## Route Constraints
410
-
411
- Add regex constraints to validate route parameters.
412
-
413
- ### Basic Constraints
414
-
415
- ```typescript
416
- // Only match if :id is numeric
417
- app.get('/users/:id', (ctx) => ({}))
418
- .where('id', /^\d+$/);
419
-
420
- // Using string pattern
421
- app.get('/posts/:slug', (ctx) => ({}))
422
- .where('slug', '^[a-z0-9-]+$');
423
-
424
- // Multiple constraints
425
- app.get('/users/:id/posts/:postId', (ctx) => ({}))
426
- .where({ id: /^\d+$/, postId: /^\d+$/ });
427
- ```
428
-
429
- ### Helper Methods
430
-
431
- ```typescript
432
- // whereNumber - digits only
433
- app.get('/users/:id', () => ({})).whereNumber('id');
434
-
435
- // whereAlpha - letters only (a-zA-Z)
436
- app.get('/categories/:name', () => ({})).whereAlpha('name');
437
-
438
- // whereAlphaNumeric - letters and digits
439
- app.get('/codes/:code', () => ({})).whereAlphaNumeric('code');
440
-
441
- // whereUuid - UUID format
442
- app.get('/items/:uuid', () => ({})).whereUuid('uuid');
443
-
444
- // whereUlid - ULID format
445
- app.get('/records/:ulid', () => ({})).whereUlid('ulid');
446
-
447
- // whereIn - specific allowed values
448
- app.get('/status/:status', () => ({})).whereIn('status', ['active', 'pending', 'archived']);
449
- ```
450
-
451
- ### Chaining Constraints
452
-
453
- ```typescript
454
- app.get('/users/:id/posts/:slug', (ctx) => ({}))
455
- .whereNumber('id')
456
- .whereAlpha('slug')
457
- .name('users.posts');
458
- ```
459
-
460
- ## Optional Parameters
461
-
462
- Use `?` to mark route parameters as optional.
463
-
464
- ```typescript
465
- // :id is optional
466
- app.get('/users/:id?', (ctx) => {
467
- if (ctx.params.id) {
468
- return { user: ctx.params.id };
469
- }
470
- return { users: [] };
471
- });
472
-
473
- // Multiple optional params
474
- app.get('/archive/:year?/:month?', (ctx) => {
475
- const { year, month } = ctx.params;
476
- // year and month may be undefined
477
- return { year, month };
478
- });
479
-
480
- // Constraints work with optional params
481
- app.get('/posts/:id?', (ctx) => ({})).whereNumber('id');
482
- ```
483
-
484
- ## Error Handling
485
-
486
- Uncaught errors in handlers return a 500 response with the error message:
487
-
488
- ```typescript
489
- app.get('/error', () => {
490
- throw new Error('Something went wrong');
491
- });
492
-
493
- // Returns: 500 Internal Server Error
494
- // Body: { error: "Something went wrong" }
495
- ```
496
-
497
- ## Types
498
-
499
- All types are exported for TypeScript users:
500
-
501
- ```typescript
502
- import type {
503
- BunaryApp,
504
- BunaryServer,
505
- RequestContext,
506
- RouteHandler,
507
- Middleware,
508
- RouteBuilder,
509
- GroupOptions,
510
- GroupRouter,
511
- GroupCallback,
512
- RouteInfo
513
- } from '@bunary/http';
514
- ```
515
-
516
- ## Requirements
517
-
518
- - Bun ≥ 1.0.0
21
+ For createApp options, route groups, middleware, named routes, and types, see [docs/index.md](./docs/index.md).
519
22
 
520
23
  ## License
521
24
 
package/dist/app.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EACX,UAAU,EACV,SAAS,EAWT,MAAM,kBAAkB,CAAC;AAE1B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,wBAAgB,SAAS,CAAC,OAAO,CAAC,EAAE,UAAU,GAAG,SAAS,CAiPzD"}
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EACX,UAAU,EACV,SAAS,EAWT,MAAM,kBAAkB,CAAC;AAE1B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,wBAAgB,SAAS,CAAC,OAAO,CAAC,EAAE,UAAU,GAAG,SAAS,CA+OzD"}
@@ -2,6 +2,10 @@ import type { AppOptions, RequestContext } from "../types/index.js";
2
2
  /**
3
3
  * Handle 500 Internal Server Error responses.
4
4
  * Uses custom onError handler if provided, otherwise returns default JSON response.
5
+ *
6
+ * In production (`NODE_ENV=production`), the default handler returns a generic
7
+ * "Internal Server Error" message to avoid leaking sensitive information.
8
+ * In development and test, the full error message is included.
5
9
  */
6
10
  export declare function handleError(ctx: RequestContext, error: unknown, options?: AppOptions): Promise<Response>;
7
11
  //# sourceMappingURL=error.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"error.d.ts","sourceRoot":"","sources":["../../src/handlers/error.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAEpE;;;GAGG;AACH,wBAAsB,WAAW,CAChC,GAAG,EAAE,cAAc,EACnB,KAAK,EAAE,OAAO,EACd,OAAO,CAAC,EAAE,UAAU,GAClB,OAAO,CAAC,QAAQ,CAAC,CAUnB"}
1
+ {"version":3,"file":"error.d.ts","sourceRoot":"","sources":["../../src/handlers/error.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAEpE;;;;;;;GAOG;AACH,wBAAsB,WAAW,CAChC,GAAG,EAAE,cAAc,EACnB,KAAK,EAAE,OAAO,EACd,OAAO,CAAC,EAAE,UAAU,GAClB,OAAO,CAAC,QAAQ,CAAC,CAenB"}
@@ -1,5 +1,5 @@
1
1
  export { handleError } from "./error.js";
2
- export { normalizeHeadMethod, toHeadResponse } from "./head.js";
2
+ export { toHeadResponse } from "./head.js";
3
3
  export { handleMethodNotAllowed } from "./methodNotAllowed.js";
4
4
  export { handleNotFound } from "./notFound.js";
5
5
  export { handleOptions } from "./options.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/handlers/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAChE,OAAO,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/handlers/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC"}
@@ -3,6 +3,9 @@ import type { AppOptions, Route } from "../types/index.js";
3
3
  * Handle 405 Method Not Allowed responses.
4
4
  * Uses custom onMethodNotAllowed handler if provided, otherwise returns default JSON response.
5
5
  * Ensures Allow header is always present.
6
+ *
7
+ * @param precomputed - Pre-computed allowed methods from resolveRoute() to avoid re-scanning.
8
+ * Falls back to scanning routes if not provided.
6
9
  */
7
- export declare function handleMethodNotAllowed(request: Request, path: string, routes: Route[], options?: AppOptions): Promise<Response>;
10
+ export declare function handleMethodNotAllowed(request: Request, path: string, routes: Route[], options?: AppOptions, precomputed?: string[]): Promise<Response>;
8
11
  //# sourceMappingURL=methodNotAllowed.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"methodNotAllowed.d.ts","sourceRoot":"","sources":["../../src/handlers/methodNotAllowed.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAkB,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAE3E;;;;GAIG;AACH,wBAAsB,sBAAsB,CAC3C,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,KAAK,EAAE,EACf,OAAO,CAAC,EAAE,UAAU,GAClB,OAAO,CAAC,QAAQ,CAAC,CAgCnB"}
1
+ {"version":3,"file":"methodNotAllowed.d.ts","sourceRoot":"","sources":["../../src/handlers/methodNotAllowed.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAkB,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAE3E;;;;;;;GAOG;AACH,wBAAsB,sBAAsB,CAC3C,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,KAAK,EAAE,EACf,OAAO,CAAC,EAAE,UAAU,EACpB,WAAW,CAAC,EAAE,MAAM,EAAE,GACpB,OAAO,CAAC,QAAQ,CAAC,CAgCnB"}
@@ -2,6 +2,9 @@ import type { AppOptions, Route } from "../types/index.js";
2
2
  /**
3
3
  * Handle OPTIONS requests.
4
4
  * Returns 204 with Allow header if path exists, otherwise delegates to 404 handler.
5
+ *
6
+ * Uses a single getAllowedMethods() scan — if the result is non-empty the path
7
+ * exists, avoiding a separate hasMatchingPath() pass.
5
8
  */
6
9
  export declare function handleOptions(request: Request, path: string, routes: Route[], options?: AppOptions): Promise<Response>;
7
10
  //# sourceMappingURL=options.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"options.d.ts","sourceRoot":"","sources":["../../src/handlers/options.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAG3D;;;GAGG;AACH,wBAAsB,aAAa,CAClC,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,KAAK,EAAE,EACf,OAAO,CAAC,EAAE,UAAU,GAClB,OAAO,CAAC,QAAQ,CAAC,CAUnB"}
1
+ {"version":3,"file":"options.d.ts","sourceRoot":"","sources":["../../src/handlers/options.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAG3D;;;;;;GAMG;AACH,wBAAsB,aAAa,CAClC,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,KAAK,EAAE,EACf,OAAO,CAAC,EAAE,UAAU,GAClB,OAAO,CAAC,QAAQ,CAAC,CAUnB"}
package/dist/index.d.ts CHANGED
@@ -19,5 +19,5 @@
19
19
  * @packageDocumentation
20
20
  */
21
21
  export { createApp } from "./app.js";
22
- export type { AppOptions, BunaryApp, BunaryServer, GroupCallback, GroupOptions, GroupRouter, HandlerResponse, HttpMethod, ListenOptions, Middleware, PathParams, RequestContext, Route, RouteBuilder, RouteHandler, RouteInfo, } from "./types/index.js";
22
+ export type { AppOptions, BunaryApp, BunaryServer, GroupCallback, GroupOptions, GroupRouter, HandlerResponse, HttpMethod, ListenOptions, Middleware, PathParams, RequestContext, RouteBuilder, RouteHandler, RouteInfo, } from "./types/index.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,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAErC,YAAY,EACX,UAAU,EACV,SAAS,EACT,YAAY,EACZ,aAAa,EACb,YAAY,EACZ,WAAW,EACX,eAAe,EACf,UAAU,EACV,aAAa,EACb,UAAU,EACV,UAAU,EACV,cAAc,EACd,KAAK,EACL,YAAY,EACZ,YAAY,EACZ,SAAS,GACT,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAGH,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAErC,YAAY,EACX,UAAU,EACV,SAAS,EACT,YAAY,EACZ,aAAa,EACb,YAAY,EACZ,WAAW,EACX,eAAe,EACf,UAAU,EACV,aAAa,EACb,UAAU,EACV,UAAU,EACV,cAAc,EACd,YAAY,EACZ,YAAY,EACZ,SAAS,GACT,MAAM,kBAAkB,CAAC"}
package/dist/index.js CHANGED
@@ -31,7 +31,8 @@ async function handleError(ctx, error, options) {
31
31
  const result = await options.onError(ctx, error);
32
32
  return toResponse(result);
33
33
  }
34
- const message = error instanceof Error ? error.message : "Internal server error";
34
+ const isProduction = Bun.env.NODE_ENV === "production";
35
+ const message = isProduction ? "Internal Server Error" : error instanceof Error ? error.message : "Internal server error";
35
36
  return new Response(JSON.stringify({ error: message }), {
36
37
  status: 500,
37
38
  headers: { "Content-Type": "application/json" }
@@ -181,6 +182,13 @@ function compilePath(path) {
181
182
  optionalParams
182
183
  };
183
184
  }
185
+ function safeDecodeURIComponent(value) {
186
+ try {
187
+ return decodeURIComponent(value);
188
+ } catch {
189
+ return value;
190
+ }
191
+ }
184
192
  function extractParams(path, route) {
185
193
  const match = path.match(route.pattern);
186
194
  if (!match)
@@ -189,7 +197,7 @@ function extractParams(path, route) {
189
197
  for (let i = 0;i < route.paramNames.length; i++) {
190
198
  const value = match[i + 1];
191
199
  if (value !== undefined && value !== "") {
192
- params[route.paramNames[i]] = value;
200
+ params[route.paramNames[i]] = safeDecodeURIComponent(value);
193
201
  }
194
202
  }
195
203
  return params;
@@ -208,27 +216,30 @@ function checkConstraints(params, constraints) {
208
216
  }
209
217
 
210
218
  // src/routes/find.ts
211
- function findRoute(routes, method, path) {
219
+ function resolveRoute(routes, method, path) {
220
+ let getFallback = null;
221
+ const allowedMethods = new Set;
212
222
  for (const route of routes) {
213
- if (route.pattern.test(path)) {
214
- if (route.method === method) {
215
- const params = extractParams(path, route);
216
- if (!checkConstraints(params, route.constraints)) {
217
- continue;
218
- }
219
- return { route, params };
220
- }
221
- }
222
- }
223
- return null;
224
- }
225
- function hasMatchingPath(routes, path) {
226
- return routes.some((route) => {
227
223
  if (!route.pattern.test(path))
228
- return false;
224
+ continue;
229
225
  const params = extractParams(path, route);
230
- return checkConstraints(params, route.constraints);
231
- });
226
+ if (!checkConstraints(params, route.constraints))
227
+ continue;
228
+ allowedMethods.add(route.method);
229
+ if (route.method === method) {
230
+ return { match: { route, params }, allowedMethods: [] };
231
+ }
232
+ if (method === "HEAD" && route.method === "GET" && !getFallback) {
233
+ getFallback = { route, params };
234
+ }
235
+ }
236
+ if (getFallback) {
237
+ return { match: getFallback, allowedMethods: [] };
238
+ }
239
+ return {
240
+ match: null,
241
+ allowedMethods: Array.from(allowedMethods).sort()
242
+ };
232
243
  }
233
244
  function getAllowedMethods(routes, path) {
234
245
  const methods = new Set;
@@ -262,6 +273,9 @@ function joinPaths(prefix, path) {
262
273
  if (normalizedPath === "/") {
263
274
  return normalizedPrefix;
264
275
  }
276
+ if (normalizedPrefix === "/") {
277
+ return normalizedPath;
278
+ }
265
279
  return normalizedPrefix + normalizedPath;
266
280
  }
267
281
 
@@ -302,20 +316,6 @@ function createGroupRouter(prefix, groupMiddleware, namePrefix, addRoute) {
302
316
  return router;
303
317
  }
304
318
  // src/handlers/head.ts
305
- function normalizeHeadMethod(method, path, routes) {
306
- if (method !== "HEAD") {
307
- return method;
308
- }
309
- const headMatch = findRoute(routes, "HEAD", path);
310
- if (headMatch) {
311
- return "HEAD";
312
- }
313
- const getMatch = findRoute(routes, "GET", path);
314
- if (getMatch) {
315
- return "GET";
316
- }
317
- return "HEAD";
318
- }
319
319
  function toHeadResponse(response) {
320
320
  return new Response(null, {
321
321
  status: response.status,
@@ -324,9 +324,9 @@ function toHeadResponse(response) {
324
324
  });
325
325
  }
326
326
  // src/handlers/methodNotAllowed.ts
327
- async function handleMethodNotAllowed(request, path, routes, options) {
327
+ async function handleMethodNotAllowed(request, path, routes, options, precomputed) {
328
328
  const url = new URL(request.url);
329
- const allowedMethods = getAllowedMethods(routes, path);
329
+ const allowedMethods = precomputed ?? getAllowedMethods(routes, path);
330
330
  const methodNotAllowedCtx = {
331
331
  request,
332
332
  params: {},
@@ -376,8 +376,8 @@ async function handleNotFound(request, _path, options) {
376
376
  }
377
377
  // src/handlers/options.ts
378
378
  async function handleOptions(request, path, routes, options) {
379
- if (hasMatchingPath(routes, path)) {
380
- const allowedMethods = getAllowedMethods(routes, path);
379
+ const allowedMethods = getAllowedMethods(routes, path);
380
+ if (allowedMethods.length > 0) {
381
381
  return new Response(null, {
382
382
  status: 204,
383
383
  headers: { Allow: allowedMethods.join(", ") }
@@ -439,11 +439,10 @@ function createApp(options) {
439
439
  if (method === "OPTIONS") {
440
440
  return await handleOptions(request, path, routes, options);
441
441
  }
442
- const actualMethod = normalizeHeadMethod(method, path, routes);
443
- const match = findRoute(routes, actualMethod, path);
442
+ const { match, allowedMethods } = resolveRoute(routes, method, path);
444
443
  if (!match) {
445
- if (hasMatchingPath(routes, path)) {
446
- return await handleMethodNotAllowed(request, path, routes, options);
444
+ if (allowedMethods.length > 0) {
445
+ return await handleMethodNotAllowed(request, path, routes, options, allowedMethods);
447
446
  }
448
447
  return await handleNotFound(request, path, options);
449
448
  }
@@ -1 +1 @@
1
- {"version":3,"file":"pathUtils.d.ts","sourceRoot":"","sources":["../src/pathUtils.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAStD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAc9D"}
1
+ {"version":3,"file":"pathUtils.d.ts","sourceRoot":"","sources":["../src/pathUtils.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAStD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAmB9D"}
package/dist/router.d.ts CHANGED
@@ -32,10 +32,11 @@ export declare function compilePath(path: string): CompiledPath;
32
32
  /**
33
33
  * Extract path parameters from a matched route.
34
34
  * Handles optional parameters by only including them if they have values.
35
+ * Applies `decodeURIComponent` to each captured value (standard behaviour).
35
36
  *
36
37
  * @param path - The request path
37
38
  * @param route - The matched route
38
- * @returns Record of parameter names to values (undefined for missing optional params)
39
+ * @returns Record of parameter names to decoded values (undefined for missing optional params)
39
40
  */
40
41
  export declare function extractParams(path: string, route: Route): Record<string, string | undefined>;
41
42
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAE9C;;GAEG;AACH,MAAM,WAAW,YAAY;IAC5B,iCAAiC;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,+BAA+B;IAC/B,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,mCAAmC;IACnC,cAAc,EAAE,MAAM,EAAE,CAAC;CACzB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAmCtD;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAa5F;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,EAC1C,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAClC,OAAO,CAUT"}
1
+ {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAE9C;;GAEG;AACH,MAAM,WAAW,YAAY;IAC5B,iCAAiC;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,+BAA+B;IAC/B,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,mCAAmC;IACnC,cAAc,EAAE,MAAM,EAAE,CAAC;CACzB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAmCtD;AAgBD;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAa5F;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,EAC1C,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAClC,OAAO,CAUT"}
@@ -3,6 +3,29 @@ export interface RouteMatch {
3
3
  route: Route;
4
4
  params: Record<string, string | undefined>;
5
5
  }
6
+ /**
7
+ * Result of a single-pass route resolution.
8
+ *
9
+ * Combines route matching, HEAD→GET fallback, and allowed-method
10
+ * collection into one scan of the route table.
11
+ */
12
+ export interface RouteResolution {
13
+ /** The matched route and extracted params, or null if no match */
14
+ match: RouteMatch | null;
15
+ /** Methods that match this path (populated when match is null and path exists for other methods) */
16
+ allowedMethods: string[];
17
+ }
18
+ /**
19
+ * Resolve a route in a single pass through the route table.
20
+ *
21
+ * For HEAD requests this also checks for a GET fallback.
22
+ * When no exact match is found it collects all methods whose path
23
+ * pattern matches (with constraints), enabling 405 responses and
24
+ * OPTIONS Allow headers without a second scan.
25
+ *
26
+ * **Complexity**: O(n) — one pass regardless of outcome.
27
+ */
28
+ export declare function resolveRoute(routes: Route[], method: string, path: string): RouteResolution;
6
29
  /**
7
30
  * Find a matching route for the given method and path.
8
31
  *
@@ -1 +1 @@
1
- {"version":3,"file":"find.d.ts","sourceRoot":"","sources":["../../src/routes/find.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAE/C,MAAM,WAAW,UAAU;IAC1B,KAAK,EAAE,KAAK,CAAC;IACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;CAC3C;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAc1F;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAMtE;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAYzE"}
1
+ {"version":3,"file":"find.d.ts","sourceRoot":"","sources":["../../src/routes/find.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAE/C,MAAM,WAAW,UAAU;IAC1B,KAAK,EAAE,KAAK,CAAC;IACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;CAC3C;AAED;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC/B,kEAAkE;IAClE,KAAK,EAAE,UAAU,GAAG,IAAI,CAAC;IACzB,oGAAoG;IACpG,cAAc,EAAE,MAAM,EAAE,CAAC;CACzB;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,eAAe,CAiC3F;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAc1F;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAMtE;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAYzE"}
@@ -1,4 +1,4 @@
1
1
  export { compilePattern, createRouteBuilder, wrapBuilderWithNamePrefix } from "./builder.js";
2
- export { findRoute, getAllowedMethods, hasMatchingPath, type RouteMatch } from "./find.js";
2
+ export { findRoute, getAllowedMethods, hasMatchingPath, type RouteMatch, type RouteResolution, resolveRoute, } from "./find.js";
3
3
  export { type AddRouteFn, createGroupRouter } from "./group.js";
4
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/routes/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,kBAAkB,EAAE,yBAAyB,EAAE,MAAM,cAAc,CAAC;AAC7F,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAE,eAAe,EAAE,KAAK,UAAU,EAAE,MAAM,WAAW,CAAC;AAC3F,OAAO,EAAE,KAAK,UAAU,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/routes/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,kBAAkB,EAAE,yBAAyB,EAAE,MAAM,cAAc,CAAC;AAC7F,OAAO,EACN,SAAS,EACT,iBAAiB,EACjB,eAAe,EACf,KAAK,UAAU,EACf,KAAK,eAAe,EACpB,YAAY,GACZ,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,KAAK,UAAU,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bunary/http",
3
- "version": "0.0.11",
3
+ "version": "0.1.3",
4
4
  "description": "HTTP routing and middleware for Bunary - a Bun-first backend framework inspired by Laravel",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",