@bjoernboss/mws 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/dist/client.js ADDED
@@ -0,0 +1,1646 @@
1
+ /* SPDX-License-Identifier: BSD-3-Clause */
2
+ /* Copyright (c) 2024-2026 Bjoern Boss Henrichsen */
3
+ import * as libLog from "./log.js";
4
+ import * as libCache from "./cache.js";
5
+ import * as libHelper from "./helper.js";
6
+ import * as libBase from "./base.js";
7
+ import * as libEvents from "events";
8
+ import * as libFs from "fs";
9
+ import * as libStream from "stream";
10
+ import * as libUrl from "url";
11
+ import * as libWs from "ws";
12
+ import * as libHttp from "http";
13
+ const BAD_HTTP_STRING_REGEX = /[\x00-\x1f\x7f]/;
14
+ const BAD_HTTP_HEADER_NAME_REGEX = /[\x00-\x1f\x7f\(\)<>@,;:\\"/\[\]\?=\{\} \t]/;
15
+ class ClientContext {
16
+ dropLogTag;
17
+ path;
18
+ translationCount;
19
+ busyCount;
20
+ headerPatchCount;
21
+ htmlPatchCount;
22
+ constructor(path, translationCount, busyCount, headerPatchCount, htmlPatchCount) {
23
+ this.dropLogTag = () => { };
24
+ this.path = path;
25
+ this.translationCount = translationCount;
26
+ this.busyCount = busyCount;
27
+ this.headerPatchCount = headerPatchCount;
28
+ this.htmlPatchCount = htmlPatchCount;
29
+ }
30
+ }
31
+ class ClientBase extends libLog.Logger {
32
+ _config;
33
+ _path;
34
+ _translation;
35
+ constructor(arg, kind, config) {
36
+ super(kind);
37
+ if (arg instanceof libUrl.URL) {
38
+ this._translation = [];
39
+ this._path = libHelper.sanitize(arg.pathname, false);
40
+ this.url = arg;
41
+ }
42
+ else {
43
+ this._translation = arg._translation;
44
+ this._path = arg._path;
45
+ this.url = arg.url;
46
+ }
47
+ this._config = config;
48
+ }
49
+ /* raw request origin (no host will result in '_'; host will be lower-case) */
50
+ url;
51
+ /* path relative to current module */
52
+ get path() {
53
+ return this._path;
54
+ }
55
+ /* configuration used by this client */
56
+ get config() {
57
+ return this._config;
58
+ }
59
+ /* check if the path relative to the current module is a sub path of the given test base path */
60
+ isSubPathOf(base) {
61
+ return libHelper.isSubPath(base, this._path);
62
+ }
63
+ /* check if the path relative to the current module is inside of the given test base path */
64
+ isInsideOf(base) {
65
+ return libHelper.isInside(base, this._path);
66
+ }
67
+ /* create a path relative from the current module into the clients traversed server space */
68
+ makePath(path) {
69
+ path = libHelper.sanitize(path, false);
70
+ let output = path;
71
+ for (let i = this._translation.length - 1; i >= 0; --i) {
72
+ let nullCheck = false, match = null;
73
+ /* find the best reverse mapping and apply it */
74
+ for (const [from, to] of Object.entries(this._translation[i])) {
75
+ if (to == null)
76
+ nullCheck = true;
77
+ else if (libHelper.isSubPath(to, output) && (match == null || match[1].length < to.length))
78
+ match = [from, to];
79
+ }
80
+ if (match != null)
81
+ output = libHelper.rebasePath(match[1], match[0], output);
82
+ /* check if the translation contained null-mappings and check if
83
+ * the final unpacked path re-maps into the null-mapping */
84
+ if (nullCheck) {
85
+ match = null;
86
+ for (const [from, to] of Object.entries(this._translation[i])) {
87
+ if (libHelper.isSubPath(from, output) && (match == null || match[0].length < from.length))
88
+ match = [from, to];
89
+ }
90
+ }
91
+ /* check if the path could not be translated */
92
+ if (match == null || match[1] == null) {
93
+ this.warning(`Path [${path}] is not mapped by translations`);
94
+ return path;
95
+ }
96
+ }
97
+ return output;
98
+ }
99
+ }
100
+ var ReceiveState;
101
+ (function (ReceiveState) {
102
+ ReceiveState[ReceiveState["none"] = 0] = "none";
103
+ ReceiveState[ReceiveState["receiving"] = 1] = "receiving";
104
+ ReceiveState[ReceiveState["completed"] = 2] = "completed";
105
+ })(ReceiveState || (ReceiveState = {}));
106
+ var UpgradeState;
107
+ (function (UpgradeState) {
108
+ UpgradeState[UpgradeState["none"] = 0] = "none";
109
+ UpgradeState[UpgradeState["upgrading"] = 1] = "upgrading";
110
+ UpgradeState[UpgradeState["upgraded"] = 2] = "upgraded";
111
+ })(UpgradeState || (UpgradeState = {}));
112
+ var ResponseState;
113
+ (function (ResponseState) {
114
+ ResponseState[ResponseState["none"] = 0] = "none";
115
+ ResponseState[ResponseState["acknowledged"] = 1] = "acknowledged";
116
+ ResponseState[ResponseState["headerSent"] = 2] = "headerSent";
117
+ ResponseState[ResponseState["completed"] = 3] = "completed";
118
+ ResponseState[ResponseState["broken"] = 4] = "broken";
119
+ })(ResponseState || (ResponseState = {}));
120
+ class HttpRequestResponse extends libStream.Writable {
121
+ writer;
122
+ totalSent;
123
+ cache;
124
+ status;
125
+ headers;
126
+ contentSize;
127
+ dynamicEncode;
128
+ contentType;
129
+ encodingFailed;
130
+ responseCompleted;
131
+ constructor(writer, status, headers, contentSize, contentType, dynamicEncode, handleData, destroy) {
132
+ super({
133
+ write: (chunk, _, cb) => handleData(chunk, cb),
134
+ final: (cb) => handleData(null, cb),
135
+ destroy: (err, cb) => destroy(err, cb)
136
+ });
137
+ this.writer = writer;
138
+ this.totalSent = 0;
139
+ this.cache = null;
140
+ this.status = status;
141
+ this.headers = headers;
142
+ this.contentSize = contentSize;
143
+ this.dynamicEncode = dynamicEncode;
144
+ this.contentType = contentType;
145
+ this.encodingFailed = false;
146
+ this.responseCompleted = false;
147
+ }
148
+ }
149
+ /*
150
+ * Does not throw any exceptions, unless explicitly stated.
151
+ * Http HEAD aware (will silently drain any data sent from a HEAD request).
152
+ *
153
+ * Request is considered acknowledged, as soon as a response has been triggered or a preparation started.
154
+ * Path remains URI encoded, as it was received, and path building will use the same encoded paths.
155
+ * Repeated request responding may override any ongoing responses and may terminate the connection; depending on the prior state.
156
+ * Not responded to requests will result in [not-found].
157
+ *
158
+ * Receiving data: Will automatically decode the stream and ensure a given maximum is not passed
159
+ * => Any errors while receiving will either auto-respond or send the connection into the broken state, and fail the receive reader (stream user does not need to respond).
160
+ * => Will terminate a connection, if the upload is not consumed or the client errors.
161
+ * => Premature destroying of receive reader will result in the connection being gracefully terminated.
162
+ * => All data must have been received before the response is completed.
163
+ * Responding data: Will automatically encode the stream and send the header accordingly
164
+ * => Will automatically determine if encoding is to be used
165
+ * => Checks if promised number of bytes is provided
166
+ * => Will automatically error, if the broken state is detected, and will auto-respond or send the connection into the broken state (stream user does not need to respond).
167
+ *
168
+ * A response sent while another is being prepared (acknowledged) will override it and close the connection.
169
+ * A response sent while data is already being streamed (header sent) will break the connection.
170
+ * Normal responses automatically add ClientConfig.responseCacheControl, if no other cache control is specified.
171
+ * File responses will automatically add ClientConfig.fileCacheControl/ClientConfig.immutableCacheControl, if no other cache control is specified.
172
+ * Responses will either use the dedicated responder interface and its highWaterMark, or a responder interface, which caches up to socket.highWaterMark.
173
+ *
174
+ * Upgrade requests, which were not accepted, will be closed after responding.
175
+ * An accept attempt must be fully awaited before completing the handling procedure.
176
+ *
177
+ * Defaults [Accept-Ranges] normally to 'none' or to 'bytes' for files
178
+ * Defaults [Vary] to 'Accept-Encoding'.
179
+ * Defaults [Connection] to 'close' for upgrade requests and for some error responses.
180
+ */
181
+ export class ClientRequest extends ClientBase {
182
+ _headerPatcher;
183
+ _htmlPatcher;
184
+ _state;
185
+ _throughput;
186
+ _native;
187
+ _request;
188
+ _cache;
189
+ constructor(cache, config, protocol, request, response) {
190
+ super(new libUrl.URL(`${protocol}://${request.headers.host?.toLowerCase() ?? '_'}${request.url}`), 'request', config);
191
+ this._headerPatcher = [];
192
+ this._htmlPatcher = [];
193
+ let respondedResolve = null, completedResolve = null, breakResolve = null;
194
+ this._state = {
195
+ respondedPromise: new Promise((resolve) => respondedResolve = resolve),
196
+ respondedResolve: () => { },
197
+ completedPromise: new Promise((resolve) => completedResolve = resolve),
198
+ completedResolve: () => { },
199
+ breakPromise: new Promise((resolve) => breakResolve = resolve),
200
+ breakResolve: () => { },
201
+ receive: ReceiveState.none,
202
+ response: ResponseState.none,
203
+ upgrade: UpgradeState.none,
204
+ breaking: null
205
+ };
206
+ this._state.respondedResolve = respondedResolve;
207
+ this._state.completedResolve = completedResolve;
208
+ this._state.breakResolve = breakResolve;
209
+ this._request = request;
210
+ this._cache = cache;
211
+ /* setup the throughput measurement to detect any stalling connections */
212
+ this._throughput = { timer: null, deadline: 0, start: 0, active: true, busyCheck: [] };
213
+ if (this.config.throughputThreshold > 0) {
214
+ this._throughput.start = Date.now() + this.config.throughputGrace;
215
+ this.updateThroughput(0);
216
+ }
217
+ /* register the necessary network error handlers */
218
+ const lostHandler = () => {
219
+ if (this._state.response != ResponseState.completed && this._state.response != ResponseState.broken)
220
+ this.markAsBroken('Connection lost', false);
221
+ };
222
+ const closedHandler = () => {
223
+ if (this._state.response != ResponseState.completed && this._state.response != ResponseState.broken)
224
+ this.markAsBroken('Connection closed by remote', false);
225
+ };
226
+ const timeoutHandler = () => {
227
+ if (this._state.response != ResponseState.completed && this._state.response != ResponseState.broken)
228
+ this.markAsBroken('Connection timed out', false);
229
+ };
230
+ request.once('error', lostHandler);
231
+ request.once('aborted', closedHandler);
232
+ request.socket.once('timeout', timeoutHandler);
233
+ request.socket.once('error', lostHandler);
234
+ request.socket.once('close', closedHandler);
235
+ /* ensure to remove the events again once the processing has completed */
236
+ this._state.completedPromise.then(() => {
237
+ request.off('error', lostHandler);
238
+ request.off('aborted', closedHandler);
239
+ request.socket.off('timeout', timeoutHandler);
240
+ request.socket.off('error', lostHandler);
241
+ request.socket.off('close', closedHandler);
242
+ });
243
+ /* configure the native interface writer, depending on the actual source parameters */
244
+ let responseWrapper = null;
245
+ let writerWrapper = null;
246
+ if (response instanceof libHttp.ServerResponse) {
247
+ writerWrapper = response, responseWrapper = {
248
+ setHeader: (name, value) => response.setHeader(name, value),
249
+ setStatus: (code, msg) => { response.statusCode = code; response.statusMessage = msg; }
250
+ };
251
+ }
252
+ else
253
+ [responseWrapper, writerWrapper] = this.wrapSocketWriter(response.socket);
254
+ this._native = { response: responseWrapper, writer: writerWrapper, socket: (response instanceof libHttp.ServerResponse ? undefined : response) };
255
+ /* overwrite the original socket timeout as the client handler will take care of it (set it to twice the conceivable upper bound) */
256
+ this._native.timeout = this._request.socket.timeout;
257
+ this._request.socket.setTimeout((this.config.throughputWindow + this.config.throughputGrace) * 2);
258
+ }
259
+ constructQuickResponse(status, logReason, headers, content) {
260
+ if (headers == null)
261
+ headers = {};
262
+ const description = `${this.isHead ? 'HEAD:' : ''}[${status.msg}]${logReason == null ? '' : `: ${logReason}`}`;
263
+ if (!('Cache-Control' in headers) && this.config.responseCacheControl != '')
264
+ headers['Cache-Control'] = this.config.responseCacheControl;
265
+ /* check if the response can still be sent (acknowledged state can be overridden; the connection
266
+ * will be closed afterwards to prevent the client from seeing inconsistent responses) */
267
+ const override = (this._state.response == ResponseState.acknowledged);
268
+ if (this._state.response == ResponseState.none || override) {
269
+ if (status.code >= 500)
270
+ this.error(`Responding with ${description}`);
271
+ else if (override)
272
+ this.warning(`Overriding in-progress response with ${description}`);
273
+ else
274
+ this.log(`Responding with ${description}`);
275
+ if (override)
276
+ headers['Connection'] = 'close';
277
+ this.sendFullResponse(status, headers, content ?? undefined);
278
+ if (override)
279
+ this.markAsBroken('Overridden in-progress response', true);
280
+ }
281
+ else if (this._state.response == ResponseState.headerSent)
282
+ this.markAsBroken(`Overlap with committed response ${description}`, false);
283
+ else if (this._state.response != ResponseState.broken)
284
+ this.warning(`Request already completed, discarding response ${description}`);
285
+ else
286
+ this.trace(`Request broken, discarding response ${description}`);
287
+ }
288
+ failThroughput() {
289
+ if (this.config.throughputThreshold <= 0 || !this._throughput.active || this._state.response == ResponseState.broken)
290
+ return;
291
+ /* check if the connection is still considered busy and should receive a grace delay */
292
+ for (const cb of this._throughput.busyCheck) {
293
+ let result = false;
294
+ try {
295
+ result = cb();
296
+ }
297
+ catch (err) {
298
+ this.error(`Unhandled exception in busy check: ${err.message}`);
299
+ }
300
+ if (!result)
301
+ continue;
302
+ this.trace(`Deferring throughput closing as connection is busy`);
303
+ this._throughput.start = Date.now() + this.config.throughputGrace;
304
+ this.updateThroughput(0);
305
+ this._request.socket.setTimeout((this.config.throughputWindow + this.config.throughputGrace) * 2);
306
+ return;
307
+ }
308
+ const description = `Throughput below [${this.config.throughputThreshold}] bytes/sec`;
309
+ const closing = (this._state.response == ResponseState.none || this._state.response == ResponseState.acknowledged);
310
+ if (closing)
311
+ this.respondRequestTimeout(description, { headers: { 'Connection': 'close' } });
312
+ this.markAsBroken((closing ? '' : description), closing);
313
+ }
314
+ updateThroughput(delta) {
315
+ if (this._throughput.timer != null)
316
+ clearTimeout(this._throughput.timer);
317
+ this._throughput.timer = null;
318
+ if (this.config.throughputThreshold <= 0 || !this._throughput.active)
319
+ return;
320
+ const _now = Date.now();
321
+ const now = Math.max(_now, this._throughput.start);
322
+ /* shift the deadline according to the bought time by the throughput */
323
+ const bought = (delta / this.config.throughputThreshold) * 1000;
324
+ this._throughput.deadline = now + Math.min(this.config.throughputWindow, Math.max(0, this._throughput.deadline - now) + bought);
325
+ this._throughput.timer = setTimeout(() => this.failThroughput(), this._throughput.deadline - _now);
326
+ }
327
+ wrapSocketWriter(socket) {
328
+ let headers = {}, status = 0, message = '';
329
+ /* wrap the response interface to catch the http parameter */
330
+ const response = {
331
+ setHeader: (name, value) => {
332
+ if (name.match(BAD_HTTP_HEADER_NAME_REGEX))
333
+ throw new Error('Bad Name');
334
+ if (value.match(BAD_HTTP_STRING_REGEX))
335
+ throw new Error('Bad Value');
336
+ headers[name] = value;
337
+ },
338
+ setStatus: (code, msg) => {
339
+ if (msg.match(BAD_HTTP_STRING_REGEX))
340
+ throw new Error('Bad Status');
341
+ status = code;
342
+ message = msg;
343
+ }
344
+ };
345
+ /* setup the buffer flush, which will also write the header out */
346
+ let headerSent = false, buffer = null, settled = false;
347
+ const flushBuffer = (cb, last) => {
348
+ let prefix = '', suffix = '';
349
+ /* check if the header first needs to be sent */
350
+ const chunked = (headerSent || !last);
351
+ if (!headerSent) {
352
+ headerSent = true;
353
+ /* construct the header text */
354
+ if (chunked)
355
+ headers['Transfer-Encoding'] = 'chunked';
356
+ else
357
+ headers['Content-Length'] = (buffer?.byteLength ?? 0).toString();
358
+ prefix = `HTTP/1.1 ${status} ${message}\r\n`;
359
+ for (const key in headers)
360
+ prefix += `${key}: ${headers[key]}\r\n`;
361
+ prefix += '\r\n';
362
+ }
363
+ /* check if a chunk prefix needs to be added and if the chunk suffix needs to be added */
364
+ if (chunked) {
365
+ if (buffer != null)
366
+ prefix += `${buffer.byteLength.toString(16)}\r\n`, suffix += '\r\n';
367
+ if (last)
368
+ suffix += '0\r\n\r\n';
369
+ }
370
+ /* construct the final chunk to be sent */
371
+ let chunks = [];
372
+ if (prefix != '')
373
+ chunks.push(Buffer.from(prefix, 'utf-8'));
374
+ if (buffer != null)
375
+ chunks.push(buffer);
376
+ if (suffix != null)
377
+ chunks.push(Buffer.from(suffix, 'utf-8'));
378
+ const chunk = (chunks.length == 1 ? chunks[0] : Buffer.concat(chunks));
379
+ /* clear the cached data and write the data to the socket (chunk can never be empty, as either a buffer must exist, or the final suffix or header needs to be sent) */
380
+ buffer = null;
381
+ if (last)
382
+ socket.end(chunk, cb);
383
+ else
384
+ socket.write(chunk, cb);
385
+ };
386
+ /* note: writer cannot interfere with later web-socket native socket, as the web-socket will only be instantiated,
387
+ * if response-state is none, and vice versa, all writers will fail once web-socket has started, as response-state
388
+ * will be header-sent (writer itself can never fail, and no error handlers will be attached to it; it uses the
389
+ * underlying sockets watermark as buffering capacity) */
390
+ const writer = new libStream.Writable({
391
+ destroy: (err, cb) => {
392
+ if (settled)
393
+ return;
394
+ settled = true;
395
+ socket.destroy(err ?? undefined);
396
+ cb(null);
397
+ },
398
+ write: (chunk, _, cb) => {
399
+ if (settled)
400
+ return;
401
+ buffer = (buffer == null ? chunk : Buffer.concat([buffer, chunk]));
402
+ if (buffer.byteLength < writer.writableHighWaterMark)
403
+ return cb(null);
404
+ flushBuffer(cb, false);
405
+ },
406
+ final: (cb) => {
407
+ if (settled)
408
+ return;
409
+ settled = true;
410
+ flushBuffer(cb, true);
411
+ },
412
+ highWaterMark: socket.writableHighWaterMark
413
+ });
414
+ return [response, writer];
415
+ }
416
+ async killNativeConnection(graceful) {
417
+ const writer = this._native.writer;
418
+ /* raw closer, which will fully destroy the connection */
419
+ const closeConnection = () => {
420
+ if (this._request.destroyed && writer.destroyed)
421
+ return;
422
+ const error = new Error('Connection broken');
423
+ this._request.destroy(error);
424
+ writer.destroy(error);
425
+ };
426
+ if (!graceful || writer.writableFinished || writer.destroyed)
427
+ return closeConnection();
428
+ /* if graceful, wait for the last queued data to be sent; may be called multiple times */
429
+ return new Promise((resolve) => {
430
+ let settled = false, handler = () => {
431
+ if (settled)
432
+ return;
433
+ settled = true;
434
+ closeConnection();
435
+ resolve();
436
+ };
437
+ writer.once('finish', () => handler());
438
+ writer.once('error', () => handler());
439
+ writer.once('close', () => handler());
440
+ });
441
+ }
442
+ markAsBroken(reason, graceful) {
443
+ this.error(`Connection broken: [${reason}]`);
444
+ this._state.response = ResponseState.broken;
445
+ if (this._state.breaking != null) {
446
+ if (!graceful)
447
+ this.killNativeConnection(false);
448
+ return;
449
+ }
450
+ this._state.respondedResolve();
451
+ /* setup the promise beforehand to ensure the promise body does not recursively
452
+ * enter this handler again, and sees the completed object still being unset */
453
+ let resolver = () => { };
454
+ this._state.breaking = new Promise((res) => resolver = res);
455
+ /* setup the break promise to ensure the connection is killed properly with the given grace */
456
+ (async () => {
457
+ let settled = false;
458
+ const forceDestroy = setTimeout(() => {
459
+ if (settled)
460
+ return;
461
+ settled = true;
462
+ this.killNativeConnection(false);
463
+ resolver();
464
+ }, this.config.killGraceTimeout);
465
+ await this.killNativeConnection(graceful);
466
+ clearTimeout(forceDestroy);
467
+ if (settled)
468
+ return;
469
+ settled = true;
470
+ resolver();
471
+ })();
472
+ this._state.breakResolve();
473
+ }
474
+ badClientUsage(reason, close) {
475
+ this.respondInternalError(`Bad Usage: ${reason}`, (close ? { headers: { 'Connection': 'close' } } : undefined));
476
+ }
477
+ closeHeader(status, headers, content) {
478
+ let logMsg = `Sending [${status.msg}] ${this.isHead ? 'HEAD ' : ''}`;
479
+ if (content?.media == null)
480
+ logMsg += `for no content`;
481
+ else {
482
+ logMsg += `[${content.media.mediaType}] of size [${content?.size ?? 'unknown'}]`;
483
+ if (content.encoding != null)
484
+ logMsg += ` dynamically encoded using [${content.encoding}]`;
485
+ }
486
+ this.trace(logMsg);
487
+ /* mark the response as determined as it will now be sent this way */
488
+ this._state.response = ResponseState.headerSent;
489
+ this._state.respondedResolve();
490
+ /* configure the default header values */
491
+ if (!('Accept-Ranges' in headers))
492
+ headers['Accept-Ranges'] = 'none';
493
+ if (!('Vary' in headers))
494
+ headers['Vary'] = 'Accept-Encoding';
495
+ if (!('Date' in headers))
496
+ headers['Date'] = new Date().toUTCString();
497
+ for (const [key, value] of Object.entries(this.config.commonHeaders)) {
498
+ if (!(key in headers))
499
+ headers[key] = value;
500
+ }
501
+ if (this.config.serverName != '' && !('Server' in headers))
502
+ headers['Server'] = this.config.serverName;
503
+ if (content != null) {
504
+ headers['Content-Type'] = libHelper.buildMediaTypeIdentifier(content.media);
505
+ if (content.size != null)
506
+ headers['Content-Length'] = content.size.toString();
507
+ }
508
+ /* check if its an upgrade request, which is always marked as being closed, as the
509
+ * underlying web-server will not take it back into the queue for keep-alive sockets */
510
+ if (this._native.socket != null)
511
+ headers['Connection'] = 'close';
512
+ /* perform the header post processing (in reverse order to ensure first added is last executed) */
513
+ for (let i = this._headerPatcher.length - 1; i >= 0; --i) {
514
+ try {
515
+ this._headerPatcher[i](status, headers);
516
+ }
517
+ catch (err) {
518
+ this.error(`Unhandled exception in header patcher: ${err.message}`);
519
+ }
520
+ }
521
+ /* setup the response status and headers (guard against invalid header values) */
522
+ try {
523
+ this._native.response.setStatus(status.code, status.msg);
524
+ }
525
+ catch (_) {
526
+ return this.markAsBroken(`Failed to finalize response: Bad status message`, false);
527
+ }
528
+ for (const [key, value] of Object.entries(headers)) {
529
+ try {
530
+ this._native.response.setHeader(key, value);
531
+ }
532
+ catch (_) {
533
+ this.error(`Failed to set header [${key}]: Bad header value`);
534
+ }
535
+ }
536
+ }
537
+ sendFullResponse(status, headers, content) {
538
+ let encoding = null;
539
+ if (content != null) {
540
+ headers['Vary'] = 'Accept-Encoding';
541
+ /* check if the data should be encoded (if the size is not known, pretend the buffer to be large enough) */
542
+ encoding = libHelper.negotiateEncoding(this.headers['accept-encoding'] ?? null, content.body?.byteLength ?? null, content.media);
543
+ if (encoding != null) {
544
+ if (content.body != null)
545
+ content.body = encoding.encodeBuffer(content.body);
546
+ headers['Content-Encoding'] = encoding.name;
547
+ }
548
+ }
549
+ this.closeHeader(status, headers, (content == null ? undefined : { media: content.media, size: content.body?.byteLength, encoding: encoding?.name }));
550
+ this._state.response = ResponseState.completed;
551
+ /* try to finalize the response (can throw an exception for invalid status or header content) */
552
+ try {
553
+ if (!this.isHead && content?.body != null) {
554
+ this.updateThroughput(content.body.length);
555
+ this._native.writer.end(content.body);
556
+ }
557
+ else
558
+ this._native.writer.end();
559
+ }
560
+ catch (err) {
561
+ this.markAsBroken(`Failed to finalize response: ${err.message}`, false);
562
+ }
563
+ }
564
+ sendClientSetupHeader(resp, chunk, cb) {
565
+ const last = (chunk == null);
566
+ const cached = (resp.cache != null);
567
+ /* check if previous data were cached and combine them */
568
+ if (resp.cache != null)
569
+ chunk = (chunk == null ? resp.cache : Buffer.concat([resp.cache, chunk]));
570
+ resp.cache = null;
571
+ /* check if the sending should be deferred to determine if compression
572
+ * should be enabled or to allow inline compression on small packets
573
+ * (for a head request, dont cache any data, immediately send the header) */
574
+ if (!last && !this.isHead && (chunk.byteLength < libBase.MIN_ENCODING_SIZE || !cached)) {
575
+ resp.cache = chunk;
576
+ return cb(null);
577
+ }
578
+ /* for a 'HEAD' request, pretend the size to not be known yet, if it has not been explicitly provided */
579
+ let fullContentSize = resp.contentSize;
580
+ if (fullContentSize == null && last && !this.isHead)
581
+ fullContentSize = (chunk?.byteLength ?? 0);
582
+ /* check if the content should be dynamically encoded */
583
+ if (!resp.dynamicEncode) {
584
+ this.closeHeader(resp.status, resp.headers, { media: resp.contentType, size: fullContentSize ?? undefined });
585
+ return this.sendClientWrite(resp, chunk, last, cb);
586
+ }
587
+ resp.headers['Vary'] = 'Accept-Encoding';
588
+ /* lookup the dynamic encoder (for [head] and no explicit content, default to size being valid to just
589
+ * assume an encoding - can always be disabled in the real run, should the data be too short) */
590
+ let encoding = libHelper.negotiateEncoding(this.headers['accept-encoding'] ?? null, fullContentSize ?? chunk?.byteLength ?? null, resp.contentType);
591
+ if (encoding == null) {
592
+ this.closeHeader(resp.status, resp.headers, { media: resp.contentType, size: fullContentSize ?? undefined });
593
+ return this.sendClientWrite(resp, chunk, last, cb);
594
+ }
595
+ resp.headers['Content-Encoding'] = encoding.name;
596
+ resp.headers['Accept-Ranges'] = 'none';
597
+ /* for HEAD, clear the content size, as it is not known through the encoder, and for real
598
+ * requests, check if the header can be encoded inplace (update the content size, as it is now
599
+ * exact but differs due to being encoded) and otherwise configure the encoding pipeline */
600
+ if (this.isHead)
601
+ fullContentSize = null;
602
+ else if (last) {
603
+ chunk = encoding.encodeBuffer(chunk);
604
+ resp.contentSize = chunk.byteLength;
605
+ fullContentSize = chunk.byteLength;
606
+ }
607
+ else {
608
+ fullContentSize = null;
609
+ const encoder = encoding.makeEncode();
610
+ encoder.pipe(resp.writer);
611
+ resp.writer = encoder;
612
+ encoder.once('error', (err) => {
613
+ resp.encodingFailed = true;
614
+ resp.destroy(err);
615
+ });
616
+ }
617
+ this.closeHeader(resp.status, resp.headers, { media: resp.contentType, size: fullContentSize ?? undefined, encoding: encoding.name });
618
+ return this.sendClientWrite(resp, chunk, last, cb);
619
+ }
620
+ sendClientWrite(resp, chunk, last, cb) {
621
+ /* check if this is a head write, in which case the response can
622
+ * just be marked as completed, and all other data can be drained */
623
+ if (this.isHead) {
624
+ if (this._state.response != ResponseState.headerSent)
625
+ return cb(null);
626
+ this._state.response = ResponseState.completed;
627
+ resp.responseCompleted = true;
628
+ resp.writer.end(() => cb(null));
629
+ return;
630
+ }
631
+ /* update the total-sent counter and check if the upper-bound is broken */
632
+ if (chunk != null) {
633
+ this.updateThroughput(chunk.byteLength);
634
+ resp.totalSent += chunk.byteLength;
635
+ if (resp.contentSize != null && resp.totalSent > resp.contentSize) {
636
+ this.badClientUsage('Sent more data than promised', false);
637
+ return cb(new Error('Sent more data than promised'));
638
+ }
639
+ }
640
+ /* check if this is an intermediate write, and write the data out */
641
+ if (!last) {
642
+ resp.writer.write(chunk, () => cb(null));
643
+ return;
644
+ }
645
+ /* check if all expected data have been provided */
646
+ if (resp.contentSize != null && resp.totalSent < resp.contentSize) {
647
+ this.badClientUsage('Sent fewer data than promised', false);
648
+ return cb(new Error('Sent fewer data than promised'));
649
+ }
650
+ /* mark the state as completed and sent the last package */
651
+ this._state.response = ResponseState.completed;
652
+ resp.responseCompleted = true;
653
+ if (chunk != null)
654
+ resp.writer.end(chunk, () => cb(null));
655
+ else
656
+ resp.writer.end(() => cb(null));
657
+ }
658
+ sendClientData(status, media, headers, dynamicEncode, contentSize) {
659
+ const makeErrorStream = (msg) => new libStream.Writable({ write(_0, _1, cb) { cb(new Error(msg)); }, final(cb) { cb(new Error(msg)); } });
660
+ /* check if the object is already responded */
661
+ if (this._state.response == ResponseState.broken)
662
+ return makeErrorStream('Connection broken');
663
+ if (this._state.response != ResponseState.none) {
664
+ this.badClientUsage('Response on already claimed connection', false);
665
+ return makeErrorStream('Connection already responded');
666
+ }
667
+ this._state.response = ResponseState.acknowledged;
668
+ /* construct the actual response wrapper, which takes care of dynamic encoding and error handling */
669
+ const output = new HttpRequestResponse(this._native.writer, status, headers, contentSize, media, dynamicEncode, (chunk, cb) => {
670
+ if (output.destroyed)
671
+ return cb(new Error('Already failed'));
672
+ /* check if the connection has been marked as failed or completed */
673
+ if (this._state.response == ResponseState.completed)
674
+ return cb(new Error('Responding to completed response'));
675
+ if (this._state.response == ResponseState.broken)
676
+ return cb(new Error('Connection broken'));
677
+ /* handle the data accordingly and check for any errors due to malformed headers */
678
+ try {
679
+ if (this._state.response == ResponseState.headerSent)
680
+ return this.sendClientWrite(output, chunk, chunk == null, cb);
681
+ this.sendClientSetupHeader(output, chunk, cb);
682
+ }
683
+ catch (err) {
684
+ this.markAsBroken(`Failed to process response: ${err.message}`, false);
685
+ return cb(new Error('Connection broken'));
686
+ }
687
+ }, (err, cb) => {
688
+ /* check if the response was already completed, in which case the error must
689
+ * be ignored, as the encoder might still contain buffered data to be sent */
690
+ if (output.responseCompleted)
691
+ return cb(err);
692
+ output.responseCompleted = true;
693
+ /* check if the output stream was an encoder, in which case it can be destroyed */
694
+ if (output.writer !== this._native.writer)
695
+ output.writer.destroy();
696
+ /* check if the error originated from the data sender and ensure the connection is closed
697
+ * (cannot be acknowledged for failed encodings, as they can first trigger on already header-sent) */
698
+ if (this._state.response != ResponseState.broken) {
699
+ const description = `${output.encodingFailed ? 'Encoding failure' : 'Response closed prematurely'}: ${err.message}`;
700
+ const closing = (this._state.response == ResponseState.acknowledged);
701
+ if (closing) {
702
+ if (output.encodingFailed)
703
+ this.respondInternalError(description, { headers: { 'Connection': 'close' } });
704
+ else
705
+ this.badClientUsage(description, true);
706
+ }
707
+ this.markAsBroken((closing ? '' : description), closing);
708
+ }
709
+ return cb(err);
710
+ });
711
+ /* register the broken handler to detect closed or failed connections */
712
+ this._state.breakPromise.then(() => output.destroy(new Error('Connection broken')));
713
+ return output;
714
+ }
715
+ receiveClientData(maxLength) {
716
+ const makeErrorStream = (msg) => new libStream.Readable({ read() { this.destroy(new Error(msg)); } });
717
+ /* check if the object is ready for receiving */
718
+ if (this._state.receive != ReceiveState.none) {
719
+ this.badClientUsage('Already receiving data', false);
720
+ return makeErrorStream('Connection is already being received');
721
+ }
722
+ if (this._state.response == ResponseState.broken)
723
+ return makeErrorStream('Connection broken');
724
+ this._state.receive = ReceiveState.receiving;
725
+ /* setup the accumulation transformer (which will also be returned in the end; mark receiving
726
+ * as completed upon destroy - will automatically drain the request on cleanup) */
727
+ let accumulated = 0;
728
+ const output = new libStream.Transform({
729
+ transform: (chunk, _, cb) => {
730
+ if (output.destroyed)
731
+ return cb(new Error('Already failed'));
732
+ /* check if the connection has been processed or marked as failed */
733
+ if (this._state.response == ResponseState.completed)
734
+ this.badClientUsage('Response completed during active receive', false);
735
+ if (this._state.response == ResponseState.broken)
736
+ return cb(new Error('Connection broken'));
737
+ /* check the maximum count (is violated) */
738
+ this.updateThroughput(chunk.byteLength);
739
+ accumulated += chunk.byteLength;
740
+ if (maxLength == null || accumulated <= maxLength)
741
+ return cb(null, chunk);
742
+ this.respondContentTooLarge(maxLength, accumulated);
743
+ cb(new Error('Request payload is too large'));
744
+ },
745
+ destroy: (err, cb) => {
746
+ if (this._state.receive == ReceiveState.receiving) {
747
+ this._request.unpipe();
748
+ this._state.receive = ReceiveState.completed;
749
+ }
750
+ cb(err);
751
+ },
752
+ final: (cb) => {
753
+ if (this._state.receive == ReceiveState.receiving) {
754
+ this._request.unpipe();
755
+ this._state.receive = ReceiveState.completed;
756
+ }
757
+ cb();
758
+ },
759
+ });
760
+ /* check if the content is encoded and create the chain of decoders (in reverse to ensure the nesting is correct) */
761
+ let stream = this._request;
762
+ if (this.headers['content-encoding'] != null) {
763
+ const encodings = libHelper.splitAndTrimList(this.headers['content-encoding'], ',', false);
764
+ for (let i = encodings.length - 1; i >= 0; --i) {
765
+ const encoding = libHelper.lookupEncoding(encodings[i]);
766
+ if (encoding == null) {
767
+ output.destroy();
768
+ this.respondUnsupported(encodings[i], libHelper.supportedEncodingNames().join(','));
769
+ return makeErrorStream('Unsupported content encoding');
770
+ }
771
+ /* configure the piping accordingly */
772
+ const decoder = encoding.makeDecode();
773
+ stream = stream.pipe(decoder);
774
+ decoder.once('error', (err) => {
775
+ if (!output.destroyed)
776
+ this.respondBadRequest('Invalid data encoding');
777
+ output.destroy(err);
778
+ });
779
+ /* register the cleanup handler to ensure the decoder is destroyed on completion */
780
+ output.once('close', () => decoder.destroy());
781
+ }
782
+ }
783
+ /* check if too many data have been promised (cannot be trusted if content-encoding is enabled) */
784
+ else if (maxLength != null && this.headers['content-length'] != null) {
785
+ const contentSize = parseInt(this.headers['content-length']);
786
+ /* check if the length is valid and otherwise mark the request as 'consumed' */
787
+ if (!isFinite(contentSize) || contentSize < 0 || contentSize > maxLength) {
788
+ output.destroy();
789
+ this.respondContentTooLarge(maxLength, contentSize);
790
+ return makeErrorStream('Request payload is too large');
791
+ }
792
+ }
793
+ /* register the broken handler to detect closed or failed connections */
794
+ this._state.breakPromise.then(() => output.destroy(new Error('Connection broken')));
795
+ /* create the plumbing between stream and output (errors are already handled) */
796
+ return stream.pipe(output);
797
+ }
798
+ _pushTranslation(map, identity) {
799
+ let sanitized = null;
800
+ let match = null;
801
+ /* check if this is only an identity map, in which case nothing complex needs to be evaluated */
802
+ if (Object.keys(map).length == 1 && map['/'] == '/')
803
+ match = ['/', '/'];
804
+ /* create the merged reverse map and check if the map applies to the current translation */
805
+ else {
806
+ sanitized = {};
807
+ for (const [_from, _to] of Object.entries(map)) {
808
+ const from = libHelper.sanitize(_from, false);
809
+ const to = (_to == null ? null : libHelper.sanitize(_to, false));
810
+ sanitized[from] = to;
811
+ /* check if the mapping can be applied to the current path */
812
+ if (this.isSubPathOf(from) && (match == null || match[0].length < from.length))
813
+ match = [from, to];
814
+ }
815
+ if (match == null || match[1] == null)
816
+ return null;
817
+ }
818
+ const current = new ClientContext(this._path, this._translation.length, this._throughput.busyCheck.length, this._headerPatcher.length, this._htmlPatcher.length);
819
+ /* setup the new path, all path translations, and the tagged logging identity */
820
+ this._path = libHelper.rebasePath(match[0], match[1], this._path);
821
+ if (sanitized != null)
822
+ this._translation.push(sanitized);
823
+ if (identity != '') {
824
+ const update = this.tagLog(identity);
825
+ current.dropLogTag = () => update();
826
+ }
827
+ return current;
828
+ }
829
+ _restoreSnapshot(snapshot) {
830
+ this._path = snapshot.path;
831
+ this._translation.splice(snapshot.translationCount);
832
+ this._throughput.busyCheck.splice(snapshot.busyCount);
833
+ this._headerPatcher.splice(snapshot.headerPatchCount);
834
+ this._htmlPatcher.splice(snapshot.htmlPatchCount);
835
+ snapshot.dropLogTag();
836
+ }
837
+ /* instantiate a request client from a web request structure (must be followed by one finalizeConnection call under all circumstances) */
838
+ static fromRequest(protocol, request, response, options) {
839
+ const cache = (options?.cache instanceof libCache.CacheHost ? options.cache : libCache.createCache(options?.cache));
840
+ return new ClientRequest(cache, BurntClientConfig.from(options?.config), protocol, request, response);
841
+ }
842
+ /* instantiate a request client from a web socket upgrade structure (instantiates a new no-server wss if none is provided; must be followed by one finalizeConnection call under all circumstances) */
843
+ static fromUpgrade(protocol, request, socket, head, options) {
844
+ const cache = (options?.cache instanceof libCache.CacheHost ? options.cache : libCache.createCache(options?.cache));
845
+ return new ClientRequest(cache, BurntClientConfig.from(options?.config), protocol, request, { socket, head, wss: options?.wss });
846
+ }
847
+ /* finalize the connection by ensuring the response is completed and pontentially also closed (must be called once at
848
+ * the end; must have been fully processed and responded to; default responds with not-found for unhandled requests) */
849
+ async finalizeConnection() {
850
+ /* ensure the connection is default replied with not-found */
851
+ if (this._state.response == ResponseState.none)
852
+ this.respondNotFound();
853
+ /* ensure that the data have been fully received (if the response is already completed,
854
+ * silently reset to 'header-sent' to ensure the connection is properly marked as broken) */
855
+ if (this._state.receive == ReceiveState.receiving && this._state.response != ResponseState.broken) {
856
+ if (this._state.response == ResponseState.completed)
857
+ this._state.response = ResponseState.headerSent;
858
+ this.badClientUsage('Receive stream not consumed', false);
859
+ }
860
+ /* check if data remain in the pipeline, in which case the connection needs
861
+ * to be closed to ensure the sender does not pipe more data over */
862
+ const request = this._request;
863
+ if (!request.readableEnded && !request.destroyed && this._state.response != ResponseState.broken) {
864
+ const length = parseInt(this.headers['content-length'] ?? '0');
865
+ const chunked = (this.headers['transfer-encoding'] != null);
866
+ if (length != 0 || chunked)
867
+ this.markAsBroken((request.readableLength > 0 ? 'Uploaded data not consumed' : 'Potential uploaded data not consumed'), true);
868
+ }
869
+ /* check if the upgrade was not fully awaited */
870
+ if (this._state.upgrade == UpgradeState.upgrading && this._state.response != ResponseState.broken)
871
+ this.badClientUsage('Upgrade not fully awaited', false);
872
+ /* ensure that the response was properly sent */
873
+ if (this._state.response != ResponseState.completed && this._state.response != ResponseState.broken)
874
+ this.badClientUsage('Response not completed', false);
875
+ /* check if the connection was an upgrade but was not was accepted, which needs to be
876
+ * killed, as the underlying web-server will not clean this connection up anymore */
877
+ if (this._native.socket != null && this._state.upgrade != UpgradeState.upgraded && this._state.response != ResponseState.broken)
878
+ this.markAsBroken('Upgrade was not accepted', true);
879
+ /* kill the throughput timer, as it either does not need to be checked anymore, or it
880
+ * will have left the connection as broken, and will automatically be closed now */
881
+ if (this._throughput.timer != null)
882
+ clearTimeout(this._throughput.timer);
883
+ this._throughput.timer = null;
884
+ this._throughput.active = false;
885
+ /* check if the connection is broken and await its grace cleanup completion */
886
+ if (this._state.response == ResponseState.broken)
887
+ await this._state.breaking;
888
+ /* recover the original socket timeout (not for sockets, as they take care of the timeout themselves) */
889
+ if (this._state.upgrade != UpgradeState.upgraded || this._state.response != ResponseState.completed)
890
+ this._request.socket.setTimeout(this._native.timeout ?? 0);
891
+ this._state.completedResolve();
892
+ }
893
+ /* respond with an internal error and kill the connection */
894
+ killConnection(reason) {
895
+ const description = `Connection killed: ${reason}`;
896
+ const closing = (this._state.response == ResponseState.none || this._state.response == ResponseState.acknowledged);
897
+ if (closing)
898
+ this.respondInternalError(description, { headers: { 'Connection': 'close' } });
899
+ this.markAsBroken((closing ? '' : description), closing);
900
+ }
901
+ /* cache host to be used with this client */
902
+ get cache() {
903
+ return this._cache;
904
+ }
905
+ /* request has not yet been acknowledged in any way */
906
+ get unhandled() {
907
+ return (this._state.response == ResponseState.none);
908
+ }
909
+ /* request has been acknowledged or already processed */
910
+ get claimed() {
911
+ return (this._state.response != ResponseState.none);
912
+ }
913
+ /* resolves whenever the response has been determined (is broken or a response header has been sent) */
914
+ get responded() {
915
+ return this._state.respondedPromise;
916
+ }
917
+ /* resolves whenever the request has been fully processed */
918
+ get completed() {
919
+ return this._state.completedPromise;
920
+ }
921
+ /* http request headers */
922
+ get headers() {
923
+ return this._request.headers;
924
+ }
925
+ /* http request method */
926
+ get method() {
927
+ return this._request.method ?? '';
928
+ }
929
+ /* was the http request a head request */
930
+ get isHead() {
931
+ return (this._request.method == 'HEAD');
932
+ }
933
+ /* return the string formatted media-type (or empty string for no media type) */
934
+ getMediaType() {
935
+ const type = libHelper.splitAndTrimList(this.headers['content-type'] ?? null, ';', true)[0] ?? '';
936
+ return type.toLowerCase();
937
+ }
938
+ /* check the content-type for a media-type and otherwise return the default type */
939
+ getMediaTypeCharset(defEncoding) {
940
+ const type = this.headers['content-type'];
941
+ if (type == null)
942
+ return defEncoding;
943
+ /* look for the first charset entry in the content-type list */
944
+ for (const part of libHelper.splitAndTrimList(type, ';', true)) {
945
+ if (part.substring(0, 8).toLowerCase() != 'charset=')
946
+ continue;
947
+ let value = part.substring(8).trim();
948
+ /* remove the potential quotes around the charset value */
949
+ const quoted = value.startsWith('"');
950
+ if (quoted != value.endsWith('"'))
951
+ break;
952
+ if (quoted)
953
+ value = value.substring(1, value.length - 1).trim();
954
+ if (value.length == 0)
955
+ break;
956
+ return value.trim().toLowerCase();
957
+ }
958
+ return defEncoding;
959
+ }
960
+ /* ensure the media-type is one of the list and otherwise return null and auto-respond with [unsupported-media-type] (defaults to first type, if [noneIsFirst]) */
961
+ requireMediaType(types, options) {
962
+ if (!Array.isArray(types))
963
+ types = [types];
964
+ const type = this.getMediaType();
965
+ if (type == '' && options?.noneIsFirst === true)
966
+ return types[0];
967
+ for (let i = 0; i < types.length; ++i) {
968
+ if (type === types[i].mediaType)
969
+ return types[i];
970
+ }
971
+ this.respondUnsupported(type, types.map(t => t.mediaType).join(','), options);
972
+ return null;
973
+ }
974
+ /* ensure the method is one of the list and otherwise return null and auto-respond with [method-not-allowed]
975
+ * if [headExplicit] is false, method will substitute HEAD for GET, framework will consume the remaining body */
976
+ requireMethod(methods, options) {
977
+ if (!Array.isArray(methods))
978
+ methods = [methods];
979
+ if (methods.indexOf(this.method) >= 0)
980
+ return this.method;
981
+ /* check if the HEAD can be converted to a GET */
982
+ const swapAllowed = (options?.headExplicit !== true && methods.indexOf('GET') >= 0 && methods.indexOf('HEAD') < 0);
983
+ if (this.isHead && swapAllowed)
984
+ return 'GET';
985
+ const allowed = methods.join(',') + (swapAllowed ? ',HEAD' : '');
986
+ this.respondMethodNotAllowed(this.method, allowed, options);
987
+ return null;
988
+ }
989
+ /* register a callback to check if the request is still being processed (delays throughput
990
+ * termintion and resets connection timeout; will only be considered within this handler context) */
991
+ busyCheck(cb) {
992
+ this._throughput.busyCheck.push(cb);
993
+ }
994
+ /* register a callback to be invoked once the response is sent, to adjust the
995
+ * headers to be sent (will only be considered within this handler context) */
996
+ patchHeaders(cb) {
997
+ this._headerPatcher.push(cb);
998
+ }
999
+ /* register a callback to be invoked if html is built, to adjust the headers or
1000
+ * the content to be sent (will only be considered within this handler context) */
1001
+ patchHtmlPage(cb) {
1002
+ this._htmlPatcher.push(cb);
1003
+ }
1004
+ /* respond with [internal-error] and a default text response (always considered an error; reason is logged server-side only) */
1005
+ respondInternalError(reason, options) {
1006
+ this.constructQuickResponse(libBase.Status.InternalError, `Failure Reason (not sent): ${reason}`, options?.headers, {
1007
+ media: libBase.Media.Text, body: Buffer.from(`An internal server error occurred while processing the request for [${this.url.pathname}].`, 'utf-8')
1008
+ });
1009
+ }
1010
+ /* respond with [forbidden] and a default text response (reason is logged server-side only) */
1011
+ respondForbidden(reason, options) {
1012
+ this.constructQuickResponse(libBase.Status.Forbidden, `Forbidden Reason (not sent): ${reason}`, options?.headers, {
1013
+ media: libBase.Media.Text, body: Buffer.from(`Access to [${this.url.pathname}] denied.`, 'utf-8')
1014
+ });
1015
+ }
1016
+ /* respond with a any response of the given configuration (defaults to media-type: text/unknown/-, status: ok);
1017
+ * if [lightResponse], the content length is suppressed for head responses (to accomodate short-circuiting responding) */
1018
+ respond(content, options) {
1019
+ const status = options?.status ?? libBase.Status.Ok;
1020
+ if (content == null)
1021
+ return this.constructQuickResponse(status, 'no body', options?.headers, null);
1022
+ let media = options?.media ?? libBase.Media.Text;
1023
+ if (typeof content == 'string')
1024
+ content = Buffer.from(content, 'utf-8');
1025
+ else if (options?.media == null)
1026
+ media = libBase.Media.Unknown;
1027
+ this.constructQuickResponse(status, `[${media.mediaType}] and size [${content.byteLength}]`, options?.headers, {
1028
+ media, body: (options?.lightResponse && this.isHead ? undefined : content)
1029
+ });
1030
+ }
1031
+ /* respond with [ok] and either a message or a default response */
1032
+ respondOk(options) {
1033
+ this.constructQuickResponse(libBase.Status.Ok, options?.message ?? null, options?.headers, {
1034
+ media: libBase.Media.Text, body: Buffer.from(options?.message ?? `${this.method} was successful for [${this.url.pathname}].`, 'utf-8')
1035
+ });
1036
+ }
1037
+ /* respond with [created] and either a message or a default response (ensure target is properly URI encoded) */
1038
+ respondCreated(target, options) {
1039
+ const header = (options?.headers ?? {});
1040
+ header['Location'] = target;
1041
+ this.constructQuickResponse(libBase.Status.Created, target, header, {
1042
+ media: libBase.Media.Text, body: Buffer.from(`Resource [${this.url.pathname}] successfully created:\n${target}`, 'utf-8')
1043
+ });
1044
+ }
1045
+ /* respond with [not-modified] and no body (ensure the etag and/or last-modified is set) */
1046
+ respondNotModified(options) {
1047
+ const header = (options?.headers ?? {});
1048
+ if (options?.etag != null && !('ETag' in header))
1049
+ header['ETag'] = options.etag;
1050
+ if (options?.lastModified != null && !('Last-Modified' in header))
1051
+ header['Last-Modified'] = options.lastModified;
1052
+ this.constructQuickResponse(libBase.Status.NotModified, null, header, null);
1053
+ }
1054
+ /* respond with [precondition-failed] and a default text response (ensure the etag and/or last-modified is set) */
1055
+ respondPreconditionFailed(reason, options) {
1056
+ const header = (options?.headers ?? {});
1057
+ if (options?.etag != null && !('ETag' in header))
1058
+ header['ETag'] = options.etag;
1059
+ if (options?.lastModified != null && !('Last-Modified' in header))
1060
+ header['Last-Modified'] = options.lastModified;
1061
+ this.constructQuickResponse(libBase.Status.PreconditionFailed, reason, options?.headers, {
1062
+ media: libBase.Media.Text, body: Buffer.from(`Precondition for resource [${this.url.pathname}] failed:\n${reason}`, 'utf-8')
1063
+ });
1064
+ }
1065
+ /* respond with [bad-request] and a default text response */
1066
+ respondBadRequest(reason, options) {
1067
+ this.constructQuickResponse(libBase.Status.BadRequest, reason, options?.headers, {
1068
+ media: libBase.Media.Text, body: Buffer.from(`Request for [${this.url.pathname}] is perceived as malformed:\n${reason}`, 'utf-8')
1069
+ });
1070
+ }
1071
+ /* respond with [range-not-satisfiable] and a default text response */
1072
+ respondRangeIssue(range, size, options) {
1073
+ const header = (options?.headers ?? {});
1074
+ header['Content-Range'] = `bytes */${size}`;
1075
+ this.constructQuickResponse(libBase.Status.RangeIssue, `[${range}] cannot be satisfied for size [${size}]`, header, {
1076
+ media: libBase.Media.Text, body: Buffer.from(`Range [${range}] cannot be satisfied for [${this.url.pathname}] of size ${size}.`, 'utf-8')
1077
+ });
1078
+ }
1079
+ /* respond with [conflict] and a default text response */
1080
+ respondConflict(conflict, options) {
1081
+ this.constructQuickResponse(libBase.Status.Conflict, conflict, options?.headers, {
1082
+ media: libBase.Media.Text, body: Buffer.from(`Conflict for resource [${this.url.pathname}]:\n${conflict}`, 'utf-8')
1083
+ });
1084
+ }
1085
+ /* respond with [not-found] and a default text response */
1086
+ respondNotFound(options) {
1087
+ this.constructQuickResponse(libBase.Status.NotFound, null, options?.headers, {
1088
+ media: libBase.Media.Text, body: Buffer.from(`Resource [${this.url.pathname}] could not be found.`, 'utf-8')
1089
+ });
1090
+ }
1091
+ /* respond with [unsupported-media-type] and a default text response */
1092
+ respondUnsupported(used, allowed, options) {
1093
+ this.constructQuickResponse(libBase.Status.UnsupportedMediaType, `Allowed was [${allowed}] but [${used}] was used`, options?.headers, {
1094
+ media: libBase.Media.Text, body: Buffer.from(`Media type [${used}] not supported for [${this.url.pathname}].\nAllowed: ${allowed}`, 'utf-8')
1095
+ });
1096
+ }
1097
+ /* respond with [invalid-method] and a default text response */
1098
+ respondMethodNotAllowed(method, allowed, options) {
1099
+ const header = (options?.headers ?? {});
1100
+ header['Allow'] = allowed;
1101
+ this.constructQuickResponse(libBase.Status.MethodNotAllowed, `Allowed was [${allowed}] but [${method}] was used`, header, {
1102
+ media: libBase.Media.Text, body: Buffer.from(`Method ${method} not allowed for [${this.url.pathname}].\nAllowed: ${allowed}.`, 'utf-8')
1103
+ });
1104
+ }
1105
+ /* respond with [request-timeout] and a default text response */
1106
+ respondRequestTimeout(reason, options) {
1107
+ const header = (options?.headers ?? {});
1108
+ header['Connection'] = 'close';
1109
+ this.constructQuickResponse(libBase.Status.RequestTimeout, reason, header, {
1110
+ media: libBase.Media.Text, body: Buffer.from(`Request processing of [${this.url.pathname}] timed out:\n${reason}`, 'utf-8')
1111
+ });
1112
+ }
1113
+ /* respond with [content-too-large] and a default text response */
1114
+ respondContentTooLarge(allowed, atLeastProvided, options) {
1115
+ this.constructQuickResponse(libBase.Status.ContentTooLarge, `[${atLeastProvided}] > [${allowed}]`, options?.headers, {
1116
+ media: libBase.Media.Text, body: Buffer.from(`Content of at least size ${atLeastProvided} too large for [${this.url.pathname}].\nAt most ${allowed} bytes are allowed.`, 'utf-8')
1117
+ });
1118
+ }
1119
+ /* respond with [update-required] and a default text response */
1120
+ respondUpdateRequired(upgrade, options) {
1121
+ const header = (options?.headers ?? {});
1122
+ if (!('Connection' in header))
1123
+ header['Connection'] = 'upgrade';
1124
+ header['Upgrade'] = upgrade;
1125
+ this.constructQuickResponse(libBase.Status.UpgradeRequired, `Required: ${upgrade}`, header, {
1126
+ media: libBase.Media.Text, body: Buffer.from(`Endpoint [${this.url.pathname}] requires an upgrade.\nRequired: ${upgrade}`, 'utf-8')
1127
+ });
1128
+ }
1129
+ /* respond with [see-other] to the given target and a default text response (forces method GET; ensure target is properly URI encoded) */
1130
+ respondSeeOther(target, options) {
1131
+ const header = (options?.headers ?? {});
1132
+ header['Location'] = target;
1133
+ this.constructQuickResponse(libBase.Status.SeeOther, target, header, {
1134
+ media: libBase.Media.Text, body: Buffer.from(`Continue at: ${target}`, 'utf-8')
1135
+ });
1136
+ }
1137
+ /* respond with [temporary-redirect] to the given target and a default text response (preserves method; ensure target is properly URI encoded) */
1138
+ respondTemporaryRedirect(target, options) {
1139
+ const header = (options?.headers ?? {});
1140
+ header['Location'] = target;
1141
+ this.constructQuickResponse(libBase.Status.TemporaryRedirect, target, header, {
1142
+ media: libBase.Media.Text, body: Buffer.from(`Resource [${this.url.pathname}] temporarily redirects to:\n${target}`, 'utf-8')
1143
+ });
1144
+ }
1145
+ /* respond with [permanent-redirect] to the given target and a default text response (preserves method; ensure target is properly URI encoded) */
1146
+ respondPermanentRedirect(target, options) {
1147
+ const header = (options?.headers ?? {});
1148
+ header['Location'] = target;
1149
+ this.constructQuickResponse(libBase.Status.PermanentRedirect, target, header, {
1150
+ media: libBase.Media.Text, body: Buffer.from(`Resource [${this.url.pathname}] permanently redirects to:\n${target}`, 'utf-8')
1151
+ });
1152
+ }
1153
+ /* respond with html, can be built on by parent modules, sent once the request has been fully processed
1154
+ * (default status is ok; for HEAD builds, no actual content will be constructed or estimated in size)
1155
+ * automatically adds ClientConfig.responseCacheControl, if no other cache control is specified */
1156
+ async respondHtml(page, options) {
1157
+ if (this._state.response != ResponseState.none)
1158
+ return this.badClientUsage('HTML response on already claimed connection', false);
1159
+ this._state.response = ResponseState.acknowledged;
1160
+ const status = (options?.status ?? libBase.Status.Ok);
1161
+ const headers = (options?.headers ?? {});
1162
+ if (!('Cache-Control' in headers) && this.config.responseCacheControl != '')
1163
+ headers['Cache-Control'] = this.config.responseCacheControl;
1164
+ /* invoke all registered html patcher to let them modify the content (in reverse order to ensure
1165
+ * first added is last executed, and check if one of them produced an alternate response) */
1166
+ for (let i = this._htmlPatcher.length - 1; i >= 0; --i) {
1167
+ try {
1168
+ await this._htmlPatcher[i](page, status, headers);
1169
+ if (this._state.response != ResponseState.acknowledged)
1170
+ return;
1171
+ }
1172
+ catch (err) {
1173
+ this.badClientUsage(`Unhandled exception in HTML patcher: ${err.message}`, false);
1174
+ return;
1175
+ }
1176
+ }
1177
+ const content = (this.isHead ? undefined : Buffer.from(page.finalize(), 'utf-8'));
1178
+ /* mark first as completed now */
1179
+ this.log(`Responding with HTML content and status [${status.msg}]${this.isHead ? ' as light-build' : ''}`);
1180
+ this.sendFullResponse(status, headers, { media: libBase.Media.Html, body: content });
1181
+ }
1182
+ /* [no-throw but errors] send data with [media type] and [status] and return a writable stream (default: status is ok, media is unknown, dynamicEncode is true);
1183
+ * if a content size is provided, stream expects exactly this amount of bytes; if [dynamicEncode], the encoder will be dynamically negotiated
1184
+ * based on the content; for a HEAD request, no encoding will be negotiated, no lengths verified, and the written data will just be drained
1185
+ * (can immediately be ended using '.end()'); automatically adds ClientConfig.responseCacheControl, if no other cache control is specified */
1186
+ respondData(options) {
1187
+ const status = options?.status ?? libBase.Status.Ok;
1188
+ const headers = (options?.headers ?? {});
1189
+ if (!('Cache-Control' in headers) && this.config.responseCacheControl != '')
1190
+ headers['Cache-Control'] = this.config.responseCacheControl;
1191
+ this.log(`Responding with data and status [${status.msg}]`);
1192
+ return this.sendClientData(status, options?.media ?? libBase.Media.Unknown, headers, options?.dynamicEncode ?? true, options?.contentSize ?? null);
1193
+ }
1194
+ /* try to respond with the given file, return false, if the file does not exist (range aware, HEAD aware); specify [checkFreshness] to
1195
+ * re-validate the file stats on disk before serving from cache; the media type can be overwritten (defaults to extracting media-type
1196
+ * from the file-path); [encoding] describes the encoding of a pre-encoded file (warning: no checks against accepted encodings
1197
+ * performed!); status will be [Ok], [partial-content], [not-modified] or according errors cache aware and etag/last-modified aware;
1198
+ * automatically adds ClientConfig.fileCacheControl/ClientConfig.immutableCacheControl, if no other cache control is specified */
1199
+ async tryRespondFile(filePath, options) {
1200
+ if (options == null)
1201
+ options = {};
1202
+ if (this._state.response != ResponseState.none) {
1203
+ this.badClientUsage('File response on already claimed connection', false);
1204
+ return true;
1205
+ }
1206
+ /* read the entry from the cache and check if it has been permanently moved and apply the move */
1207
+ let cached = null;
1208
+ try {
1209
+ cached = this.cache.fetchImmutable(filePath, { checkFreshness: options.checkFreshness });
1210
+ if (cached == null)
1211
+ return false;
1212
+ }
1213
+ catch (err) {
1214
+ this.respondInternalError(`Failed to read file: ${err.message}`);
1215
+ return true;
1216
+ }
1217
+ if (typeof cached == 'string') {
1218
+ this.respondPermanentRedirect(cached);
1219
+ return true;
1220
+ }
1221
+ /* parse the range and ensure that its well formed */
1222
+ const range = libHelper.parseRangeHeader(this.headers.range ?? null, cached.fileSize());
1223
+ if (range.state == libHelper.RangeState.malformed) {
1224
+ this.respondBadRequest(`Issues while parsing http-header range: [${this.headers.range}]`);
1225
+ return true;
1226
+ }
1227
+ else if (range.state == libHelper.RangeState.issue) {
1228
+ this.respondRangeIssue(this.headers.range, cached.fileSize());
1229
+ return true;
1230
+ }
1231
+ /* update the cached reader to read the encoded content (no encoding if already encoded or a range request has occurred,
1232
+ * as the encoded byte representation might not be stable; this is also the reason why the e-tag must be forced to weak,
1233
+ * as the content cannot be guaranteed to be stabled across cache flushes or reloads) */
1234
+ const media = (options.media ?? libHelper.lookupMediaTypeFromFile(filePath) ?? libBase.Media.Unknown);
1235
+ let dynamicEncoder = ((options.encoded != null || range.state != libHelper.RangeState.noRange) ? null : libHelper.negotiateEncoding(this.headers['accept-encoding'] ?? null, cached.fileSize(), media));
1236
+ let reader = null;
1237
+ if (dynamicEncoder != null)
1238
+ reader = cached.encoded(dynamicEncoder);
1239
+ /* mark byte-ranges to be supported in principle and add the caching properties */
1240
+ const headers = (options.headers ?? {});
1241
+ const etag = `${(dynamicEncoder != null) ? 'W/' : ''}"${cached.uniqueId()}"`;
1242
+ headers['Vary'] = 'Accept-Encoding';
1243
+ if (dynamicEncoder != null || options.encoded != null)
1244
+ headers['Content-Encoding'] = dynamicEncoder?.name ?? options.encoded;
1245
+ headers['Accept-Ranges'] = (dynamicEncoder != null ? 'none' : 'bytes');
1246
+ headers['Last-Modified'] = cached.lastModified();
1247
+ headers['ETag'] = etag;
1248
+ if (!('Cache-Control' in headers)) {
1249
+ if (cached.isImmutable() && this.config.immutableCacheControl != '')
1250
+ headers['Cache-Control'] = this.config.immutableCacheControl;
1251
+ else if (this.config.fileCacheControl != '')
1252
+ headers['Cache-Control'] = this.config.fileCacheControl;
1253
+ }
1254
+ /* validate the conditions (e-tag more relevant than last-modified; invalid times are not
1255
+ * considered errors; no need to set etag/last-modified, as they are already set) */
1256
+ if (this.headers['if-match'] != null) {
1257
+ if (!libHelper.etagMatchesList(etag, this.headers['if-match'], true)) {
1258
+ this.respondPreconditionFailed(`New etag [${etag}]`, { headers });
1259
+ return true;
1260
+ }
1261
+ }
1262
+ else if (this.headers['if-unmodified-since'] != null) {
1263
+ const result = libHelper.timestampCompare(cached.lastModified(), this.headers['if-unmodified-since']);
1264
+ if (result != null && result > 0) {
1265
+ this.respondPreconditionFailed(`Modified at [${cached.lastModified()}]`, { headers });
1266
+ return true;
1267
+ }
1268
+ }
1269
+ /* check if the response can be skipped due to the resource not having been modified since
1270
+ * the last fetch (etag outweighs last-modified; invalid times are not considered errors) */
1271
+ if (this.headers['if-none-match'] != null) {
1272
+ if (libHelper.etagMatchesList(etag, this.headers['if-none-match'], false)) {
1273
+ this.respondNotModified({ headers });
1274
+ return true;
1275
+ }
1276
+ }
1277
+ else if (this.headers['if-modified-since'] != null) {
1278
+ const result = libHelper.timestampCompare(cached.lastModified(), this.headers['if-modified-since']);
1279
+ if (result != null && result <= 0) {
1280
+ this.respondNotModified({ headers });
1281
+ return true;
1282
+ }
1283
+ }
1284
+ /* check if the file is empty (can only happen for unused ranges, which would otherwise have issues) */
1285
+ if ((reader == null ? cached.fileSize() : reader.contentSize()) === 0) {
1286
+ this.log(`Sending empty content for [${filePath}]`);
1287
+ this.sendFullResponse(libBase.Status.Ok, headers, { media, body: Buffer.alloc(0) });
1288
+ return true;
1289
+ }
1290
+ if (range.state == libHelper.RangeState.valid)
1291
+ headers['Content-Range'] = `bytes ${range.first}-${range.last}/${cached.fileSize()}`;
1292
+ /* create the writer stream (doesn't throw, but errors; enforce the selected encoder) */
1293
+ const status = (range.state == libHelper.RangeState.noRange ? libBase.Status.Ok : libBase.Status.PartialContent);
1294
+ let stream = this.sendClientData(status, media, headers, false, (reader == null ? range.last - range.first + 1 : reader.contentSize()));
1295
+ let logMsg = `Responding with file-${this.isHead ? 'HEAD' : 'content'} [${range.first} - ${range.last}/${cached.fileSize()}] from [${filePath}]`;
1296
+ if (reader != null) {
1297
+ logMsg += ` encoded using [${dynamicEncoder.name}]`;
1298
+ if (reader.contentSize() != null)
1299
+ logMsg += ` from cache as [${reader.contentSize()}] bytes`;
1300
+ }
1301
+ this.log(logMsg);
1302
+ /* check if this is a head request, in which case the stream can just immediately be closed again, to prevent
1303
+ * the file from consuming resources (null-catch any errors to ensure they are not propagated out of the connection) */
1304
+ if (this.isHead) {
1305
+ stream.once('error', () => { });
1306
+ return new Promise((resolve) => stream.end(() => resolve(true)));
1307
+ }
1308
+ /* create the source stream of the file to read from (will not throw any exceptions) */
1309
+ let source = (reader != null ? reader.stream() : cached.stream({ start: range.first, end: range.last }));
1310
+ /* pipe the components together and await completion */
1311
+ let settled = false;
1312
+ return new Promise((resolve) => {
1313
+ source.pipe(stream);
1314
+ source.once('error', (err) => {
1315
+ if (settled)
1316
+ return;
1317
+ settled = true;
1318
+ this.respondInternalError(`Failed to stream file: ${err.message}`);
1319
+ stream.destroy(err);
1320
+ });
1321
+ stream.once('error', (err) => {
1322
+ if (settled)
1323
+ return;
1324
+ settled = true;
1325
+ source.destroy(err);
1326
+ });
1327
+ stream.once('close', () => {
1328
+ settled = true;
1329
+ resolve(true);
1330
+ });
1331
+ });
1332
+ }
1333
+ /* [throws] receive the payload of given max length and write it directly to a file; will fail
1334
+ * if the file already exists and delete the file if it could not be received in full
1335
+ * automatically responds with given exceptions if the payload cannot be received properly or file operations fail */
1336
+ async receiveToFile(path, maxLength) {
1337
+ this.trace(`Collecting data from [${this.url.pathname}] to: [${path}]`);
1338
+ return new Promise((resolve, reject) => {
1339
+ let source = this.receiveClientData(maxLength);
1340
+ /* create the stream to the file to be written and setup the plumbing */
1341
+ const destination = libFs.createWriteStream(path, { flags: 'wx' });
1342
+ let fileFailed = false, sourceFailed = false, opened = false;
1343
+ source.once('error', (err) => {
1344
+ sourceFailed = true;
1345
+ destination.destroy(err);
1346
+ });
1347
+ destination.once('open', () => {
1348
+ opened = true;
1349
+ if (!sourceFailed)
1350
+ source.pipe(destination);
1351
+ else
1352
+ destination.destroy();
1353
+ });
1354
+ destination.once('error', (err) => {
1355
+ if (!sourceFailed)
1356
+ this.respondInternalError(`Failed to write uploaded file: ${err.message}`);
1357
+ fileFailed = true;
1358
+ /* destroy the source to clean up the receiving pipeline (will
1359
+ * not close the underlying request, just the pass-through reader) */
1360
+ source.destroy();
1361
+ /* check if the file was opened and remove it */
1362
+ if (!opened)
1363
+ return reject(err);
1364
+ libFs.unlink(path, (err2) => {
1365
+ if (err2 != null)
1366
+ this.error(`Failed to remove temporary file [${path}]: ${err2.message}`);
1367
+ reject(err);
1368
+ });
1369
+ });
1370
+ destination.once('close', () => {
1371
+ if (!sourceFailed && !fileFailed)
1372
+ resolve();
1373
+ });
1374
+ });
1375
+ }
1376
+ /* [no-throw but errors] receive the payload of given max length as a readable stream
1377
+ * automatically responds with given exceptions if the payload cannot be received properly
1378
+ * automatically drained if the readable stream is destroyed before reading all data */
1379
+ receiveData(maxLength) {
1380
+ return this.receiveClientData(maxLength);
1381
+ }
1382
+ /* [throws] receive the payload of given max length as a single complete buffer
1383
+ * automatically responds with given exceptions if the payload cannot be received properly */
1384
+ async receiveAllBuffer(maxLength) {
1385
+ return new Promise((resolve, reject) => {
1386
+ let stream = this.receiveClientData(maxLength);
1387
+ const buffers = [];
1388
+ stream.on('data', (chunk) => buffers.push(chunk));
1389
+ stream.once('end', () => resolve(Buffer.concat(buffers)));
1390
+ stream.once('error', (err) => reject(err));
1391
+ });
1392
+ }
1393
+ /* [throws] receive the payload of given max length as a single complete decoded string
1394
+ * automatically responds with given exceptions if the payload cannot be received properly */
1395
+ async receiveAllText(encoding, maxLength) {
1396
+ /* wait for the buffer (let all errors propagate out) */
1397
+ const buffer = await this.receiveAllBuffer(maxLength);
1398
+ try {
1399
+ return buffer.toString(encoding);
1400
+ }
1401
+ catch (err) {
1402
+ this.respondBadRequest('Unable to decode content');
1403
+ throw err;
1404
+ }
1405
+ }
1406
+ /* marks the object as having been handled and returns a web socket or
1407
+ * automatically responds with a corresponding error and returns null */
1408
+ async acceptWebSocket() {
1409
+ if (this._state.response != ResponseState.none) {
1410
+ this.badClientUsage('WebSocket upgrade on already claimed connection', false);
1411
+ return null;
1412
+ }
1413
+ /* check if the connection is a valid upgrade request (and ensure that the underlying web-server, also detected it) */
1414
+ let connection = libHelper.splitAndTrimList(this.headers.connection?.toLowerCase() ?? null, ',', false);
1415
+ if (connection.indexOf('upgrade') == -1 || this.headers?.upgrade?.toLowerCase() != 'websocket' || this.method != 'GET') {
1416
+ this.respondUpdateRequired('websocket');
1417
+ return null;
1418
+ }
1419
+ if (this._native.socket == null) {
1420
+ this.respondInternalError('Request was not provided as upgradable');
1421
+ return null;
1422
+ }
1423
+ const native = this._native.socket;
1424
+ /* mark the connection as being accepted */
1425
+ this._state.response = ResponseState.headerSent;
1426
+ this._state.respondedResolve();
1427
+ this._state.upgrade = UpgradeState.upgrading;
1428
+ this.trace(`Performing upgrade on web socket connection: [${this.url.pathname}]`);
1429
+ /* await the actual websocket upgrade */
1430
+ const ws = await new Promise((resolve) => {
1431
+ let settled = false;
1432
+ /* register the broken listener (to detect failures of the upgrading or network errors) */
1433
+ this._state.breakPromise.then(() => {
1434
+ if (settled)
1435
+ return;
1436
+ settled = true;
1437
+ this.error('Failed to upgrade to WebSocket');
1438
+ resolve(null);
1439
+ });
1440
+ /* start the upgrade process (web-socket upgrade handler will automatically send error messages) */
1441
+ const wss = (native.wss ?? new libWs.WebSocketServer({ noServer: true, clientTracking: false }));
1442
+ wss.handleUpgrade(this._request, native.socket, native.head, (ws, _) => {
1443
+ if (!settled && this._state.response == ResponseState.headerSent) {
1444
+ settled = true, this._state.response = ResponseState.completed;
1445
+ /* ensure that the socket is valid as otherwise proper cleanup might not be guaranteed (no
1446
+ * need to log errors, as this will trigger the broken state, which will be logged) */
1447
+ if (native.socket.destroyed)
1448
+ return resolve(null);
1449
+ /* clear the socket timeout (should already have been done in the first place by the web-socket-server) */
1450
+ this._request.socket.setTimeout(0);
1451
+ return resolve(ClientSocket._fromRequest(ws, this));
1452
+ }
1453
+ settled = true;
1454
+ this.markAsBroken('Broken connection upgraded', false);
1455
+ resolve(null);
1456
+ });
1457
+ });
1458
+ this._state.upgrade = UpgradeState.upgraded;
1459
+ return ws;
1460
+ }
1461
+ }
1462
+ /*
1463
+ * WebSocket with integrated alive checks.
1464
+ * Structured WebSocket, which takes care of error handling.
1465
+ * The 'close' event is guaranteed to fire exactly once and no 'data' events will follow.
1466
+ * Takes ownership of the socket.
1467
+ */
1468
+ export class ClientSocket extends ClientBase {
1469
+ _ws;
1470
+ _alive;
1471
+ _closing;
1472
+ _emitter;
1473
+ _extension;
1474
+ constructor(ws, source) {
1475
+ super(source, 'socket', source.config);
1476
+ this._ws = ws;
1477
+ this._alive = { timer: null, isAlive: true };
1478
+ this._closing = { promise: null, closed: null, defer: 0 };
1479
+ this._emitter = new libEvents.EventEmitter();
1480
+ this._ws.on('pong', () => {
1481
+ this.trace(`Alive check pong received`, { extension: this._extension });
1482
+ this.selfIsAlive();
1483
+ });
1484
+ this._ws.on('message', (data) => {
1485
+ this.selfIsAlive();
1486
+ if (this._closing.promise != null || this._emitter.listenerCount('data') == 0)
1487
+ return;
1488
+ ++this._closing.defer;
1489
+ const buffer = (Buffer.isBuffer(data) ? data : (Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data)));
1490
+ this.emitEventSync('data', buffer);
1491
+ --this._closing.defer;
1492
+ if (this._closing.promise != null)
1493
+ this.handleClosing();
1494
+ });
1495
+ this._ws.once('close', () => {
1496
+ this.handleClosing();
1497
+ /* check if any timers remain (must be the grace-kill timer, can be stopped, as this point can
1498
+ * only be reached with an active timer, if defer was somehow still > 0, in which case its
1499
+ * nested leaving will trigger the proper cleanup, but the timer is not necessary anymore) */
1500
+ if (this._alive.timer != null)
1501
+ clearTimeout(this._alive.timer);
1502
+ this._alive.timer = null;
1503
+ });
1504
+ this._ws.once('error', (err) => {
1505
+ this.handleClosing(`WebSocket error: ${err.message}`);
1506
+ });
1507
+ /* start the first alive check (no need to consider the socket timeout, as it will have been cleared already) */
1508
+ this.selfIsAlive();
1509
+ /* perserve the log extension of the base and preserve it to be re-used for internal logs */
1510
+ source.log(`WebSocket accepted: [${this.logIdentity}]`);
1511
+ this.tagLog(source.logExtension);
1512
+ this._extension = source.logExtension;
1513
+ }
1514
+ checkIsAlive() {
1515
+ if (this._closing.promise != null)
1516
+ return;
1517
+ this._alive.timer = null;
1518
+ if (this.config.webSocketTimeout == 0)
1519
+ return;
1520
+ /* check if the connection is not alive anymore and should be killed */
1521
+ if (!this._alive.isAlive || this.config.webSocketAliveTimeout == 0)
1522
+ return this.handleClosing('Closing dead websocket');
1523
+ this._alive.isAlive = false;
1524
+ this._alive.timer = setTimeout(() => this.checkIsAlive(), this.config.webSocketAliveTimeout);
1525
+ /* try to ping the remote to check the liveliness */
1526
+ try {
1527
+ this.trace(`Sending ping to determine if connection is alive`, { extension: this._extension });
1528
+ this._ws.ping();
1529
+ }
1530
+ catch (err) {
1531
+ this.handleClosing(`WebSocket error while pinging: ${err.message}`);
1532
+ }
1533
+ }
1534
+ selfIsAlive() {
1535
+ this._alive.isAlive = true;
1536
+ if (this._closing.promise != null)
1537
+ return;
1538
+ if (this._alive.timer != null)
1539
+ clearTimeout(this._alive.timer);
1540
+ this._alive.timer = (this.config.webSocketTimeout == 0 ? null : setTimeout(() => this.checkIsAlive(), this.config.webSocketTimeout));
1541
+ }
1542
+ handleClosing(terminate) {
1543
+ /* register the initial closing to mark a closing being imminent */
1544
+ if (this._closing.promise == null) {
1545
+ this._closing.promise = new Promise((res) => this._closing.closed = res);
1546
+ /* kill the last timer (alive timer) */
1547
+ if (this._alive.timer != null)
1548
+ clearTimeout(this._alive.timer);
1549
+ this._alive.timer = null;
1550
+ /* check if a termination should be triggered and otherwise start the grace termination timer */
1551
+ if (terminate != null) {
1552
+ this.error(terminate, { extension: this._extension });
1553
+ this._ws.terminate();
1554
+ }
1555
+ else {
1556
+ this._alive.timer = setTimeout(() => {
1557
+ this._alive.timer = null;
1558
+ if (this._closing.closed != null) {
1559
+ this.error('Closing connection', { extension: this._extension });
1560
+ this._ws.terminate();
1561
+ }
1562
+ }, this.config.killGraceTimeout);
1563
+ }
1564
+ }
1565
+ if (this._closing.closed == null || this._closing.defer > 0)
1566
+ return;
1567
+ const closed = this._closing.closed;
1568
+ this._closing.closed = null;
1569
+ this.emitEventSync('close');
1570
+ this.trace('Socket connection closed', { extension: this._extension });
1571
+ closed();
1572
+ }
1573
+ static _fromRequest(ws, source) {
1574
+ return new ClientSocket(ws, source);
1575
+ }
1576
+ /* send data to the remote (ignored if connection is being closed) */
1577
+ send(data) {
1578
+ if (this._closing.promise != null)
1579
+ return;
1580
+ try {
1581
+ this._ws.send(data);
1582
+ }
1583
+ catch (err) {
1584
+ this.handleClosing(`WebSocket error while sending data: ${err.message}`);
1585
+ }
1586
+ }
1587
+ /* close the web socket (promise resolved once the close callback has been fully invoked) */
1588
+ close() {
1589
+ if (this._closing.promise == null) {
1590
+ this._ws.close();
1591
+ this.handleClosing();
1592
+ }
1593
+ return this._closing.promise;
1594
+ }
1595
+ /* -------- event handler interfaces -------- */
1596
+ on(event, listener) {
1597
+ this._emitter.on(event, listener);
1598
+ return this;
1599
+ }
1600
+ off(event, listener) {
1601
+ this._emitter.off(event, listener);
1602
+ return this;
1603
+ }
1604
+ once(event, listener) {
1605
+ this._emitter.once(event, listener);
1606
+ return this;
1607
+ }
1608
+ emitEventSync(event, ...args) {
1609
+ try {
1610
+ this._emitter.emit(event, ...args);
1611
+ }
1612
+ catch (err) {
1613
+ this.error(`Unhandled exception in ${event} listener: ${err.message}`, { extension: this._extension });
1614
+ }
1615
+ }
1616
+ }
1617
+ export class BurntClientConfig {
1618
+ serverName;
1619
+ commonHeaders;
1620
+ webSocketTimeout;
1621
+ webSocketAliveTimeout;
1622
+ killGraceTimeout;
1623
+ fileCacheControl;
1624
+ immutableCacheControl;
1625
+ responseCacheControl;
1626
+ throughputGrace;
1627
+ throughputThreshold;
1628
+ throughputWindow;
1629
+ constructor(config) {
1630
+ this.serverName = config?.serverName ?? 'Modular Web Server';
1631
+ this.commonHeaders = config?.commonHeaders ?? { 'X-Content-Type-Options': 'nosniff' };
1632
+ this.webSocketTimeout = config?.webSocketTimeout ?? 180_000;
1633
+ this.webSocketAliveTimeout = config?.webSocketAliveTimeout ?? 2_000;
1634
+ this.killGraceTimeout = config?.killGraceTimeout ?? 1_000;
1635
+ this.fileCacheControl = config?.fileCacheControl ?? 'public, max-age=600, must-revalidate';
1636
+ this.immutableCacheControl = config?.immutableCacheControl ?? 'public, max-age=2592000, immutable';
1637
+ this.responseCacheControl = config?.responseCacheControl ?? 'private, no-cache';
1638
+ this.throughputGrace = config?.throughputGrace ?? 10_000;
1639
+ this.throughputThreshold = config?.throughputThreshold ?? 1_000;
1640
+ this.throughputWindow = config?.throughputWindow ?? 30_000;
1641
+ }
1642
+ static from(config) {
1643
+ return (config instanceof BurntClientConfig ? config : new BurntClientConfig(config));
1644
+ }
1645
+ }
1646
+ //# sourceMappingURL=client.js.map