fulmine.js 5.4.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,6 +46,7 @@ shared machine, so the number would describe the machine rather than the framewo
46
46
  - [Attribution](#attribution)
47
47
  - [Difference from similar projects](#difference-from-similar-projects)
48
48
  - [Migrating](#migrating)
49
+ - [Angular SSR](#angular-ssr)
49
50
  - [When Express is somebody else's dependency](#when-express-is-somebody-elses-dependency)
50
51
  - [Docker](#docker)
51
52
  - [Differences from Express](#differences-from-express)
@@ -96,7 +97,7 @@ to run it yourself.
96
97
  Numbers produced by a project about itself deserve suspicion, so Fulmine also stands in public arenas, run by their own rigs under their own rules:
97
98
 
98
99
  - **[HttpArena](https://www.http-arena.com/#sort=rps:-1&q=Js)** (the link lands filtered on the JavaScript entries): first among the JavaScript entries, across fifteen subscribed profiles. The saved runs measure 24.3 million WebSocket echoes per second pipelined and 3.77 million one-at-a-time (past Bun's own dedicated WebSocket entry), 7.3 million pipelined HTTP requests per second, 1.12 million on the json profile with 1.04 million of that surviving TLS, 457 thousand on compressed json, and 359 thousand on the Postgres CRUD profile, within ten percent of the leading Rust and C# entries there.
99
- - **[web-frameworks](https://github.com/the-benchmarker/web-frameworks)**: entry merged, numbers arrive with their next published round.
100
+ - **[web-frameworks](https://web-frameworks-benchmark.netlify.app/result?l=javascript)**: entry merged, numbers arrive with their next published round.
100
101
 
101
102
  More to come as their maintainers take the entries in.
102
103
 
@@ -130,6 +131,28 @@ npx fulmine differences # print the list below and change nothing
130
131
  The command is installed under both `fulmine` and `fulmine.js`. Use `fulmine`: `npx` cannot run a
131
132
  command whose name ends in `.js` on Windows, where it exits without a word.
132
133
 
134
+ ### Angular SSR
135
+
136
+ The `server.ts` that `ng add @angular/ssr` generates is an ordinary Express application, so the same
137
+ one-line change applies, and `@angular/ssr`'s own `AngularNodeAppEngine` and
138
+ `writeResponseToNodeResponse` work against Fulmine's request and response unchanged. One extra step
139
+ is needed, and it is Angular's build rather than this library: the server bundle is built with
140
+ esbuild, which tries to inline every dependency and cannot load µWS's native binary. Declare the two
141
+ as external in `angular.json`:
142
+
143
+ ```json
144
+ "architect": { "build": { "options": {
145
+ "externalDependencies": ["fulmine.js", "uWebSockets.js"]
146
+ } } }
147
+ ```
148
+
149
+ What it is worth, measured on an Angular 22 application with each server reporting its own CPU per
150
+ request, nine alternating rounds: **static assets 3.29x**, and **a page served from an in-process
151
+ cache 1.90x**, but only when the cache keeps the body's ETag and length beside the bytes. A cache
152
+ that stores the bytes alone measures level with Express, because both then hash the document again
153
+ on every hit. The render itself is the same JavaScript on both sides and measures the same: on a
154
+ cache miss the framework is not what your page is waiting for.
155
+
133
156
  ### When Express is somebody else's dependency
134
157
 
135
158
  A framework built on Express does not `require("express")` in your code, it requires it in its own,
@@ -627,6 +650,7 @@ Fulmine adds three of its own:
627
650
  - ✅ res.removeHeader()
628
651
  - ✅ res.write()
629
652
  - ✅ res.writeHead()
653
+ - ✅ res.flushHeaders()
630
654
 
631
655
  ### Router
632
656
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.4.0",
3
+ "version": "5.5.0",
4
4
  "description": "Drop-in Express 5 replacement on uWebSockets.js. Your existing middleware keeps working.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/request.js CHANGED
@@ -266,9 +266,6 @@ Object.defineProperty(LazyReadableBase.prototype, "readable", {
266
266
  });
267
267
 
268
268
  module.exports = class Request extends LazyReadable {
269
- /** @type {Record<string, any>|null} */
270
- #cachedQuery = null;
271
-
272
269
  /** @type {Record<string, any>|null} */
273
270
  #cachedHeaders = null;
274
271
 
@@ -892,7 +889,6 @@ module.exports = class Request extends LazyReadable {
892
889
  this._rawQuery = queryIndex === -1 ? "" : newUrl.slice(queryIndex + 1);
893
890
  // a rewrite to "/a?" keeps its "?", as one arriving that way does
894
891
  this.urlQuery = queryIndex === -1 ? "" : "?" + this._rawQuery;
895
- this.#cachedQuery = null;
896
892
  this._originalPath = prefix + newPath;
897
893
  this.path = newPath;
898
894
  this.endsWithSlash = newPath.charCodeAt(newPath.length - 1) === 0x2f;
@@ -913,27 +909,28 @@ module.exports = class Request extends LazyReadable {
913
909
  * the parse cached and handed out as itself, the sanitised value leaked into req.query here and
914
910
  * a handler written against express read a trimmed value where express gives it the raw one.
915
911
  *
916
- * The parse itself is still done once. What is copied per read is the shallow result, which is
917
- * cheaper than express's re-parse and answers the same for everything but a write to a nested
918
- * key, which only the extended parser can produce.
912
+ * And that is why there is no cache: the fresh object comes from parsing the raw string again,
913
+ * not from copying a kept parse. As first shipped this was parse-once-copy-per-read, and the
914
+ * copy was the expensive half: Object.assign between null-prototype objects, which live in
915
+ * V8's dictionary mode, measured 638ns for a two-parameter query where parsing the same string
916
+ * measures 119ns, and on a benchmark whose every request carries such a query it cost +1.5us
917
+ * of CPU per request, which a public arena saw as -8% on its query-carrying rows. A handler
918
+ * that reads req.query once per request now pays exactly what it paid when the parse was
919
+ * cached, one parse, and a handler that reads it N times pays N parses, which is express's
920
+ * own cost shape.
919
921
  *
920
922
  * @returns {Record<string, any>}
921
923
  */
922
924
  get query() {
923
- let parsed = this.#cachedQuery;
924
- if (parsed === null) {
925
- const qp = this.app.get("query parser fn");
926
- // the vendored default already answers on a bare null prototype, so it goes out as is;
927
- // any other parser is copied onto one, which is what kept fast-querystring's result from
928
- // inspecting as "Empty <[Object: null prototype] {}>" where Express shows the bare form
929
- parsed = qp
930
- ? qp === parseQuery
931
- ? parseQuery(this._rawQuery)
932
- : Object.assign(Object.create(null), qp(this._rawQuery))
933
- : Object.create(null);
934
- this.#cachedQuery = parsed;
935
- }
936
- return Object.assign(Object.create(null), parsed);
925
+ const qp = this.app.get("query parser fn");
926
+ // the vendored default already answers on a bare null prototype, so it goes out as is;
927
+ // any other parser is copied onto one, which is what kept fast-querystring's result from
928
+ // inspecting as "Empty <[Object: null prototype] {}>" where Express shows the bare form
929
+ return qp
930
+ ? qp === parseQuery
931
+ ? parseQuery(this._rawQuery)
932
+ : Object.assign(Object.create(null), qp(this._rawQuery))
933
+ : Object.create(null);
937
934
  }
938
935
 
939
936
  /**
package/src/response.js CHANGED
@@ -658,6 +658,10 @@ module.exports = class Response extends LazyWritable {
658
658
  * @param {any} cb
659
659
  */
660
660
  _finish(data, cb) {
661
+ // read before the head is written below, which is what sets the flag: what matters further
662
+ // down is whether something had already committed the framing, a flushHeaders() or a first
663
+ // res.write(), not whether this call is about to write the head itself
664
+ const headWasAlreadyOut = this.headersSent;
661
665
  if (!this.headersSent) {
662
666
  // freshness is not decided here. node's end() knows nothing about conditional
663
667
  // requests, and Express answers 304 from send() and from sendFile(), each of
@@ -679,6 +683,18 @@ module.exports = class Response extends LazyWritable {
679
683
  this._res.endWithoutBody();
680
684
  } else if (!data && contentLength) {
681
685
  this._res.endWithoutBody(contentLength.toString());
686
+ } else if (headWasAlreadyOut && this.chunkedTransfer) {
687
+ // The head has already gone out without a length, which is what flushHeaders() and the
688
+ // first res.write() both do, so this response is committed to chunked framing and a
689
+ // length can no longer describe it. node is committed the same way: after a flush,
690
+ // res.end("body") sends a chunk, not a Content-Length. Handing the body to uWS's end()
691
+ // here would have it append a length to a head that already said otherwise, which is
692
+ // what the comparison test caught.
693
+ if (data) {
694
+ this._res.write(data);
695
+ this._sentBody = data;
696
+ }
697
+ this._res.endWithoutBody();
682
698
  } else {
683
699
  // a Buffer goes to uWS as the view it is: copying it into a fresh ArrayBuffer was
684
700
  // an allocation per body, and uWS reads the view's own offset and length
@@ -1232,6 +1248,34 @@ module.exports = class Response extends LazyWritable {
1232
1248
  return this;
1233
1249
  }
1234
1250
 
1251
+ /**
1252
+ * Sends the status line and the headers now, without waiting for a body, which is node's
1253
+ * flushHeaders(). Callers use it to let the client start on the head while the body is still
1254
+ * being produced, and one of them is `@angular/ssr`'s writeResponseToNodeResponse, which calls
1255
+ * it before streaming a rendered page.
1256
+ *
1257
+ * A second call does nothing, as node's does. Nothing is written for a response already
1258
+ * finished or aborted: uWS has let go of it by then.
1259
+ *
1260
+ * @returns {void}
1261
+ */
1262
+ flushHeaders() {
1263
+ if (this.headersSent || this.finished || this.aborted) {
1264
+ return;
1265
+ }
1266
+ this._res.cork(() => {
1267
+ this.writeHead(this.statusCode);
1268
+ // the same rule the chunked write path follows: uWS emits the 200 head itself, byte for
1269
+ // byte, so writing it again would only cost a crossing
1270
+ if (this.statusCode !== 200 || this.statusText !== undefined) {
1271
+ this._res.writeStatus(statusLine(this.statusCode, this.statusText));
1272
+ }
1273
+ // true, as the chunked path passes for a string chunk: what follows a flush is a body
1274
+ // written in pieces, and the framing has to be the one that allows them
1275
+ this.writeHeaders(true);
1276
+ });
1277
+ }
1278
+
1235
1279
  /**
1236
1280
  * Node asks this before validating a header value, and answering true keeps it permissive.
1237
1281
  * Only reached through code that goes down node's own header path.
package/src/types.d.ts CHANGED
@@ -96,10 +96,39 @@ declare module "fulmine.js" {
96
96
  }
97
97
 
98
98
  interface Fulmine extends Omit<e.Express, "listen"> {
99
+ /**
100
+ * The app is a node request handler, so it can be handed to anything that takes one. That
101
+ * is what `http.createServer(app)` does, what supertest does, and what `@angular/ssr`'s
102
+ * createNodeRequestHandler(app) does in the server.ts Angular generates.
103
+ *
104
+ * Express declares this through its own RequestHandler on the Express interface; here it
105
+ * has to be written out, because the request and response an application sees are this
106
+ * project's own and node's shapes only arrive through the shim. It has always worked at
107
+ * runtime and the type did not say so, which compiled fine in JavaScript and stopped a
108
+ * TypeScript consumer at the first line that passed the app anywhere.
109
+ */
110
+ (
111
+ req: import("http").IncomingMessage,
112
+ res: import("http").ServerResponse,
113
+ next?: (err?: unknown) => void
114
+ ): void | Promise<void>;
115
+
99
116
  readonly uwsApp: uWS.TemplatedApp;
100
- listen(port: number, callback?: (token: any) => void): FulmineServer;
101
- listen(port: number, host: string, callback?: (token: any) => void): FulmineServer;
102
- listen(callback: (token: any) => void): FulmineServer;
117
+
118
+ /**
119
+ * Binds, and calls back the way Express 5 does: with nothing when the socket is listening,
120
+ * and with the error when the bind failed, since Express registers the listen callback on
121
+ * 'error' as well as on 'listening'. `this` inside it is the app, which is what listen
122
+ * returns here and what Express's http.Server is there.
123
+ *
124
+ * A string port is accepted and is what `process.env.PORT` gives you: numeric strings are
125
+ * bound as ports, anything else is taken as a unix socket path and bound through µWS's
126
+ * listen_unix. The four shapes below are node's own.
127
+ */
128
+ listen(callback?: (error?: Error) => void): FulmineServer;
129
+ listen(port: number | string, callback?: (error?: Error) => void): FulmineServer;
130
+ listen(port: number | string, host: string, callback?: (error?: Error) => void): FulmineServer;
131
+ listen(port: number | string, host: string, backlog: number, callback?: (error?: Error) => void): FulmineServer;
103
132
  ws(path: string, behavior: WebSocketBehavior): this;
104
133
  publish(topic: string, message: string | ArrayBuffer | Buffer, isBinary?: boolean, compress?: boolean): boolean;
105
134
  numSubscribers(topic: string): number;