@rexeus/typeweaver-server 0.5.1
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/README.md +393 -0
- package/dist/LICENSE +202 -0
- package/dist/NOTICE +4 -0
- package/dist/index.cjs +110 -0
- package/dist/index.d.cts +20 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +101 -0
- package/dist/lib/Errors.ts +38 -0
- package/dist/lib/FetchApiAdapter.ts +362 -0
- package/dist/lib/Middleware.ts +71 -0
- package/dist/lib/NodeAdapter.ts +121 -0
- package/dist/lib/PathMatcher.ts +56 -0
- package/dist/lib/RequestHandler.ts +35 -0
- package/dist/lib/Router.ts +263 -0
- package/dist/lib/ServerContext.ts +54 -0
- package/dist/lib/StateMap.ts +57 -0
- package/dist/lib/TypedMiddleware.ts +140 -0
- package/dist/lib/TypeweaverApp.ts +376 -0
- package/dist/lib/TypeweaverRouter.ts +138 -0
- package/dist/lib/index.ts +38 -0
- package/dist/metafile-cjs.json +1 -0
- package/dist/metafile-esm.json +1 -0
- package/dist/templates/Router.ejs +43 -0
- package/package.json +71 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var path = require('node:path');
|
|
4
|
+
var node_url = require('node:url');
|
|
5
|
+
var typeweaverGen = require('@rexeus/typeweaver-gen');
|
|
6
|
+
var typeweaverCore = require('@rexeus/typeweaver-core');
|
|
7
|
+
var Case = require('case');
|
|
8
|
+
|
|
9
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
10
|
+
|
|
11
|
+
var path__default = /*#__PURE__*/_interopDefault(path);
|
|
12
|
+
var Case__default = /*#__PURE__*/_interopDefault(Case);
|
|
13
|
+
|
|
14
|
+
// ../../node_modules/.pnpm/tsup@8.5.1_jiti@2.6.1_postcss@8.5.6_tsx@4.21.0_typescript@5.9.3/node_modules/tsup/assets/cjs_shims.js
|
|
15
|
+
var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
|
|
16
|
+
var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
|
|
17
|
+
var RouterGenerator = class {
|
|
18
|
+
/**
|
|
19
|
+
* Generates router files for all resources in the given context.
|
|
20
|
+
*
|
|
21
|
+
* @param context - The generator context containing resources, templates, and output configuration
|
|
22
|
+
*/
|
|
23
|
+
static generate(context) {
|
|
24
|
+
const moduleDir2 = path__default.default.dirname(node_url.fileURLToPath(importMetaUrl));
|
|
25
|
+
const templateFile = path__default.default.join(moduleDir2, "templates", "Router.ejs");
|
|
26
|
+
for (const [entityName, entityResource] of Object.entries(
|
|
27
|
+
context.resources.entityResources
|
|
28
|
+
)) {
|
|
29
|
+
this.writeRouter(
|
|
30
|
+
entityName,
|
|
31
|
+
templateFile,
|
|
32
|
+
entityResource.operations,
|
|
33
|
+
context
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
static writeRouter(entityName, templateFile, operationResources, context) {
|
|
38
|
+
const pascalCaseEntityName = Case__default.default.pascal(entityName);
|
|
39
|
+
const outputDir = path__default.default.join(context.outputDir, entityName);
|
|
40
|
+
const outputPath = path__default.default.join(outputDir, `${pascalCaseEntityName}Router.ts`);
|
|
41
|
+
const operations = operationResources.filter((resource) => resource.definition.method !== typeweaverCore.HttpMethod.HEAD).map((resource) => this.createOperationData(resource)).sort((a, b) => this.compareRoutes(a, b));
|
|
42
|
+
const content = context.renderTemplate(templateFile, {
|
|
43
|
+
coreDir: typeweaverGen.Path.relative(outputDir, context.outputDir),
|
|
44
|
+
entityName,
|
|
45
|
+
pascalCaseEntityName,
|
|
46
|
+
operations
|
|
47
|
+
});
|
|
48
|
+
const relativePath = path__default.default.relative(context.outputDir, outputPath);
|
|
49
|
+
context.writeFile(relativePath, content);
|
|
50
|
+
}
|
|
51
|
+
static createOperationData(resource) {
|
|
52
|
+
const className = Case__default.default.pascal(resource.definition.operationId);
|
|
53
|
+
return {
|
|
54
|
+
className,
|
|
55
|
+
handlerName: `handle${className}Request`,
|
|
56
|
+
method: resource.definition.method,
|
|
57
|
+
path: resource.definition.path
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
static compareRoutes(a, b) {
|
|
61
|
+
const aSegments = a.path.split("/").filter((s) => s);
|
|
62
|
+
const bSegments = b.path.split("/").filter((s) => s);
|
|
63
|
+
if (aSegments.length !== bSegments.length) {
|
|
64
|
+
return aSegments.length - bSegments.length;
|
|
65
|
+
}
|
|
66
|
+
for (let i = 0; i < aSegments.length; i++) {
|
|
67
|
+
const aSegment = aSegments[i];
|
|
68
|
+
const bSegment = bSegments[i];
|
|
69
|
+
const aIsParam = aSegment.startsWith(":");
|
|
70
|
+
const bIsParam = bSegment.startsWith(":");
|
|
71
|
+
if (aIsParam !== bIsParam) {
|
|
72
|
+
return aIsParam ? 1 : -1;
|
|
73
|
+
}
|
|
74
|
+
if (aSegment !== bSegment) {
|
|
75
|
+
return aSegment.localeCompare(bSegment);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return this.getMethodPriority(a.method) - this.getMethodPriority(b.method);
|
|
79
|
+
}
|
|
80
|
+
static METHOD_PRIORITY = {
|
|
81
|
+
GET: 1,
|
|
82
|
+
POST: 2,
|
|
83
|
+
PUT: 3,
|
|
84
|
+
PATCH: 4,
|
|
85
|
+
DELETE: 5,
|
|
86
|
+
OPTIONS: 6,
|
|
87
|
+
HEAD: 7
|
|
88
|
+
};
|
|
89
|
+
static getMethodPriority(method) {
|
|
90
|
+
return this.METHOD_PRIORITY[method] ?? 999;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// src/index.ts
|
|
95
|
+
var moduleDir = path__default.default.dirname(node_url.fileURLToPath(importMetaUrl));
|
|
96
|
+
var ServerPlugin = class extends typeweaverGen.BasePlugin {
|
|
97
|
+
name = "server";
|
|
98
|
+
/**
|
|
99
|
+
* Generates the server runtime and typed routers for all resources.
|
|
100
|
+
*
|
|
101
|
+
* @param context - The generator context
|
|
102
|
+
*/
|
|
103
|
+
generate(context) {
|
|
104
|
+
const libSourceDir = path__default.default.join(moduleDir, "lib");
|
|
105
|
+
this.copyLibFiles(context, libSourceDir, this.name);
|
|
106
|
+
RouterGenerator.generate(context);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
module.exports = ServerPlugin;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { BasePlugin, GeneratorContext } from '@rexeus/typeweaver-gen';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Typeweaver plugin that generates a lightweight, dependency-free server
|
|
5
|
+
* with built-in routing and middleware support.
|
|
6
|
+
*
|
|
7
|
+
* Copies the runtime library files (`TypeweaverApp`, `TypeweaverRouter`, `Router`,
|
|
8
|
+
* `Middleware`, etc.) and generates typed router classes for each resource.
|
|
9
|
+
*/
|
|
10
|
+
declare class ServerPlugin extends BasePlugin {
|
|
11
|
+
name: string;
|
|
12
|
+
/**
|
|
13
|
+
* Generates the server runtime and typed routers for all resources.
|
|
14
|
+
*
|
|
15
|
+
* @param context - The generator context
|
|
16
|
+
*/
|
|
17
|
+
generate(context: GeneratorContext): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { ServerPlugin as default };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { BasePlugin, GeneratorContext } from '@rexeus/typeweaver-gen';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Typeweaver plugin that generates a lightweight, dependency-free server
|
|
5
|
+
* with built-in routing and middleware support.
|
|
6
|
+
*
|
|
7
|
+
* Copies the runtime library files (`TypeweaverApp`, `TypeweaverRouter`, `Router`,
|
|
8
|
+
* `Middleware`, etc.) and generates typed router classes for each resource.
|
|
9
|
+
*/
|
|
10
|
+
declare class ServerPlugin extends BasePlugin {
|
|
11
|
+
name: string;
|
|
12
|
+
/**
|
|
13
|
+
* Generates the server runtime and typed routers for all resources.
|
|
14
|
+
*
|
|
15
|
+
* @param context - The generator context
|
|
16
|
+
*/
|
|
17
|
+
generate(context: GeneratorContext): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { ServerPlugin as default };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { BasePlugin, Path } from '@rexeus/typeweaver-gen';
|
|
4
|
+
import { HttpMethod } from '@rexeus/typeweaver-core';
|
|
5
|
+
import Case from 'case';
|
|
6
|
+
|
|
7
|
+
// src/index.ts
|
|
8
|
+
var RouterGenerator = class {
|
|
9
|
+
/**
|
|
10
|
+
* Generates router files for all resources in the given context.
|
|
11
|
+
*
|
|
12
|
+
* @param context - The generator context containing resources, templates, and output configuration
|
|
13
|
+
*/
|
|
14
|
+
static generate(context) {
|
|
15
|
+
const moduleDir2 = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const templateFile = path.join(moduleDir2, "templates", "Router.ejs");
|
|
17
|
+
for (const [entityName, entityResource] of Object.entries(
|
|
18
|
+
context.resources.entityResources
|
|
19
|
+
)) {
|
|
20
|
+
this.writeRouter(
|
|
21
|
+
entityName,
|
|
22
|
+
templateFile,
|
|
23
|
+
entityResource.operations,
|
|
24
|
+
context
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
static writeRouter(entityName, templateFile, operationResources, context) {
|
|
29
|
+
const pascalCaseEntityName = Case.pascal(entityName);
|
|
30
|
+
const outputDir = path.join(context.outputDir, entityName);
|
|
31
|
+
const outputPath = path.join(outputDir, `${pascalCaseEntityName}Router.ts`);
|
|
32
|
+
const operations = operationResources.filter((resource) => resource.definition.method !== HttpMethod.HEAD).map((resource) => this.createOperationData(resource)).sort((a, b) => this.compareRoutes(a, b));
|
|
33
|
+
const content = context.renderTemplate(templateFile, {
|
|
34
|
+
coreDir: Path.relative(outputDir, context.outputDir),
|
|
35
|
+
entityName,
|
|
36
|
+
pascalCaseEntityName,
|
|
37
|
+
operations
|
|
38
|
+
});
|
|
39
|
+
const relativePath = path.relative(context.outputDir, outputPath);
|
|
40
|
+
context.writeFile(relativePath, content);
|
|
41
|
+
}
|
|
42
|
+
static createOperationData(resource) {
|
|
43
|
+
const className = Case.pascal(resource.definition.operationId);
|
|
44
|
+
return {
|
|
45
|
+
className,
|
|
46
|
+
handlerName: `handle${className}Request`,
|
|
47
|
+
method: resource.definition.method,
|
|
48
|
+
path: resource.definition.path
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
static compareRoutes(a, b) {
|
|
52
|
+
const aSegments = a.path.split("/").filter((s) => s);
|
|
53
|
+
const bSegments = b.path.split("/").filter((s) => s);
|
|
54
|
+
if (aSegments.length !== bSegments.length) {
|
|
55
|
+
return aSegments.length - bSegments.length;
|
|
56
|
+
}
|
|
57
|
+
for (let i = 0; i < aSegments.length; i++) {
|
|
58
|
+
const aSegment = aSegments[i];
|
|
59
|
+
const bSegment = bSegments[i];
|
|
60
|
+
const aIsParam = aSegment.startsWith(":");
|
|
61
|
+
const bIsParam = bSegment.startsWith(":");
|
|
62
|
+
if (aIsParam !== bIsParam) {
|
|
63
|
+
return aIsParam ? 1 : -1;
|
|
64
|
+
}
|
|
65
|
+
if (aSegment !== bSegment) {
|
|
66
|
+
return aSegment.localeCompare(bSegment);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return this.getMethodPriority(a.method) - this.getMethodPriority(b.method);
|
|
70
|
+
}
|
|
71
|
+
static METHOD_PRIORITY = {
|
|
72
|
+
GET: 1,
|
|
73
|
+
POST: 2,
|
|
74
|
+
PUT: 3,
|
|
75
|
+
PATCH: 4,
|
|
76
|
+
DELETE: 5,
|
|
77
|
+
OPTIONS: 6,
|
|
78
|
+
HEAD: 7
|
|
79
|
+
};
|
|
80
|
+
static getMethodPriority(method) {
|
|
81
|
+
return this.METHOD_PRIORITY[method] ?? 999;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// src/index.ts
|
|
86
|
+
var moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
87
|
+
var ServerPlugin = class extends BasePlugin {
|
|
88
|
+
name = "server";
|
|
89
|
+
/**
|
|
90
|
+
* Generates the server runtime and typed routers for all resources.
|
|
91
|
+
*
|
|
92
|
+
* @param context - The generator context
|
|
93
|
+
*/
|
|
94
|
+
generate(context) {
|
|
95
|
+
const libSourceDir = path.join(moduleDir, "lib");
|
|
96
|
+
this.copyLibFiles(context, libSourceDir, this.name);
|
|
97
|
+
RouterGenerator.generate(context);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export { ServerPlugin as default };
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* Error thrown when the request body cannot be parsed.
|
|
10
|
+
* Caught by TypeweaverApp to return a 400 Bad Request response.
|
|
11
|
+
*/
|
|
12
|
+
export class BodyParseError extends Error {
|
|
13
|
+
public override readonly name = "BodyParseError";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Error thrown when the request body exceeds the configured size limit.
|
|
18
|
+
* Caught by TypeweaverApp to return a 413 Payload Too Large response.
|
|
19
|
+
*/
|
|
20
|
+
export class PayloadTooLargeError extends Error {
|
|
21
|
+
public override readonly name = "PayloadTooLargeError";
|
|
22
|
+
public constructor(
|
|
23
|
+
public readonly contentLength: number,
|
|
24
|
+
public readonly maxBodySize: number
|
|
25
|
+
) {
|
|
26
|
+
super(
|
|
27
|
+
`Request body too large: ${contentLength} bytes exceeds limit of ${maxBodySize} bytes`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Error thrown when the response body cannot be serialized to JSON.
|
|
34
|
+
* Typically caused by circular references or non-serializable values.
|
|
35
|
+
*/
|
|
36
|
+
export class ResponseSerializationError extends Error {
|
|
37
|
+
public override readonly name = "ResponseSerializationError";
|
|
38
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
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 {
|
|
9
|
+
HttpMethod,
|
|
10
|
+
IHttpBody,
|
|
11
|
+
IHttpHeader,
|
|
12
|
+
IHttpQuery,
|
|
13
|
+
IHttpRequest,
|
|
14
|
+
IHttpResponse,
|
|
15
|
+
} from "@rexeus/typeweaver-core";
|
|
16
|
+
import {
|
|
17
|
+
BodyParseError,
|
|
18
|
+
PayloadTooLargeError,
|
|
19
|
+
ResponseSerializationError,
|
|
20
|
+
} from "./Errors";
|
|
21
|
+
|
|
22
|
+
export type FetchApiAdapterOptions = {
|
|
23
|
+
readonly maxBodySize?: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Converts between Fetch API `Request`/`Response` and typeweaver's
|
|
28
|
+
* `IHttpRequest`/`IHttpResponse` at the server boundary.
|
|
29
|
+
*
|
|
30
|
+
* This is the **only** place where framework-specific types exist.
|
|
31
|
+
* Everything inside the middleware pipeline and handlers works
|
|
32
|
+
* exclusively with typeweaver's native types.
|
|
33
|
+
*
|
|
34
|
+
* Works with all runtimes that support the Fetch API:
|
|
35
|
+
* Bun, Deno, Node.js (>=18), Cloudflare Workers.
|
|
36
|
+
*/
|
|
37
|
+
export class FetchApiAdapter {
|
|
38
|
+
private static readonly DEFAULT_MAX_BODY_SIZE = 1_048_576; // 1 MB
|
|
39
|
+
|
|
40
|
+
private readonly maxBodySize: number;
|
|
41
|
+
|
|
42
|
+
public constructor(options?: FetchApiAdapterOptions) {
|
|
43
|
+
this.maxBodySize =
|
|
44
|
+
options?.maxBodySize ?? FetchApiAdapter.DEFAULT_MAX_BODY_SIZE;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Converts a Fetch API Request to an IHttpRequest.
|
|
49
|
+
*
|
|
50
|
+
* Accepts an optional pre-parsed URL to avoid redundant parsing.
|
|
51
|
+
*
|
|
52
|
+
* @param request - The Fetch API Request object
|
|
53
|
+
* @param url - Optional pre-parsed URL object to avoid double parsing
|
|
54
|
+
* @returns Promise resolving to an IHttpRequest
|
|
55
|
+
* @throws BodyParseError when the request body is malformed
|
|
56
|
+
*/
|
|
57
|
+
public async toRequest(request: Request, url?: URL): Promise<IHttpRequest> {
|
|
58
|
+
const parsedUrl = url ?? new URL(request.url);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
method: request.method.toUpperCase() as HttpMethod,
|
|
62
|
+
path: parsedUrl.pathname,
|
|
63
|
+
header: FetchApiAdapter.extractHeaders(request.headers),
|
|
64
|
+
query: FetchApiAdapter.extractQueryParams(parsedUrl),
|
|
65
|
+
body: await this.parseRequestBody(request),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Converts an IHttpResponse to a Fetch API Response.
|
|
71
|
+
*
|
|
72
|
+
* @param response - The IHttpResponse to convert
|
|
73
|
+
* @returns A Fetch API Response object
|
|
74
|
+
*/
|
|
75
|
+
public toResponse(response: IHttpResponse): Response {
|
|
76
|
+
const { statusCode, body, header } = response;
|
|
77
|
+
|
|
78
|
+
return new Response(FetchApiAdapter.serializeResponseBody(body), {
|
|
79
|
+
status: statusCode,
|
|
80
|
+
headers: FetchApiAdapter.buildResponseHeaders(header, body),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private static extractMediaType(contentType: string | null): string | null {
|
|
85
|
+
if (!contentType) return null;
|
|
86
|
+
return contentType.split(";")[0]!.trim().toLowerCase();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private static isJsonContentType(contentType: string | null): boolean {
|
|
90
|
+
const mediaType = FetchApiAdapter.extractMediaType(contentType);
|
|
91
|
+
if (!mediaType) return false;
|
|
92
|
+
return mediaType === "application/json" || mediaType.endsWith("+json");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private static isTextContentType(contentType: string | null): boolean {
|
|
96
|
+
const mediaType = FetchApiAdapter.extractMediaType(contentType);
|
|
97
|
+
if (!mediaType) return false;
|
|
98
|
+
return mediaType.startsWith("text/");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private static isFormUrlencodedContentType(
|
|
102
|
+
contentType: string | null
|
|
103
|
+
): boolean {
|
|
104
|
+
return (
|
|
105
|
+
FetchApiAdapter.extractMediaType(contentType) ===
|
|
106
|
+
"application/x-www-form-urlencoded"
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private static isMultipartFormDataContentType(
|
|
111
|
+
contentType: string | null
|
|
112
|
+
): boolean {
|
|
113
|
+
return (
|
|
114
|
+
FetchApiAdapter.extractMediaType(contentType) === "multipart/form-data"
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private static extractHeaders(headers: Headers): IHttpHeader {
|
|
119
|
+
const result: Record<string, string | string[]> = Object.create(null);
|
|
120
|
+
headers.forEach((value, key) => {
|
|
121
|
+
FetchApiAdapter.addMultiValue(result, key, value);
|
|
122
|
+
});
|
|
123
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private static extractQueryParams(url: URL): IHttpQuery {
|
|
127
|
+
const result: Record<string, string | string[]> = Object.create(null);
|
|
128
|
+
url.searchParams.forEach((value, key) => {
|
|
129
|
+
FetchApiAdapter.addMultiValue(result, key, value);
|
|
130
|
+
});
|
|
131
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private async parseRequestBody(request: Request): Promise<IHttpBody> {
|
|
135
|
+
if (!request.body) return undefined;
|
|
136
|
+
|
|
137
|
+
const checkedRequest = await this.enforceBodySizeLimit(request);
|
|
138
|
+
const contentType = checkedRequest.headers.get("content-type");
|
|
139
|
+
|
|
140
|
+
if (FetchApiAdapter.isJsonContentType(contentType)) {
|
|
141
|
+
return FetchApiAdapter.parseJsonBody(checkedRequest);
|
|
142
|
+
}
|
|
143
|
+
if (FetchApiAdapter.isTextContentType(contentType)) {
|
|
144
|
+
return FetchApiAdapter.parseTextBody(checkedRequest);
|
|
145
|
+
}
|
|
146
|
+
if (FetchApiAdapter.isFormUrlencodedContentType(contentType)) {
|
|
147
|
+
return FetchApiAdapter.parseFormUrlencodedBody(checkedRequest);
|
|
148
|
+
}
|
|
149
|
+
if (FetchApiAdapter.isMultipartFormDataContentType(contentType)) {
|
|
150
|
+
return FetchApiAdapter.parseMultipartBody(checkedRequest);
|
|
151
|
+
}
|
|
152
|
+
return FetchApiAdapter.parseRawBody(checkedRequest);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private static async parseJsonBody(request: Request): Promise<IHttpBody> {
|
|
156
|
+
try {
|
|
157
|
+
const text = await request.text();
|
|
158
|
+
return JSON.parse(text, (key, value) => {
|
|
159
|
+
if (key === "__proto__") return undefined;
|
|
160
|
+
return value;
|
|
161
|
+
});
|
|
162
|
+
} catch (error) {
|
|
163
|
+
throw new BodyParseError("Invalid JSON in request body", {
|
|
164
|
+
cause: error,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private static async parseTextBody(request: Request): Promise<IHttpBody> {
|
|
170
|
+
try {
|
|
171
|
+
return await request.text();
|
|
172
|
+
} catch (error) {
|
|
173
|
+
throw new BodyParseError("Failed to read text request body", {
|
|
174
|
+
cause: error,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private static async parseFormUrlencodedBody(
|
|
180
|
+
request: Request
|
|
181
|
+
): Promise<IHttpBody> {
|
|
182
|
+
let text: string;
|
|
183
|
+
try {
|
|
184
|
+
text = await request.text();
|
|
185
|
+
} catch (error) {
|
|
186
|
+
throw new BodyParseError("Failed to read form-urlencoded request body", {
|
|
187
|
+
cause: error,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const result: Record<string, string | string[]> = Object.create(null);
|
|
192
|
+
new URLSearchParams(text).forEach((value, key) => {
|
|
193
|
+
FetchApiAdapter.addMultiValue(result, key, value);
|
|
194
|
+
});
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private static async parseMultipartBody(
|
|
199
|
+
request: Request
|
|
200
|
+
): Promise<IHttpBody> {
|
|
201
|
+
let formData: FormData;
|
|
202
|
+
try {
|
|
203
|
+
formData = await request.formData();
|
|
204
|
+
} catch (error) {
|
|
205
|
+
throw new BodyParseError("Invalid multipart/form-data in request body", {
|
|
206
|
+
cause: error,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const result: Record<string, string | File | (string | File)[]> =
|
|
211
|
+
Object.create(null);
|
|
212
|
+
formData.forEach((value, key) => {
|
|
213
|
+
const existing = result[key];
|
|
214
|
+
if (existing === undefined) {
|
|
215
|
+
result[key] = value;
|
|
216
|
+
} else if (Array.isArray(existing)) {
|
|
217
|
+
existing.push(value);
|
|
218
|
+
} else {
|
|
219
|
+
result[key] = [existing, value];
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private static async parseRawBody(request: Request): Promise<IHttpBody> {
|
|
226
|
+
try {
|
|
227
|
+
const text = await request.text();
|
|
228
|
+
return text || undefined;
|
|
229
|
+
} catch (error) {
|
|
230
|
+
throw new BodyParseError("Failed to read request body", {
|
|
231
|
+
cause: error,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private async enforceBodySizeLimit(request: Request): Promise<Request> {
|
|
237
|
+
const contentLengthHeader = request.headers.get("content-length");
|
|
238
|
+
if (contentLengthHeader === null) {
|
|
239
|
+
return this.readBodyWithLimit(request);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const contentLength = Number(contentLengthHeader);
|
|
243
|
+
if (!Number.isFinite(contentLength) || contentLength < 0) {
|
|
244
|
+
return this.readBodyWithLimit(request);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (contentLength > this.maxBodySize) {
|
|
248
|
+
throw new PayloadTooLargeError(contentLength, this.maxBodySize);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return request;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async readBodyWithLimit(request: Request): Promise<Request> {
|
|
255
|
+
if (!request.body) return request;
|
|
256
|
+
|
|
257
|
+
const reader = request.body.getReader();
|
|
258
|
+
const chunks: Uint8Array[] = [];
|
|
259
|
+
let totalBytes = 0;
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
for (;;) {
|
|
263
|
+
const { done, value } = await reader.read();
|
|
264
|
+
if (done) break;
|
|
265
|
+
|
|
266
|
+
totalBytes += value.byteLength;
|
|
267
|
+
if (totalBytes > this.maxBodySize) {
|
|
268
|
+
await reader.cancel();
|
|
269
|
+
throw new PayloadTooLargeError(totalBytes, this.maxBodySize);
|
|
270
|
+
}
|
|
271
|
+
chunks.push(value);
|
|
272
|
+
}
|
|
273
|
+
} finally {
|
|
274
|
+
reader.releaseLock();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return new Request(request.url, {
|
|
278
|
+
method: request.method,
|
|
279
|
+
headers: request.headers,
|
|
280
|
+
body: FetchApiAdapter.concatChunks(chunks, totalBytes),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private static concatChunks(
|
|
285
|
+
chunks: Uint8Array[],
|
|
286
|
+
totalBytes: number
|
|
287
|
+
): Uint8Array {
|
|
288
|
+
const buffer = new Uint8Array(totalBytes);
|
|
289
|
+
let offset = 0;
|
|
290
|
+
for (const chunk of chunks) {
|
|
291
|
+
buffer.set(chunk, offset);
|
|
292
|
+
offset += chunk.byteLength;
|
|
293
|
+
}
|
|
294
|
+
return buffer;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private static serializeResponseBody(
|
|
298
|
+
body: any
|
|
299
|
+
): string | ArrayBuffer | Blob | null {
|
|
300
|
+
if (body === undefined || body === null) return null;
|
|
301
|
+
if (typeof body === "string") return body;
|
|
302
|
+
if (body instanceof Blob || body instanceof ArrayBuffer) return body;
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
return JSON.stringify(body);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
throw new ResponseSerializationError(
|
|
308
|
+
"Failed to serialize response body to JSON",
|
|
309
|
+
{ cause: error }
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private static buildResponseHeaders(
|
|
315
|
+
header?: IHttpHeader,
|
|
316
|
+
body?: any
|
|
317
|
+
): Headers {
|
|
318
|
+
const headers = new Headers();
|
|
319
|
+
|
|
320
|
+
if (header) {
|
|
321
|
+
for (const [key, value] of Object.entries(header)) {
|
|
322
|
+
if (value === undefined) continue;
|
|
323
|
+
if (Array.isArray(value)) {
|
|
324
|
+
for (const v of value) headers.append(key, v);
|
|
325
|
+
} else {
|
|
326
|
+
headers.set(key, String(value));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!headers.has("content-type") && FetchApiAdapter.isJsonBody(body)) {
|
|
332
|
+
headers.set("content-type", "application/json");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return headers;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private static isJsonBody(body: any): boolean {
|
|
339
|
+
return (
|
|
340
|
+
body !== undefined &&
|
|
341
|
+
body !== null &&
|
|
342
|
+
typeof body !== "string" &&
|
|
343
|
+
!(body instanceof Blob) &&
|
|
344
|
+
!(body instanceof ArrayBuffer)
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private static addMultiValue(
|
|
349
|
+
record: Record<string, string | string[]>,
|
|
350
|
+
key: string,
|
|
351
|
+
value: string
|
|
352
|
+
): void {
|
|
353
|
+
const existing = record[key];
|
|
354
|
+
if (existing === undefined) {
|
|
355
|
+
record[key] = value;
|
|
356
|
+
} else if (Array.isArray(existing)) {
|
|
357
|
+
existing.push(value);
|
|
358
|
+
} else {
|
|
359
|
+
record[key] = [existing, value];
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|