@stacksjs/bun-router 0.0.13 → 0.0.15

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/dist/index.js CHANGED
@@ -66,7 +66,7 @@ import {
66
66
  signRequest,
67
67
  singleFileUpload,
68
68
  verifySignature
69
- } from "./chunk-1mvtv6jw.js";
69
+ } from "./chunk-j0e7z7hd.js";
70
70
 
71
71
  // src/auth.ts
72
72
  function base64UrlEncode(str) {
@@ -3915,12 +3915,19 @@ function registerFileBasedRouting(RouterClass) {
3915
3915
  async value() {
3916
3916
  if (this._fileRoutesInitialized)
3917
3917
  return;
3918
+ if (!Object.prototype.hasOwnProperty.call(this, "_fileBasedRoutes")) {
3919
+ this._fileBasedRoutes = [];
3920
+ }
3918
3921
  const viewsDir = this._viewsDir || detectViewsDirectory();
3919
3922
  if (!viewsDir) {
3920
3923
  this._fileRoutesInitialized = true;
3921
3924
  return;
3922
3925
  }
3923
3926
  const config2 = this._fileRoutingConfig || {};
3927
+ if (config2.enabled === false) {
3928
+ this._fileRoutesInitialized = true;
3929
+ return;
3930
+ }
3924
3931
  const extensions = config2.extensions || DEFAULT_EXTENSIONS;
3925
3932
  const exclude = config2.exclude || DEFAULT_EXCLUDES;
3926
3933
  if (!hasRoutableFiles(viewsDir, extensions, exclude)) {
@@ -4291,6 +4298,11 @@ class FileStreamHandler {
4291
4298
  }
4292
4299
 
4293
4300
  // src/router/file-streaming.ts
4301
+ function closeReadableStreamAfterError(controller, error) {
4302
+ try {
4303
+ controller.close();
4304
+ } catch {}
4305
+ }
4294
4306
  function registerFileStreaming(RouterClass) {
4295
4307
  Object.defineProperties(RouterClass.prototype, {
4296
4308
  streamFile: {
@@ -4390,7 +4402,7 @@ function registerFileStreaming(RouterClass) {
4390
4402
  }
4391
4403
  controller.close();
4392
4404
  } catch (error) {
4393
- controller.error(error);
4405
+ closeReadableStreamAfterError(controller, error);
4394
4406
  }
4395
4407
  }
4396
4408
  });
@@ -4434,7 +4446,7 @@ function registerFileStreaming(RouterClass) {
4434
4446
  controller.enqueue(encoder.encode("}"));
4435
4447
  controller.close();
4436
4448
  } catch (error) {
4437
- controller.error(error);
4449
+ closeReadableStreamAfterError(controller, error);
4438
4450
  }
4439
4451
  }
4440
4452
  });
@@ -4482,7 +4494,7 @@ function registerFileStreaming(RouterClass) {
4482
4494
  }
4483
4495
  controller.close();
4484
4496
  } catch (error) {
4485
- controller.error(error);
4497
+ closeReadableStreamAfterError(controller, error);
4486
4498
  }
4487
4499
  }
4488
4500
  });
@@ -4514,7 +4526,7 @@ function registerFileStreaming(RouterClass) {
4514
4526
  }
4515
4527
  controller.close();
4516
4528
  } catch (error) {
4517
- controller.error(error);
4529
+ closeReadableStreamAfterError(controller, error);
4518
4530
  }
4519
4531
  }
4520
4532
  });
@@ -8161,12 +8173,10 @@ function registerRouteBuilding(RouterClass) {
8161
8173
  const constraintsRecord = route.constraints && !Array.isArray(route.constraints) ? route.constraints : undefined;
8162
8174
  matchPath(path, url2.pathname, params, constraintsRecord);
8163
8175
  const enhancedReq = this.enhanceRequest(req, params);
8164
- const middlewareResult = await this.runMiddleware(enhancedReq, [...this.globalMiddleware, ...route.middleware]);
8165
- if (middlewareResult) {
8166
- return this.applyModifiedCookies(middlewareResult, enhancedReq);
8167
- }
8168
- const response2 = await this.resolveHandler(route.handler, enhancedReq);
8169
- return this.applyModifiedCookies(response2, enhancedReq);
8176
+ const routeHandlerMiddleware = async (req2, _next) => await this.resolveHandler(route.handler, req2);
8177
+ const middlewareStack = [...this.globalMiddleware, ...route.middleware, routeHandlerMiddleware];
8178
+ const response2 = await this.runMiddleware(enhancedReq, middlewareStack);
8179
+ return this.applyModifiedCookies(response2 ?? new Response("No response from middleware chain", { status: 500 }), enhancedReq);
8170
8180
  };
8171
8181
  } else {
8172
8182
  const methodHandlers = {};
@@ -8178,12 +8188,10 @@ function registerRouteBuilding(RouterClass) {
8178
8188
  const constraintsRecord = route.constraints && !Array.isArray(route.constraints) ? route.constraints : undefined;
8179
8189
  matchPath(path, url2.pathname, params, constraintsRecord);
8180
8190
  const enhancedReq = this.enhanceRequest(req, params);
8181
- const middlewareResult = await this.runMiddleware(enhancedReq, [...this.globalMiddleware, ...route.middleware]);
8182
- if (middlewareResult) {
8183
- return this.applyModifiedCookies(middlewareResult, enhancedReq);
8184
- }
8185
- const response2 = await this.resolveHandler(route.handler, enhancedReq);
8186
- return this.applyModifiedCookies(response2, enhancedReq);
8191
+ const routeHandlerMiddleware = async (req2, _next) => await this.resolveHandler(route.handler, req2);
8192
+ const middlewareStack = [...this.globalMiddleware, ...route.middleware, routeHandlerMiddleware];
8193
+ const response2 = await this.runMiddleware(enhancedReq, middlewareStack);
8194
+ return this.applyModifiedCookies(response2 ?? new Response("No response from middleware chain", { status: 500 }), enhancedReq);
8187
8195
  };
8188
8196
  }
8189
8197
  bunRoutes[path] = methodHandlers;
@@ -10419,6 +10427,29 @@ class ResponseWithMacros extends Response {
10419
10427
  return responseMacroRegistry.has(name);
10420
10428
  }
10421
10429
  }
10430
+ function resolveResponseInit(statusOrInit, headers) {
10431
+ if (typeof statusOrInit === "number")
10432
+ return { status: statusOrInit, mergedHeaders: headers };
10433
+ if (statusOrInit && typeof statusOrInit === "object") {
10434
+ const status = typeof statusOrInit.status === "number" ? statusOrInit.status : 200;
10435
+ let mergedHeaders = headers;
10436
+ if (statusOrInit.headers) {
10437
+ mergedHeaders = {};
10438
+ if (statusOrInit.headers instanceof Headers) {
10439
+ statusOrInit.headers.forEach((value, key) => {
10440
+ mergedHeaders[key] = value;
10441
+ });
10442
+ } else if (Array.isArray(statusOrInit.headers)) {
10443
+ for (const [key, value] of statusOrInit.headers)
10444
+ mergedHeaders[key] = value;
10445
+ } else {
10446
+ Object.assign(mergedHeaders, statusOrInit.headers);
10447
+ }
10448
+ }
10449
+ return { status, mergedHeaders };
10450
+ }
10451
+ return { status: 200, mergedHeaders: headers };
10452
+ }
10422
10453
  var BuiltInResponseMacros = {
10423
10454
  success: (data, message, status = 200) => {
10424
10455
  const body = {};
@@ -10496,39 +10527,43 @@ var BuiltInResponseMacros = {
10496
10527
  headers: { "Content-Type": "application/json" }
10497
10528
  });
10498
10529
  },
10499
- json: (data, status = 200, headers = {}) => {
10530
+ json: (data, statusOrInit = 200, headers = {}) => {
10531
+ const { status, mergedHeaders } = resolveResponseInit(statusOrInit, headers);
10500
10532
  return new Response(JSON.stringify(data), {
10501
10533
  status,
10502
10534
  headers: {
10503
10535
  "Content-Type": "application/json",
10504
- ...headers
10536
+ ...mergedHeaders
10505
10537
  }
10506
10538
  });
10507
10539
  },
10508
- html: (content, status = 200, headers = {}) => {
10540
+ html: (content, statusOrInit = 200, headers = {}) => {
10541
+ const { status, mergedHeaders } = resolveResponseInit(statusOrInit, headers);
10509
10542
  return new Response(content, {
10510
10543
  status,
10511
10544
  headers: {
10512
10545
  "Content-Type": "text/html",
10513
- ...headers
10546
+ ...mergedHeaders
10514
10547
  }
10515
10548
  });
10516
10549
  },
10517
- text: (content, status = 200, headers = {}) => {
10550
+ text: (content, statusOrInit = 200, headers = {}) => {
10551
+ const { status, mergedHeaders } = resolveResponseInit(statusOrInit, headers);
10518
10552
  return new Response(content, {
10519
10553
  status,
10520
10554
  headers: {
10521
10555
  "Content-Type": "text/plain",
10522
- ...headers
10556
+ ...mergedHeaders
10523
10557
  }
10524
10558
  });
10525
10559
  },
10526
- xml: (content, status = 200, headers = {}) => {
10560
+ xml: (content, statusOrInit = 200, headers = {}) => {
10561
+ const { status, mergedHeaders } = resolveResponseInit(statusOrInit, headers);
10527
10562
  return new Response(content, {
10528
10563
  status,
10529
10564
  headers: {
10530
10565
  "Content-Type": "application/xml",
10531
- ...headers
10566
+ ...mergedHeaders
10532
10567
  }
10533
10568
  });
10534
10569
  },
@@ -144,21 +144,32 @@ export declare const BuiltInResponseMacros: {
144
144
  path: string;
145
145
  }, message?: string) => Response;
146
146
  /**
147
- * JSON response with custom headers
147
+ * JSON response with custom headers. Accepts BOTH the native
148
+ * `Response.json(data, init?)` shape (where `init` is a
149
+ * `ResponseInit` object) AND the legacy positional
150
+ * `Response.json(data, status, headers)` shape. Required for
151
+ * backwards-compatibility with framework + third-party code that
152
+ * calls `Response.json(data, { status: 422 })` — the standard WHATWG
153
+ * signature that Bun's native `Response.json` ships with.
154
+ *
155
+ * See stacksjs/stacks#1857.
148
156
  */
149
- json: (data: any, status?: number, headers?: Record<string, string>) => Response;
157
+ json: (data: any, statusOrInit?: number | ResponseInit, headers?: Record<string, string>) => Response;
150
158
  /**
151
- * HTML response
159
+ * HTML response. Dual-shape: native `(content, init?)` or positional
160
+ * `(content, status, headers)`.
152
161
  */
153
- html: (content: string, status?: number, headers?: Record<string, string>) => Response;
162
+ html: (content: string, statusOrInit?: number | ResponseInit, headers?: Record<string, string>) => Response;
154
163
  /**
155
- * Plain text response
164
+ * Plain text response. Dual-shape: native `(content, init?)` or
165
+ * positional `(content, status, headers)`.
156
166
  */
157
- text: (content: string, status?: number, headers?: Record<string, string>) => Response;
167
+ text: (content: string, statusOrInit?: number | ResponseInit, headers?: Record<string, string>) => Response;
158
168
  /**
159
- * XML response
169
+ * XML response. Dual-shape: native `(content, init?)` or positional
170
+ * `(content, status, headers)`.
160
171
  */
161
- xml: (content: string, status?: number, headers?: Record<string, string>) => Response;
172
+ xml: (content: string, statusOrInit?: number | ResponseInit, headers?: Record<string, string>) => Response;
162
173
  /**
163
174
  * Redirect response
164
175
  */
@@ -99,37 +99,60 @@ export declare class Router {
99
99
  */
100
100
  resolveMiddleware(middleware: string | MiddlewareHandler): MiddlewareHandler | null;
101
101
  /**
102
- * HTTP GET method
102
+ * HTTP GET method.
103
+ *
104
+ * `TPath` is inferred from the path literal at the call site, so an
105
+ * inline handler's `request.params` narrows to the extracted keyset:
106
+ *
107
+ * ```ts
108
+ * router.get('/api/users/{id}', (req) => {
109
+ * req.params.id // typed `string`, no `as any`
110
+ * // @ts-expect-error wrong key
111
+ * req.params.bogus
112
+ * })
113
+ * ```
114
+ *
115
+ * The string-action / class-handler forms (`'Actions/Foo/BarAction'`)
116
+ * keep working — `ActionHandler<TPath>` is a union that accepts them.
117
+ * See stacksjs/stacks#1851 for the broader typed-request work.
103
118
  */
104
- get(path: string, handler: ActionHandler, type?: 'api' | 'web', name?: string, middleware?: (string | MiddlewareHandler)[]): Router;
119
+ get<TPath extends string>(path: TPath, handler: ActionHandler<TPath>, type?: 'api' | 'web', name?: string, middleware?: (string | MiddlewareHandler)[]): Router;
105
120
  /**
106
- * HTTP POST method
121
+ * HTTP POST method. See {@link get} for `TPath`-driven param
122
+ * narrowing on inline handlers.
107
123
  */
108
- post(path: string, handler: ActionHandler, type?: 'api' | 'web', name?: string, middleware?: (string | MiddlewareHandler)[]): Router;
124
+ post<TPath extends string>(path: TPath, handler: ActionHandler<TPath>, type?: 'api' | 'web', name?: string, middleware?: (string | MiddlewareHandler)[]): Router;
109
125
  /**
110
- * HTTP PUT method
126
+ * HTTP PUT method. See {@link get} for `TPath`-driven param
127
+ * narrowing on inline handlers.
111
128
  */
112
- put(path: string, handler: ActionHandler, type?: 'api' | 'web', name?: string, middleware?: (string | MiddlewareHandler)[]): Router;
129
+ put<TPath extends string>(path: TPath, handler: ActionHandler<TPath>, type?: 'api' | 'web', name?: string, middleware?: (string | MiddlewareHandler)[]): Router;
113
130
  /**
114
- * HTTP PATCH method
131
+ * HTTP PATCH method. See {@link get} for `TPath`-driven param
132
+ * narrowing on inline handlers.
115
133
  */
116
- patch(path: string, handler: ActionHandler, type?: 'api' | 'web', name?: string, middleware?: (string | MiddlewareHandler)[]): Router;
134
+ patch<TPath extends string>(path: TPath, handler: ActionHandler<TPath>, type?: 'api' | 'web', name?: string, middleware?: (string | MiddlewareHandler)[]): Router;
117
135
  /**
118
- * HTTP DELETE method
136
+ * HTTP DELETE method. See {@link get} for `TPath`-driven param
137
+ * narrowing on inline handlers.
119
138
  */
120
- delete(path: string, handler: ActionHandler, type?: 'api' | 'web', name?: string, middleware?: (string | MiddlewareHandler)[]): Router;
139
+ delete<TPath extends string>(path: TPath, handler: ActionHandler<TPath>, type?: 'api' | 'web', name?: string, middleware?: (string | MiddlewareHandler)[]): Router;
121
140
  /**
122
- * HTTP OPTIONS method
141
+ * HTTP OPTIONS method. See {@link get} for `TPath`-driven param
142
+ * narrowing on inline handlers.
123
143
  */
124
- options(path: string, handler: ActionHandler, type?: 'api' | 'web', name?: string, middleware?: (string | MiddlewareHandler)[]): Router;
144
+ options<TPath extends string>(path: TPath, handler: ActionHandler<TPath>, type?: 'api' | 'web', name?: string, middleware?: (string | MiddlewareHandler)[]): Router;
125
145
  /**
126
- * Register route for multiple HTTP methods
146
+ * Register the same handler against multiple HTTP methods. The
147
+ * handler is type-narrowed via {@link ActionHandler}'s `TPath`
148
+ * generic, same as the single-method overloads.
127
149
  */
128
- match(methods: string[], path: string, handler: ActionHandler, type?: 'api' | 'web', name?: string, middleware?: (string | MiddlewareHandler)[]): Router;
150
+ match<TPath extends string>(methods: string[], path: TPath, handler: ActionHandler<TPath>, type?: 'api' | 'web', name?: string, middleware?: (string | MiddlewareHandler)[]): Router;
129
151
  /**
130
- * Register route for any HTTP method
152
+ * Register the handler against every HTTP method. See {@link get}
153
+ * for `TPath`-driven param narrowing on inline handlers.
131
154
  */
132
- any(path: string, handler: ActionHandler, type?: 'api' | 'web', name?: string, middleware?: (string | MiddlewareHandler)[]): Router;
155
+ any<TPath extends string>(path: TPath, handler: ActionHandler<TPath>, type?: 'api' | 'web', name?: string, middleware?: (string | MiddlewareHandler)[]): Router;
133
156
  /**
134
157
  * Set fallback handler for unmatched routes
135
158
  */
package/dist/types.d.ts CHANGED
@@ -1716,6 +1716,18 @@ export interface RequestMacroMethods {
1716
1716
  param: <T = InputValue>(key: string, defaultValue?: T) => T;
1717
1717
  cookie: (name: string, defaultValue?: string) => string | null;
1718
1718
  cookies: () => Record<string, string>;
1719
+ /**
1720
+ * Lookup a single uploaded file by form field name. Returns the
1721
+ * matching `FileInfo` (structurally an `UploadedFile`) or `null` when
1722
+ * no field with that name was uploaded. Populated by the multipart
1723
+ * file-upload middleware alongside `files: UploadedFile[]`. Mirrors
1724
+ * Laravel's `$request->file('avatar')`:
1725
+ *
1726
+ * ```ts
1727
+ * const avatar = req.file('avatar')
1728
+ * if (!avatar) return new Response('Missing avatar', { status: 400 })
1729
+ * ```
1730
+ */
1719
1731
  file: (name: string) => FileInfo | null;
1720
1732
  hasFile: (name: string) => boolean;
1721
1733
  path: () => string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/bun-router",
3
3
  "type": "module",
4
- "version": "0.0.13",
4
+ "version": "0.0.15",
5
5
  "description": "A fast, type-safe router for Bun.",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",
7
7
  "license": "MIT",
@@ -73,8 +73,8 @@
73
73
  "zip:windows-x64": "zip -j bin/router-windows-x64.zip bin/router-windows-x64.exe",
74
74
  "zip:darwin-x64": "zip -j bin/router-darwin-x64.zip bin/router-darwin-x64",
75
75
  "zip:darwin-arm64": "zip -j bin/router-darwin-arm64.zip bin/router-darwin-arm64",
76
- "lint": "bunx --bun eslint .",
77
- "lint:fix": "bunx --bun eslint . --fix",
76
+ "lint": "bunx --bun pickier .",
77
+ "lint:fix": "bunx --bun pickier . --fix",
78
78
  "fresh": "bunx rimraf node_modules/ bun.lock && bun i",
79
79
  "changelog": "bunx changelogen --output CHANGELOG.md",
80
80
  "prepublishOnly": "bun --bun run build",
@@ -86,8 +86,5 @@
86
86
  "dependencies": {
87
87
  "@stacksjs/clapp": "^0.2.0",
88
88
  "ts-rate-limiter": "^0.4.2"
89
- },
90
- "lint-staged": {
91
- "*.{js,ts}": "bunx --bun pickier . --fix"
92
89
  }
93
90
  }
@@ -58,7 +58,8 @@ export function extractBasicAuth(authHeader: string): { username: string, passwo
58
58
  return { username, password }
59
59
  }
60
60
  catch (error) {
61
- console.error(error)
61
+ void error
62
+
62
63
  return null
63
64
  }
64
65
  }
@@ -162,6 +162,17 @@ export default class FileUpload {
162
162
  configurable: true,
163
163
  })
164
164
 
165
+ // Typed lookup by form field name. Mirrors Laravel's
166
+ // `$request->file('avatar')` ergonomics: returns the matching
167
+ // UploadedFile or null. See stacksjs/stacks#1856.
168
+ Object.defineProperty(req, 'file', {
169
+ value: (fieldName: string): UploadedFile | null =>
170
+ uploadedFiles.find(f => f.fieldName === fieldName) ?? null,
171
+ writable: true,
172
+ enumerable: false,
173
+ configurable: true,
174
+ })
175
+
165
176
  Object.defineProperty(req, 'formBody', {
166
177
  value: fields,
167
178
  writable: true,
@@ -124,6 +124,44 @@ export class ResponseWithMacros extends Response {
124
124
  }
125
125
  }
126
126
 
127
+ /**
128
+ * Reconcile the second argument of `Response.<method>(body, statusOrInit, headers?)`.
129
+ * Accepts either a positional status number (legacy bun-router shape) or a
130
+ * `ResponseInit` object (native WHATWG shape `{ status?, headers?, statusText? }`).
131
+ * When a `ResponseInit` provides `headers`, those replace the positional `headers`
132
+ * arg entirely — matching the principle that the more-explicit form wins.
133
+ *
134
+ * Headers normalize to a plain string map so downstream spread works uniformly.
135
+ */
136
+ function resolveResponseInit(
137
+ statusOrInit: number | ResponseInit,
138
+ headers: Record<string, string>,
139
+ ): { status: number, mergedHeaders: Record<string, string> } {
140
+ if (typeof statusOrInit === 'number')
141
+ return { status: statusOrInit, mergedHeaders: headers }
142
+
143
+ if (statusOrInit && typeof statusOrInit === 'object') {
144
+ const status = typeof statusOrInit.status === 'number' ? statusOrInit.status : 200
145
+ let mergedHeaders = headers
146
+ if (statusOrInit.headers) {
147
+ mergedHeaders = {}
148
+ if (statusOrInit.headers instanceof Headers) {
149
+ statusOrInit.headers.forEach((value, key) => { mergedHeaders[key] = value })
150
+ }
151
+ else if (Array.isArray(statusOrInit.headers)) {
152
+ for (const [key, value] of statusOrInit.headers)
153
+ mergedHeaders[key] = value
154
+ }
155
+ else {
156
+ Object.assign(mergedHeaders, statusOrInit.headers as Record<string, string>)
157
+ }
158
+ }
159
+ return { status, mergedHeaders }
160
+ }
161
+
162
+ return { status: 200, mergedHeaders: headers }
163
+ }
164
+
127
165
  /**
128
166
  * Built-in response macros
129
167
  */
@@ -274,53 +312,68 @@ export const BuiltInResponseMacros = {
274
312
  },
275
313
 
276
314
  /**
277
- * JSON response with custom headers
315
+ * JSON response with custom headers. Accepts BOTH the native
316
+ * `Response.json(data, init?)` shape (where `init` is a
317
+ * `ResponseInit` object) AND the legacy positional
318
+ * `Response.json(data, status, headers)` shape. Required for
319
+ * backwards-compatibility with framework + third-party code that
320
+ * calls `Response.json(data, { status: 422 })` — the standard WHATWG
321
+ * signature that Bun's native `Response.json` ships with.
322
+ *
323
+ * See stacksjs/stacks#1857.
278
324
  */
279
- json: (data: any, status = 200, headers: Record<string, string> = {}): Response => {
325
+ json: (data: any, statusOrInit: number | ResponseInit = 200, headers: Record<string, string> = {}): Response => {
326
+ const { status, mergedHeaders } = resolveResponseInit(statusOrInit, headers)
280
327
  return new Response(JSON.stringify(data), {
281
328
  status,
282
329
  headers: {
283
330
  'Content-Type': 'application/json',
284
- ...headers,
331
+ ...mergedHeaders,
285
332
  },
286
333
  })
287
334
  },
288
335
 
289
336
  /**
290
- * HTML response
337
+ * HTML response. Dual-shape: native `(content, init?)` or positional
338
+ * `(content, status, headers)`.
291
339
  */
292
- html: (content: string, status = 200, headers: Record<string, string> = {}): Response => {
340
+ html: (content: string, statusOrInit: number | ResponseInit = 200, headers: Record<string, string> = {}): Response => {
341
+ const { status, mergedHeaders } = resolveResponseInit(statusOrInit, headers)
293
342
  return new Response(content, {
294
343
  status,
295
344
  headers: {
296
345
  'Content-Type': 'text/html',
297
- ...headers,
346
+ ...mergedHeaders,
298
347
  },
299
348
  })
300
349
  },
301
350
 
302
351
  /**
303
- * Plain text response
352
+ * Plain text response. Dual-shape: native `(content, init?)` or
353
+ * positional `(content, status, headers)`.
304
354
  */
305
- text: (content: string, status = 200, headers: Record<string, string> = {}): Response => {
355
+ text: (content: string, statusOrInit: number | ResponseInit = 200, headers: Record<string, string> = {}): Response => {
356
+ const { status, mergedHeaders } = resolveResponseInit(statusOrInit, headers)
306
357
  return new Response(content, {
307
358
  status,
308
359
  headers: {
309
360
  'Content-Type': 'text/plain',
310
- ...headers,
361
+ ...mergedHeaders,
311
362
  },
312
363
  })
313
364
  },
314
365
 
315
366
  /**
316
- * XML response
367
+ * XML response. Dual-shape: native `(content, init?)` or positional
368
+ * `(content, status, headers)`.
317
369
  */
318
- xml: (content: string, status = 200, headers: Record<string, string> = {}): Response => {
370
+ xml: (content: string, statusOrInit: number | ResponseInit = 200, headers: Record<string, string> = {}): Response => {
371
+ const { status, mergedHeaders } = resolveResponseInit(statusOrInit, headers)
319
372
  return new Response(content, {
320
373
  status,
321
374
  headers: {
322
375
  'Content-Type': 'application/xml',
323
- ...headers,
376
+ ...mergedHeaders,
324
377
  },
325
378
  })
326
379
  },
@@ -488,6 +488,16 @@ export function registerFileBasedRouting(RouterClass: typeof Router): void {
488
488
  async value(): Promise<void> {
489
489
  if (this._fileRoutesInitialized) return
490
490
 
491
+ // Ensure each Router instance gets its OWN routes array. The
492
+ // `_fileBasedRoutes: { value: [] }` declaration on the prototype
493
+ // means every instance shares a single array reference until
494
+ // someone reassigns. Without this lazy-init, calls like
495
+ // `instanceA.getFileRoutes()` leak routes registered by
496
+ // `instanceB._initFileRoutes()` — cross-instance contamination.
497
+ if (!Object.prototype.hasOwnProperty.call(this, '_fileBasedRoutes')) {
498
+ this._fileBasedRoutes = []
499
+ }
500
+
491
501
  // Auto-detect views directory if not configured
492
502
  const viewsDir = this._viewsDir || detectViewsDirectory()
493
503
  if (!viewsDir) {
@@ -496,6 +506,16 @@ export function registerFileBasedRouting(RouterClass: typeof Router): void {
496
506
  }
497
507
 
498
508
  const config = this._fileRoutingConfig || {}
509
+
510
+ // Honour the opt-out flag set by `router.disableFileRouting()` and by
511
+ // anyone passing `{ enabled: false }` to `router.views(...)`. Before
512
+ // this check the flag was stored but never read, so the public
513
+ // `disableFileRouting()` API silently did nothing.
514
+ if (config.enabled === false) {
515
+ this._fileRoutesInitialized = true
516
+ return
517
+ }
518
+
499
519
  const extensions = config.extensions || DEFAULT_EXTENSIONS
500
520
  const exclude = config.exclude || DEFAULT_EXCLUDES
501
521
 
@@ -5,6 +5,17 @@ import type {
5
5
  import type { Router } from './router'
6
6
  import { FileStreamHandler, StreamHandler } from '../streaming/stream-handler'
7
7
 
8
+ function closeReadableStreamAfterError(controller: ReadableStreamDefaultController, error: unknown): void {
9
+ void error
10
+
11
+ try {
12
+ controller.close()
13
+ }
14
+ catch {
15
+ // The stream may already be closed or errored by the runtime.
16
+ }
17
+ }
18
+
8
19
  /**
9
20
  * File streaming extension for Router class
10
21
  */
@@ -169,7 +180,7 @@ export function registerFileStreaming(RouterClass: typeof Router): void {
169
180
  controller.close()
170
181
  }
171
182
  catch (error) {
172
- controller.error(error)
183
+ closeReadableStreamAfterError(controller, error)
173
184
  }
174
185
  },
175
186
  })
@@ -233,7 +244,7 @@ export function registerFileStreaming(RouterClass: typeof Router): void {
233
244
  controller.close()
234
245
  }
235
246
  catch (error) {
236
- controller.error(error)
247
+ closeReadableStreamAfterError(controller, error)
237
248
  }
238
249
  },
239
250
  })
@@ -294,7 +305,7 @@ export function registerFileStreaming(RouterClass: typeof Router): void {
294
305
  controller.close()
295
306
  }
296
307
  catch (error) {
297
- controller.error(error)
308
+ closeReadableStreamAfterError(controller, error)
298
309
  }
299
310
  },
300
311
  })
@@ -340,7 +351,7 @@ export function registerFileStreaming(RouterClass: typeof Router): void {
340
351
  controller.close()
341
352
  }
342
353
  catch (error) {
343
- controller.error(error)
354
+ closeReadableStreamAfterError(controller, error)
344
355
  }
345
356
  },
346
357
  })