@boo-labs/server 0.0.0-stage → 1.0.0
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 +229 -2
- package/index.mjs +23 -0
- package/package.json +71 -4
- package/source/application/Kernel/index.mjs +1137 -0
- package/source/application/Server/index.mjs +1201 -0
- package/source/application/configurations/Http2CleartextServerConfiguration/index.mjs +168 -0
- package/source/application/configurations/Http2SecureServerConfiguration/index.mjs +172 -0
- package/source/application/configurations/HttpServerConfiguration/index.mjs +169 -0
- package/source/application/configurations/HttpsServerConfiguration/index.mjs +172 -0
- package/source/application/configurations/ServerConfiguration/Options/CORSOptions/CORSOptionsValue.mjs +158 -0
- package/source/application/configurations/ServerConfiguration/Options/CORSOptions/index.mjs +87 -0
- package/source/application/configurations/ServerConfiguration/Options/CSRFOptions/CSRFOptionsValue.mjs +178 -0
- package/source/application/configurations/ServerConfiguration/Options/CSRFOptions/index.mjs +80 -0
- package/source/application/configurations/ServerConfiguration/Options/CompressOptions/CompressOptionsValue.mjs +126 -0
- package/source/application/configurations/ServerConfiguration/Options/CompressOptions/index.mjs +81 -0
- package/source/application/configurations/ServerConfiguration/Options/CookieParserOptions/CookieParserOptionsValue.mjs +107 -0
- package/source/application/configurations/ServerConfiguration/Options/CookieParserOptions/index.mjs +82 -0
- package/source/application/configurations/ServerConfiguration/Options/CookieSessionOptions/CookieSessionOptionsValue.mjs +182 -0
- package/source/application/configurations/ServerConfiguration/Options/CookieSessionOptions/index.mjs +86 -0
- package/source/application/configurations/ServerConfiguration/Options/ListenOptions/ListenOptionsValue.mjs +95 -0
- package/source/application/configurations/ServerConfiguration/Options/ListenOptions/index.mjs +47 -0
- package/source/application/configurations/ServerConfiguration/Options/MiddlewareOption.mjs +102 -0
- package/source/application/configurations/ServerConfiguration/Options/Options.mjs +105 -0
- package/source/application/configurations/ServerConfiguration/Options/RateLimitOptions/RateLimitOptionsValue.mjs +164 -0
- package/source/application/configurations/ServerConfiguration/Options/RateLimitOptions/index.mjs +84 -0
- package/source/application/configurations/ServerConfiguration/Options/RequestOptions/RequestOptionsValue.mjs +108 -0
- package/source/application/configurations/ServerConfiguration/Options/RequestOptions/index.mjs +47 -0
- package/source/application/configurations/ServerConfiguration/Options/ResponseOptions/ResponseOptionsValue.mjs +101 -0
- package/source/application/configurations/ServerConfiguration/Options/ResponseOptions/index.mjs +47 -0
- package/source/application/configurations/ServerConfiguration/Options/SecurityHeadersOptions/SecurityHeadersOptionsValue.mjs +272 -0
- package/source/application/configurations/ServerConfiguration/Options/SecurityHeadersOptions/index.mjs +85 -0
- package/source/application/configurations/ServerConfiguration/Options/ServerOption.mjs +50 -0
- package/source/application/configurations/ServerConfiguration/Options/StaticOptions/StaticOptionsValue.mjs +164 -0
- package/source/application/configurations/ServerConfiguration/Options/StaticOptions/index.mjs +89 -0
- package/source/application/configurations/ServerConfiguration/index.mjs +781 -0
- package/source/application/resolvers/TransportResolver/index.mjs +119 -0
- package/source/application/use-cases/CloseServer/index.mjs +85 -0
- package/source/application/use-cases/ListenServer/index.mjs +98 -0
- package/source/core/EventSystem/index.mjs +110 -0
- package/source/core/Exchange/ExchangeContext.mjs +291 -0
- package/source/core/Exchange/Telemetry.mjs +193 -0
- package/source/core/Exchange/index.mjs +604 -0
- package/source/core/ExchangeLayerBuilder/index.mjs +133 -0
- package/source/core/IPCMain/IPCMain.mjs +507 -0
- package/source/core/IPCMain/IPCProtocol.mjs +441 -0
- package/source/core/IPCMain/IPCServerChannel.mjs +331 -0
- package/source/core/IPCMain/WebSocketAdapter.mjs +189 -0
- package/source/core/Request/BodyParser/BodyParser.mjs +197 -0
- package/source/core/Request/BodyParser/JSONParser.mjs +70 -0
- package/source/core/Request/BodyParser/MultipartParser.mjs +612 -0
- package/source/core/Request/BodyParser/URLEncodedParser.mjs +71 -0
- package/source/core/Request/RequestHeaders.mjs +177 -0
- package/source/core/Request/index.mjs +805 -0
- package/source/core/Response/ResponseHeaders.mjs +402 -0
- package/source/core/Response/index.mjs +1370 -0
- package/source/core/middlewares/CORSMiddleware/index.mjs +289 -0
- package/source/core/middlewares/CSRFMiddleware/index.mjs +284 -0
- package/source/core/middlewares/CompressMiddleware/index.mjs +460 -0
- package/source/core/middlewares/CookieParserMiddleware/index.mjs +194 -0
- package/source/core/middlewares/CookieSessionMiddleware/index.mjs +460 -0
- package/source/core/middlewares/RateLimitMiddleware/index.mjs +245 -0
- package/source/core/middlewares/SecurityHeadersMiddleware/index.mjs +151 -0
- package/source/core/middlewares/StaticMiddleware/index.mjs +628 -0
- package/source/core/routing/ExchangeLayer/ExchangeMatcher.mjs +127 -0
- package/source/core/routing/ExchangeLayer/index.mjs +254 -0
- package/source/core/routing/Pipeline/index.mjs +157 -0
- package/source/core/routing/Router/Route.mjs +184 -0
- package/source/core/routing/Router/index.mjs +523 -0
- package/source/core/routing/RouterRegister/OptionsMiddleware.mjs +147 -0
- package/source/core/routing/RouterRegister/index.mjs +150 -0
- package/source/core/routing/RouterSystem/index.mjs +225 -0
- package/source/core/value-objects/Address/index.mjs +281 -0
- package/source/core/value-objects/Endpoint/index.mjs +293 -0
- package/source/index.mjs +62 -0
- package/source/infrastructure/adapters/Http2Adapter/index.mjs +144 -0
- package/source/infrastructure/adapters/Http2CleartextAdapter/index.mjs +147 -0
- package/source/infrastructure/adapters/HttpAdapter/index.mjs +124 -0
- package/source/infrastructure/adapters/HttpsAdapter/index.mjs +124 -0
- package/source/infrastructure/adapters/TransportAdapter/createWebSocketOpenRequest.mjs +843 -0
- package/source/infrastructure/adapters/TransportAdapter/index.mjs +555 -0
- package/source/shared/Identifier/index.mjs +671 -0
- package/source/shared/constants/symbols/brands/index.mjs +45 -0
- package/source/shared/domain/UUIDV7/index.mjs +346 -0
- package/source/shared/errors/BaseError/index.mjs +157 -0
- package/source/shared/errors/IPCError/index.mjs +97 -0
- package/source/shared/errors/MiddlewareError/index.mjs +106 -0
- package/source/shared/errors/PathnamePatternError/index.mjs +103 -0
- package/source/shared/errors/RequestError/index.mjs +116 -0
- package/source/shared/errors/ResponseError/index.mjs +140 -0
- package/source/shared/errors/RouterError/index.mjs +90 -0
- package/source/shared/errors/ServerConfigurationError/index.mjs +551 -0
- package/source/shared/errors/ServerError/index.mjs +127 -0
- package/source/shared/errors/WebSocketError/index.mjs +58 -0
- package/source/shared/outcome/ErrorSchema.mjs +361 -0
- package/source/shared/outcome/Result/index.mjs +239 -0
- package/source/shared/outcome/Validation/index.mjs +208 -0
- package/source/shared/validations/Middleware/index.mjs +160 -0
- package/source/shared/validations/PathnamePattern/index.mjs +256 -0
- package/source/shared/validations/Server/NotFoundHandler/index.mjs +94 -0
- package/source/shared/validations/Server/Router/index.mjs +68 -0
- package/source/shared/validations/Server/ServerConfiguration/index.mjs +68 -0
- package/source/shared/validations/Server/SystemErrorHandler/index.mjs +94 -0
- package/source/shared/validations/ServerConfiguration/CORSOptions/index.mjs +310 -0
- package/source/shared/validations/ServerConfiguration/CSRFOptions/index.mjs +299 -0
- package/source/shared/validations/ServerConfiguration/CompressOptions/index.mjs +204 -0
- package/source/shared/validations/ServerConfiguration/CookieParserOptions/index.mjs +102 -0
- package/source/shared/validations/ServerConfiguration/CookieSessionOptions/index.mjs +332 -0
- package/source/shared/validations/ServerConfiguration/ListenOptions/index.mjs +178 -0
- package/source/shared/validations/ServerConfiguration/RateLimitOptions/index.mjs +229 -0
- package/source/shared/validations/ServerConfiguration/RequestOptions/index.mjs +132 -0
- package/source/shared/validations/ServerConfiguration/ResponseOptions/index.mjs +122 -0
- package/source/shared/validations/ServerConfiguration/SecurityHeadersOptions/index.mjs +137 -0
- package/source/shared/validations/ServerConfiguration/StaticOptions/index.mjs +248 -0
- package/types/index.d.ts +719 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Defines the JSON body parser.
|
|
4
|
+
*
|
|
5
|
+
* @description Provides the `JSONParser` class for parsing `application/json`
|
|
6
|
+
* request bodies.
|
|
7
|
+
*
|
|
8
|
+
* @module JSONParser
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ━━ IMPORT MODULES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
12
|
+
// » IMPORT LOCAL MODULES
|
|
13
|
+
import BodyParser from './BodyParser.mjs';
|
|
14
|
+
import RequestError from '#shared/errors/RequestError';
|
|
15
|
+
|
|
16
|
+
// ━━ TYPE DEFINITIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
17
|
+
/**
|
|
18
|
+
* Represents an incoming HTTP/1.1 or HTTPS/1.1 request message from Node.js.
|
|
19
|
+
*
|
|
20
|
+
* @typedef {import('node:http').IncomingMessage} HttpIncomingMessage
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Represents an incoming HTTP/2 request message from Node.js.
|
|
25
|
+
*
|
|
26
|
+
* @typedef {import('node:http2').Http2ServerRequest} Http2IncomingMessage
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Represents an incoming request message supported by the server.
|
|
31
|
+
*
|
|
32
|
+
* @typedef {HttpIncomingMessage|Http2IncomingMessage} IncomingRequest
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
// ━━ MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
36
|
+
/**
|
|
37
|
+
* Parses `application/json` request bodies into JavaScript values.
|
|
38
|
+
*
|
|
39
|
+
* @augments BodyParser
|
|
40
|
+
*/
|
|
41
|
+
class JSONParser extends BodyParser {
|
|
42
|
+
/**
|
|
43
|
+
* Parses the incoming JSON request body.
|
|
44
|
+
*
|
|
45
|
+
* @returns {Promise<unknown>} Parsed JavaScript value.
|
|
46
|
+
* @throws {RequestError} If the body cannot be parsed as JSON.
|
|
47
|
+
* @example
|
|
48
|
+
* ```js
|
|
49
|
+
* const body = await parser.parse();
|
|
50
|
+
*
|
|
51
|
+
* body.username; // 'john'
|
|
52
|
+
* body.age; // 30
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
async parse() {
|
|
56
|
+
const buffer = await this.readBody();
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(buffer.toString('utf8'));
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throw new RequestError('BODY_PARSE_FAILED', {
|
|
62
|
+
values: { reason: 'invalid JSON' },
|
|
63
|
+
cause: error instanceof Error ? error : new Error(String(error)),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ━━ EXPORT MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
70
|
+
export default JSONParser;
|
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Defines the multipart/form-data body parser.
|
|
4
|
+
*
|
|
5
|
+
* @description Provides the `MultipartParser` class for parsing
|
|
6
|
+
* `multipart/form-data` HTTP request bodies.
|
|
7
|
+
*
|
|
8
|
+
* @remarks This parser targets the multipart shape produced by standard browsers.
|
|
9
|
+
*
|
|
10
|
+
* It does not aim to implement the full RFC 7578 grammar. In particular:
|
|
11
|
+
*
|
|
12
|
+
* - It accepts `name` and `filename` parameters in quoted or token form.
|
|
13
|
+
* - It accepts `filename*` (RFC 5987) values with UTF-8 percent-decoding, Other charsets are rejected.
|
|
14
|
+
* - It does not implement folded headers.
|
|
15
|
+
*
|
|
16
|
+
* Requires Node.js >= 18.0.0
|
|
17
|
+
*
|
|
18
|
+
* @module MultipartParser
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// ━━ IMPORT MODULES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
22
|
+
// » IMPORT LOCAL MODULES
|
|
23
|
+
import BodyParser from './BodyParser.mjs';
|
|
24
|
+
import RequestError from '#shared/errors/RequestError';
|
|
25
|
+
|
|
26
|
+
// ━━ TYPE DEFINITIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
27
|
+
/**
|
|
28
|
+
* Represents an incoming HTTP/1.1 or HTTPS/1.1 request message from Node.js.
|
|
29
|
+
*
|
|
30
|
+
* @typedef {import('node:http').IncomingMessage} HttpIncomingMessage
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Represents an incoming HTTP/2 request message from Node.js.
|
|
35
|
+
*
|
|
36
|
+
* @typedef {import('node:http2').Http2ServerRequest} Http2IncomingMessage
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Represents an incoming request message supported by the server.
|
|
41
|
+
*
|
|
42
|
+
* @typedef {HttpIncomingMessage|Http2IncomingMessage} IncomingRequest
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Represents the parsed `Content-Disposition` parameters of a multipart
|
|
47
|
+
* section.
|
|
48
|
+
*
|
|
49
|
+
* @typedef {object} MultipartDisposition
|
|
50
|
+
* @property {string} fieldName - Multipart field name.
|
|
51
|
+
* @property {string|undefined} filename - Uploaded filename, if present.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Represents the parsed headers of a multipart section.
|
|
56
|
+
*
|
|
57
|
+
* @typedef {object} MultipartHeaders
|
|
58
|
+
* @property {string} disposition - Content-Disposition header value.
|
|
59
|
+
* @property {string|undefined} contentType - Content-Type header value, if present.
|
|
60
|
+
* @property {number} end - Buffer position immediately after the headers.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
// ━━ CONSTANTS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
64
|
+
/**
|
|
65
|
+
* Maximum allowed length for a multipart boundary, per RFC 2046.
|
|
66
|
+
*
|
|
67
|
+
* @type {number}
|
|
68
|
+
*/
|
|
69
|
+
const MAX_BOUNDARY_LENGTH = 70;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validates the RFC 2046 boundary grammar.
|
|
73
|
+
*
|
|
74
|
+
* @type {RegExp}
|
|
75
|
+
*/
|
|
76
|
+
const BOUNDARY_PATTERN = /^[0-9A-Za-z'()+_,\-./:=? ]{0,69}[0-9A-Za-z'()+_,\-./:=?]$/;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* ASCII code for carriage return (`\r`).
|
|
80
|
+
*
|
|
81
|
+
* @type {number}
|
|
82
|
+
*/
|
|
83
|
+
const CR = 0x0d;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* ASCII code for line feed (`\n`).
|
|
87
|
+
*
|
|
88
|
+
* @type {number}
|
|
89
|
+
*/
|
|
90
|
+
const LF = 0x0a;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* ASCII code for hyphen (`-`).
|
|
94
|
+
*
|
|
95
|
+
* @type {number}
|
|
96
|
+
*/
|
|
97
|
+
const HYPHEN = 0x2d;
|
|
98
|
+
|
|
99
|
+
// ━━ MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
100
|
+
/**
|
|
101
|
+
* Parses `multipart/form-data` HTTP request bodies into `FormData`.
|
|
102
|
+
*
|
|
103
|
+
* @augments BodyParser
|
|
104
|
+
*/
|
|
105
|
+
class MultipartParser extends BodyParser {
|
|
106
|
+
/**
|
|
107
|
+
* Complete multipart request body.
|
|
108
|
+
*
|
|
109
|
+
* @type {Buffer}
|
|
110
|
+
*/
|
|
111
|
+
#buffer = Buffer.alloc(0);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Multipart boundary prefixed with the required `--` delimiter.
|
|
115
|
+
*
|
|
116
|
+
* @type {Buffer}
|
|
117
|
+
*/
|
|
118
|
+
#boundary = Buffer.alloc(0);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* CRLF sequence used to delimit multipart sections.
|
|
122
|
+
*
|
|
123
|
+
* @type {Buffer}
|
|
124
|
+
*/
|
|
125
|
+
#lineBreak = Buffer.alloc(0);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* CRLF sequence separating multipart headers from their content.
|
|
129
|
+
*
|
|
130
|
+
* @type {Buffer}
|
|
131
|
+
*/
|
|
132
|
+
#headerSeparator = Buffer.alloc(0);
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Double hyphen marker indicating the closing multipart delimiter.
|
|
136
|
+
*
|
|
137
|
+
* @type {Buffer}
|
|
138
|
+
*/
|
|
139
|
+
#closingMarker = Buffer.alloc(0);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Multipart boundary prefixed with the preceding CRLF sequence.
|
|
143
|
+
*
|
|
144
|
+
* @type {Buffer}
|
|
145
|
+
*/
|
|
146
|
+
#boundaryWithCRLF = Buffer.alloc(0);
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Current byte position within the multipart request body.
|
|
150
|
+
*
|
|
151
|
+
* @type {number}
|
|
152
|
+
*/
|
|
153
|
+
#position = 0;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Form data containing the parsed multipart sections.
|
|
157
|
+
*
|
|
158
|
+
* @type {FormData}
|
|
159
|
+
*/
|
|
160
|
+
#formData = new FormData();
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Parses the incoming multipart request body.
|
|
164
|
+
*
|
|
165
|
+
* @returns {Promise<FormData>} Parsed multipart form data.
|
|
166
|
+
* @throws {RequestError} If the body cannot be parsed.
|
|
167
|
+
* @example
|
|
168
|
+
* ```js
|
|
169
|
+
* const formData = await parser.parse();
|
|
170
|
+
* formData.get('username'); // 'john'
|
|
171
|
+
* formData.get('avatar'); // File
|
|
172
|
+
* ```
|
|
173
|
+
*/
|
|
174
|
+
async parse() {
|
|
175
|
+
const boundary = this.#extractBoundary();
|
|
176
|
+
const buffer = await this.readBody();
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
return this.#parsePayload(buffer, boundary);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (error instanceof RequestError) {
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
throw new RequestError('BODY_PARSE_FAILED', {
|
|
186
|
+
values: { reason: 'invalid multipart body' },
|
|
187
|
+
cause: error instanceof Error ? error : new Error(String(error)),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Extracts the multipart boundary from the request `Content-Type` header.
|
|
194
|
+
*
|
|
195
|
+
* @returns {string} Multipart boundary value.
|
|
196
|
+
* @throws {RequestError} If the boundary is missing or invalid.
|
|
197
|
+
*/
|
|
198
|
+
#extractBoundary() {
|
|
199
|
+
const contentType = this.incomingMessage.headers['content-type'] || '';
|
|
200
|
+
const match = contentType.match(/(?:^|;)\s*boundary=(?:"([^"]+)"|([^;]+))/i);
|
|
201
|
+
|
|
202
|
+
if (!match) {
|
|
203
|
+
throw new RequestError('BODY_PARSE_FAILED', {
|
|
204
|
+
values: { reason: 'multipart request missing boundary' },
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const boundary = (match[1] ?? match[2]).trim();
|
|
209
|
+
this.#validateBoundary(boundary);
|
|
210
|
+
return boundary;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Validates a multipart boundary value against the RFC 2046 grammar.
|
|
215
|
+
*
|
|
216
|
+
* @param {string} boundary - Multipart boundary value.
|
|
217
|
+
* @throws {RequestError} If the boundary is invalid.
|
|
218
|
+
*/
|
|
219
|
+
#validateBoundary(boundary) {
|
|
220
|
+
const isValid =
|
|
221
|
+
boundary.length > 0 &&
|
|
222
|
+
boundary.length <= MAX_BOUNDARY_LENGTH &&
|
|
223
|
+
BOUNDARY_PATTERN.test(boundary);
|
|
224
|
+
|
|
225
|
+
if (!isValid) {
|
|
226
|
+
throw new RequestError('BODY_PARSE_FAILED', {
|
|
227
|
+
values: { reason: 'invalid multipart boundary' },
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Initializes the parser state for a multipart payload.
|
|
234
|
+
*
|
|
235
|
+
* @param {Buffer} buffer - Complete multipart request body.
|
|
236
|
+
* @param {string} boundary - Multipart boundary value.
|
|
237
|
+
*/
|
|
238
|
+
#initialize(buffer, boundary) {
|
|
239
|
+
this.#buffer = buffer;
|
|
240
|
+
this.#boundary = Buffer.from(`--${boundary}`);
|
|
241
|
+
this.#lineBreak = Buffer.from('\r\n');
|
|
242
|
+
this.#headerSeparator = Buffer.from('\r\n\r\n');
|
|
243
|
+
this.#closingMarker = Buffer.from('--');
|
|
244
|
+
this.#boundaryWithCRLF = Buffer.concat([this.#lineBreak, this.#boundary]);
|
|
245
|
+
this.#position = 0;
|
|
246
|
+
this.#formData = new FormData();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Parses a complete multipart payload.
|
|
251
|
+
*
|
|
252
|
+
* @param {Buffer} buffer - Complete multipart request body.
|
|
253
|
+
* @param {string} boundary - Multipart boundary value.
|
|
254
|
+
* @returns {FormData} Parsed multipart form data.
|
|
255
|
+
* @throws {RequestError} If the multipart payload is malformed.
|
|
256
|
+
*/
|
|
257
|
+
#parsePayload(buffer, boundary) {
|
|
258
|
+
this.#initialize(buffer, boundary);
|
|
259
|
+
this.#consume(this.#boundary, 'invalid initial boundary');
|
|
260
|
+
|
|
261
|
+
if (this.#isEmptyMultipart()) {
|
|
262
|
+
return this.#formData;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
this.#consume(this.#lineBreak, 'invalid multipart boundary');
|
|
266
|
+
this.#parseParts();
|
|
267
|
+
return this.#formData;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Determines whether the multipart payload contains no parts.
|
|
272
|
+
*
|
|
273
|
+
* @returns {boolean} `true` when the closing marker immediately follows
|
|
274
|
+
* the initial boundary.
|
|
275
|
+
* @throws {RequestError} If trailing data follows the closing marker.
|
|
276
|
+
*/
|
|
277
|
+
#isEmptyMultipart() {
|
|
278
|
+
if (!this.#isCurrentPositionEqualTo(this.#closingMarker)) {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
this.#position += this.#closingMarker.length;
|
|
283
|
+
this.#consumeOptionalTrailingCRLF();
|
|
284
|
+
|
|
285
|
+
if (this.#position !== this.#buffer.length) {
|
|
286
|
+
throw new RequestError('BODY_PARSE_FAILED', {
|
|
287
|
+
values: { reason: 'invalid multipart payload' },
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Parses all multipart sections until the closing boundary is found.
|
|
296
|
+
*
|
|
297
|
+
* @throws {RequestError} If the payload ends before the closing boundary.
|
|
298
|
+
*/
|
|
299
|
+
#parseParts() {
|
|
300
|
+
while (this.#position < this.#buffer.length) {
|
|
301
|
+
if (this.#parsePart()) {
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (this.#position !== this.#buffer.length) {
|
|
307
|
+
throw new RequestError('BODY_PARSE_FAILED', {
|
|
308
|
+
values: { reason: 'invalid multipart payload' },
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Parses a single multipart section.
|
|
315
|
+
*
|
|
316
|
+
* @returns {boolean} `true` when the closing boundary has been consumed.
|
|
317
|
+
*/
|
|
318
|
+
#parsePart() {
|
|
319
|
+
const headers = this.#extractHeaders();
|
|
320
|
+
const { fieldName, filename } = this.#parseDisposition(headers.disposition);
|
|
321
|
+
|
|
322
|
+
this.#position = headers.end;
|
|
323
|
+
|
|
324
|
+
const content = this.#extractContent();
|
|
325
|
+
|
|
326
|
+
this.#appendPart(fieldName, filename, headers.contentType, content);
|
|
327
|
+
|
|
328
|
+
if (this.#isFinalBoundary()) {
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
this.#consume(this.#lineBreak, 'invalid multipart boundary');
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Extracts the headers of the current multipart section.
|
|
338
|
+
*
|
|
339
|
+
* @returns {MultipartHeaders} Parsed multipart headers and the position
|
|
340
|
+
* immediately after them.
|
|
341
|
+
* @throws {RequestError} If the part headers are malformed.
|
|
342
|
+
*/
|
|
343
|
+
#extractHeaders() {
|
|
344
|
+
const headerEnd = this.#buffer.indexOf(this.#headerSeparator, this.#position);
|
|
345
|
+
|
|
346
|
+
if (headerEnd === -1) {
|
|
347
|
+
throw new RequestError('BODY_PARSE_FAILED', {
|
|
348
|
+
values: { reason: 'invalid multipart part headers' },
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const headersText = this.#buffer.subarray(this.#position, headerEnd).toString('latin1');
|
|
353
|
+
|
|
354
|
+
let disposition;
|
|
355
|
+
let contentType;
|
|
356
|
+
|
|
357
|
+
for (const line of headersText.split('\r\n')) {
|
|
358
|
+
const separatorIndex = line.indexOf(':');
|
|
359
|
+
|
|
360
|
+
if (separatorIndex === -1) {
|
|
361
|
+
throw new RequestError('BODY_PARSE_FAILED', {
|
|
362
|
+
values: { reason: 'invalid multipart part headers' },
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const name = line.slice(0, separatorIndex).trim().toLowerCase();
|
|
367
|
+
const value = line.slice(separatorIndex + 1).trim();
|
|
368
|
+
|
|
369
|
+
if (!name) {
|
|
370
|
+
throw new RequestError('BODY_PARSE_FAILED', {
|
|
371
|
+
values: { reason: 'invalid multipart part headers' },
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (name === 'content-disposition') {
|
|
376
|
+
disposition = value;
|
|
377
|
+
} else if (name === 'content-type') {
|
|
378
|
+
contentType = value;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (disposition === undefined) {
|
|
383
|
+
throw new RequestError('BODY_PARSE_FAILED', {
|
|
384
|
+
values: { reason: 'multipart part missing content-disposition' },
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
disposition,
|
|
390
|
+
contentType,
|
|
391
|
+
end: headerEnd + this.#headerSeparator.length,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Parses a multipart `Content-Disposition` header.
|
|
397
|
+
*
|
|
398
|
+
* @param {string} value - `Content-Disposition` header value.
|
|
399
|
+
* @returns {MultipartDisposition} Parsed disposition parameters.
|
|
400
|
+
* @throws {RequestError} If the disposition type is not `form-data`.
|
|
401
|
+
* @throws {RequestError} If the field name is missing.
|
|
402
|
+
*/
|
|
403
|
+
#parseDisposition(value) {
|
|
404
|
+
if (!/^form-data(?:;|\s*$)/i.test(value.trim())) {
|
|
405
|
+
throw new RequestError('BODY_PARSE_FAILED', {
|
|
406
|
+
values: { reason: 'multipart part must use form-data disposition' },
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const fieldName = this.#extractDispositionParam(value, 'name');
|
|
411
|
+
|
|
412
|
+
if (fieldName === null) {
|
|
413
|
+
throw new RequestError('BODY_PARSE_FAILED', {
|
|
414
|
+
values: { reason: 'multipart part missing field name' },
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const filename = this.#extractDispositionParam(value, 'filename');
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
fieldName,
|
|
422
|
+
filename: filename ?? undefined,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Extracts a single parameter from a `Content-Disposition` header value.
|
|
428
|
+
*
|
|
429
|
+
* Extended parameters (`param*=`) are percent-decoded as UTF-8. Other
|
|
430
|
+
* charsets are rejected. Parameter names must appear at a parameter
|
|
431
|
+
* boundary (`;`) or at the start of the value, so that `name` does not
|
|
432
|
+
* match inside `filename`.
|
|
433
|
+
*
|
|
434
|
+
* @param {string} value - `Content-Disposition` header value.
|
|
435
|
+
* @param {string} param - Parameter name to extract.
|
|
436
|
+
* @returns {string|null} Extracted parameter, or `null` when absent.
|
|
437
|
+
* @throws {RequestError} If the extended parameter has an unsupported
|
|
438
|
+
* charset or invalid percent-encoding.
|
|
439
|
+
*/
|
|
440
|
+
#extractDispositionParam(value, param) {
|
|
441
|
+
const extendedPattern = new RegExp(`(?:^|;)\\s*${param}\\*=([^']*)'([^']*)'([^;]+)`, 'i');
|
|
442
|
+
const extendedMatch = value.match(extendedPattern);
|
|
443
|
+
|
|
444
|
+
if (extendedMatch) {
|
|
445
|
+
const charset = extendedMatch[1].toLowerCase();
|
|
446
|
+
|
|
447
|
+
if (charset !== '' && charset !== 'utf-8') {
|
|
448
|
+
throw new RequestError('BODY_PARSE_FAILED', {
|
|
449
|
+
values: { reason: `unsupported ${param} charset` },
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
return decodeURIComponent(extendedMatch[3]);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
throw new RequestError('BODY_PARSE_FAILED', {
|
|
457
|
+
values: { reason: `invalid ${param} encoding` },
|
|
458
|
+
cause: error instanceof Error ? error : new Error(String(error)),
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const quotedPattern = new RegExp(`(?:^|;)\\s*${param}="([^"]*)"`, 'i');
|
|
464
|
+
const quotedMatch = value.match(quotedPattern);
|
|
465
|
+
|
|
466
|
+
if (quotedMatch) {
|
|
467
|
+
return quotedMatch[1];
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const tokenPattern = new RegExp(`(?:^|;)\\s*${param}=([^;\\s]+)`, 'i');
|
|
471
|
+
const tokenMatch = value.match(tokenPattern);
|
|
472
|
+
|
|
473
|
+
if (tokenMatch) {
|
|
474
|
+
return tokenMatch[1];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Extracts the content of the current multipart section.
|
|
482
|
+
*
|
|
483
|
+
* @returns {Buffer} Raw multipart section content.
|
|
484
|
+
* @throws {RequestError} If the next boundary cannot be found.
|
|
485
|
+
*/
|
|
486
|
+
#extractContent() {
|
|
487
|
+
const boundaryIndex = this.#findBoundaryCandidate();
|
|
488
|
+
|
|
489
|
+
if (boundaryIndex === -1) {
|
|
490
|
+
throw new RequestError('BODY_PARSE_FAILED', {
|
|
491
|
+
values: { reason: 'invalid multipart payload' },
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const content = this.#buffer.subarray(this.#position, boundaryIndex);
|
|
496
|
+
this.#position = boundaryIndex + this.#lineBreak.length + this.#boundary.length;
|
|
497
|
+
|
|
498
|
+
return content;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Finds the next valid boundary delimiter.
|
|
503
|
+
*
|
|
504
|
+
* A valid delimiter is the CRLF-preceded boundary followed by `\r`
|
|
505
|
+
* (normal boundary) or `-` (closing boundary).
|
|
506
|
+
*
|
|
507
|
+
* @returns {number} The index of the valid delimiter, or `-1` when absent.
|
|
508
|
+
*/
|
|
509
|
+
#findBoundaryCandidate() {
|
|
510
|
+
let searchFrom = this.#position;
|
|
511
|
+
|
|
512
|
+
while (searchFrom < this.#buffer.length) {
|
|
513
|
+
const candidate = this.#buffer.indexOf(this.#boundaryWithCRLF, searchFrom);
|
|
514
|
+
|
|
515
|
+
if (candidate === -1) {
|
|
516
|
+
return -1;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const nextIndex = candidate + this.#boundaryWithCRLF.length;
|
|
520
|
+
const nextByte = this.#buffer[nextIndex];
|
|
521
|
+
|
|
522
|
+
if (nextByte === CR || nextByte === HYPHEN) {
|
|
523
|
+
return candidate;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
searchFrom = candidate + 1;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return -1;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Appends a multipart section to the resulting `FormData`.
|
|
534
|
+
*
|
|
535
|
+
* @param {string} fieldName - Multipart field name.
|
|
536
|
+
* @param {string|undefined} filename - Uploaded filename.
|
|
537
|
+
* @param {string|undefined} contentType - Part `Content-Type`.
|
|
538
|
+
* @param {Buffer} content - Raw part content.
|
|
539
|
+
*/
|
|
540
|
+
#appendPart(fieldName, filename, contentType, content) {
|
|
541
|
+
if (filename !== undefined) {
|
|
542
|
+
const file = new File([new Uint8Array(content)], filename, {
|
|
543
|
+
type: contentType || 'application/octet-stream',
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
this.#formData.append(fieldName, file);
|
|
547
|
+
} else {
|
|
548
|
+
this.#formData.append(fieldName, content.toString('utf8'));
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Determines whether the closing multipart marker is at the
|
|
554
|
+
* current parser position.
|
|
555
|
+
*
|
|
556
|
+
* @returns {boolean} `true` when the closing marker is present.
|
|
557
|
+
* @throws {RequestError} If trailing data follows the closing marker.
|
|
558
|
+
*/
|
|
559
|
+
#isFinalBoundary() {
|
|
560
|
+
if (!this.#isCurrentPositionEqualTo(this.#closingMarker)) {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
this.#position += this.#closingMarker.length;
|
|
565
|
+
this.#consumeOptionalTrailingCRLF();
|
|
566
|
+
|
|
567
|
+
if (this.#position !== this.#buffer.length) {
|
|
568
|
+
throw new RequestError('BODY_PARSE_FAILED', {
|
|
569
|
+
values: { reason: 'invalid multipart payload' },
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Consumes an optional trailing CRLF after a closing boundary.
|
|
578
|
+
*/
|
|
579
|
+
#consumeOptionalTrailingCRLF() {
|
|
580
|
+
if (this.#buffer[this.#position] === CR && this.#buffer[this.#position + 1] === LF) {
|
|
581
|
+
this.#position += 2;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Determines whether the bytes at the current position equal the specified value.
|
|
587
|
+
*
|
|
588
|
+
* @param {Buffer} value - Buffer value to compare.
|
|
589
|
+
* @returns {boolean} `true` when the values are equal.
|
|
590
|
+
*/
|
|
591
|
+
#isCurrentPositionEqualTo(value) {
|
|
592
|
+
return this.#buffer.subarray(this.#position, this.#position + value.length).equals(value);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Consumes the specified value from the current parser position.
|
|
597
|
+
*
|
|
598
|
+
* @param {Buffer} value - Expected buffer value.
|
|
599
|
+
* @param {string} reason - Reason used in the error when the value is missing.
|
|
600
|
+
* @throws {RequestError} If the expected value is not present.
|
|
601
|
+
*/
|
|
602
|
+
#consume(value, reason) {
|
|
603
|
+
if (!this.#isCurrentPositionEqualTo(value)) {
|
|
604
|
+
throw new RequestError('BODY_PARSE_FAILED', { values: { reason } });
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
this.#position += value.length;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ━━ EXPORT MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
612
|
+
export default MultipartParser;
|