@rexeus/typeweaver-server 0.10.3 → 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.mjs.map +1 -1
- package/dist/lib/BodyLimitPolicy.ts +8 -1
- package/dist/lib/FetchApiAdapter.ts +5 -1
- package/dist/lib/NodeAdapter.ts +325 -13
- package/dist/lib/PathMatcher.ts +54 -10
- package/dist/lib/Router.ts +16 -4
- package/dist/lib/TypeweaverApp.ts +22 -5
- 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 +5 -5
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/routerGenerator.ts","../src/index.ts"],"sourcesContent":["import path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { HttpMethod } from \"@rexeus/typeweaver-core\";\nimport { compareRoutes, relative } from \"@rexeus/typeweaver-gen\";\nimport type {\n GeneratorContext,\n NormalizedOperation,\n NormalizedResource,\n} from \"@rexeus/typeweaver-gen\";\nimport { pascalCase } from \"polycase\";\n\ntype OperationData = {\n readonly operationId: string;\n readonly className: string;\n readonly handlerName: string;\n readonly method: string;\n readonly path: string;\n};\n\n/**\n * Generates TypeweaverRouter subclasses from API definitions.\n *\n * For each resource (e.g., `Todo`, `Account`), produces a `<ResourceName>Router.ts`\n * file that extends `TypeweaverRouter` and registers all operations as routes.\n */\n\n/**\n * Generates router files for all resources in the given context.\n *\n * @param context - The generator context containing resources, templates, and output configuration\n */\nexport function generate(context:
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/routerGenerator.ts","../src/index.ts"],"sourcesContent":["import path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { HttpMethod } from \"@rexeus/typeweaver-core\";\nimport { compareRoutes, relative } from \"@rexeus/typeweaver-gen\";\nimport type {\n GeneratorContext,\n NormalizedOperation,\n NormalizedResource,\n} from \"@rexeus/typeweaver-gen\";\nimport { pascalCase } from \"polycase\";\n\nexport type RouterGenerationContext = Pick<\n GeneratorContext,\n | \"normalizedSpec\"\n | \"outputDir\"\n | \"getResourceOutputDir\"\n | \"renderTemplate\"\n | \"writeFile\"\n>;\n\ntype OperationData = {\n readonly operationId: string;\n readonly className: string;\n readonly handlerName: string;\n readonly method: string;\n readonly path: string;\n};\n\n/**\n * Generates TypeweaverRouter subclasses from API definitions.\n *\n * For each resource (e.g., `Todo`, `Account`), produces a `<ResourceName>Router.ts`\n * file that extends `TypeweaverRouter` and registers all operations as routes.\n */\n\n/**\n * Generates router files for all resources in the given context.\n *\n * @param context - The generator context containing resources, templates, and output configuration\n */\nexport function generate(context: RouterGenerationContext): void {\n const moduleDir = path.dirname(fileURLToPath(import.meta.url));\n const templateFile = path.join(moduleDir, \"templates\", \"Router.ejs\");\n\n for (const resource of context.normalizedSpec.resources) {\n writeRouter(resource, templateFile, context);\n }\n}\n\nfunction writeRouter(\n resource: NormalizedResource,\n templateFile: string,\n context: RouterGenerationContext\n): void {\n const pascalCaseEntityName = pascalCase(resource.name);\n const outputDir = context.getResourceOutputDir(resource.name);\n const outputPath = path.join(outputDir, `${pascalCaseEntityName}Router.ts`);\n\n const operations = resource.operations\n .filter(operation => operation.method !== HttpMethod.HEAD)\n .map(operation => createOperationData(operation))\n .sort((a, b) => compareRoutes(a, b));\n\n const content = context.renderTemplate(templateFile, {\n coreDir: relative(outputDir, context.outputDir),\n entityName: resource.name,\n pascalCaseEntityName,\n operations,\n });\n\n const relativePath = path.relative(context.outputDir, outputPath);\n context.writeFile(relativePath, content);\n}\n\nfunction createOperationData(operation: NormalizedOperation): OperationData {\n const operationId = operation.operationId;\n const className = pascalCase(operationId);\n\n return {\n operationId,\n className,\n handlerName: `handle${className}Request`,\n method: operation.method,\n path: operation.path,\n };\n}\n","import path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { BasePlugin } from \"@rexeus/typeweaver-gen\";\nimport type { GeneratorContext } from \"@rexeus/typeweaver-gen\";\nimport { generate as generateRouters } from \"./routerGenerator.js\";\n\nconst moduleDir = path.dirname(fileURLToPath(import.meta.url));\n\n/**\n * Typeweaver plugin that generates a lightweight, dependency-free server\n * with built-in routing and middleware support.\n *\n * Copies the runtime library files (`TypeweaverApp`, `TypeweaverRouter`, `Router`,\n * `Middleware`, etc.) and generates typed router classes for each resource.\n */\nexport class ServerPlugin extends BasePlugin {\n public name = \"server\";\n public override depends = [\"types\"];\n\n /**\n * Generates the server runtime and typed routers for all resources.\n *\n * @param context - The generator context\n */\n public override generate(context: GeneratorContext): void {\n const libSourceDir = path.join(moduleDir, \"lib\");\n this.copyLibFiles(context, libSourceDir, this.name);\n\n generateRouters(context);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAwCA,SAAgB,SAAS,SAAwC;CAC/D,MAAM,YAAY,KAAK,QAAQ,cAAc,OAAO,KAAK,IAAI,CAAC;CAC9D,MAAM,eAAe,KAAK,KAAK,WAAW,aAAa,aAAa;AAEpE,MAAK,MAAM,YAAY,QAAQ,eAAe,UAC5C,aAAY,UAAU,cAAc,QAAQ;;AAIhD,SAAS,YACP,UACA,cACA,SACM;CACN,MAAM,uBAAuB,WAAW,SAAS,KAAK;CACtD,MAAM,YAAY,QAAQ,qBAAqB,SAAS,KAAK;CAC7D,MAAM,aAAa,KAAK,KAAK,WAAW,GAAG,qBAAqB,WAAW;CAE3E,MAAM,aAAa,SAAS,WACzB,QAAO,cAAa,UAAU,WAAW,WAAW,KAAK,CACzD,KAAI,cAAa,oBAAoB,UAAU,CAAC,CAChD,MAAM,GAAG,MAAM,cAAc,GAAG,EAAE,CAAC;CAEtC,MAAM,UAAU,QAAQ,eAAe,cAAc;EACnD,SAAS,SAAS,WAAW,QAAQ,UAAU;EAC/C,YAAY,SAAS;EACrB;EACA;EACD,CAAC;CAEF,MAAM,eAAe,KAAK,SAAS,QAAQ,WAAW,WAAW;AACjE,SAAQ,UAAU,cAAc,QAAQ;;AAG1C,SAAS,oBAAoB,WAA+C;CAC1E,MAAM,cAAc,UAAU;CAC9B,MAAM,YAAY,WAAW,YAAY;AAEzC,QAAO;EACL;EACA;EACA,aAAa,SAAS,UAAU;EAChC,QAAQ,UAAU;EAClB,MAAM,UAAU;EACjB;;;;AC9EH,MAAM,YAAY,KAAK,QAAQ,cAAc,OAAO,KAAK,IAAI,CAAC;;;;;;;;AAS9D,IAAa,eAAb,cAAkC,WAAW;CAC3C,OAAc;CACd,UAA0B,CAAC,QAAQ;;;;;;CAOnC,SAAyB,SAAiC;EACxD,MAAM,eAAe,KAAK,KAAK,WAAW,MAAM;AAChD,OAAK,aAAa,SAAS,cAAc,KAAK,KAAK;AAEnD,WAAgB,QAAQ"}
|
|
@@ -34,6 +34,8 @@ const FETCH_BODY_LIMIT_CAPABILITY: BodyLimitCapability =
|
|
|
34
34
|
const NODE_BODY_LIMIT_CAPABILITY: BodyLimitCapability =
|
|
35
35
|
"prevalidated-request-body";
|
|
36
36
|
|
|
37
|
+
const CONTENT_LENGTH_HEADER_PATTERN = /^\d+$/;
|
|
38
|
+
|
|
37
39
|
export function createFetchBodyLimitPolicy(
|
|
38
40
|
maxBodySize?: number
|
|
39
41
|
): BodyLimitPolicy {
|
|
@@ -64,7 +66,12 @@ export function parseContentLength(
|
|
|
64
66
|
return undefined;
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
const
|
|
69
|
+
const trimmedValue = rawValue.trim();
|
|
70
|
+
if (!CONTENT_LENGTH_HEADER_PATTERN.test(trimmedValue)) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const contentLength = Number(trimmedValue);
|
|
68
75
|
if (!Number.isFinite(contentLength) || contentLength < 0) {
|
|
69
76
|
return undefined;
|
|
70
77
|
}
|
|
@@ -326,7 +326,11 @@ export class FetchApiAdapter {
|
|
|
326
326
|
if (body instanceof Blob) return body;
|
|
327
327
|
|
|
328
328
|
try {
|
|
329
|
-
|
|
329
|
+
const serializedBody = JSON.stringify(body);
|
|
330
|
+
if (serializedBody === undefined) {
|
|
331
|
+
throw new TypeError("Response body cannot be serialized to JSON");
|
|
332
|
+
}
|
|
333
|
+
return serializedBody;
|
|
330
334
|
} catch (error) {
|
|
331
335
|
throw new ResponseSerializationError(
|
|
332
336
|
"Failed to serialize response body to JSON",
|
package/dist/lib/NodeAdapter.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
badRequestDefaultError,
|
|
9
10
|
createDefaultErrorBody,
|
|
10
11
|
internalServerErrorDefaultError,
|
|
11
12
|
payloadTooLargeDefaultError,
|
|
@@ -39,6 +40,15 @@ type DrainRequestOptions = {
|
|
|
39
40
|
};
|
|
40
41
|
|
|
41
42
|
const REQUEST_DRAIN_TIMEOUT_MS = 5_000;
|
|
43
|
+
const ORIGIN_FORM_BASE_URL_PROTOCOL = "http:";
|
|
44
|
+
const AUTHORITY_LIKE_REQUEST_TARGET_PREFIX = /^[\\/]{2}/;
|
|
45
|
+
const ASTERISK_FORM_REQUEST_TARGET = "*";
|
|
46
|
+
|
|
47
|
+
type ParsedAuthority = {
|
|
48
|
+
readonly host: string;
|
|
49
|
+
readonly hostname: string;
|
|
50
|
+
readonly port: string;
|
|
51
|
+
};
|
|
42
52
|
|
|
43
53
|
/**
|
|
44
54
|
* Adapts a `TypeweaverApp` to Node.js `http.createServer`.
|
|
@@ -82,12 +92,16 @@ async function handleRequest(
|
|
|
82
92
|
reportError: (error: unknown) => void
|
|
83
93
|
): Promise<void> {
|
|
84
94
|
try {
|
|
85
|
-
const url =
|
|
95
|
+
const url = createRequestUrl(req);
|
|
96
|
+
if (url === undefined) {
|
|
97
|
+
writeBadRequestResponse(req, res, bodyLimitPolicy.maxBodySize);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
86
100
|
const shouldValidateBody = shouldValidateRequestBody(req.method);
|
|
87
101
|
|
|
88
102
|
enforceContentLengthLimit(req, bodyLimitPolicy.maxBodySize);
|
|
89
103
|
|
|
90
|
-
if (!shouldValidateBody) {
|
|
104
|
+
if (!shouldValidateBody && hasReadableRequestBody(req)) {
|
|
91
105
|
const drainResult = await drainRequest(req, bodyLimitPolicy.maxBodySize, {
|
|
92
106
|
destroyOnLimitExceeded: false,
|
|
93
107
|
});
|
|
@@ -111,7 +125,7 @@ async function handleRequest(
|
|
|
111
125
|
|
|
112
126
|
const request = new Request(url, {
|
|
113
127
|
method: req.method,
|
|
114
|
-
headers: req.headers
|
|
128
|
+
headers: createRequestHeaders(req.headers),
|
|
115
129
|
body,
|
|
116
130
|
});
|
|
117
131
|
if (shouldValidateBody) {
|
|
@@ -119,6 +133,11 @@ async function handleRequest(
|
|
|
119
133
|
}
|
|
120
134
|
|
|
121
135
|
const response = await app.fetch(request);
|
|
136
|
+
const responseBody = await readWritableResponseBody(
|
|
137
|
+
req.method,
|
|
138
|
+
response,
|
|
139
|
+
reportError
|
|
140
|
+
);
|
|
122
141
|
|
|
123
142
|
response.headers.forEach((value, key) => {
|
|
124
143
|
if (key.toLowerCase() !== "set-cookie") {
|
|
@@ -130,20 +149,193 @@ async function handleRequest(
|
|
|
130
149
|
res.setHeader("set-cookie", cookies);
|
|
131
150
|
}
|
|
132
151
|
res.writeHead(response.status);
|
|
133
|
-
res.end(
|
|
152
|
+
res.end(responseBody);
|
|
134
153
|
} catch (error) {
|
|
135
154
|
reportError(error);
|
|
136
155
|
|
|
137
156
|
if (isRequestBodyLimitError(error)) {
|
|
138
|
-
writeDefaultErrorResponse(res, payloadTooLargeDefaultError,
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
157
|
+
writeDefaultErrorResponse(res, payloadTooLargeDefaultError, {
|
|
158
|
+
method: req.method,
|
|
159
|
+
onFinished: () => {
|
|
160
|
+
void drainRequest(req, bodyLimitPolicy.maxBodySize, {
|
|
161
|
+
destroyOnLimitExceeded: true,
|
|
162
|
+
});
|
|
163
|
+
},
|
|
142
164
|
});
|
|
143
165
|
return;
|
|
144
166
|
}
|
|
145
167
|
|
|
146
|
-
writeDefaultErrorResponse(res, internalServerErrorDefaultError
|
|
168
|
+
writeDefaultErrorResponse(res, internalServerErrorDefaultError, {
|
|
169
|
+
method: req.method,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function createRequestUrl(req: IncomingMessage): URL | undefined {
|
|
175
|
+
const rawUrl = req.url ?? "/";
|
|
176
|
+
|
|
177
|
+
if (rawUrl === ASTERISK_FORM_REQUEST_TARGET) {
|
|
178
|
+
return createAsteriskFormRequestUrl(req);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (hasAuthorityLikeRequestTargetPrefix(rawUrl)) {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const url = new URL(rawUrl);
|
|
187
|
+
return isAbsoluteRequestHostAllowed(url, req) ? url : undefined;
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (!(error instanceof TypeError) || !rawUrl.startsWith("/")) {
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const host = parseRequestHostHeader(req, ORIGIN_FORM_BASE_URL_PROTOCOL);
|
|
195
|
+
if (host === undefined) {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return new URL(rawUrl, `${ORIGIN_FORM_BASE_URL_PROTOCOL}//${host.host}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function createAsteriskFormRequestUrl(req: IncomingMessage): URL | undefined {
|
|
203
|
+
if (req.method !== "OPTIONS") {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const host = parseRequestHostHeader(req, ORIGIN_FORM_BASE_URL_PROTOCOL);
|
|
208
|
+
if (host === undefined) {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return new URL(
|
|
213
|
+
ASTERISK_FORM_REQUEST_TARGET,
|
|
214
|
+
`${ORIGIN_FORM_BASE_URL_PROTOCOL}//${host.host}/`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function hasAuthorityLikeRequestTargetPrefix(rawUrl: string): boolean {
|
|
219
|
+
return AUTHORITY_LIKE_REQUEST_TARGET_PREFIX.test(rawUrl);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isAbsoluteRequestHostAllowed(url: URL, req: IncomingMessage): boolean {
|
|
223
|
+
const host = parseRequestHostHeader(req, url.protocol);
|
|
224
|
+
if (host === undefined) {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const urlAuthority = getUrlAuthority(url);
|
|
229
|
+
return (
|
|
230
|
+
host.hostname.toLowerCase() === urlAuthority.hostname.toLowerCase() &&
|
|
231
|
+
host.port === urlAuthority.port
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseRequestHostHeader(
|
|
236
|
+
req: IncomingMessage,
|
|
237
|
+
protocol: string
|
|
238
|
+
): ParsedAuthority | undefined {
|
|
239
|
+
if (!hasExactlyOneHostHeaderLine(req)) {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return parseHostHeader(req.headers.host, protocol);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function hasExactlyOneHostHeaderLine(req: IncomingMessage): boolean {
|
|
247
|
+
const headersDistinctHostCount = getHeadersDistinctHostCount(req);
|
|
248
|
+
if (
|
|
249
|
+
headersDistinctHostCount !== undefined &&
|
|
250
|
+
headersDistinctHostCount !== 1
|
|
251
|
+
) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const rawHostHeaderCount = countRawHostHeaderLines(req.rawHeaders);
|
|
256
|
+
if (rawHostHeaderCount > 0) {
|
|
257
|
+
return rawHostHeaderCount === 1;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return headersDistinctHostCount === 1;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function getHeadersDistinctHostCount(req: IncomingMessage): number | undefined {
|
|
264
|
+
const hostHeader = req.headersDistinct?.host;
|
|
265
|
+
if (hostHeader === undefined) {
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return Array.isArray(hostHeader) ? hostHeader.length : 1;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function countRawHostHeaderLines(rawHeaders: readonly string[]): number {
|
|
273
|
+
let count = 0;
|
|
274
|
+
|
|
275
|
+
for (let index = 0; index < rawHeaders.length; index += 2) {
|
|
276
|
+
if (rawHeaders[index]?.toLowerCase() === "host") {
|
|
277
|
+
count += 1;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return count;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function parseHostHeader(
|
|
285
|
+
hostHeader: IncomingMessage["headers"]["host"],
|
|
286
|
+
protocol: string
|
|
287
|
+
): ParsedAuthority | undefined {
|
|
288
|
+
if (hostHeader === undefined || Array.isArray(hostHeader)) {
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const host = hostHeader.trim();
|
|
293
|
+
if (host === "" || host !== hostHeader) {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const parsed = new URL(`${protocol}//${host}`);
|
|
299
|
+
if (
|
|
300
|
+
parsed.username !== "" ||
|
|
301
|
+
parsed.password !== "" ||
|
|
302
|
+
parsed.pathname !== "/" ||
|
|
303
|
+
parsed.search !== "" ||
|
|
304
|
+
parsed.hash !== ""
|
|
305
|
+
) {
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return getUrlAuthority(parsed);
|
|
310
|
+
} catch {
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function getUrlAuthority(url: URL): ParsedAuthority {
|
|
316
|
+
return {
|
|
317
|
+
host: url.host,
|
|
318
|
+
hostname: url.hostname,
|
|
319
|
+
port: getEffectivePort(url),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function getEffectivePort(url: URL): string {
|
|
324
|
+
return url.port === "" ? getDefaultPort(url.protocol) : url.port;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function getDefaultPort(protocol: string): string {
|
|
328
|
+
switch (protocol) {
|
|
329
|
+
case "http:":
|
|
330
|
+
case "ws:":
|
|
331
|
+
return "80";
|
|
332
|
+
case "https:":
|
|
333
|
+
case "wss:":
|
|
334
|
+
return "443";
|
|
335
|
+
case "ftp:":
|
|
336
|
+
return "21";
|
|
337
|
+
default:
|
|
338
|
+
return "";
|
|
147
339
|
}
|
|
148
340
|
}
|
|
149
341
|
|
|
@@ -151,6 +343,86 @@ function shouldValidateRequestBody(method?: string): boolean {
|
|
|
151
343
|
return method !== "GET" && method !== "HEAD";
|
|
152
344
|
}
|
|
153
345
|
|
|
346
|
+
function shouldWriteResponseBody(
|
|
347
|
+
method: string | undefined,
|
|
348
|
+
status: number
|
|
349
|
+
): boolean {
|
|
350
|
+
return method !== "HEAD" && status !== 204 && status !== 304;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function readWritableResponseBody(
|
|
354
|
+
method: string | undefined,
|
|
355
|
+
response: Response,
|
|
356
|
+
reportError: (error: unknown) => void
|
|
357
|
+
): Promise<Buffer | undefined> {
|
|
358
|
+
if (shouldWriteResponseBody(method, response.status)) {
|
|
359
|
+
return Buffer.from(await response.arrayBuffer());
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
cancelSuppressedResponseBody(response, reportError);
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function cancelSuppressedResponseBody(
|
|
367
|
+
response: Response,
|
|
368
|
+
reportError: (error: unknown) => void
|
|
369
|
+
): void {
|
|
370
|
+
try {
|
|
371
|
+
void response.body?.cancel().catch(error => {
|
|
372
|
+
reportSuppressedResponseBodyCancelError(error, reportError);
|
|
373
|
+
});
|
|
374
|
+
} catch (error) {
|
|
375
|
+
reportSuppressedResponseBodyCancelError(error, reportError);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function reportSuppressedResponseBodyCancelError(
|
|
380
|
+
error: unknown,
|
|
381
|
+
reportError: (error: unknown) => void
|
|
382
|
+
): void {
|
|
383
|
+
try {
|
|
384
|
+
reportError(error);
|
|
385
|
+
} catch (onErrorFailure) {
|
|
386
|
+
console.error(
|
|
387
|
+
"TypeweaverApp: onError callback threw while handling error",
|
|
388
|
+
{ onErrorFailure, originalError: error }
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function hasReadableRequestBody(req: IncomingMessage): boolean {
|
|
394
|
+
return (
|
|
395
|
+
req.headers["content-length"] !== undefined ||
|
|
396
|
+
req.headers["transfer-encoding"] !== undefined
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function createRequestHeaders(headers: IncomingMessage["headers"]): Headers {
|
|
401
|
+
const requestHeaders = new Headers();
|
|
402
|
+
|
|
403
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
404
|
+
if (value === undefined) {
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (Array.isArray(value)) {
|
|
409
|
+
if (name.toLowerCase() === "cookie") {
|
|
410
|
+
requestHeaders.set(name, value.join("; "));
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
for (const item of value) {
|
|
415
|
+
requestHeaders.append(name, item);
|
|
416
|
+
}
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
requestHeaders.set(name, value);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return requestHeaders;
|
|
424
|
+
}
|
|
425
|
+
|
|
154
426
|
function isRequestBodyLimitError(
|
|
155
427
|
error: unknown
|
|
156
428
|
): error is PayloadTooLargeError | RequestBodyDrainTimeoutError {
|
|
@@ -177,9 +449,13 @@ function enforceContentLengthLimit(
|
|
|
177
449
|
function writeDefaultErrorResponse(
|
|
178
450
|
res: ServerResponse,
|
|
179
451
|
error:
|
|
452
|
+
| typeof badRequestDefaultError
|
|
180
453
|
| typeof payloadTooLargeDefaultError
|
|
181
454
|
| typeof internalServerErrorDefaultError,
|
|
182
|
-
|
|
455
|
+
options: {
|
|
456
|
+
readonly method?: string;
|
|
457
|
+
readonly onFinished?: () => void;
|
|
458
|
+
} = {}
|
|
183
459
|
): void {
|
|
184
460
|
if (!res.headersSent) {
|
|
185
461
|
res.writeHead(error.statusCode, {
|
|
@@ -187,11 +463,47 @@ function writeDefaultErrorResponse(
|
|
|
187
463
|
});
|
|
188
464
|
}
|
|
189
465
|
|
|
190
|
-
if (onFinished !== undefined) {
|
|
191
|
-
res.once("finish", onFinished);
|
|
466
|
+
if (options.onFinished !== undefined) {
|
|
467
|
+
res.once("finish", options.onFinished);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const body = shouldWriteResponseBody(options.method, error.statusCode)
|
|
471
|
+
? JSON.stringify(createDefaultErrorBody(error))
|
|
472
|
+
: undefined;
|
|
473
|
+
res.end(body);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function writeBadRequestResponse(
|
|
477
|
+
req: IncomingMessage,
|
|
478
|
+
res: ServerResponse,
|
|
479
|
+
maxBodySize: number
|
|
480
|
+
): void {
|
|
481
|
+
writeDefaultErrorResponse(res, badRequestDefaultError, {
|
|
482
|
+
method: req.method,
|
|
483
|
+
onFinished: createRejectedRequestBodyCleanup(req, maxBodySize),
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function createRejectedRequestBodyCleanup(
|
|
488
|
+
req: IncomingMessage,
|
|
489
|
+
maxBodySize: number
|
|
490
|
+
): (() => void) | undefined {
|
|
491
|
+
if (!hasReadableRequestBody(req)) {
|
|
492
|
+
return undefined;
|
|
192
493
|
}
|
|
193
494
|
|
|
194
|
-
|
|
495
|
+
return () => {
|
|
496
|
+
const contentLength = parseContentLength(req.headers["content-length"]);
|
|
497
|
+
if (
|
|
498
|
+
contentLength !== undefined &&
|
|
499
|
+
isBodySizeOverLimit(contentLength, maxBodySize)
|
|
500
|
+
) {
|
|
501
|
+
req.destroy();
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
void drainRequest(req, maxBodySize, { destroyOnLimitExceeded: true });
|
|
506
|
+
};
|
|
195
507
|
}
|
|
196
508
|
|
|
197
509
|
async function drainRequest(
|
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;
|
|
@@ -13,9 +13,11 @@ import {
|
|
|
13
13
|
internalServerErrorDefaultError,
|
|
14
14
|
isTypedHttpResponse,
|
|
15
15
|
methodNotAllowedDefaultError,
|
|
16
|
+
normalizeHttpResponse,
|
|
16
17
|
notFoundDefaultError,
|
|
17
18
|
payloadTooLargeDefaultError,
|
|
18
19
|
RequestValidationError,
|
|
20
|
+
toHttpResponse,
|
|
19
21
|
validationDefaultError,
|
|
20
22
|
} from "@rexeus/typeweaver-core";
|
|
21
23
|
import type { IHttpResponse } from "@rexeus/typeweaver-core";
|
|
@@ -240,13 +242,21 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
|
|
|
240
242
|
const routeCtx = this.withPathParams(ctx, match.params);
|
|
241
243
|
try {
|
|
242
244
|
const response = await this.executeHandler(routeCtx, match.route);
|
|
243
|
-
return await this.validateResponse(
|
|
245
|
+
return await this.validateResponse(
|
|
246
|
+
match.route,
|
|
247
|
+
normalizeHttpResponse(response),
|
|
248
|
+
routeCtx
|
|
249
|
+
);
|
|
244
250
|
} catch (error) {
|
|
245
251
|
if (
|
|
246
252
|
isTypedHttpResponse(error) &&
|
|
247
253
|
match.route.routerConfig.validateResponses
|
|
248
254
|
) {
|
|
249
|
-
return await this.validateResponse(
|
|
255
|
+
return await this.validateResponse(
|
|
256
|
+
match.route,
|
|
257
|
+
toHttpResponse(error),
|
|
258
|
+
routeCtx
|
|
259
|
+
);
|
|
250
260
|
}
|
|
251
261
|
return this.handleError(error, routeCtx, match.route);
|
|
252
262
|
}
|
|
@@ -289,7 +299,7 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
|
|
|
289
299
|
* - `validateResponses: true` (default) → runs validation:
|
|
290
300
|
* - Valid response → returns the stripped response (extra fields removed).
|
|
291
301
|
* - Invalid response + handler configured → calls the handler safely.
|
|
292
|
-
* If the handler throws,
|
|
302
|
+
* If the handler throws, fails closed with a sanitized 500 response.
|
|
293
303
|
* - Invalid response + `handleResponseValidationErrors: false` → returns
|
|
294
304
|
* the original (invalid) response as-is.
|
|
295
305
|
*
|
|
@@ -307,7 +317,9 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
|
|
|
307
317
|
|
|
308
318
|
const result = route.responseValidator.safeValidate(response);
|
|
309
319
|
|
|
310
|
-
if (result.isValid)
|
|
320
|
+
if (result.isValid) {
|
|
321
|
+
return normalizeHttpResponse(result.data);
|
|
322
|
+
}
|
|
311
323
|
|
|
312
324
|
const handler = this.resolveErrorHandler<ResponseValidationErrorHandler>(
|
|
313
325
|
route.routerConfig.handleResponseValidationErrors,
|
|
@@ -319,6 +331,11 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
|
|
|
319
331
|
handler(result.error, response, ctx)
|
|
320
332
|
);
|
|
321
333
|
if (handlerResponse) return handlerResponse;
|
|
334
|
+
return TypeweaverApp.defaultResponseValidationHandler(
|
|
335
|
+
result.error,
|
|
336
|
+
response,
|
|
337
|
+
ctx
|
|
338
|
+
);
|
|
322
339
|
}
|
|
323
340
|
|
|
324
341
|
return response;
|
|
@@ -461,7 +478,7 @@ export class TypeweaverApp<TState extends Record<string, unknown> = {}> {
|
|
|
461
478
|
|
|
462
479
|
private static defaultHttpResponseHandler: HttpResponseErrorHandler = (
|
|
463
480
|
err
|
|
464
|
-
): IHttpResponse => err;
|
|
481
|
+
): IHttpResponse => toHttpResponse(err);
|
|
465
482
|
|
|
466
483
|
private readonly defaultUnknownHandler: UnknownErrorHandler = (
|
|
467
484
|
error
|
|
@@ -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({
|
|
@@ -10,10 +10,15 @@ export function poweredBy(options?: PoweredByOptions) {
|
|
|
10
10
|
|
|
11
11
|
return defineMiddleware(async (_ctx, next) => {
|
|
12
12
|
const response = await next();
|
|
13
|
+
const responseHeaders = Object.fromEntries(
|
|
14
|
+
Object.entries(response.header ?? {}).filter(
|
|
15
|
+
([headerName]) => headerName.toLowerCase() !== "x-powered-by"
|
|
16
|
+
)
|
|
17
|
+
);
|
|
13
18
|
|
|
14
19
|
return {
|
|
15
20
|
...response,
|
|
16
|
-
header: { ...
|
|
21
|
+
header: { ...responseHeaders, "x-powered-by": value },
|
|
17
22
|
} satisfies IHttpResponse;
|
|
18
23
|
});
|
|
19
24
|
}
|
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
import type { IHttpResponse } from "@rexeus/typeweaver-core";
|
|
2
2
|
import { defineMiddleware } from "../TypedMiddleware.js";
|
|
3
|
+
import { omitHeaders, readSingletonHeader } from "./header.js";
|
|
3
4
|
|
|
4
5
|
export type RequestIdOptions = {
|
|
5
6
|
readonly headerName?: string;
|
|
6
7
|
readonly generator?: () => string;
|
|
7
8
|
};
|
|
8
9
|
|
|
10
|
+
const isValidRequestId = (value: string | undefined): value is string =>
|
|
11
|
+
value !== undefined && value.length > 0 && !/[\r\n]/.test(value);
|
|
12
|
+
|
|
9
13
|
export function requestId(options?: RequestIdOptions) {
|
|
10
14
|
const headerName = (options?.headerName ?? "x-request-id").toLowerCase();
|
|
11
15
|
const generator = options?.generator ?? (() => crypto.randomUUID());
|
|
12
16
|
|
|
13
17
|
return defineMiddleware<{ requestId: string }>(async (ctx, next) => {
|
|
14
|
-
const existing = ctx.request.header
|
|
15
|
-
const id =
|
|
16
|
-
typeof existing === "string"
|
|
17
|
-
? existing
|
|
18
|
-
: Array.isArray(existing)
|
|
19
|
-
? (existing[0] ?? generator())
|
|
20
|
-
: generator();
|
|
18
|
+
const existing = readSingletonHeader(ctx.request.header, headerName);
|
|
19
|
+
const id = isValidRequestId(existing) ? existing : generator();
|
|
21
20
|
|
|
22
21
|
const response = await next({ requestId: id });
|
|
22
|
+
const header = omitHeaders(response.header, [headerName]);
|
|
23
23
|
|
|
24
24
|
return {
|
|
25
25
|
...response,
|
|
26
|
-
header: { ...
|
|
26
|
+
header: { ...header, [headerName]: id },
|
|
27
27
|
} satisfies IHttpResponse;
|
|
28
28
|
});
|
|
29
29
|
}
|
|
@@ -2,55 +2,70 @@ import { pathMatcher } from "../PathMatcher.js";
|
|
|
2
2
|
import { defineMiddleware } from "../TypedMiddleware.js";
|
|
3
3
|
import type { TypedMiddleware } from "../TypedMiddleware.js";
|
|
4
4
|
|
|
5
|
+
type NoProvidedKeys<TProvides extends Record<string, unknown>> = [
|
|
6
|
+
keyof TProvides,
|
|
7
|
+
] extends [never]
|
|
8
|
+
? unknown
|
|
9
|
+
: never;
|
|
10
|
+
|
|
5
11
|
/**
|
|
6
12
|
* Restricts a middleware to only run on paths matching the given patterns.
|
|
7
13
|
*
|
|
8
14
|
* Accepts the same pattern syntax as {@link pathMatcher}: exact (`"/users"`),
|
|
9
15
|
* prefix (`"/api/*"`), and parameterized (`"/users/:id"`).
|
|
10
16
|
*
|
|
11
|
-
* Only accepts non-state middleware
|
|
17
|
+
* Only accepts non-state-providing middleware to preserve
|
|
12
18
|
* TypeWeaver's compile-time state guarantees — skipping a state-providing
|
|
13
19
|
* middleware would leave downstream consumers with missing state.
|
|
20
|
+
* Any upstream state requirements declared by the wrapped middleware are
|
|
21
|
+
* preserved on the returned middleware descriptor.
|
|
14
22
|
*
|
|
15
23
|
* @example
|
|
16
24
|
* ```typescript
|
|
17
25
|
* app.use(scoped(["/api/*"], cors({ origin: "https://app.com" })));
|
|
18
26
|
* ```
|
|
19
27
|
*/
|
|
20
|
-
export function scoped
|
|
28
|
+
export function scoped<
|
|
29
|
+
TProvides extends Record<string, unknown>,
|
|
30
|
+
TRequires extends Record<string, unknown>,
|
|
31
|
+
>(
|
|
21
32
|
paths: readonly string[],
|
|
22
|
-
middleware: TypedMiddleware<
|
|
23
|
-
): TypedMiddleware<
|
|
33
|
+
middleware: TypedMiddleware<TProvides, TRequires> & NoProvidedKeys<TProvides>
|
|
34
|
+
): TypedMiddleware<TProvides, TRequires> {
|
|
24
35
|
const matchers = paths.map(pathMatcher);
|
|
25
36
|
|
|
26
|
-
return defineMiddleware(async (ctx, next) => {
|
|
37
|
+
return defineMiddleware<{}, TRequires>(async (ctx, next) => {
|
|
27
38
|
if (!matchers.some(match => match(ctx.request.path))) {
|
|
28
39
|
return next();
|
|
29
40
|
}
|
|
30
41
|
return middleware.handler(ctx, next);
|
|
31
|
-
})
|
|
42
|
+
}) as TypedMiddleware<TProvides, TRequires>;
|
|
32
43
|
}
|
|
33
44
|
|
|
34
45
|
/**
|
|
35
46
|
* Runs a middleware on all paths *except* those matching the given patterns.
|
|
36
47
|
*
|
|
37
|
-
* The inverse of {@link scoped}. Same pattern syntax and type
|
|
48
|
+
* The inverse of {@link scoped}. Same pattern syntax and type constraints,
|
|
49
|
+
* including preservation of wrapped middleware state requirements.
|
|
38
50
|
*
|
|
39
51
|
* @example
|
|
40
52
|
* ```typescript
|
|
41
53
|
* app.use(except(["/health", "/ready"], logger()));
|
|
42
54
|
* ```
|
|
43
55
|
*/
|
|
44
|
-
export function except
|
|
56
|
+
export function except<
|
|
57
|
+
TProvides extends Record<string, unknown>,
|
|
58
|
+
TRequires extends Record<string, unknown>,
|
|
59
|
+
>(
|
|
45
60
|
paths: readonly string[],
|
|
46
|
-
middleware: TypedMiddleware<
|
|
47
|
-
): TypedMiddleware<
|
|
61
|
+
middleware: TypedMiddleware<TProvides, TRequires> & NoProvidedKeys<TProvides>
|
|
62
|
+
): TypedMiddleware<TProvides, TRequires> {
|
|
48
63
|
const matchers = paths.map(pathMatcher);
|
|
49
64
|
|
|
50
|
-
return defineMiddleware(async (ctx, next) => {
|
|
65
|
+
return defineMiddleware<{}, TRequires>(async (ctx, next) => {
|
|
51
66
|
if (matchers.some(match => match(ctx.request.path))) {
|
|
52
67
|
return next();
|
|
53
68
|
}
|
|
54
69
|
return middleware.handler(ctx, next);
|
|
55
|
-
})
|
|
70
|
+
}) as TypedMiddleware<TProvides, TRequires>;
|
|
56
71
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IHttpResponse } from "@rexeus/typeweaver-core";
|
|
2
2
|
import { defineMiddleware } from "../TypedMiddleware.js";
|
|
3
|
+
import { omitHeaders } from "./header.js";
|
|
3
4
|
|
|
4
5
|
export type SecureHeadersOptions = {
|
|
5
6
|
readonly contentTypeOptions?: string | false;
|
|
@@ -48,10 +49,11 @@ export function secureHeaders(options?: SecureHeadersOptions) {
|
|
|
48
49
|
|
|
49
50
|
return defineMiddleware(async (_ctx, next) => {
|
|
50
51
|
const response = await next();
|
|
52
|
+
const header = omitHeaders(response.header, Object.keys(headers));
|
|
51
53
|
|
|
52
54
|
return {
|
|
53
55
|
...response,
|
|
54
|
-
header: { ...
|
|
56
|
+
header: { ...header, ...headers },
|
|
55
57
|
} satisfies IHttpResponse;
|
|
56
58
|
});
|
|
57
59
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rexeus/typeweaver-server",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.4",
|
|
4
4
|
"description": "Generates a lightweight, dependency-free server with built-in routing and middleware from your API definitions. Powered by Typeweaver.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -47,15 +47,15 @@
|
|
|
47
47
|
},
|
|
48
48
|
"homepage": "https://github.com/rexeus/typeweaver#readme",
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@rexeus/typeweaver-core": "^0.10.
|
|
51
|
-
"@rexeus/typeweaver-gen": "^0.10.
|
|
50
|
+
"@rexeus/typeweaver-core": "^0.10.4",
|
|
51
|
+
"@rexeus/typeweaver-gen": "^0.10.4"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"get-port": "^7.2.0",
|
|
55
55
|
"test-utils": "file:../test-utils",
|
|
56
56
|
"tsx": "^4.21.0",
|
|
57
|
-
"@rexeus/typeweaver-core": "^0.10.
|
|
58
|
-
"@rexeus/typeweaver-gen": "^0.10.
|
|
57
|
+
"@rexeus/typeweaver-core": "^0.10.4",
|
|
58
|
+
"@rexeus/typeweaver-gen": "^0.10.4"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"polycase": "^1.1.0"
|