fulmine.js 5.5.1 → 5.6.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.
@@ -0,0 +1,400 @@
1
+ /*
2
+ Copyright 2026 Nigro Simone
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ // express.compression(), which answers with a compressed body when the client asked for one.
18
+ //
19
+ // The options, the defaults and the order the decision is taken in are the compression module's,
20
+ // so a front that already uses it can drop the require and change nothing else. Two things are
21
+ // different, and both only ever turn a worse answer into a better one:
22
+ //
23
+ // - a response that arrives whole, which is every res.send() and res.json(), is compressed in
24
+ // one call instead of through a transform stream, and goes out with a Content-Length. The
25
+ // bytes are the same bytes: zlib.gzipSync and a createGzip that receives the same body in one
26
+ // write produce the same deflate output.
27
+ // - partial content is left alone. The compression module compresses a 206 as well, and the
28
+ // result is a byte range of the file described as gzip, which no client can decode.
29
+ //
30
+ // The streaming half is the module's own design, because it is the right one: a transform stream,
31
+ // its output written as it comes, and the drain listeners moved onto it so a pipe that fills up
32
+ // hears from the compressor rather than from a socket that is no longer what it is waiting for.
33
+
34
+ "use strict";
35
+
36
+ const zlib = require("zlib");
37
+ const bytes = require("bytes");
38
+ const compressible = require("compressible");
39
+ const { negotiateEncoding, ENCODING_ANY } = require("./utils.js");
40
+
41
+ // Cache-Control: no-transform forbids recoding the body, which is what this does
42
+ const NO_TRANSFORM = /(?:^|,)\s*?no-transform\s*?(?:,|$)/;
43
+
44
+ // what enforceEncoding is allowed to name, the compression module's list
45
+ const ENFORCEABLE = new Set(["gzip", "deflate", "identity", "br"]);
46
+
47
+ // Up to this many bytes a whole body is compressed on this thread, and above it on the libuv pool.
48
+ // One call either way; what changes is who waits. A small body pays more for the hop onto the pool
49
+ // than the compression costs, and a large one is worth handing over, since the pool has four
50
+ // threads and the loop has everyone else to serve: measured with gzip at the default level, sync
51
+ // wins by 43% at 1.4KB and by 22% at 16KB, and loses by 32% at 32KB and by 90% at 78KB.
52
+ const SYNC_LIMIT = 24 * 1024;
53
+
54
+ /**
55
+ * The default filter: whether the content type is worth compressing at all. A response with no
56
+ * type is left alone, since nothing says what its bytes are.
57
+ *
58
+ * @param {any} req
59
+ * @param {any} res
60
+ * @returns {boolean}
61
+ */
62
+ function shouldCompress(req, res) {
63
+ const type = res.getHeader("Content-Type");
64
+ if (type === undefined || !compressible(String(type))) {
65
+ return false;
66
+ }
67
+ return true;
68
+ }
69
+
70
+ /**
71
+ * How many bytes a chunk is, which is what the threshold is compared against.
72
+ *
73
+ * @param {any} chunk
74
+ * @param {BufferEncoding} [encoding]
75
+ * @returns {number}
76
+ */
77
+ function chunkLength(chunk, encoding) {
78
+ if (chunk === undefined || chunk === null) {
79
+ return 0;
80
+ }
81
+ return Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk, encoding);
82
+ }
83
+
84
+ /**
85
+ * The bytes of a chunk, whatever it arrived as.
86
+ *
87
+ * @param {any} chunk
88
+ * @param {BufferEncoding} [encoding]
89
+ * @returns {Buffer}
90
+ */
91
+ function toBuffer(chunk, encoding) {
92
+ if (Buffer.isBuffer(chunk)) {
93
+ return chunk;
94
+ }
95
+ // end() with nothing to send still has to hand the compressor something, and a threshold of 0
96
+ // lets an empty body reach it: Buffer.from(undefined) throws where this sends the empty answer
97
+ if (chunk === undefined || chunk === null) {
98
+ return Buffer.alloc(0);
99
+ }
100
+ return Buffer.from(chunk, encoding);
101
+ }
102
+
103
+ /**
104
+ * Compresses a response body as the client asked for it.
105
+ *
106
+ * @param {object} [options]
107
+ * @param {number|string} [options.threshold] the smallest body worth compressing, bytes or "1kb".
108
+ * Default 1024. A response whose size is not known in advance is compressed whatever its size.
109
+ * @param {(req: any, res: any) => boolean} [options.filter] whether this response should be
110
+ * compressed at all. The default says yes to any compressible content type.
111
+ * @param {string} [options.enforceEncoding] what to use when the request carries no
112
+ * Accept-Encoding at all. Default "identity", which is to say nothing is compressed.
113
+ * @param {object} [options.brotli] brotli options, `params` included. The default quality is 4.
114
+ * @param {number} [options.level] zlib compression level, for gzip and deflate.
115
+ * @param {number} [options.chunkSize] zlib chunk size.
116
+ * @param {number} [options.memLevel] zlib memory level.
117
+ * @param {number} [options.strategy] zlib strategy.
118
+ * @param {number} [options.windowBits] zlib window size.
119
+ * @returns {(req: any, res: any, next: (err?: any) => void) => void} the middleware
120
+ */
121
+ function compression(options) {
122
+ const opts = options || {};
123
+ // the whole bag goes to zlib, as the compression module does: level, memLevel, strategy,
124
+ // windowBits and chunkSize arrive under their own names and zlib ignores the rest
125
+ const zlibOptions = /** @type {any} */ (opts);
126
+ const brotliOptions = { ...opts.brotli };
127
+ brotliOptions.params = {
128
+ [zlib.constants.BROTLI_PARAM_QUALITY]: 4,
129
+ ...(opts.brotli && /** @type {any} */ (opts.brotli).params)
130
+ };
131
+ const filter = opts.filter || shouldCompress;
132
+ const enforceEncoding = opts.enforceEncoding || "identity";
133
+ // bytes.parse reads "1kb" and hands back null for anything it cannot, an absent option
134
+ // included, which is where the default comes in
135
+ const threshold = bytes.parse(/** @type {any} */ (opts.threshold)) ?? 1024;
136
+
137
+ /**
138
+ * A whole body, compressed on this thread. Blocks the event loop for as long as it takes,
139
+ * which is why only a small one comes here, see SYNC_LIMIT.
140
+ *
141
+ * @param {string} method
142
+ * @param {Buffer} body
143
+ * @returns {Buffer}
144
+ */
145
+ function compressWhole(method, body) {
146
+ if (method === "gzip") {
147
+ return zlib.gzipSync(body, zlibOptions);
148
+ }
149
+ if (method === "br") {
150
+ return zlib.brotliCompressSync(body, brotliOptions);
151
+ }
152
+ return zlib.deflateSync(body, zlibOptions);
153
+ }
154
+
155
+ /**
156
+ * The same, on the libuv thread pool.
157
+ *
158
+ * @param {string} method
159
+ * @param {Buffer} body
160
+ * @param {(err: Error|null, out: Buffer) => void} done
161
+ */
162
+ function compressWholeAsync(method, body, done) {
163
+ if (method === "gzip") {
164
+ zlib.gzip(body, zlibOptions, done);
165
+ } else if (method === "br") {
166
+ zlib.brotliCompress(body, brotliOptions, done);
167
+ } else {
168
+ zlib.deflate(body, zlibOptions, done);
169
+ }
170
+ }
171
+
172
+ /**
173
+ * @param {string} method
174
+ * @returns {any} the transform stream for a body that arrives in pieces
175
+ */
176
+ function compressStream(method) {
177
+ if (method === "gzip") {
178
+ return zlib.createGzip(zlibOptions);
179
+ }
180
+ if (method === "br") {
181
+ return zlib.createBrotliCompress(brotliOptions);
182
+ }
183
+ return zlib.createDeflate(zlibOptions);
184
+ }
185
+
186
+ return function compression(req, res, next) {
187
+ const _write = res.write;
188
+ const _end = res.end;
189
+ const _on = res.on;
190
+
191
+ /** drain listeners parked until there is a compressor to hang them on, see res.on below */
192
+ let listeners = /** @type {any[][]|null} */ ([]);
193
+ /** @type {any} */
194
+ let stream = null;
195
+ let decided = false;
196
+ let ended = false;
197
+ /** what end() was given to call back, held until the compressor has finished */
198
+ let endCallback = /** @type {any} */ (undefined);
199
+
200
+ // the compression module adds this, and code written against it calls it: an SSE feed
201
+ // pushes its event out with res.flush(). Nothing to flush before there is a compressor
202
+ res.flush = function flush() {
203
+ if (stream) {
204
+ stream.flush();
205
+ }
206
+ };
207
+
208
+ /**
209
+ * Hands back the parked drain listeners: this response is not being compressed, so the
210
+ * response itself is what a pipe should hear from.
211
+ * @returns {string} the empty method, so the callers can `return noCompress()`
212
+ */
213
+ function noCompress() {
214
+ if (listeners) {
215
+ for (const listener of listeners) {
216
+ _on.call(res, listener[0], listener[1]);
217
+ }
218
+ listeners = null;
219
+ }
220
+ return "";
221
+ }
222
+
223
+ /**
224
+ * Whether this response is compressed, and how. Taken once, when the first byte of the
225
+ * body arrives, which is also when the headers are decided: everything read here is set
226
+ * by then. The order is the compression module's, and so is the Vary, which is added even
227
+ * when the answer goes out uncompressed because the answer still depends on the header.
228
+ *
229
+ * @param {number} [length] the size of the body, when end() already has all of it
230
+ * @returns {string} the encoding chosen, "" to send the body as it is
231
+ */
232
+ function decide(length) {
233
+ decided = true;
234
+ // res.flushHeaders() commits the head here rather than holding it until the body, so a
235
+ // response that used it has no room left for a Content-Encoding
236
+ if (res.headersSent) {
237
+ return noCompress();
238
+ }
239
+ if (!filter(req, res)) {
240
+ return noCompress();
241
+ }
242
+ const cacheControl = res.getHeader("Cache-Control");
243
+ if (cacheControl && NO_TRANSFORM.test(String(cacheControl))) {
244
+ return noCompress();
245
+ }
246
+ res.vary("Accept-Encoding");
247
+ // NaN when there is no Content-Length, and a comparison against NaN is false: a body
248
+ // whose size is not known yet is compressed whatever the threshold says
249
+ if (Number(res.getHeader("Content-Length")) < threshold || Number(length) < threshold) {
250
+ return noCompress();
251
+ }
252
+ const already = res.getHeader("Content-Encoding");
253
+ if (already && already !== "identity") {
254
+ return noCompress();
255
+ }
256
+ if (req.method === "HEAD") {
257
+ return noCompress();
258
+ }
259
+ // a range is a window into the bytes on disk, and a client that asked for one cannot
260
+ // decode a compressed answer to it
261
+ if (res.statusCode === 206 || res.getHeader("Content-Range") !== undefined) {
262
+ return noCompress();
263
+ }
264
+ const accept = req.headers["accept-encoding"];
265
+ let method = negotiateEncoding(accept === undefined ? "" : accept, ENCODING_ANY);
266
+ if (accept === undefined && ENFORCEABLE.has(enforceEncoding)) {
267
+ method = enforceEncoding;
268
+ }
269
+ if (!method || method === "identity") {
270
+ return noCompress();
271
+ }
272
+ res.setHeader("Content-Encoding", method);
273
+ // what it says is the size of the body before this middleware saw it. The whole-body
274
+ // path below puts the right one back; the streaming one cannot know it in advance
275
+ res.removeHeader("Content-Length");
276
+ return method;
277
+ }
278
+
279
+ /**
280
+ * Starts the compressor for a body that arrives in pieces, and wires it to the response.
281
+ * @param {string} method
282
+ */
283
+ function startStream(method) {
284
+ stream = compressStream(method);
285
+ // The parked listeners, and the list itself stays rather than being emptied: res.on
286
+ // reads it to know that a drain listener belongs on the compressor from here on. That
287
+ // matters because a pipe registers its own the first time write() tells it to slow
288
+ // down, which is after this, and from here on the compressor is what fills up.
289
+ for (const listener of /** @type {any[][]} */ (listeners)) {
290
+ stream.on(listener[0], listener[1]);
291
+ }
292
+ stream.on("data", (chunk) => {
293
+ if (_write.call(res, chunk) === false) {
294
+ stream.pause();
295
+ }
296
+ });
297
+ stream.on("end", () => {
298
+ _end.call(res, endCallback);
299
+ });
300
+ _on.call(res, "drain", () => stream.resume());
301
+ // an aborted response never reaches the end of the stream, and the zlib context behind
302
+ // it is native memory that a garbage collector is in no hurry to reach
303
+ _on.call(res, "close", () => stream.destroy());
304
+ }
305
+
306
+ res.write = function write(chunk, encoding, callback) {
307
+ if (typeof encoding === "function") {
308
+ callback = encoding;
309
+ encoding = undefined;
310
+ }
311
+ if (ended) {
312
+ return false;
313
+ }
314
+ if (!decided) {
315
+ const method = decide();
316
+ if (method) {
317
+ startStream(method);
318
+ }
319
+ }
320
+ if (stream) {
321
+ return stream.write(toBuffer(chunk, encoding), callback);
322
+ }
323
+ return _write.call(this, chunk, encoding, callback);
324
+ };
325
+
326
+ res.end = function end(chunk, encoding, callback) {
327
+ // node's shapes, of which this project's own end() takes (data, cb): the third
328
+ // argument only arrives from code written against node's ServerResponse
329
+ if (typeof chunk === "function") {
330
+ callback = chunk;
331
+ chunk = undefined;
332
+ encoding = undefined;
333
+ } else if (typeof encoding === "function") {
334
+ callback = encoding;
335
+ encoding = undefined;
336
+ }
337
+ if (ended) {
338
+ return this;
339
+ }
340
+ if (stream) {
341
+ ended = true;
342
+ endCallback = callback;
343
+ if (chunk === undefined || chunk === null || chunk === "") {
344
+ stream.end();
345
+ } else {
346
+ stream.end(toBuffer(chunk, encoding));
347
+ }
348
+ return this;
349
+ }
350
+ if (!decided) {
351
+ const method = decide(chunkLength(chunk, encoding));
352
+ if (method) {
353
+ // the whole answer is here, so it is compressed in one call rather than
354
+ // through a stream, and goes out with the length it ended up being
355
+ ended = true;
356
+ const input = toBuffer(chunk, encoding);
357
+ if (input.length <= SYNC_LIMIT) {
358
+ const body = compressWhole(method, input);
359
+ res.setHeader("Content-Length", String(body.length));
360
+ return _end.call(this, body, callback);
361
+ }
362
+ compressWholeAsync(method, input, (err, body) => {
363
+ // the client can leave while the pool is working, and writing to a
364
+ // response that is already gone is not something uWS survives
365
+ if (res.aborted || res.finished) {
366
+ return;
367
+ }
368
+ if (err) {
369
+ return res.destroy(err);
370
+ }
371
+ res.setHeader("Content-Length", String(body.length));
372
+ _end.call(res, body, callback);
373
+ });
374
+ return this;
375
+ }
376
+ }
377
+ ended = true;
378
+ return _end.call(this, chunk, callback);
379
+ };
380
+
381
+ res.on = function on(type, listener) {
382
+ if (!listeners || type !== "drain") {
383
+ return _on.call(this, type, listener);
384
+ }
385
+ if (stream) {
386
+ return stream.on(type, listener);
387
+ }
388
+ // there is nothing to listen to yet: a compressor that does not exist has not filled up
389
+ listeners.push([type, listener]);
390
+ return this;
391
+ };
392
+
393
+ next();
394
+ };
395
+ }
396
+
397
+ module.exports = compression;
398
+ // the compression module exports its default filter, and a front that wants to compress one more
399
+ // type than the default calls it and adds to what it says
400
+ module.exports.filter = shouldCompress;
package/src/index.js CHANGED
@@ -49,6 +49,7 @@ try {
49
49
  * response: object,
50
50
  * application: object,
51
51
  * static: Function,
52
+ * compression: Function,
52
53
  * json: Function,
53
54
  * urlencoded: Function,
54
55
  * text: Function,
@@ -72,6 +73,9 @@ module.exports.response = Response.prototype;
72
73
  module.exports.application = Application.Application.prototype;
73
74
 
74
75
  module.exports.static = middlewares.static;
76
+ // not one of express's, since express has none: the compression module is what everyone installs
77
+ // instead, and this is that middleware's options and behaviour without the install
78
+ module.exports.compression = require("./compression.js");
75
79
  module.exports.json = middlewares.json;
76
80
  module.exports.urlencoded = middlewares.urlencoded;
77
81
  module.exports.text = middlewares.text;
@@ -22,11 +22,22 @@ const path = require("path");
22
22
  const bytes = require("bytes");
23
23
  const zlib = require("fast-zlib");
24
24
  const typeis = require("type-is");
25
+ const mime = require("mime-types");
25
26
  const qs = require("qs");
26
27
  const parseQuery = require("./parse-query.js");
27
28
  const { kGetSafe } = require("./usage.js");
28
29
  const { AsyncResource } = require("async_hooks");
29
- const { fastQueryParse, NullObject, asStatError, httpError, memoizeByString, containsDotFile } = require("./utils.js");
30
+ const {
31
+ fastQueryParse,
32
+ NullObject,
33
+ asStatError,
34
+ httpError,
35
+ memoizeByString,
36
+ containsDotFile,
37
+ negotiateEncoding,
38
+ ENCODING_BR,
39
+ ENCODING_GZIP
40
+ } = require("./utils.js");
30
41
 
31
42
  // largest content-length we will allocate a body buffer for up front. above this the body is
32
43
  // collected chunk by chunk instead, so a declared-but-unsent body cannot pin more memory than a
@@ -36,6 +47,14 @@ const MAX_PREALLOCATED_BODY = 1024 * 1024;
36
47
  // what the finish pass feeds zlib: no bytes, only the flush flag
37
48
  const EMPTY_BUFFER = Buffer.alloc(0);
38
49
 
50
+ // What express.static serves instead of the file itself when preCompressed is on and the client
51
+ // takes it: the suffix nginx, brotli_static and every build tool that writes these agree on.
52
+ // Ordered by what is worth having, and negotiation decides between them.
53
+ const PRECOMPRESSED = [
54
+ { encoding: "br", suffix: ".br", flag: ENCODING_BR },
55
+ { encoding: "gzip", suffix: ".gz", flag: ENCODING_GZIP }
56
+ ];
57
+
39
58
  // The failures express.static answers by moving on to the next handler rather than by reporting
40
59
  // them, when fallthrough is on. They all mean the same thing: the request is not a file here.
41
60
  //
@@ -248,6 +267,46 @@ function bodyError(message, status, type, extra) {
248
267
  return Object.assign(err, extra);
249
268
  }
250
269
 
270
+ /**
271
+ * The compressed twin of a file to serve in its place, or undefined when the client would rather
272
+ * have the file itself or the twin is not there.
273
+ *
274
+ * The stat comes back with it, and is what sendFile then answers from: the ETag and the
275
+ * Last-Modified of a variant are its own, which is the whole point. Two bodies sharing one ETag is
276
+ * how a shared cache ends up handing brotli to a client that cannot read it.
277
+ *
278
+ * At most two stats, and usually one: negotiation picks the best of the two encodings first, and
279
+ * only looks at the other when the client takes it too and the first file is missing.
280
+ *
281
+ * @param {string} filePath absolute path of the file that was asked for
282
+ * @param {string|undefined} accept the request's Accept-Encoding
283
+ * @returns {{suffix: string, encoding: string, stat: import("fs").Stats}|undefined}
284
+ */
285
+ function pickPrecompressed(filePath, accept) {
286
+ if (!accept) {
287
+ return undefined;
288
+ }
289
+ let allowed = ENCODING_BR | ENCODING_GZIP;
290
+ // twice at most: the second pass is the case where brotli won and there is no .br on disk
291
+ for (let attempt = 0; attempt < 2; attempt++) {
292
+ const chosen = negotiateEncoding(accept, allowed);
293
+ const variant = PRECOMPRESSED.find((candidate) => candidate.encoding === chosen);
294
+ if (!variant) {
295
+ return undefined;
296
+ }
297
+ try {
298
+ const stat = fs.statSync(filePath + variant.suffix);
299
+ if (!stat.isDirectory()) {
300
+ return { suffix: variant.suffix, encoding: variant.encoding, stat };
301
+ }
302
+ } catch {
303
+ // not on disk, which is the ordinary case for a file nobody precompressed
304
+ }
305
+ allowed &= ~variant.flag;
306
+ }
307
+ return undefined;
308
+ }
309
+
251
310
  /**
252
311
  * express.static, which is a thin front for res.sendFile: it resolves the path, refuses anything
253
312
  * that climbs out of the root, applies the dotfiles and index rules, and hands the rest over.
@@ -337,6 +396,9 @@ function serveStatic(root, options) {
337
396
  }
338
397
  let _path = url;
339
398
  const fullpath = path.resolve(path.join(root, url));
399
+ // the same file as _path, absolute: the two move together through the index and extension
400
+ // rules below, and only the precompressed lookup needs the absolute one
401
+ let filePath = fullpath;
340
402
  // What serve-static hands send is this path, except that a bare "/" under a mount the
341
403
  // request did not write with one becomes "": without that rule a mount whose root is a file
342
404
  // would ask the disk for a directory and could never answer at all.
@@ -400,6 +462,7 @@ function serveStatic(root, options) {
400
462
  try {
401
463
  stat = fs.statSync(fullpath + "." + options.extensions[i]);
402
464
  _path = url + "." + options.extensions[i];
465
+ filePath = fullpath + "." + options.extensions[i];
403
466
  break;
404
467
  } catch (extensionError) {
405
468
  statError = extensionError;
@@ -453,6 +516,7 @@ function serveStatic(root, options) {
453
516
  try {
454
517
  stat = fs.statSync(path.join(fullpath, options.index));
455
518
  _path = path.join(url, options.index);
519
+ filePath = path.join(fullpath, options.index);
456
520
  } catch (err) {
457
521
  if (!options.fallthrough) {
458
522
  res.status(404);
@@ -473,6 +537,23 @@ function serveStatic(root, options) {
473
537
  }
474
538
  }
475
539
 
540
+ if (options.preCompressed) {
541
+ // whatever is served, the answer depended on the header, so a shared cache has to be
542
+ // told. Said before the lookup, because it is true even when there is no variant
543
+ res.vary("Accept-Encoding");
544
+ const variant = pickPrecompressed(filePath, req.headers["accept-encoding"]);
545
+ if (variant) {
546
+ _path += variant.suffix;
547
+ stat = variant.stat;
548
+ res.setHeader("Content-Encoding", variant.encoding);
549
+ // from the name of the file that was asked for, since the one being sent ends in
550
+ // .br and nothing would call that javascript. sendFile leaves a content-type that
551
+ // is already there alone, which is what makes this the deciding one
552
+ const type = mime.lookup(filePath);
553
+ res.type(type || "application/octet-stream");
554
+ }
555
+ }
556
+
476
557
  options._stat = stat;
477
558
 
478
559
  return res.sendFile(
package/src/options.d.ts CHANGED
@@ -68,6 +68,12 @@ export interface StaticOptions extends SendFileOptions {
68
68
  fallthrough?: boolean;
69
69
  /** Extensions tried when the path names no file, or false to try none. */
70
70
  extensions?: string[] | false;
71
+ /**
72
+ * Serve `file.br` or `file.gz` in place of `file` when one is on disk and the client takes it.
73
+ * Off by default. Vary: Accept-Encoding is sent whether or not a variant is found, and the
74
+ * content type stays the one the requested name implies.
75
+ */
76
+ preCompressed?: boolean;
71
77
  }
72
78
 
73
79
  /** A body parser's options once its factory has filled in every default it needs. */
package/src/request.js CHANGED
@@ -756,10 +756,6 @@ module.exports = class Request extends LazyReadable {
756
756
  * @param {() => void} [callback]
757
757
  * @returns {this}
758
758
  */
759
-
760
- /**
761
- *
762
- */
763
759
  setTimeout(msecs, callback) {
764
760
  if (typeof callback === "function") {
765
761
  this.once("timeout", callback);