@rexeus/typeweaver-server 0.10.2 → 0.10.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.
@@ -9,13 +9,17 @@
9
9
  * Creates a predicate that tests whether a request path matches a pattern.
10
10
  *
11
11
  * Supports three pattern types:
12
- * - **Exact match**: `"/users"` matches only `"/users"`
12
+ * - **Exact match**: `"/users"` matches `"/users"` and canonically
13
+ * equivalent rooted paths such as `"/users/"` and `"/users//"`
13
14
  * - **Prefix match**: `"/users/*"` matches `"/users"` and any path beneath it
14
15
  * (e.g. `"/users/123"`, `"/users/123/posts"`)
15
16
  * - **Parameterized segments**: `"/users/:id"` matches paths where `:id` stands
16
17
  * for exactly one segment (e.g. `"/users/123"` but not `"/users/123/posts"`)
17
18
  *
18
19
  * Uses the same `:paramName` syntax as typeweaver route definitions.
20
+ * Rooted request paths are canonicalized with the same empty-segment
21
+ * filtering used by the router, so duplicate and trailing slashes match the
22
+ * route they dispatch to. Unrooted request paths do not match rooted patterns.
19
23
  *
20
24
  * @example
21
25
  * ```typescript
@@ -30,27 +34,67 @@
30
34
  * ```
31
35
  */
32
36
  export function pathMatcher(pattern: string): (path: string) => boolean {
37
+ const patternSegments = patternToSegments(pattern);
38
+
33
39
  if (pattern.endsWith("/*")) {
34
- const prefix = pattern.slice(0, -2);
35
- return path => path === prefix || path.startsWith(prefix + "/");
40
+ const prefixSegments = patternToSegments(pattern.slice(0, -2));
41
+
42
+ return path => {
43
+ const pathSegments = toSegments(path);
44
+ if (pathSegments === undefined) return false;
45
+ if (pathSegments.length < prefixSegments.length) return false;
46
+
47
+ return prefixSegments.every(
48
+ (segment, index) => pathSegments[index] === segment
49
+ );
50
+ };
36
51
  }
37
52
 
38
- const segments = pattern.split("/");
39
- const hasParams = segments.some(s => s.startsWith(":"));
53
+ const hasParams = patternSegments.some(s => s.startsWith(":"));
40
54
 
41
55
  if (!hasParams) {
42
- return path => path === pattern;
56
+ return path => {
57
+ const pathSegments = toSegments(path);
58
+ if (pathSegments === undefined) return false;
59
+
60
+ return segmentsEqual(patternSegments, pathSegments);
61
+ };
43
62
  }
44
63
 
45
- const segmentCount = segments.length;
46
- const matchers = segments.map(s => (s.startsWith(":") ? null : s));
64
+ const segmentCount = patternSegments.length;
65
+ const matchers = patternSegments.map(s => (s.startsWith(":") ? null : s));
47
66
 
48
67
  return path => {
49
- const parts = path.split("/");
68
+ const parts = toSegments(path);
69
+ if (parts === undefined) return false;
50
70
  if (parts.length !== segmentCount) return false;
71
+
51
72
  for (let i = 0; i < segmentCount; i++) {
52
- if (matchers[i] !== null && matchers[i] !== parts[i]) return false;
73
+ if (matchers[i] === null) {
74
+ continue;
75
+ }
76
+
77
+ if (matchers[i] !== parts[i]) return false;
53
78
  }
54
79
  return true;
55
80
  };
56
81
  }
82
+
83
+ function toSegments(path: string): readonly string[] | undefined {
84
+ if (!path.startsWith("/")) return undefined;
85
+
86
+ return patternToSegments(path);
87
+ }
88
+
89
+ function patternToSegments(path: string): readonly string[] {
90
+ return path.split("/").filter(segment => segment.length > 0);
91
+ }
92
+
93
+ function segmentsEqual(
94
+ left: readonly string[],
95
+ right: readonly string[]
96
+ ): boolean {
97
+ if (left.length !== right.length) return false;
98
+
99
+ return left.every((segment, index) => right[index] === segment);
100
+ }
@@ -135,6 +135,11 @@ export class Router {
135
135
  * Register a route in the radix tree.
136
136
  */
137
137
  public add(definition: RouteDefinition): void {
138
+ const method = definition.method.toUpperCase();
139
+ const normalizedDefinition =
140
+ definition.method === method
141
+ ? definition
142
+ : { ...definition, method: method as HttpMethod };
138
143
  const segments = Router.toSegments(definition.path);
139
144
 
140
145
  let current = this.root;
@@ -161,13 +166,13 @@ export class Router {
161
166
  }
162
167
  }
163
168
 
164
- if (current.methods.has(definition.method)) {
169
+ if (current.methods.has(method)) {
165
170
  throw new Error(
166
- `Route conflict: ${definition.method} ${definition.path} is already registered`
171
+ `Route conflict: ${method} ${definition.path} is already registered`
167
172
  );
168
173
  }
169
174
 
170
- current.methods.set(definition.method, definition);
175
+ current.methods.set(method, normalizedDefinition);
171
176
  }
172
177
 
173
178
  /**
@@ -280,7 +285,14 @@ export class Router {
280
285
  private static decodePathSegment(segment: string): string {
281
286
  try {
282
287
  const decoded = decodeURIComponent(segment);
283
- if (decoded === ".." || decoded === ".") return segment;
288
+ if (
289
+ decoded === ".." ||
290
+ decoded === "." ||
291
+ decoded.includes("/") ||
292
+ decoded.includes("\\")
293
+ ) {
294
+ return segment;
295
+ }
284
296
  return decoded;
285
297
  } catch {
286
298
  return segment;
@@ -5,6 +5,7 @@
5
5
  * @generated by @rexeus/typeweaver
6
6
  */
7
7
 
8
+ // oxlint-disable import/max-dependencies
8
9
  import {
9
10
  badRequestDefaultError,
10
11
  createDefaultErrorBody,
@@ -12,26 +13,29 @@ import {
12
13
  internalServerErrorDefaultError,
13
14
  isTypedHttpResponse,
14
15
  methodNotAllowedDefaultError,
16
+ normalizeHttpResponse,
15
17
  notFoundDefaultError,
16
18
  payloadTooLargeDefaultError,
17
19
  RequestValidationError,
20
+ toHttpResponse,
18
21
  validationDefaultError,
19
22
  } from "@rexeus/typeweaver-core";
20
23
  import type { IHttpResponse } from "@rexeus/typeweaver-core";
21
24
  import { BodyParseError, PayloadTooLargeError } from "./Errors.js";
22
- import { FetchApiAdapter } from "./FetchApiAdapter.js";
23
25
  import { executeMiddlewarePipeline } from "./Middleware.js";
24
26
  import { Router } from "./Router.js";
25
27
  import { StateMap } from "./StateMap.js";
28
+ import { initializeTypeweaverAppRuntime } from "./TypeweaverAppRuntime.js";
29
+ import type { FetchApiAdapter } from "./FetchApiAdapter.js";
26
30
  import type { Middleware } from "./Middleware.js";
27
31
  import type { RequestHandler } from "./RequestHandler.js";
28
32
  import type {
29
33
  HttpResponseErrorHandler,
34
+ RequestValidationErrorHandler,
30
35
  ResponseValidationErrorHandler,
31
36
  RouteDefinition,
32
37
  RouteMatch,
33
38
  UnknownErrorHandler,
34
- RequestValidationErrorHandler,
35
39
  } from "./Router.js";
36
40
  import type { ServerContext } from "./ServerContext.js";
37
41
  import type {
@@ -82,8 +86,12 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
82
86
  private readonly onError: (error: unknown) => void;
83
87
 
84
88
  public constructor(options?: TypeweaverAppOptions) {
85
- this.adapter = new FetchApiAdapter({ maxBodySize: options?.maxBodySize });
86
- this.onError = options?.onError ?? console.error;
89
+ this.onError = options?.onError ?? (error => console.error(error));
90
+ this.adapter = initializeTypeweaverAppRuntime({
91
+ app: this,
92
+ options,
93
+ reportError: error => this.safeOnError(error),
94
+ });
87
95
  }
88
96
 
89
97
  private safeOnError(error: unknown): void {
@@ -234,13 +242,21 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
234
242
  const routeCtx = this.withPathParams(ctx, match.params);
235
243
  try {
236
244
  const response = await this.executeHandler(routeCtx, match.route);
237
- return await this.validateResponse(match.route, response, routeCtx);
245
+ return await this.validateResponse(
246
+ match.route,
247
+ normalizeHttpResponse(response),
248
+ routeCtx
249
+ );
238
250
  } catch (error) {
239
251
  if (
240
252
  isTypedHttpResponse(error) &&
241
253
  match.route.routerConfig.validateResponses
242
254
  ) {
243
- return await this.validateResponse(match.route, error, routeCtx);
255
+ return await this.validateResponse(
256
+ match.route,
257
+ toHttpResponse(error),
258
+ routeCtx
259
+ );
244
260
  }
245
261
  return this.handleError(error, routeCtx, match.route);
246
262
  }
@@ -283,7 +299,7 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
283
299
  * - `validateResponses: true` (default) → runs validation:
284
300
  * - Valid response → returns the stripped response (extra fields removed).
285
301
  * - Invalid response + handler configured → calls the handler safely.
286
- * If the handler throws, falls back to the original response.
302
+ * If the handler throws, fails closed with a sanitized 500 response.
287
303
  * - Invalid response + `handleResponseValidationErrors: false` → returns
288
304
  * the original (invalid) response as-is.
289
305
  *
@@ -301,7 +317,9 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
301
317
 
302
318
  const result = route.responseValidator.safeValidate(response);
303
319
 
304
- if (result.isValid) return result.data;
320
+ if (result.isValid) {
321
+ return normalizeHttpResponse(result.data);
322
+ }
305
323
 
306
324
  const handler = this.resolveErrorHandler<ResponseValidationErrorHandler>(
307
325
  route.routerConfig.handleResponseValidationErrors,
@@ -313,6 +331,11 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
313
331
  handler(result.error, response, ctx)
314
332
  );
315
333
  if (handlerResponse) return handlerResponse;
334
+ return TypeweaverApp.defaultResponseValidationHandler(
335
+ result.error,
336
+ response,
337
+ ctx
338
+ );
316
339
  }
317
340
 
318
341
  return response;
@@ -455,7 +478,7 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
455
478
 
456
479
  private static defaultHttpResponseHandler: HttpResponseErrorHandler = (
457
480
  err
458
- ): IHttpResponse => err;
481
+ ): IHttpResponse => toHttpResponse(err);
459
482
 
460
483
  private readonly defaultUnknownHandler: UnknownErrorHandler = (
461
484
  error
@@ -0,0 +1,37 @@
1
+ /**
2
+ * This file was automatically generated by typeweaver.
3
+ * DO NOT EDIT. Instead, modify the source definition file and generate again.
4
+ *
5
+ * @generated by @rexeus/typeweaver
6
+ */
7
+
8
+ import { createFetchBodyLimitPolicy } from "./BodyLimitPolicy.js";
9
+ import { FetchApiAdapter } from "./FetchApiAdapter.js";
10
+ import { setTypeweaverAppRuntimeContext } from "./TypeweaverInternals.js";
11
+ import type { TypeweaverApp } from "./TypeweaverApp.js";
12
+
13
+ type InitializeTypeweaverAppRuntimeOptions = {
14
+ readonly maxBodySize?: number;
15
+ };
16
+
17
+ type InitializeTypeweaverAppRuntimeParameters = {
18
+ readonly app: TypeweaverApp<any>;
19
+ readonly options?: InitializeTypeweaverAppRuntimeOptions;
20
+ readonly reportError: (error: unknown) => void;
21
+ };
22
+
23
+ export function initializeTypeweaverAppRuntime({
24
+ app,
25
+ options,
26
+ reportError,
27
+ }: InitializeTypeweaverAppRuntimeParameters): FetchApiAdapter {
28
+ const bodyLimitPolicy = createFetchBodyLimitPolicy(options?.maxBodySize);
29
+ const adapter = new FetchApiAdapter({ bodyLimitPolicy });
30
+
31
+ setTypeweaverAppRuntimeContext(app, {
32
+ bodyLimitPolicy,
33
+ reportError,
34
+ });
35
+
36
+ return adapter;
37
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * This file was automatically generated by typeweaver.
3
+ * DO NOT EDIT. Instead, modify the source definition file and generate again.
4
+ *
5
+ * @generated by @rexeus/typeweaver
6
+ */
7
+
8
+ import type { BodyLimitPolicy } from "./BodyLimitPolicy.js";
9
+ import type { TypeweaverApp } from "./TypeweaverApp.js";
10
+
11
+ /** Runtime context shared privately between TypeweaverApp and adapters. */
12
+ export type TypeweaverRuntimeContext = {
13
+ readonly bodyLimitPolicy: BodyLimitPolicy;
14
+ readonly reportError: (error: unknown) => void;
15
+ };
16
+
17
+ const appRuntimeContextRegistry = new WeakMap<
18
+ TypeweaverApp<any>,
19
+ TypeweaverRuntimeContext
20
+ >();
21
+
22
+ function fallbackReportError(error: unknown): void {
23
+ console.error(error);
24
+ }
25
+
26
+ export function setTypeweaverAppRuntimeContext(
27
+ app: TypeweaverApp<any>,
28
+ runtimeContext: TypeweaverRuntimeContext
29
+ ): void {
30
+ appRuntimeContextRegistry.set(app, runtimeContext);
31
+ }
32
+
33
+ export function getTypeweaverAppRuntimeContext(
34
+ app: TypeweaverApp<any>
35
+ ): TypeweaverRuntimeContext | undefined {
36
+ return appRuntimeContextRegistry.get(app);
37
+ }
38
+
39
+ export function getTypeweaverAppErrorReporter(
40
+ app: TypeweaverApp<any>
41
+ ): (error: unknown) => void {
42
+ return (
43
+ getTypeweaverAppRuntimeContext(app)?.reportError ?? fallbackReportError
44
+ );
45
+ }
package/dist/lib/index.ts CHANGED
@@ -33,6 +33,7 @@ export {
33
33
  export {
34
34
  BodyParseError,
35
35
  PayloadTooLargeError,
36
+ RequestBodyDrainTimeoutError,
36
37
  ResponseSerializationError,
37
38
  } from "./Errors.js";
38
39
  export { FetchApiAdapter } from "./FetchApiAdapter.js";
@@ -4,6 +4,7 @@ import {
4
4
  } from "@rexeus/typeweaver-core";
5
5
  import type { IHttpResponse } from "@rexeus/typeweaver-core";
6
6
  import { defineMiddleware } from "../TypedMiddleware.js";
7
+ import { readSingletonHeader } from "./header.js";
7
8
  import type { ServerContext } from "../ServerContext.js";
8
9
 
9
10
  export type BasicAuthOptions = {
@@ -31,10 +32,18 @@ export function basicAuth(options: BasicAuthOptions) {
31
32
  options.onUnauthorized?.(ctx) ?? defaultResponse;
32
33
 
33
34
  return defineMiddleware<{ username: string }>(async (ctx, next) => {
34
- const authorization = ctx.request.header?.["authorization"];
35
+ const authorization = readSingletonHeader(
36
+ ctx.request.header,
37
+ "authorization"
38
+ );
35
39
  if (typeof authorization !== "string") return deny(ctx);
36
40
 
37
- if (!authorization.startsWith(BASIC_PREFIX)) return deny(ctx);
41
+ if (
42
+ authorization.slice(0, BASIC_PREFIX.length).toLowerCase() !==
43
+ BASIC_PREFIX.toLowerCase()
44
+ ) {
45
+ return deny(ctx);
46
+ }
38
47
 
39
48
  const encoded = authorization.slice(BASIC_PREFIX.length);
40
49
  if (encoded.length === 0) return deny(ctx);
@@ -4,6 +4,7 @@ import {
4
4
  } from "@rexeus/typeweaver-core";
5
5
  import type { IHttpResponse } from "@rexeus/typeweaver-core";
6
6
  import { defineMiddleware } from "../TypedMiddleware.js";
7
+ import { readSingletonHeader } from "./header.js";
7
8
  import type { ServerContext } from "../ServerContext.js";
8
9
 
9
10
  export type BearerAuthOptions = {
@@ -30,10 +31,18 @@ export function bearerAuth(options: BearerAuthOptions) {
30
31
  options.onUnauthorized?.(ctx) ?? defaultResponse;
31
32
 
32
33
  return defineMiddleware<{ token: string }>(async (ctx, next) => {
33
- const authorization = ctx.request.header?.["authorization"];
34
+ const authorization = readSingletonHeader(
35
+ ctx.request.header,
36
+ "authorization"
37
+ );
34
38
  if (typeof authorization !== "string") return deny(ctx);
35
39
 
36
- if (!authorization.startsWith(BEARER_PREFIX)) return deny(ctx);
40
+ if (
41
+ authorization.slice(0, BEARER_PREFIX.length).toLowerCase() !==
42
+ BEARER_PREFIX.toLowerCase()
43
+ ) {
44
+ return deny(ctx);
45
+ }
37
46
 
38
47
  const token = authorization.slice(BEARER_PREFIX.length);
39
48
  if (token.length === 0) return deny(ctx);
@@ -1,5 +1,10 @@
1
1
  import type { IHttpResponse } from "@rexeus/typeweaver-core";
2
2
  import { defineMiddleware } from "../TypedMiddleware.js";
3
+ import {
4
+ hasHeaderName,
5
+ readHeaderValues,
6
+ readSingletonHeader,
7
+ } from "./header.js";
3
8
 
4
9
  export type CorsOptions = {
5
10
  readonly origin?:
@@ -22,13 +27,22 @@ const DEFAULT_METHODS = [
22
27
  "DELETE",
23
28
  ] as const;
24
29
 
30
+ const POLICY_CONTROLLED_CORS_HEADERS = new Set([
31
+ "access-control-allow-origin",
32
+ "access-control-allow-credentials",
33
+ "access-control-expose-headers",
34
+ "access-control-allow-methods",
35
+ "access-control-allow-headers",
36
+ "access-control-max-age",
37
+ ]);
38
+
25
39
  function resolveOrigin(
26
40
  configOrigin: CorsOptions["origin"],
27
41
  requestOrigin: string | undefined,
28
42
  credentials: boolean
29
43
  ): string | undefined {
30
44
  if (configOrigin === undefined || configOrigin === "*") {
31
- if (credentials && requestOrigin) return requestOrigin;
45
+ if (credentials) return undefined;
32
46
  return "*";
33
47
  }
34
48
 
@@ -48,21 +62,107 @@ function resolveOrigin(
48
62
  function getRequestOrigin(
49
63
  header: Record<string, string | string[]> | undefined
50
64
  ): string | undefined {
51
- const origin = header?.["origin"];
52
- return typeof origin === "string" ? origin : undefined;
65
+ return readSingletonHeader(header, "origin");
66
+ }
67
+
68
+ function isOriginDependentWithoutRequestOrigin(
69
+ configOrigin: CorsOptions["origin"],
70
+ credentials: boolean
71
+ ): boolean {
72
+ return (
73
+ typeof configOrigin === "function" ||
74
+ Array.isArray(configOrigin) ||
75
+ ((configOrigin === undefined || configOrigin === "*") && credentials)
76
+ );
77
+ }
78
+
79
+ function splitHeaderValues(values: readonly string[]): readonly string[] {
80
+ return values.flatMap(value =>
81
+ value
82
+ .split(",")
83
+ .map(item => item.trim())
84
+ .filter(item => item.length > 0)
85
+ );
86
+ }
87
+
88
+ function mergeVary(existing: readonly string[], value: string): string {
89
+ const values = splitHeaderValues(existing);
90
+ if (values.length === 0) return value;
91
+
92
+ const hasValue = values.some(
93
+ item => item.toLowerCase() === value.toLowerCase()
94
+ );
95
+
96
+ return hasValue ? values.join(", ") : [...values, value].join(", ");
97
+ }
98
+
99
+ function removePolicyControlledCorsHeaders(
100
+ responseHeaders: Record<string, string | string[]> | undefined
101
+ ): Record<string, string | string[]> {
102
+ const result: Record<string, string | string[]> = {};
103
+
104
+ for (const [key, value] of Object.entries(responseHeaders ?? {})) {
105
+ if (POLICY_CONTROLLED_CORS_HEADERS.has(key.toLowerCase())) continue;
106
+
107
+ result[key] = value;
108
+ }
109
+
110
+ return result;
111
+ }
112
+
113
+ function mergeResponseHeaders(
114
+ responseHeaders: Record<string, string | string[]> | undefined,
115
+ corsHeaders: Record<string, string>
116
+ ): Record<string, string | string[]> {
117
+ const result = removePolicyControlledCorsHeaders(responseHeaders);
118
+
119
+ const mergedCorsHeaders = { ...corsHeaders };
120
+ if (corsHeaders.vary !== undefined) {
121
+ for (const key of Object.keys(result)) {
122
+ if (key.toLowerCase() === "vary") delete result[key];
123
+ }
124
+
125
+ mergedCorsHeaders.vary = mergeVary(
126
+ readHeaderValues(responseHeaders, "vary"),
127
+ corsHeaders.vary
128
+ );
129
+ }
130
+
131
+ return { ...result, ...mergedCorsHeaders };
53
132
  }
54
133
 
55
134
  export function cors(options?: CorsOptions) {
56
135
  const credentials = options?.credentials ?? false;
136
+
57
137
  const methods = (options?.allowMethods ?? DEFAULT_METHODS).join(", ");
58
138
  const exposeHeaders = options?.exposeHeaders?.join(", ");
59
139
  const maxAge = options?.maxAge?.toString();
60
140
 
61
141
  return defineMiddleware(async (ctx, next) => {
62
142
  const requestOrigin = getRequestOrigin(ctx.request.header);
63
- const origin = resolveOrigin(options?.origin, requestOrigin, credentials);
143
+ const hasOrigin = hasHeaderName(ctx.request.header, "origin");
144
+ const resolvedOrigin =
145
+ hasOrigin && requestOrigin === undefined
146
+ ? undefined
147
+ : resolveOrigin(options?.origin, requestOrigin, credentials);
148
+ const origin =
149
+ credentials && resolvedOrigin === "*" ? undefined : resolvedOrigin;
150
+
151
+ if (origin === undefined) {
152
+ const response = await next();
153
+
154
+ if (
155
+ !hasOrigin &&
156
+ !isOriginDependentWithoutRequestOrigin(options?.origin, credentials)
157
+ ) {
158
+ return response;
159
+ }
64
160
 
65
- if (origin === undefined) return next();
161
+ return {
162
+ ...response,
163
+ header: mergeResponseHeaders(response.header, { vary: "Origin" }),
164
+ } satisfies IHttpResponse;
165
+ }
66
166
 
67
167
  const corsHeaders: Record<string, string> = {
68
168
  "access-control-allow-origin": origin,
@@ -82,18 +182,26 @@ export function cors(options?: CorsOptions) {
82
182
 
83
183
  const isPreflight =
84
184
  ctx.request.method === "OPTIONS" &&
85
- ctx.request.header?.["access-control-request-method"] !== undefined;
185
+ requestOrigin !== undefined &&
186
+ readSingletonHeader(
187
+ ctx.request.header,
188
+ "access-control-request-method"
189
+ ) !== undefined;
86
190
 
87
191
  if (isPreflight) {
88
192
  corsHeaders["access-control-allow-methods"] = methods;
89
193
 
90
194
  const configuredHeaders = options?.allowHeaders;
91
- if (configuredHeaders && configuredHeaders.length > 0) {
92
- corsHeaders["access-control-allow-headers"] =
93
- configuredHeaders.join(", ");
195
+ if (configuredHeaders !== undefined) {
196
+ if (configuredHeaders.length > 0) {
197
+ corsHeaders["access-control-allow-headers"] =
198
+ configuredHeaders.join(", ");
199
+ }
94
200
  } else {
95
- const requestedHeaders =
96
- ctx.request.header?.["access-control-request-headers"];
201
+ const requestedHeaders = readSingletonHeader(
202
+ ctx.request.header,
203
+ "access-control-request-headers"
204
+ );
97
205
  if (typeof requestedHeaders === "string") {
98
206
  corsHeaders["access-control-allow-headers"] = requestedHeaders;
99
207
  }
@@ -113,7 +221,7 @@ export function cors(options?: CorsOptions) {
113
221
 
114
222
  return {
115
223
  ...response,
116
- header: { ...corsHeaders, ...response.header },
224
+ header: mergeResponseHeaders(response.header, corsHeaders),
117
225
  } satisfies IHttpResponse;
118
226
  });
119
227
  }
@@ -0,0 +1,59 @@
1
+ export type HeaderMap = Record<string, string | string[]> | undefined;
2
+
3
+ export function readSingletonHeader(
4
+ header: HeaderMap,
5
+ name: string
6
+ ): string | undefined {
7
+ const normalizedName = name.toLowerCase();
8
+ let foundValue: string | undefined;
9
+
10
+ for (const [key, value] of Object.entries(header ?? {})) {
11
+ if (key.toLowerCase() !== normalizedName) continue;
12
+ if (foundValue !== undefined || typeof value !== "string") {
13
+ return undefined;
14
+ }
15
+
16
+ foundValue = value;
17
+ }
18
+
19
+ return foundValue;
20
+ }
21
+
22
+ export function hasHeaderName(header: HeaderMap, name: string): boolean {
23
+ const normalizedName = name.toLowerCase();
24
+
25
+ return Object.keys(header ?? {}).some(
26
+ key => key.toLowerCase() === normalizedName
27
+ );
28
+ }
29
+
30
+ export function readHeaderValues(
31
+ header: HeaderMap,
32
+ name: string
33
+ ): readonly string[] {
34
+ const normalizedName = name.toLowerCase();
35
+ const values: string[] = [];
36
+
37
+ for (const [key, value] of Object.entries(header ?? {})) {
38
+ if (key.toLowerCase() !== normalizedName) continue;
39
+
40
+ values.push(...(Array.isArray(value) ? value : [value]));
41
+ }
42
+
43
+ return values;
44
+ }
45
+
46
+ export function omitHeaders(
47
+ header: HeaderMap,
48
+ names: readonly string[]
49
+ ): Record<string, string | string[]> {
50
+ const normalizedNames = new Set(names.map(name => name.toLowerCase()));
51
+ const headers: Record<string, string | string[]> = {};
52
+
53
+ for (const [key, value] of Object.entries(header ?? {})) {
54
+ if (normalizedNames.has(key.toLowerCase())) continue;
55
+ headers[key] = value;
56
+ }
57
+
58
+ return headers;
59
+ }
@@ -10,6 +10,7 @@ export type LogData = {
10
10
  export type LoggerOptions = {
11
11
  readonly logFn?: (message: string) => void;
12
12
  readonly format?: (data: LogData) => string;
13
+ readonly nowMs?: () => number;
13
14
  };
14
15
 
15
16
  const defaultFormat = (data: LogData): string =>
@@ -18,11 +19,12 @@ const defaultFormat = (data: LogData): string =>
18
19
  export function logger(options?: LoggerOptions) {
19
20
  const logFn = options?.logFn ?? console.log;
20
21
  const format = options?.format ?? defaultFormat;
22
+ const nowMs = options?.nowMs ?? (() => performance.now());
21
23
 
22
24
  return defineMiddleware(async (ctx, next) => {
23
- const start = performance.now();
25
+ const start = nowMs();
24
26
  const response = await next();
25
- const durationMs = Math.round(performance.now() - start);
27
+ const durationMs = Math.round(nowMs() - start);
26
28
 
27
29
  logFn(
28
30
  format({