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 +25 -1
- package/package.json +1 -1
- package/src/request.js +18 -21
- package/src/response.js +44 -0
- package/src/types.d.ts +32 -3
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://
|
|
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
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
|
-
*
|
|
917
|
-
*
|
|
918
|
-
*
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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;
|