@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.
Files changed (114) hide show
  1. package/README.md +229 -2
  2. package/index.mjs +23 -0
  3. package/package.json +71 -4
  4. package/source/application/Kernel/index.mjs +1137 -0
  5. package/source/application/Server/index.mjs +1201 -0
  6. package/source/application/configurations/Http2CleartextServerConfiguration/index.mjs +168 -0
  7. package/source/application/configurations/Http2SecureServerConfiguration/index.mjs +172 -0
  8. package/source/application/configurations/HttpServerConfiguration/index.mjs +169 -0
  9. package/source/application/configurations/HttpsServerConfiguration/index.mjs +172 -0
  10. package/source/application/configurations/ServerConfiguration/Options/CORSOptions/CORSOptionsValue.mjs +158 -0
  11. package/source/application/configurations/ServerConfiguration/Options/CORSOptions/index.mjs +87 -0
  12. package/source/application/configurations/ServerConfiguration/Options/CSRFOptions/CSRFOptionsValue.mjs +178 -0
  13. package/source/application/configurations/ServerConfiguration/Options/CSRFOptions/index.mjs +80 -0
  14. package/source/application/configurations/ServerConfiguration/Options/CompressOptions/CompressOptionsValue.mjs +126 -0
  15. package/source/application/configurations/ServerConfiguration/Options/CompressOptions/index.mjs +81 -0
  16. package/source/application/configurations/ServerConfiguration/Options/CookieParserOptions/CookieParserOptionsValue.mjs +107 -0
  17. package/source/application/configurations/ServerConfiguration/Options/CookieParserOptions/index.mjs +82 -0
  18. package/source/application/configurations/ServerConfiguration/Options/CookieSessionOptions/CookieSessionOptionsValue.mjs +182 -0
  19. package/source/application/configurations/ServerConfiguration/Options/CookieSessionOptions/index.mjs +86 -0
  20. package/source/application/configurations/ServerConfiguration/Options/ListenOptions/ListenOptionsValue.mjs +95 -0
  21. package/source/application/configurations/ServerConfiguration/Options/ListenOptions/index.mjs +47 -0
  22. package/source/application/configurations/ServerConfiguration/Options/MiddlewareOption.mjs +102 -0
  23. package/source/application/configurations/ServerConfiguration/Options/Options.mjs +105 -0
  24. package/source/application/configurations/ServerConfiguration/Options/RateLimitOptions/RateLimitOptionsValue.mjs +164 -0
  25. package/source/application/configurations/ServerConfiguration/Options/RateLimitOptions/index.mjs +84 -0
  26. package/source/application/configurations/ServerConfiguration/Options/RequestOptions/RequestOptionsValue.mjs +108 -0
  27. package/source/application/configurations/ServerConfiguration/Options/RequestOptions/index.mjs +47 -0
  28. package/source/application/configurations/ServerConfiguration/Options/ResponseOptions/ResponseOptionsValue.mjs +101 -0
  29. package/source/application/configurations/ServerConfiguration/Options/ResponseOptions/index.mjs +47 -0
  30. package/source/application/configurations/ServerConfiguration/Options/SecurityHeadersOptions/SecurityHeadersOptionsValue.mjs +272 -0
  31. package/source/application/configurations/ServerConfiguration/Options/SecurityHeadersOptions/index.mjs +85 -0
  32. package/source/application/configurations/ServerConfiguration/Options/ServerOption.mjs +50 -0
  33. package/source/application/configurations/ServerConfiguration/Options/StaticOptions/StaticOptionsValue.mjs +164 -0
  34. package/source/application/configurations/ServerConfiguration/Options/StaticOptions/index.mjs +89 -0
  35. package/source/application/configurations/ServerConfiguration/index.mjs +781 -0
  36. package/source/application/resolvers/TransportResolver/index.mjs +119 -0
  37. package/source/application/use-cases/CloseServer/index.mjs +85 -0
  38. package/source/application/use-cases/ListenServer/index.mjs +98 -0
  39. package/source/core/EventSystem/index.mjs +110 -0
  40. package/source/core/Exchange/ExchangeContext.mjs +291 -0
  41. package/source/core/Exchange/Telemetry.mjs +193 -0
  42. package/source/core/Exchange/index.mjs +604 -0
  43. package/source/core/ExchangeLayerBuilder/index.mjs +133 -0
  44. package/source/core/IPCMain/IPCMain.mjs +507 -0
  45. package/source/core/IPCMain/IPCProtocol.mjs +441 -0
  46. package/source/core/IPCMain/IPCServerChannel.mjs +331 -0
  47. package/source/core/IPCMain/WebSocketAdapter.mjs +189 -0
  48. package/source/core/Request/BodyParser/BodyParser.mjs +197 -0
  49. package/source/core/Request/BodyParser/JSONParser.mjs +70 -0
  50. package/source/core/Request/BodyParser/MultipartParser.mjs +612 -0
  51. package/source/core/Request/BodyParser/URLEncodedParser.mjs +71 -0
  52. package/source/core/Request/RequestHeaders.mjs +177 -0
  53. package/source/core/Request/index.mjs +805 -0
  54. package/source/core/Response/ResponseHeaders.mjs +402 -0
  55. package/source/core/Response/index.mjs +1370 -0
  56. package/source/core/middlewares/CORSMiddleware/index.mjs +289 -0
  57. package/source/core/middlewares/CSRFMiddleware/index.mjs +284 -0
  58. package/source/core/middlewares/CompressMiddleware/index.mjs +460 -0
  59. package/source/core/middlewares/CookieParserMiddleware/index.mjs +194 -0
  60. package/source/core/middlewares/CookieSessionMiddleware/index.mjs +460 -0
  61. package/source/core/middlewares/RateLimitMiddleware/index.mjs +245 -0
  62. package/source/core/middlewares/SecurityHeadersMiddleware/index.mjs +151 -0
  63. package/source/core/middlewares/StaticMiddleware/index.mjs +628 -0
  64. package/source/core/routing/ExchangeLayer/ExchangeMatcher.mjs +127 -0
  65. package/source/core/routing/ExchangeLayer/index.mjs +254 -0
  66. package/source/core/routing/Pipeline/index.mjs +157 -0
  67. package/source/core/routing/Router/Route.mjs +184 -0
  68. package/source/core/routing/Router/index.mjs +523 -0
  69. package/source/core/routing/RouterRegister/OptionsMiddleware.mjs +147 -0
  70. package/source/core/routing/RouterRegister/index.mjs +150 -0
  71. package/source/core/routing/RouterSystem/index.mjs +225 -0
  72. package/source/core/value-objects/Address/index.mjs +281 -0
  73. package/source/core/value-objects/Endpoint/index.mjs +293 -0
  74. package/source/index.mjs +62 -0
  75. package/source/infrastructure/adapters/Http2Adapter/index.mjs +144 -0
  76. package/source/infrastructure/adapters/Http2CleartextAdapter/index.mjs +147 -0
  77. package/source/infrastructure/adapters/HttpAdapter/index.mjs +124 -0
  78. package/source/infrastructure/adapters/HttpsAdapter/index.mjs +124 -0
  79. package/source/infrastructure/adapters/TransportAdapter/createWebSocketOpenRequest.mjs +843 -0
  80. package/source/infrastructure/adapters/TransportAdapter/index.mjs +555 -0
  81. package/source/shared/Identifier/index.mjs +671 -0
  82. package/source/shared/constants/symbols/brands/index.mjs +45 -0
  83. package/source/shared/domain/UUIDV7/index.mjs +346 -0
  84. package/source/shared/errors/BaseError/index.mjs +157 -0
  85. package/source/shared/errors/IPCError/index.mjs +97 -0
  86. package/source/shared/errors/MiddlewareError/index.mjs +106 -0
  87. package/source/shared/errors/PathnamePatternError/index.mjs +103 -0
  88. package/source/shared/errors/RequestError/index.mjs +116 -0
  89. package/source/shared/errors/ResponseError/index.mjs +140 -0
  90. package/source/shared/errors/RouterError/index.mjs +90 -0
  91. package/source/shared/errors/ServerConfigurationError/index.mjs +551 -0
  92. package/source/shared/errors/ServerError/index.mjs +127 -0
  93. package/source/shared/errors/WebSocketError/index.mjs +58 -0
  94. package/source/shared/outcome/ErrorSchema.mjs +361 -0
  95. package/source/shared/outcome/Result/index.mjs +239 -0
  96. package/source/shared/outcome/Validation/index.mjs +208 -0
  97. package/source/shared/validations/Middleware/index.mjs +160 -0
  98. package/source/shared/validations/PathnamePattern/index.mjs +256 -0
  99. package/source/shared/validations/Server/NotFoundHandler/index.mjs +94 -0
  100. package/source/shared/validations/Server/Router/index.mjs +68 -0
  101. package/source/shared/validations/Server/ServerConfiguration/index.mjs +68 -0
  102. package/source/shared/validations/Server/SystemErrorHandler/index.mjs +94 -0
  103. package/source/shared/validations/ServerConfiguration/CORSOptions/index.mjs +310 -0
  104. package/source/shared/validations/ServerConfiguration/CSRFOptions/index.mjs +299 -0
  105. package/source/shared/validations/ServerConfiguration/CompressOptions/index.mjs +204 -0
  106. package/source/shared/validations/ServerConfiguration/CookieParserOptions/index.mjs +102 -0
  107. package/source/shared/validations/ServerConfiguration/CookieSessionOptions/index.mjs +332 -0
  108. package/source/shared/validations/ServerConfiguration/ListenOptions/index.mjs +178 -0
  109. package/source/shared/validations/ServerConfiguration/RateLimitOptions/index.mjs +229 -0
  110. package/source/shared/validations/ServerConfiguration/RequestOptions/index.mjs +132 -0
  111. package/source/shared/validations/ServerConfiguration/ResponseOptions/index.mjs +122 -0
  112. package/source/shared/validations/ServerConfiguration/SecurityHeadersOptions/index.mjs +137 -0
  113. package/source/shared/validations/ServerConfiguration/StaticOptions/index.mjs +248 -0
  114. package/types/index.d.ts +719 -0
@@ -0,0 +1,331 @@
1
+ // @ts-check
2
+ /**
3
+ * @file Orchestrates IPC request processing for one WebSocket connection.
4
+ *
5
+ * @description
6
+ * IPCServerChannel binds one established WebSocket connection to the IPCMain
7
+ * dispatch capability. It owns request decoding, response serialization,
8
+ * connection-local state, and transport writes.
9
+ *
10
+ * IPCServerChannel does not own IPC handlers, event listeners, or application
11
+ * dispatch rules. Those responsibilities remain with IPCMain.
12
+ *
13
+ * @module IPCServerChannel
14
+ */
15
+
16
+ // ━━ IMPORT MODULES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
17
+ // » IMPORT LOCAL MODULES
18
+ import IPCProtocol from './IPCProtocol.mjs';
19
+
20
+ // ━━ TYPE DEFINITIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
21
+ /**
22
+ * Represents an established WebSocket connection contract.
23
+ *
24
+ * @typedef {import('#adapters/TransportAdapter').WebSocketConnection} WebSocketConnection
25
+ */
26
+
27
+ /**
28
+ * Represents a decoded IPC request.
29
+ *
30
+ * @typedef {import('./IPCProtocol.mjs').IPCRequest} IPCRequest
31
+ */
32
+
33
+ /**
34
+ * Represents the normalized IPC wire error.
35
+ *
36
+ * @typedef {import('./IPCProtocol.mjs').IPCError} IPCError
37
+ */
38
+
39
+ /**
40
+ * Represents a successful response payload supplied by the IPC host.
41
+ *
42
+ * @typedef {object} IPCSuccessPayload
43
+ * @property {unknown} result - Handler result returned to the IPC caller.
44
+ */
45
+
46
+ /**
47
+ * Represents an error response payload supplied by the IPC host.
48
+ *
49
+ * @typedef {object} IPCErrorPayload
50
+ * @property {unknown} error - Error value produced while handling the request.
51
+ */
52
+
53
+ /**
54
+ * Represents a response payload supplied by the IPC host.
55
+ *
56
+ * @typedef {IPCSuccessPayload|IPCErrorPayload} IPCResponsePayload
57
+ */
58
+
59
+ /**
60
+ * Dispatches a decoded IPC request and provides a callback for its response.
61
+ *
62
+ * @typedef {(request: IPCRequest, respond: (payload: IPCResponsePayload) => void) => Promise<void>} DispatchFunction
63
+ */
64
+
65
+ /**
66
+ * Represents the internal capability supplied by IPCMain to dispatch IPC requests.
67
+ *
68
+ * @typedef {object} IPCChannelHost
69
+ * @property {DispatchFunction} dispatch - Dispatches an IPC request and reports its response through the supplied callback.
70
+ */
71
+
72
+ // ━━ FUNCTIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
73
+ /**
74
+ * Determines whether a value is a non-null plain object.
75
+ *
76
+ * @private
77
+ * @param {unknown} value - Value to inspect.
78
+ * @returns {value is Record<string, unknown>} `true` if the value is a plain object.
79
+ */
80
+ function isPlainObject(value) {
81
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
82
+ }
83
+
84
+ // ━━ MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
85
+ /**
86
+ * Orchestrates IPC request processing for one WebSocket connection.
87
+ *
88
+ * @classdesc IPCServerChannel binds one established WebSocket connection to the
89
+ * IPCMain dispatch capability. It owns request decoding, response
90
+ * serialization, connection-local state, and transport writes.
91
+ *
92
+ * IPCServerChannel does not own IPC handlers, event listeners, or application
93
+ * dispatch rules. Those responsibilities remain with IPCMain.
94
+ */
95
+ class IPCServerChannel {
96
+ /**
97
+ * Established WebSocket connection owned by this IPC channel.
98
+ *
99
+ * @type {WebSocketConnection}
100
+ */
101
+ #connection;
102
+
103
+ /**
104
+ * Internal IPC host used to dispatch requests.
105
+ *
106
+ * @type {IPCChannelHost}
107
+ */
108
+ #host;
109
+
110
+ /**
111
+ * IPC wire protocol used to decode requests and serialize responses.
112
+ *
113
+ * @type {IPCProtocol}
114
+ */
115
+ #protocol = new IPCProtocol();
116
+
117
+ /**
118
+ * Indicates whether this IPC channel is closed.
119
+ *
120
+ * @type {boolean}
121
+ */
122
+ #closed = false;
123
+
124
+ /**
125
+ * Creates an IPC channel bound to a WebSocket connection and an IPC host.
126
+ *
127
+ * Registers message, close, and error handlers on the connection. The
128
+ * channel is considered closed once the connection emits either `close`
129
+ * or `error`.
130
+ *
131
+ * @param {WebSocketConnection} connection - Established WebSocket connection.
132
+ * @param {IPCChannelHost} host - IPC host providing the dispatch capability.
133
+ * @example
134
+ * ```js
135
+ * const channel = new IPCServerChannel(connection, {
136
+ * dispatch: async (request, respond) => {
137
+ * respond({ result: { ok: true } });
138
+ * },
139
+ * });
140
+ * ```
141
+ */
142
+ constructor(connection, host) {
143
+ this.#connection = connection;
144
+ this.#host = host;
145
+
146
+ connection.on('message', raw => {
147
+ void this.#onMessage(raw);
148
+ });
149
+
150
+ connection.on('close', () => {
151
+ this.#closed = true;
152
+ });
153
+
154
+ connection.on('error', () => {
155
+ this.#closed = true;
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Decodes and dispatches one incoming WebSocket message.
161
+ *
162
+ * @param {string|Buffer} raw - Raw WebSocket message received from the connection.
163
+ * @returns {Promise<void>} Resolves once the message has been dispatched.
164
+ */
165
+ async #onMessage(raw) {
166
+ if (!this.#closed) {
167
+ const text = typeof raw === 'string' ? raw : raw.toString('utf8');
168
+ const request = this.#decodeOrHandleFailure(text);
169
+
170
+ if (request !== null) {
171
+ await this.#dispatchRequest(request);
172
+ }
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Decodes an IPC request and handles decoding failures.
178
+ *
179
+ * @param {string} text - Raw IPC payload to decode.
180
+ * @returns {IPCRequest|null} Decoded IPC request, or null when decoding fails.
181
+ */
182
+ #decodeOrHandleFailure(text) {
183
+ try {
184
+ return this.#protocol.decode(text);
185
+ } catch (error) {
186
+ this.#handleDecodeFailure(text, error);
187
+ return null;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Dispatches a decoded IPC request to the IPC host.
193
+ *
194
+ * @param {IPCRequest} request - Decoded IPC request to dispatch.
195
+ * @returns {Promise<void>} Resolves once the request has been dispatched.
196
+ */
197
+ async #dispatchRequest(request) {
198
+ if (request.type === 'event') {
199
+ try {
200
+ await this.#host.dispatch(request, () => {});
201
+ } catch {
202
+ // Event dispatch errors are intentionally isolated from the connection.
203
+ }
204
+ } else {
205
+ const id = request.id;
206
+ let responded = false;
207
+
208
+ /**
209
+ * Responds to the current invocation once.
210
+ *
211
+ * Subsequent invocations are ignored to preserve the one-response
212
+ * contract of IPC invocations.
213
+ *
214
+ * @param {IPCResponsePayload} payload - Response payload supplied by the IPC host.
215
+ */
216
+ const respond = payload => {
217
+ if (!responded) {
218
+ responded = true;
219
+ this.#sendResponse(id, payload);
220
+ }
221
+ };
222
+
223
+ try {
224
+ await this.#host.dispatch(request, respond);
225
+ } catch (error) {
226
+ respond({ error });
227
+ }
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Handles a message that could not be decoded as a valid IPC request.
233
+ *
234
+ * A second lightweight parse is intentionally used to recover an invocation
235
+ * identifier when possible, allowing the peer to correlate the protocol
236
+ * error with its original request.
237
+ *
238
+ * @param {string} text - Raw IPC payload that failed to decode.
239
+ * @param {unknown} error - Error raised while decoding the payload.
240
+ */
241
+ #handleDecodeFailure(text, error) {
242
+ const id = this.#peekInvokeId(text);
243
+
244
+ if (id !== null) {
245
+ const reason = error instanceof Error ? error.message : 'Invalid IPC message';
246
+
247
+ this.#sendRaw(this.#protocol.badMessage(id, reason));
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Attempts to recover an invocation identifier without validating the full
253
+ * IPC request a second time.
254
+ *
255
+ * @param {string} text - Raw IPC payload to inspect.
256
+ * @returns {string|null} Recovered invocation identifier, or null when absent.
257
+ */
258
+ #peekInvokeId(text) {
259
+ try {
260
+ const value = JSON.parse(text);
261
+
262
+ if (
263
+ isPlainObject(value) &&
264
+ value.type === 'invoke' &&
265
+ typeof value.id === 'string' &&
266
+ value.id.length > 0
267
+ ) {
268
+ return value.id;
269
+ }
270
+ } catch {
271
+ // Ignore recovery failures.
272
+ }
273
+
274
+ return null;
275
+ }
276
+
277
+ /**
278
+ * Encodes and sends an IPC response.
279
+ *
280
+ * Raw errors are deliberately forwarded to IPCProtocol, which owns their
281
+ * normalization into the IPC wire representation.
282
+ *
283
+ * @param {string} id - Invocation identifier associated with the request.
284
+ * @param {IPCResponsePayload} payload - Response payload supplied by the IPC host.
285
+ */
286
+ #sendResponse(id, payload) {
287
+ if (!this.#closed) {
288
+ const encoded =
289
+ 'error' in payload
290
+ ? this.#protocol.error(id, payload.error)
291
+ : this.#protocol.success(id, payload.result);
292
+
293
+ this.#sendRaw(encoded);
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Sends an already encoded IPC message through the WebSocket connection.
299
+ *
300
+ * @param {string} encoded - Encoded IPC message to send.
301
+ */
302
+ #sendRaw(encoded) {
303
+ if (!this.#closed) {
304
+ try {
305
+ this.#connection.send(encoded);
306
+ } catch {
307
+ this.#forceClose();
308
+ }
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Marks the channel closed and attempts to close its transport connection.
314
+ *
315
+ * Invoked when the transport reports a failure that prevents further
316
+ * writes. Errors thrown while closing the connection are ignored, since
317
+ * the channel is already considered closed.
318
+ */
319
+ #forceClose() {
320
+ this.#closed = true;
321
+
322
+ try {
323
+ this.#connection.close();
324
+ } catch {
325
+ // Ignore transport close failures.
326
+ }
327
+ }
328
+ }
329
+
330
+ // ━━ EXPORT MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
331
+ export default IPCServerChannel;
@@ -0,0 +1,189 @@
1
+ // @ts-check
2
+ /**
3
+ * @file Adapts transport-level WebSocket upgrades to connection listeners.
4
+ *
5
+ * @description WebSocketAdapter bridges the TransportAdapter WebSocket upgrade
6
+ * capability with consumers that need established WebSocket connections.
7
+ *
8
+ * The adapter does not own active connections. It only owns the subscription
9
+ * to the transport upgrade source and the current connection listener.
10
+ *
11
+ * @module WebSocketAdapter
12
+ */
13
+
14
+ // ━━ TYPE DEFINITIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
15
+ /**
16
+ * Represents a permitted transport identity.
17
+ *
18
+ * @typedef {import('#adapters/TransportAdapter').TransportIdentity} TransportIdentity
19
+ */
20
+
21
+ /**
22
+ * Represents a transport adapter capable of managing a permitted transport.
23
+ *
24
+ * @typedef {import('#adapters/TransportAdapter').default<TransportIdentity>} TransportAdapter
25
+ */
26
+
27
+ /**
28
+ * Represents an established WebSocket connection contract.
29
+ *
30
+ * @typedef {import('#adapters/TransportAdapter').WebSocketConnection} WebSocketConnection
31
+ */
32
+
33
+ /**
34
+ * Transport-level request used to accept or reject a WebSocket handshake.
35
+ *
36
+ * @typedef {import('#adapters/TransportAdapter').WebSocketOpenRequest} WebSocketOpenRequest
37
+ */
38
+
39
+ /**
40
+ * Receives an established WebSocket connection.
41
+ *
42
+ * @callback WebSocketConnectionListener
43
+ * @param {WebSocketConnection} connection - Established WebSocket connection.
44
+ * @returns {void}
45
+ */
46
+
47
+ /**
48
+ * Configuration for WebSocketAdapter.
49
+ *
50
+ * @typedef {object} WebSocketAdapterOptions
51
+ * @property {(url: string) => boolean} [accept] - Optional predicate used to accept or reject WebSocket upgrade URLs.
52
+ */
53
+
54
+ // ━━ MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
55
+ class WebSocketAdapter {
56
+ /**
57
+ * Underlying transport adapter responsible for WebSocket upgrade handling.
58
+ *
59
+ * @type {TransportAdapter}
60
+ */
61
+ #transport;
62
+
63
+ /**
64
+ * Optional predicate used to determine whether a WebSocket URL is accepted.
65
+ *
66
+ * @type {((url: string) => boolean)|null}
67
+ */
68
+ #accept;
69
+
70
+ /**
71
+ * Current listener that receives accepted WebSocket connections.
72
+ *
73
+ * @type {WebSocketConnectionListener|null}
74
+ */
75
+ #listener = null;
76
+
77
+ /**
78
+ * Indicates whether the adapter has subscribed to transport WebSocket upgrades.
79
+ *
80
+ * @type {boolean}
81
+ */
82
+ #subscribed = false;
83
+
84
+ /**
85
+ * Initializes the WebSocket adapter with a transport adapter.
86
+ *
87
+ * @param {TransportAdapter} transport - Transport adapter responsible for handling WebSocket upgrade requests.
88
+ * @param {WebSocketAdapterOptions} [options] - Optional WebSocket adapter configuration.
89
+ *
90
+ * @example
91
+ * ```js
92
+ * const adapter = new WebSocketAdapter(transport, {
93
+ * accept: url => url.startsWith('/ws/')
94
+ * });
95
+ * ```
96
+ */
97
+ constructor(transport, options = {}) {
98
+ this.#transport = transport;
99
+ this.#accept = typeof options.accept === 'function' ? options.accept : null;
100
+ }
101
+
102
+ /**
103
+ * Registers the current listener that receives accepted WebSocket connections.
104
+ *
105
+ * Calling this method again replaces the previously registered listener.
106
+ * The underlying transport is subscribed only once, regardless of how many
107
+ * times this method is called.
108
+ *
109
+ * @param {WebSocketConnectionListener} listener - Callback invoked when a WebSocket connection is established.
110
+ * @throws {TypeError} If listener is not a function.
111
+ *
112
+ * @example
113
+ * ```js
114
+ * adapter.onConnection(connection => {
115
+ * connection.on('message', data => {
116
+ * connection.send(`Echo: ${data}`);
117
+ * });
118
+ * });
119
+ * ```
120
+ */
121
+ onConnection(listener) {
122
+ if (typeof listener !== 'function') {
123
+ throw new TypeError('listener must be a function');
124
+ }
125
+
126
+ this.#listener = listener;
127
+
128
+ if (!this.#subscribed) {
129
+ this.#subscribed = true;
130
+
131
+ this.#transport.onWebSocket(openRequest => {
132
+ this.#handleOpen(openRequest);
133
+ });
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Stops delivering newly accepted connections to the current listener.
139
+ *
140
+ * After `close()` is called, incoming WebSocket upgrade requests are
141
+ * rejected with `503 WebSocket unavailable`. The adapter cannot be
142
+ * reopened once closed.
143
+ *
144
+ * Existing connections remain owned by their consumer.
145
+ *
146
+ * @example
147
+ * ```js
148
+ * adapter.close();
149
+ * ```
150
+ */
151
+ close() {
152
+ this.#listener = null;
153
+ }
154
+
155
+ /**
156
+ * Processes one transport-level WebSocket upgrade request.
157
+ *
158
+ * @param {WebSocketOpenRequest} openRequest - Transport-level request used to accept or reject the WebSocket handshake.
159
+ */
160
+ #handleOpen(openRequest) {
161
+ if (this.#listener === null) {
162
+ openRequest.reject(503, 'WebSocket unavailable');
163
+ } else if (this.#accept && !this.#accept(openRequest.url)) {
164
+ openRequest.reject(404, 'Not Found');
165
+ } else {
166
+ Promise.resolve()
167
+ .then(() => openRequest.accept())
168
+ .then(
169
+ connection => {
170
+ if (this.#listener !== null) {
171
+ this.#listener(connection);
172
+ } else {
173
+ try {
174
+ connection.close();
175
+ } catch {
176
+ // Ignore transport close failures during cleanup.
177
+ }
178
+ }
179
+ },
180
+ () => {
181
+ openRequest.reject(500, 'WebSocket accept failed');
182
+ },
183
+ );
184
+ }
185
+ }
186
+ }
187
+
188
+ // ━━ EXPORT MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
189
+ export default WebSocketAdapter;
@@ -0,0 +1,197 @@
1
+ // @ts-check
2
+ /**
3
+ * @file Defines the base abstraction for parsing incoming request bodies.
4
+ *
5
+ * @description Provides the common functionality required by concrete body
6
+ * parsers to consume an incoming request stream while enforcing the
7
+ * configured maximum body size.
8
+ *
9
+ * When the body limit is exceeded, the underlying request is destroyed so
10
+ * that the socket is not left consuming bytes. As a consequence, the
11
+ * connection may be closed abruptly instead of producing a normal `413`
12
+ * response.
13
+ *
14
+ * @module BodyParser
15
+ */
16
+
17
+ // ━━ IMPORT MODULES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
18
+ // » IMPORT LOCAL MODULES
19
+ import RequestError from '#shared/errors/RequestError';
20
+
21
+ // ━━ TYPE DEFINITIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
22
+ /**
23
+ * Represents an incoming HTTP/1.1 or HTTPS/1.1 request message from Node.js.
24
+ *
25
+ * @typedef {import('node:http').IncomingMessage} HttpIncomingMessage
26
+ */
27
+
28
+ /**
29
+ * Represents an incoming HTTP/2 request message from Node.js.
30
+ *
31
+ * @typedef {import('node:http2').Http2ServerRequest} Http2IncomingMessage
32
+ */
33
+
34
+ /**
35
+ * Represents an incoming request message supported by the server.
36
+ *
37
+ * @typedef {HttpIncomingMessage|Http2IncomingMessage} IncomingRequest
38
+ */
39
+
40
+ // ━━ MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41
+ /**
42
+ * Provides the base abstraction for parsing incoming request bodies.
43
+ *
44
+ * @classdesc BodyParser provides common functionality for safely consuming
45
+ * an incoming request stream while enforcing the configured maximum body
46
+ * size. Concrete parsers implement `parse()` to transform the request body
47
+ * into the appropriate representation.
48
+ */
49
+ class BodyParser {
50
+ /**
51
+ * Underlying Node.js incoming request message.
52
+ *
53
+ * @type {IncomingRequest}
54
+ */
55
+ #incomingMessage;
56
+
57
+ /**
58
+ * Maximum number of bytes allowed for the request body.
59
+ *
60
+ * @type {number}
61
+ */
62
+ #bodyLimit;
63
+
64
+ /**
65
+ * Creates a body parser.
66
+ *
67
+ * @param {IncomingRequest} incomingMessage - Incoming HTTP request message.
68
+ * @param {number} bodyLimit - Maximum number of bytes allowed for the body.
69
+ * @throws {Error} Thrown when attempting to instantiate `BodyParser` directly.
70
+ */
71
+ constructor(incomingMessage, bodyLimit) {
72
+ if (new.target === BodyParser) {
73
+ throw new Error('BodyParser is abstract and cannot be instantiated directly');
74
+ }
75
+
76
+ this.#incomingMessage = incomingMessage;
77
+ this.#bodyLimit = bodyLimit;
78
+ }
79
+
80
+ /**
81
+ * Returns the underlying Node.js incoming request message.
82
+ *
83
+ * Concrete parsers use this stream to consume the request body.
84
+ *
85
+ * @type {IncomingRequest}
86
+ */
87
+ get incomingMessage() {
88
+ return this.#incomingMessage;
89
+ }
90
+
91
+ /**
92
+ * Parses the incoming request body.
93
+ *
94
+ * Concrete parsers must implement this method and return the parsed
95
+ * representation appropriate for their media type.
96
+ *
97
+ * @abstract
98
+ * @returns {Promise<unknown>} Parsed request body.
99
+ * @throws {Error} If the method is not implemented.
100
+ */
101
+ async parse() {
102
+ throw new Error('BodyParser.parse() must be implemented.');
103
+ }
104
+
105
+ /**
106
+ * Reads the complete request body from the underlying stream.
107
+ *
108
+ * When `Content-Length` is present, its value is validated before the
109
+ * stream is consumed. The actual number of bytes received is tracked to
110
+ * enforce the configured body size limit. When the limit is exceeded, the
111
+ * underlying request is destroyed so the socket is not left consuming
112
+ * further bytes.
113
+ *
114
+ * Stream-level failures (such as client disconnections) are normalized
115
+ * into `RequestError` instances. `RequestError` instances produced by
116
+ * this method are preserved as-is.
117
+ *
118
+ * @returns {Promise<Buffer>} The complete request body as a Buffer.
119
+ * @throws {RequestError} If `Content-Length` is invalid.
120
+ * @throws {RequestError} If the request body exceeds the configured limit.
121
+ * @throws {RequestError} If the underlying stream fails while being read.
122
+ * @example
123
+ * ```js
124
+ * const buffer = await bodyParser.readBody();
125
+ * ```
126
+ */
127
+ async readBody() {
128
+ const contentLength = this.#incomingMessage.headers['content-length'];
129
+
130
+ if (contentLength !== undefined) {
131
+ const length = Number(contentLength);
132
+
133
+ if (!Number.isSafeInteger(length) || length < 0) {
134
+ throw new RequestError('INVALID_CONTENT_LENGTH');
135
+ }
136
+
137
+ if (length > this.#bodyLimit) {
138
+ this.#incomingMessage.destroy();
139
+ throw new RequestError('BODY_LIMIT_EXCEEDED', {
140
+ values: {
141
+ bodySize: length,
142
+ bodyLimit: this.#bodyLimit,
143
+ },
144
+ });
145
+ }
146
+ }
147
+
148
+ let bytesRead = 0;
149
+ const chunks = [];
150
+
151
+ try {
152
+ for await (const chunk of this.#incomingMessage) {
153
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
154
+ bytesRead += buffer.length;
155
+
156
+ if (bytesRead > this.#bodyLimit) {
157
+ this.#incomingMessage.destroy();
158
+ throw new RequestError('BODY_LIMIT_EXCEEDED', {
159
+ values: {
160
+ bodySize: bytesRead,
161
+ bodyLimit: this.#bodyLimit,
162
+ },
163
+ });
164
+ }
165
+
166
+ chunks.push(buffer);
167
+ }
168
+
169
+ return Buffer.concat(chunks);
170
+ } catch (error) {
171
+ throw this.#normalizeStreamError(error);
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Normalizes a thrown value from a stream read operation.
177
+ *
178
+ * Preserves existing `RequestError` instances (for example
179
+ * `BODY_LIMIT_EXCEEDED`) and wraps everything else, including native
180
+ * stream errors such as `ECONNRESET`, in a `RequestError`.
181
+ *
182
+ * @param {unknown} error - The value thrown by the stream.
183
+ * @returns {RequestError} The normalized error.
184
+ */
185
+ #normalizeStreamError(error) {
186
+ if (error instanceof RequestError) {
187
+ return error;
188
+ }
189
+
190
+ return new RequestError('BODY_STREAM_ERROR', {
191
+ cause: error instanceof Error ? error : new Error(String(error)),
192
+ });
193
+ }
194
+ }
195
+
196
+ // ━━ EXPORT MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
197
+ export default BodyParser;