fulmine.js 5.2.0 → 5.3.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/src/types.d.ts CHANGED
@@ -1,3 +1,19 @@
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
+
1
17
  declare module "fulmine.js" {
2
18
  import e from "express";
3
19
  import uWS from "uWebSockets.js";
package/src/usage.js CHANGED
@@ -1,3 +1,19 @@
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
+
1
17
  "use strict";
2
18
 
3
19
  const acorn = require("acorn");
package/src/utils.js CHANGED
@@ -2,6 +2,8 @@
2
2
  Copyright 2024 dimden.dev
3
3
  Copyright 2026 Nigro Simone
4
4
 
5
+ This file is derived from Ultimate Express and has been modified.
6
+
5
7
  Licensed under the Apache License, Version 2.0 (the "License");
6
8
  you may not use this file except in compliance with the License.
7
9
  You may obtain a copy of the License at
@@ -26,6 +28,12 @@ const { Stats } = require("fs");
26
28
 
27
29
  const EMPTY_REGEX = new RegExp(``);
28
30
 
31
+ // what express hands qs for a query string. allowPrototypes keeps a key named "constructor" or
32
+ // "toString" instead of dropping it, and it is safe here for the reason it is safe there: the
33
+ // result below sits on a null prototype, so such a key is inert data. A caller with options of its
34
+ // own, the extended body parser, passes them and does not get these.
35
+ const QUERY_QS_OPTIONS = { allowPrototypes: true };
36
+
29
37
  /**
30
38
  * Parses a query string the way the "extended" parser is expected to, which means qs and its
31
39
  * support for nested keys, but without paying for qs on the strings that cannot need it. A short
@@ -45,12 +53,29 @@ function fastQueryParse(query, options) {
45
53
  return Object.create(null);
46
54
  }
47
55
  if (len <= 128) {
48
- if (!query.includes("[") && !query.includes("%5B") && !query.includes(".") && !query.includes("%2E")) {
56
+ // An empty name is the other place the two disagree: "=v" and "a=1&=2" are a pair under
57
+ // the empty key to fast-querystring, and nothing at all to qs. A query carrying one goes
58
+ // the slow way, which is what makes the shortcut above safe to take for the rest.
59
+ if (
60
+ !query.includes("[") &&
61
+ !query.includes("%5B") &&
62
+ !query.includes(".") &&
63
+ !query.includes("%2E") &&
64
+ query.charCodeAt(0) !== 0x3d &&
65
+ !query.includes("&=")
66
+ ) {
49
67
  // already on a bare null prototype, no copy needed, see parse-query.js
50
- return parseQuery(query);
68
+ const parsed = parseQuery(query);
69
+ // qs drops a "__proto__" key whatever allowPrototypes says, and a null prototype makes
70
+ // this one an ordinary own property rather than the setter, so reading it is a plain
71
+ // load and the encoded spelling is covered too: the name is decoded before it lands
72
+ if (parsed.__proto__ !== undefined) {
73
+ delete parsed.__proto__;
74
+ }
75
+ return parsed;
51
76
  }
52
77
  }
53
- return Object.assign(Object.create(null), qs.parse(query, options));
78
+ return Object.assign(Object.create(null), qs.parse(query, options ?? QUERY_QS_OPTIONS));
54
79
  }
55
80
 
56
81
  /**
@@ -64,6 +89,69 @@ function removeDuplicateSlashes(path) {
64
89
  return path.replace(/\/{2,}/g, "/");
65
90
  }
66
91
 
92
+ // What path-to-regexp takes as a parameter name, which is a javascript identifier: /:café and
93
+ // /:año are names to express and were a dead route here, because \w stops at ASCII. A named
94
+ // capture group accepts the same spellings, so the compiled pattern still carries the name.
95
+ const ID_START = /[$_\p{ID_Start}]/u;
96
+ // the two joiners are written as escapes on purpose: as themselves they are invisible here
97
+ const ID_CONTINUE = /[$\u200c\u200d\p{ID_Continue}]/u;
98
+
99
+ /**
100
+ * Reads a parameter name out of a pattern. Walks code points rather than code units, so a name
101
+ * starting outside the basic plane is read whole instead of as half a surrogate pair.
102
+ *
103
+ * @param {string} text the pattern, or the contents of one optional group
104
+ * @param {number} from the index just past the ":" or the "*"
105
+ * @returns {{name: string, next: number}} the name, empty when there is none, and where it ended
106
+ */
107
+ function readParamName(text, from) {
108
+ let i = from;
109
+ let name = "";
110
+ while (i < text.length) {
111
+ const char = String.fromCodePoint(/** @type {number} */ (text.codePointAt(i)));
112
+ if (!(name === "" ? ID_START : ID_CONTINUE).test(char)) {
113
+ break;
114
+ }
115
+ name += char;
116
+ i += char.length;
117
+ }
118
+ return { name, next: i };
119
+ }
120
+
121
+ /**
122
+ * Escapes a string for use inside a regular expression, as path-to-regexp escapes it.
123
+ * @param {string} str
124
+ * @returns {string}
125
+ */
126
+ function escapeRe(str) {
127
+ return str.replace(/[.+*?^${}()[\]|/\\]/g, "\\$&");
128
+ }
129
+
130
+ /**
131
+ * A character class that matches anything except what these two strings could start, which is how
132
+ * path-to-regexp stops a capture from backtracking over the literal text that follows it. Ported
133
+ * from its negate(), so the patterns compiled here agree with the ones express matches.
134
+ *
135
+ * @param {string} a
136
+ * @param {string} b
137
+ * @returns {string}
138
+ */
139
+ function negate(a, b) {
140
+ if (b.length > a.length) {
141
+ return negate(b, a);
142
+ }
143
+ if (a === b) {
144
+ b = "";
145
+ }
146
+ if (b.length > 1) {
147
+ return `(?:(?!${escapeRe(a)}|${escapeRe(b)})[^])`;
148
+ }
149
+ if (a.length > 1) {
150
+ return `(?:(?!${escapeRe(a)})[^${escapeRe(b)}])`;
151
+ }
152
+ return `[^${escapeRe(a + b)}]`;
153
+ }
154
+
67
155
  // the opening of a named capture group, which is how the parameter names are read back out of a
68
156
  // finished pattern
69
157
  const NAMED_GROUP = /\(\?<([^>]+)>/g;
@@ -73,7 +161,7 @@ const NAMED_GROUP = /\(\?<([^>]+)>/g;
73
161
  * into an array. Here and not on the regex itself, because one own property takes a RegExp off V8's
74
162
  * fast path: replace() went from 37ns to 808ns and test() from 22ns to 78ns.
75
163
  *
76
- * @typedef {{wildcardNames: string[], paramNames: string[], isWildcard: boolean[]}} PatternMeta
164
+ * @typedef {{wildcardNames: string[], paramNames: string[], outputNames: string[], isWildcard: boolean[]}} PatternMeta
77
165
  */
78
166
  const patternMeta = new WeakMap();
79
167
 
@@ -103,7 +191,7 @@ function getPatternMeta(pattern) {
103
191
  * throw: a route that quietly stops matching is worse than one that fails at startup. The names it
104
192
  * captures go in a WeakMap beside the regex, see PatternMeta.
105
193
  */
106
- function patternToRegex(pattern, isPrefix = false, caseSensitive = true) {
194
+ function patternToRegex(pattern, isPrefix = false, caseSensitive = true, strict = false) {
107
195
  if (pattern instanceof RegExp) {
108
196
  // the application's own RegExp matches as written, whatever the routing setting says,
109
197
  // which is what express does with one
@@ -117,15 +205,122 @@ function patternToRegex(pattern, isPrefix = false, caseSensitive = true) {
117
205
  let i = 0;
118
206
  const len = pattern.length;
119
207
  const wildcardNames = [];
208
+ // express takes /:a/:a, and two capture groups cannot share a name, so a repeat is compiled
209
+ // under a spelling of its own and mapped back when the parameters are read out. Reading them
210
+ // in order then leaves the last occurrence in place, which is the value express reports
211
+ const groupOutputName = new Map();
212
+ const uniqueGroupName = (name) => {
213
+ if (!groupOutputName.has(name)) {
214
+ groupOutputName.set(name, name);
215
+ return name;
216
+ }
217
+ let n = 2;
218
+ while (groupOutputName.has(name + "$" + n)) n++;
219
+ const group = name + "$" + n;
220
+ groupOutputName.set(group, name);
221
+ return group;
222
+ };
120
223
  // whether the token just emitted was a :parameter, which decides how greedy the next
121
224
  // optional group is allowed to be. see the comment where it is read
122
225
  let lastTokenWasParam = false;
226
+ // What path-to-regexp calls the wildcard backtrack: the literal text written since the last
227
+ // wildcard. Once a wildcard has eaten slashes, a later one in the same path is held to a single
228
+ // segment, or the two would divide the path between them in more than one way and the regex
229
+ // would have to backtrack to find out which. /*a/*b against /x/y/ is the case that shows it:
230
+ // express refuses it under strict routing, and a second greedy wildcard accepts it.
231
+ // the text written since the last capture of any kind, and the text written since the last
232
+ // wildcard, which are the two path-to-regexp weighs
233
+ let backtrack = "";
234
+ let wildcardBacktrack = "";
235
+ let lastCaptureWasWildcard = false;
236
+ let wildcardInSegment = false;
237
+ let paramInSegment = false;
238
+ /** Records literal text as it is emitted, which is what the rules above are written against. */
239
+ const literal = (text) => {
240
+ backtrack += text;
241
+ if (lastCaptureWasWildcard) {
242
+ wildcardBacktrack += text;
243
+ }
244
+ if (text.includes("/")) {
245
+ wildcardInSegment = false;
246
+ paramInSegment = false;
247
+ }
248
+ };
249
+ /**
250
+ * Whether a wildcard is still to come in the segment being written, which makes the parameters
251
+ * before it give ground so that it has something left to match.
252
+ *
253
+ * @param {number} from where to look from
254
+ * @returns {boolean}
255
+ */
256
+ const wildcardLaterInSegment = (from) => {
257
+ for (let j = from; j < len; j++) {
258
+ const c = pattern[j];
259
+ if (c === "\\") {
260
+ j++;
261
+ } else if (c === "/") {
262
+ return false;
263
+ } else if (c === "*") {
264
+ return true;
265
+ }
266
+ }
267
+ return false;
268
+ };
269
+ /**
270
+ * The literal text written immediately after this point, which is what the capture before it
271
+ * must not swallow.
272
+ *
273
+ * @param {number} from
274
+ * @returns {string}
275
+ */
276
+ const textAfter = (from) => {
277
+ let out = "";
278
+ for (let j = from; j < len; j++) {
279
+ const c = pattern[j];
280
+ if (c === "\\") {
281
+ out += pattern[++j] ?? "";
282
+ continue;
283
+ }
284
+ if (":*{}".includes(c)) {
285
+ break;
286
+ }
287
+ out += c;
288
+ }
289
+ return out;
290
+ };
291
+ /** A :parameter ends the run of text and stops the wildcard one from growing. */
292
+ const noteParam = () => {
293
+ backtrack = "";
294
+ lastCaptureWasWildcard = false;
295
+ paramInSegment = true;
296
+ lastTokenWasParam = true;
297
+ };
298
+ /**
299
+ * What a wildcard is allowed to match here, and the bookkeeping that goes with emitting one.
300
+ * The first wildcard of a path takes everything; one sharing a segment with an earlier wildcard
301
+ * stops at the text between them; and one in a later segment is held to a single segment.
302
+ *
303
+ * @returns {string} the body of the capture group
304
+ */
305
+ const wildcardClass = () => {
306
+ const body = wildcardInSegment
307
+ ? `${negate(backtrack, "")}+`
308
+ : wildcardBacktrack
309
+ ? `${negate(wildcardBacktrack, "")}+|${negate("/", "")}+`
310
+ : "[^]+";
311
+ backtrack = "";
312
+ wildcardBacktrack = "";
313
+ lastCaptureWasWildcard = true;
314
+ wildcardInSegment = true;
315
+ return body;
316
+ };
123
317
 
124
318
  while (i < len) {
125
319
  const ch = pattern[i];
126
320
 
127
321
  if (ch === "\\" && i + 1 < len) {
128
322
  regexPattern += "\\" + pattern[i + 1];
323
+ literal(pattern[i + 1]);
129
324
  i += 2;
130
325
  continue;
131
326
  }
@@ -134,16 +329,17 @@ function patternToRegex(pattern, isPrefix = false, caseSensitive = true) {
134
329
  // boundary, so /te*st is literal "/te" followed by a wildcard named "st"
135
330
  if (ch === "*") {
136
331
  const at = i;
137
- i++;
138
- let name = "";
139
- while (i < len && /\w/.test(pattern[i])) {
140
- name += pattern[i++];
141
- }
332
+ const splat = readParamName(pattern, i + 1);
333
+ const name = splat.name;
334
+ i = splat.next;
142
335
  if (!name) {
143
- throw new Error(`Missing parameter name at index ${at}: ${pattern}`);
336
+ throw new Error(
337
+ `Missing parameter name at index ${at + 1}: ${pattern}; visit https://git.new/pathToRegexpError for info`
338
+ );
144
339
  }
145
- wildcardNames.push(name);
146
- regexPattern += `(?<${name}>[^]+)`;
340
+ const splatGroup = uniqueGroupName(name);
341
+ wildcardNames.push(splatGroup);
342
+ regexPattern += `(?<${splatGroup}>${wildcardClass()})`;
147
343
  lastTokenWasParam = false;
148
344
  continue;
149
345
  }
@@ -160,14 +356,23 @@ function patternToRegex(pattern, isPrefix = false, caseSensitive = true) {
160
356
  if (!name) {
161
357
  throw new Error(`Wildcard must be named in Express 5: use {*splat} (in "${pattern}")`);
162
358
  }
163
- wildcardNames.push(name);
359
+ const optionalGroup = uniqueGroupName(name);
360
+ wildcardNames.push(optionalGroup);
164
361
  if (regexPattern.endsWith("/") || regexPattern.endsWith("\\/")) {
165
- // the slash belongs to the optional part, otherwise /{*splat} would not match /
362
+ // the slash is part of the alternative rather than optional on its own: a
363
+ // mount at /a/{*w} answers /a/ and /a/x, and not /a, which is what express
364
+ // compiles it to
166
365
  regexPattern = regexPattern.slice(0, regexPattern.endsWith("\\/") ? -2 : -1);
167
- regexPattern += `(?:/(?<${name}>.+))?/?`;
366
+ // under strict routing the slash is part of the alternative, so /a/{*w}
367
+ // answers /a/ and /a/x and not /a. Without it express loosens the pattern and
368
+ // allows a trailing slash instead, which is the shape the path arrives in here
369
+ regexPattern += strict
370
+ ? `(?:/(?<${optionalGroup}>${wildcardClass()})|/)`
371
+ : `(?:/(?<${optionalGroup}>${wildcardClass()}))?/?`;
168
372
  } else {
169
- regexPattern += `(?<${name}>.*)`;
373
+ regexPattern += `(?<${optionalGroup}>${wildcardClass()}|)`;
170
374
  }
375
+ lastTokenWasParam = false;
171
376
  continue;
172
377
  }
173
378
 
@@ -197,12 +402,10 @@ function patternToRegex(pattern, isPrefix = false, caseSensitive = true) {
197
402
  let gi = 0;
198
403
  while (gi < groupContent.length) {
199
404
  if (groupContent[gi] === ":") {
200
- gi++;
201
- let paramName = "";
202
- while (gi < groupContent.length && /\w/.test(groupContent[gi])) {
203
- paramName += groupContent[gi++];
204
- }
205
- groupRegex += `(?<${paramName}>${groupParamClass})`;
405
+ const inner = readParamName(groupContent, gi + 1);
406
+ const paramName = inner.name;
407
+ gi = inner.next;
408
+ groupRegex += `(?<${uniqueGroupName(paramName)}>${groupParamClass})`;
206
409
  } else if (groupContent[gi] === ".") {
207
410
  groupRegex += "\\.";
208
411
  gi++;
@@ -215,22 +418,43 @@ function patternToRegex(pattern, isPrefix = false, caseSensitive = true) {
215
418
  }
216
419
  }
217
420
  regexPattern += `(?:${groupRegex})?`;
421
+ literal(groupContent);
218
422
  lastTokenWasParam = false;
219
423
  continue;
220
424
  }
221
425
 
222
426
  if (ch === ":") {
223
- i++;
224
- let name = "";
225
- while (i < len && /\w/.test(pattern[i])) {
226
- name += pattern[i++];
227
- }
427
+ const named = readParamName(pattern, i + 1);
428
+ const name = named.name;
429
+ i = named.next;
228
430
  if (!name) {
229
- throw new Error(`Missing parameter name at index ${i - 1}: ${pattern}`);
431
+ throw new Error(
432
+ `Missing parameter name at index ${i}: ${pattern}; visit https://git.new/pathToRegexpError for info`
433
+ );
230
434
  }
231
435
  // a following optional group needs room to match, so the parameter gives ground
232
- regexPattern += i < len && pattern[i] === "{" ? `(?<${name}>[^/]+?)` : `(?<${name}>[^/]+)`;
233
- lastTokenWasParam = true;
436
+ const paramGroup = uniqueGroupName(name);
437
+ // How much of its segment a parameter may take, which path-to-regexp decides by what
438
+ // else shares the segment with it. Alone it takes everything up to the next slash; with
439
+ // a wildcard beside it, before or after, it stops at the text that separates the two,
440
+ // so the wildcard has something left to match; and a second parameter in the segment
441
+ // may also be exactly that separating text, which is the alternative below.
442
+ let head;
443
+ let alternative = "";
444
+ if (wildcardInSegment) {
445
+ head = negate("/", backtrack);
446
+ } else if (wildcardLaterInSegment(i)) {
447
+ head = negate("/", textAfter(i));
448
+ } else if (paramInSegment) {
449
+ head = negate("/", backtrack);
450
+ alternative = "|" + escapeRe(backtrack);
451
+ } else {
452
+ head = "[^/]";
453
+ }
454
+ // a following optional group needs room to match, so the parameter gives ground
455
+ const lazy = i < len && pattern[i] === "{" ? "?" : "";
456
+ regexPattern += `(?<${paramGroup}>${head}+${lazy}${alternative})`;
457
+ noteParam();
234
458
  continue;
235
459
  }
236
460
 
@@ -246,19 +470,24 @@ function patternToRegex(pattern, isPrefix = false, caseSensitive = true) {
246
470
  } else {
247
471
  regexPattern += ch;
248
472
  }
473
+ literal(ch);
249
474
  lastTokenWasParam = false;
250
475
  i++;
251
476
  }
252
477
 
253
- const regex = /** @type {PathRegExp} */ (
254
- new RegExp(`^${regexPattern}${isPrefix ? "(?=$|/)" : "$"}`, caseSensitive ? "" : "i")
255
- );
478
+ // Without strict routing express allows one trailing slash at the end of the pattern
479
+ // rather than taking it off the path, which is the only way /things and /things/ can be the
480
+ // same route while // and /things/ stay different paths. A mount ends at a segment boundary
481
+ // instead, and the slash after it belongs to what follows.
482
+ const ending = isPrefix ? "(?=$|/)" : strict ? "$" : "/?$";
483
+ const regex = /** @type {PathRegExp} */ (new RegExp(`^${regexPattern}${ending}`, caseSensitive ? "" : "i"));
256
484
  // read back out of the finished pattern, so the list cannot disagree with the regex. Asking for
257
485
  // each name in turn beats walking match.groups with for-in: 176ns against 349
258
486
  const paramNames = [...regexPattern.matchAll(NAMED_GROUP)].map((m) => m[1]);
259
487
  patternMeta.set(regex, {
260
488
  wildcardNames,
261
489
  paramNames,
490
+ outputNames: paramNames.map((name) => groupOutputName.get(name) ?? name),
262
491
  isWildcard: paramNames.map((name) => wildcardNames.includes(name))
263
492
  });
264
493
  return regex;
@@ -307,7 +536,7 @@ function canBeOptimized(pattern) {
307
536
 
308
537
  // a parameter that is the whole segment, which is the only shape µWS matches the same way Express
309
538
  // does. "/flights/:from-:to" is one segment to µWS and two parameters to Express.
310
- const WHOLE_SEGMENT_PARAM = /^:\w+$/;
539
+ const WHOLE_SEGMENT_PARAM = /^:[$_\p{ID_Start}][$\u200c\u200d\p{ID_Continue}]*$/u;
311
540
 
312
541
  /**
313
542
  * Whether µWS's own router matches this path exactly as Express would, parameters included.
@@ -704,7 +933,9 @@ function decodeParam(value) {
704
933
  try {
705
934
  return decodeURIComponent(value);
706
935
  } catch {
707
- const err = /** @type {any} */ (new Error(`Failed to decode param '${value}'`));
936
+ // a URIError, not an Error: express throws what decodeURIComponent threw, so an error
937
+ // handler written as `err instanceof URIError` has to keep working here
938
+ const err = /** @type {any} */ (new URIError(`Failed to decode param '${value}'`));
708
939
  err.status = 400;
709
940
  err.statusCode = 400;
710
941
  err.expose = true;
package/src/view.js CHANGED
@@ -2,6 +2,8 @@
2
2
  Copyright 2024 dimden.dev
3
3
  Copyright 2026 Nigro Simone
4
4
 
5
+ This file is derived from Ultimate Express and has been modified.
6
+
5
7
  Licensed under the Apache License, Version 2.0 (the "License");
6
8
  you may not use this file except in compliance with the License.
7
9
  You may obtain a copy of the License at
package/src/websocket.js CHANGED
@@ -1,3 +1,19 @@
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
+
1
17
  "use strict";
2
18
 
3
19
  const { canBeOptimizedWithParams, decodeParam, NullObject } = require("./utils.js");
package/src/worker.js CHANGED
@@ -2,6 +2,8 @@
2
2
  Copyright 2024 dimden.dev
3
3
  Copyright 2026 Nigro Simone
4
4
 
5
+ This file is derived from Ultimate Express and has been modified.
6
+
5
7
  Licensed under the Apache License, Version 2.0 (the "License");
6
8
  you may not use this file except in compliance with the License.
7
9
  You may obtain a copy of the License at