@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.
- package/dist/index.cjs +3 -2
- package/dist/index.mjs +4 -3
- package/dist/index.mjs.map +1 -1
- package/dist/lib/BodyLimitPolicy.ts +118 -0
- package/dist/lib/Errors.ts +16 -0
- package/dist/lib/FetchApiAdapter.ts +54 -22
- package/dist/lib/NodeAdapter.ts +570 -36
- package/dist/lib/PathMatcher.ts +54 -10
- package/dist/lib/Router.ts +16 -4
- package/dist/lib/TypeweaverApp.ts +32 -9
- package/dist/lib/TypeweaverAppRuntime.ts +37 -0
- package/dist/lib/TypeweaverInternals.ts +45 -0
- package/dist/lib/index.ts +1 -0
- package/dist/lib/middleware/basicAuth.ts +11 -2
- package/dist/lib/middleware/bearerAuth.ts +11 -2
- package/dist/lib/middleware/cors.ts +120 -12
- package/dist/lib/middleware/header.ts +59 -0
- package/dist/lib/middleware/logger.ts +4 -2
- package/dist/lib/middleware/poweredBy.ts +6 -1
- package/dist/lib/middleware/requestId.ts +8 -8
- package/dist/lib/middleware/scoped.ts +27 -12
- package/dist/lib/middleware/secureHeaders.ts +3 -1
- package/package.json +8 -5
package/dist/lib/PathMatcher.ts
CHANGED
|
@@ -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
|
|
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
|
|
35
|
-
|
|
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
|
|
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 =>
|
|
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 =
|
|
46
|
-
const matchers =
|
|
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
|
|
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]
|
|
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
|
+
}
|
package/dist/lib/Router.ts
CHANGED
|
@@ -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(
|
|
169
|
+
if (current.methods.has(method)) {
|
|
165
170
|
throw new Error(
|
|
166
|
-
`Route conflict: ${
|
|
171
|
+
`Route conflict: ${method} ${definition.path} is already registered`
|
|
167
172
|
);
|
|
168
173
|
}
|
|
169
174
|
|
|
170
|
-
current.methods.set(
|
|
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 (
|
|
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.
|
|
86
|
-
this.
|
|
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(
|
|
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(
|
|
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,
|
|
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)
|
|
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
|
@@ -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 =
|
|
35
|
+
const authorization = readSingletonHeader(
|
|
36
|
+
ctx.request.header,
|
|
37
|
+
"authorization"
|
|
38
|
+
);
|
|
35
39
|
if (typeof authorization !== "string") return deny(ctx);
|
|
36
40
|
|
|
37
|
-
if (
|
|
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 =
|
|
34
|
+
const authorization = readSingletonHeader(
|
|
35
|
+
ctx.request.header,
|
|
36
|
+
"authorization"
|
|
37
|
+
);
|
|
34
38
|
if (typeof authorization !== "string") return deny(ctx);
|
|
35
39
|
|
|
36
|
-
if (
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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:
|
|
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 =
|
|
25
|
+
const start = nowMs();
|
|
24
26
|
const response = await next();
|
|
25
|
-
const durationMs = Math.round(
|
|
27
|
+
const durationMs = Math.round(nowMs() - start);
|
|
26
28
|
|
|
27
29
|
logFn(
|
|
28
30
|
format({
|