Releases: Moro-JS/engine
Release list
v1.1.7
A correctness release with one transport improvement. Nothing on the wire
changes, nothing in the API is removed or renamed, and the one new
capability is additive and feature-detected through probe().capabilities.
A 1.8.x/1.9.x MoroJS keeps working on this binary; a MoroJS built for this
release keeps working on 1.1.6.
Fixed — callbacks now drain the microtask queue
Every trampoline into JS (onRequest, onRequestBatch, onAborted,
onWritable, onWsOpen, onWsMessage, onWsClose) runs inside a Node
callback scope, the same scope Node opens around its own I/O callbacks.
When the outermost scope closes, Node runs the process.nextTick queue and
drains V8's microtask queue.
Before this release the engine invoked callbacks with a plain V8 call, so a
continuation queued during dispatch — an await on an already-settled
promise, a .then chain, a framework's asynchronous not-found path — stayed
queued until some later Node-managed callback ran. On a quiet server that
was whatever timer fired next: a response sent from such a continuation
took seconds to leave (measured: 5–29 s for a 404 or a Promise.then
completion, and under load zero such responses completed). Completions that
arrived through setImmediate, timers or sockets were never affected, which
is why the bug hid behind real I/O.
capabilities.callbackScope: trueadvertises it. A framework that armed
its ownsetImmediatedrain as a workaround can skip it when this flag is
set (MoroJS does).- Cost per callback: an async-context push/pop and, with an empty queue, one
check. No measurable change in the raw hello-world numbers. - Test:
test/callback-scope.test.mjs— a response sent from a microtask, a
settledawaitand anextTickeach leave in well under a second; the
old binary fails the test by timing out.
io_uring — DEFER_TASKRUN behind a registered eventfd
The 1.1.6 measurements found io_uring halving the syscalls per request but
spending more CPU per completion than libuv, because under COOP_TASKRUN
every completion is its own task-work round trip. The ring now has two
modes (src/uring.h, "Ring modes"):
defer-taskrun(first choice): completions stay queued as local task
work until the engine's ownio_uring_enter(GETEVENTS)runs them as one
batch. Such a ring posts nothing to its fd, so the loop waits on a
registered eventfd, which the kernel signals when local work is queued —
one extrareadper wake, many completions per wake.coop-taskrun(the 1.1.6 mode): kept as the fallback, and selectable
withMORO_ENGINE_URING_TASKRUN=coopfor A/B runs
(MORO_ENGINE_URING_TASKRUN=deferpins the new one with no fallback).
The probe proves the wake behaviourally for whichever mode it selects,
exactly as it already proved the ring-fd wake, and probe().transportMode
reports 'uv', 'defer-taskrun' or 'coop-taskrun'.
Measured on the same VM as the 1.1.6 numbers (Docker Desktop, linuxkit 6.12,
arm64, server pinned to 2 cores, zrk closed-loop over keep-alive, two
rounds): defer beats coop by 13–30% in every cell with lower p99, and lands
at parity with libuv overall (ahead at 64 connections, behind at 256, split
at 512) where coop was 10–25% behind. libuv stays the default; io_uring
stays opt-in via MORO_ENGINE_TRANSPORT=uring until a run on the reference
hardware flips the go/no-go in docs/DESIGN.md.
Two follow-ups listed in the 1.1.6 design notes are closed without code:
ring-batched sends were already in place (a SEND is only prepared at issue
time; the reap loop's next enter submits the whole round), and
RECVSEND_BUNDLE cannot help a one-request-in-flight keep-alive shape.
Build
- The Linux build is warning-free from the source again: the six
(void)Function::Call(...)sites, which gcc flagged regardless of the
cast, consume the result explicitly.
Compatibility
- No wire changes. No removed or renamed exports (
tools/check-exports.mjs:
no drift). - New:
capabilities.callbackScope,probe().transportMode,
MORO_ENGINE_URING_TASKRUN. - Kernels without
DEFER_TASKRUNor whose eventfd wake the probe cannot
prove getcoop-taskrun; kernels or sandboxes without io_uring get libuv,
silently, as before. Verified: libuv, io_uring in both modes, and the
seccomp-blocked lane (test/ci/seccomp-block-io_uring.json) — 207 suite
tests each.
v1.1.6
@morojs/engine 1.1.6
The JS boundary release. Nothing on the wire changes except one header line,
nothing in the API is removed or renamed, and every new native function is
additive and feature-detected through probe().capabilities. MoroJS 1.9.0
consumes it; a 1.8.x framework keeps working on this binary and a 1.9.0
framework keeps working on 1.1.x binaries.
Performance — the boundary, not the parser
- Prepared response templates (
capabilities.responseTemplates):
prepareResponse(serverId, status, headersFlat)materialises the fixed part
of a response once;respondPrepared(reqId, tplId, body)replays it per
request. The per-request header walk (two property reads per header,
validation, hop-by-hop filtering, Content-Length parsing) is gone from the
hot path.respondPreparedEmpty,writeHeadPreparedandendWithround it
out. Static routes (setStaticRoute,capabilities.staticRoutes) are a
template plus a fixed body answered inside the engine before the request
reaches JS. - V8 fast API calls (
capabilities.fastCalls,probe().fastApi): the hot
entry points —respondPrepared,respondPreparedEmpty,writeHeadPrepared,
write,end,endWith,isAborted— carry a fast-call target. An
optimised caller (Maglev/TurboFan) reaches the engine's C++ directly, with no
FunctionCallbackInfo, no HandleScope and no argument boxing, whenever the
arguments are already machine-typed (Smi ids, a sequential one-byte body).
Everything else takes the regular callback, which does identical work. Node's
headers tarball omitsv8-fast-api-calls.h; the build fetches the exact
per-tag copy (sha256-pinned intools/build.mjs) and the targets install only
when the host V8's major.minor matches the compiled one. - Zero-copy string bodies: on Node 23+ a one-byte string body is borrowed
straight out of the V8 heap throughString::ValueView(no copy, no size
cap); Latin-1 bytes are UTF-8-encoded into a reused buffer. The Node 25/26
builds regain the malloc-free string path (the oldWriteOneBytebail-out
on V8 ≥ 14 is replaced byWriteOneByteV2). - The FIN travels with the last bytes of a
Connection: closeresponse
(macOSTCP_NOPUSHaround thesendthenshutdown, LinuxMSG_MORE+
shutdown): the peer can no longer close first and inherit the TIME_WAIT.
On macOS, where a closed port stays unusable for 30 s, a
one-connection-per-request load against the engine decayed to ~5.8k conn/s
as the client drained its ephemeral ports; it now sustains 27.5k, ahead of
Bun and uWebSockets.js on the same harness. Linux churn gains the same
guarantee. - One syscall less per accepted connection:
TCP_NODELAYis set once on
the listening socket and inherited by every accepted socket (Linux and
XNU both do;test/sockopt-unit.cppchecks the running kernel), instead of
asetsockoptper accept. Windows keeps the per-socket call. - Lingering close (RFC 9112 §9.6): after that FIN the socket stays open,
with its input discarded, until the peer's own FIN (or 2 s). A client that
writes its next request the instant a response completes, before it has
processed the FIN — autocannon does — used to have that request answered
by the kernel with a RST, which can also discard the response it had not
read yet: one error and two reconnects per connection. Now the stray
request is absorbed and the connection ends cleanly, as node:http and
uWebSockets.js already did. Lingering connections count against
maxConnectionsand are bounded by the deadline. - HTTP/1.1 keep-alive responses no longer carry
Connection: keep-alive
(persistence is the HTTP/1.1 default, RFC 9112 §9.3): 24 fewer bytes per
response. HTTP/1.0 keep-alive still affirms it; every close path is unchanged. - Batched pipelined dispatch (
capabilities.batchDispatch): a consumer
that registersonRequestBatch(count)receives complete pipelined requests
as one call overgetBatchBuffers()descriptors (reqId, method, interned
path index) and answers them in order, following a control cell the engine
advances as each response completes; one JS crossing per batch instead of
one per request, with the same byte stream as sequential dispatch
(test/batch-dispatch.test.mjs). MoroJS 1.9.0 and the raw benchmark
servers use it: +4-5% pipelined throughput on the reference box, plain
traffic unchanged (docs/DESIGN.md).MORO_ENGINE_BATCH=0turns it off. - io_uring transport on Linux, opt-in (
MORO_ENGINE_TRANSPORT=uring,
probe().transport): on Linux 6.1+ the engine can run its sockets on an
io_uring (src/uring.h, hand-rolled, no liburing): multishot accept,
multishot recv into kernel-provided buffers (parsed in place, no copy into
a read buffer), one in-flightSENDper connection,
shutdown-then-cancel-close teardown, one ring per loop thread driven from
the same libuv loop. It is probed once per process — the required setup
flags and features, an opcode probe, a provided-buffer ring, and an
epoll-wake self-test on a socketpair — and any refusal keeps libuv
silently: kernels before 6.1, gVisor, and containers under Docker's default
seccomp profile (which blocksio_uring_setup; the reason is in
probe().transportReason). The bytes on the wire are identical on both
transports (test/transport-parity.test.mjs), and both run the whole
suite, the sanitizers, and a connection-churn soak in CI. It halves the
syscalls per request (2.0 → 1.0 keep-alive, 8 → 3 per connection) but on
the reference box costs more CPU per completion wherever few completions
batch per loop turn, so libuv stays the default; the measurements and the
follow-ups that could flip that are indocs/DESIGN.mdand
docs/ROADMAP.md. - PGO release binaries: darwin, linux-gnu and linux-musl binaries are built
with clang + lld and profile-guided optimisation (tools/pgo.mjs: instrument
→ train on the engine's own workload + the test suites → merge → optimise),
verified by a strict CI lane. A profile problem never fails a build; the plain
build ships instead.
Wire-byte identity across respond(), respondPrepared() and static routes,
across statuses, header sets, ASCII/Latin-1/two-byte/Buffer bodies, GET/HEAD,
keep-alive/close/HTTP-1.0 and pipelining, is proven by test/wire-parity.test.mjs.
Behaviour changes
onAborted/onWritableare delivered on a later loop turn
(capabilities.asyncNotify), never re-entrantly from inside a
respond()/writeHead()/write()/end()call. Before, a write failure or a
responseBackpressureLimittrip insiderespond()reachedonAbortedin the
middle of that very call.isAborted(reqId)is already true inside
onAborted. The one exception:close()delivers every pendingonAborted
synchronously before it returns.MORO_ENGINE_NOTIFY=syncrestores the old
delivery for bisecting (fast calls are then disabled).- A
Connection: closeexchange ends with a half-close, not an immediate
close(): the server's FIN still goes first (with the response bytes),
but the fd is released only when the peer's FIN arrives or 2 s pass. A
client that inspects the server side will see the connection in
FIN_WAIT_2for the microseconds a normal peer takes to close; one that
never closes holds a connection slot for 2 s instead of 0. The connection
sweep now always runs (at 1 s granularity by default; it is unref'd and
never keeps the process alive), even when every timeout is set to 0. - Safe inside
worker_threads(capabilities.workerThreads): a server
still open when its thread's environment is torn down (worker.terminate(),
process.exit()inside a worker, an uncaught error) is closed by an
environment cleanup hook. Previously that aborted the whole process with
uv_loop_close() while having open handles.
Diagnostics and tooling
probe()gainsnotify,transport/transportReason,fastApi { compiled, installed, reason, compiledV8, runtimeV8 },fastCallStats(with
MORO_ENGINE_FASTCALL_STATS=1), and the capabilitiesstaticRoutes,
responseTemplates,asyncNotify,workerThreads,fastCalls,
batchDispatch.- Kill switches (diagnostics only, never required):
MORO_ENGINE_FASTCALL=0,
MORO_ENGINE_NOTIFY=sync,MORO_ENGINE_BATCH=0,MORO_ENGINE_TRANSPORT=uv; build-time
--no-fast-api;MORO_ENGINE_BINARYpoints the loader at a specific
.node(the PGO training run uses it). npm run check:exports(tools/check-exports.mjs) diffs the native surface
acrosssrc/binding.cpp,index.js,index.mjsandindex.d.ts; it runs
in CI and in the release gate.npm run test:suitesis the single list of node --test suites every CI and
release lane runs. New suites:notify-deferred,worker-threads,
templates,wire-parity,fast-api,static-routes,probe-transport,
transport-parity,batch-dispatch;npm run test:soak(connection churn with aborts and
non-readers); new C++ units:text-unit,response-template-unit,
uring-fake-unit(a fake kernel behind the ring, every OS) and
uring-unit(the real ring, Linux); a libFuzzer target for the ring.- CI: an in-repo h1spec job (33/33), a strict PGO lane, kill-switch variant
lanes, io_uring lanes on x64 and arm64 (Node 20-26), a seccomp-blocked
fallback lane, a musl-on-io_uring lane, sanitizers on both transports, and
Linux shipping lanes on clang-18 + lld. tools/dev-linux.shbuilds and tests inside a Linux container from any
host (io_uring, musl, and the seccomp fallback without a CI round trip).
Upgrading
Nothing to do. Consumers that pass null headers or call respond() see the
same bytes as before minus the keep-alive line. If ...
v1.1.5
Performance release from the 2026-08 hot-path/memory headroom audit. No API
or wire changes — every change is internal to the request path, the build,
or memory management, so this is a drop-in for 1.1.x. Headlines: +20%
pipelined throughput (new all-time peak), steady-state RSS at raw-uWS
parity, one behavior fix that brings getMethod() in line with its
published type.
Performance — request hot path
- Receive buffer: one per loop, not 64 KiB per connection. Every
connection owned a 64 KiB read buffer thatstd::string::resizehad
zero-filled — the memset made all 16 pages resident even for connections
carrying 200-byte requests, so resident set scaled O(connections) × 64 KiB.
POSIX now uses a single thread-local receive buffer shared by every
connection on the loop (safe: libuv's alloc→read is synchronous and every
consumer copies out before the next read; the WebSocket in-place unmask
completes inside the same read callback). Windows/IOCP keeps a
per-connection buffer — an overlappedWSARecvholds it across the
callback — but no longer zero-fills it. - Pipelined parsing is no longer quadratic. The parser compacted its
buffer witherase(0, consumed)after every request, memmoving the
entire unparsed backlog once per request — O(batch²) bytes moved across a
pipelined batch (~32× write amplification at depth 64). The consumed prefix
now stays in place as a dead region and compaction runs only when the
buffer is fully drained (free) or the dead prefix outgrows the live
remainder. This is the bulk of the pipelined gain. - reqId registry: zero-allocation flat map. The per-thread
reqId→connection registry was astd::unordered_mappaying a node malloc
on every insert and a free on every erase — one of each per request. It is
now a flat open-addressing map (newsrc/flat_map.h): linear probing over
parallel arrays with backward-shift deletion, no per-operation allocation,
and semantics identical to the old map including the id-wrap collision
guard. Covered by a new differential unit suite (randomized workloads
checked againststd::unordered_map, including adversarial probe-chain
clustering). - V8 boundary: malloc-free header and body encoding. The response header
block was a freshstd::stringper response and every header name, header
value, and string body crossed viaString::Utf8Value— a malloc plus two
scans each. Now: the header block is a leased thread-local buffer
(re-entrancy-safe — array getters can run JS and re-enterrespond(),
nested calls get a local); ASCII one-byte strings (names/values, and bodies
up to 64 KiB) are read withWriteOneByteinto reused buffers; and
getHeadersserves header names from a bounded per-server interned-string
cache (same pattern and lifetime as the existing path cache). Two-byte or
non-ASCII strings — and V8 ≥ 14 builds, where the write API changed shape —
take exactly the oldUtf8Valuepath. - Serialization micro-costs. The
Dateheader is cached as the complete
header line (one append, refreshed per second);Content-Lengthand
chunk-size lines use small stack writers instead ofstd::to_string/
snprintf; the corked fast path reserves once up front;
finalizeHeadersruns one pass over the headers instead of four (error
precedence byte-for-byte preserved — proven with targeted precedence
checks); the request-line kept a dead full copy of the target, now removed,
and path/query splitting reuses buffer capacity instead of move-assigning
it away; header-name lowercasing uses a lookup table; per-connection
parsers reference the server's limits struct instead of copying 160 bytes.
Performance — memory hygiene
- Idle keep-alive retention watermarks lowered 64 KiB → 16 KiB for the
response scratch, cork buffer, parsed body, and WebSocket message buffers,
so one burst of large requests/responses no longer parks ~a quarter MB on
every idle connection until it closes. The parser's input buffer keeps
its 64 KiB watermark — pipelined leftovers live there.
Fixed
getMethod()returned""for every known method. The parser only
populates the method string for unrecognized (OTHER) methods, so the
function matched itsindex.d.tscontract only for those. Known methods
now answer from the canonical table ("GET","POST", …). MoroJS was
unaffected (it resolves known methods from the method index), but direct
consumers ofgetMethod()will now see real values.
Build
- arm64 ISA floor raised to armv8.2-a (Graviton2 / Apple Silicon and
later), matching the x86-64-v2 floor x64 has had since 1.1.x — arm64
previously shipped baseline armv8-a. - Opt-in PGO support in the build driver:
MORO_PGO=generateinstruments
a build and writes profiles toMORO_PGO_DIR(defaultbuild/pgo); after
running a representative workload, merge withllvm-profdataand rebuild
withMORO_PGO=use MORO_PGO_PROFILE=<file.profdata>. POSIX/clang only,
never on by default; release artifacts are unchanged unless the release
pipeline opts in.
Measured
Paired same-session A/B on loopback (wrk -c 100 -d 30, best-of-3 per
profile, Node 24.11, Apple M2 Ultra), npm binary vs this build, raw engine
(no framework):
| npm baseline | 1.1.5 | delta | |
|---|---|---|---|
| Req/s, no pipelining | 108,038 | 110,511 | +2.3% (at the loopback ceiling) |
| Req/s, pipelined ×10 | 703,488 | 845,088 | +20.1% |
| RSS under load | 73 MB | 54 MB | −19 MB — raw-uWS parity (53 MB, same box) |
845k pipelined is a new all-time peak for the engine (prior published peak
663,735). The realistic-profile column saturates the single-box loopback
ceiling and understates the per-request win; the pipelined microbenchmark is
where per-request cost is visible. Single-box loopback numbers as always —
the publication-grade matrix should be re-run per the benchmark repo's
protocol.
Compatibility
- No API surface, option, or wire-format changes. Drop-in for 1.1.x;
MoroJS picks it up via^1.1.0. The only observable behavior change is
getMethod()now doing what its type declaration always said.
Verification
- Full socket-level wire matrix green: HTTP conformance + edge + hardening +
regression, WebSocket, limits, TLS + TLS-hardening, permessage-deflate —
154node --test, 0 failures. (One pre-existingstopListening
test-helper race flakes at the same-or-higher rate on the unpatched tree —
tracked separately, not a 1.1.5 change.) - C++ unit suites: 620 checks (141 HTTP parser + 352 WebSocket + 100
permessage-deflate + 27 new flat-map differential), with the parser and
flat-map suites additionally run under ASan/UBSan. - The full MoroJS framework suite (1055 tests, including 75 engine-gated
integration tests) runs green against this build. - Native build clean on ABI 137 (Node 24, darwin/arm64); the full ABI matrix
builds in CI with npm provenance on release.
v1.1.4
Correctness-hardening release from the engine code-quality audit. No breaking
changes — every change is an internal robustness fix or a type-declaration
catch-up, so this is a drop-in for 1.1.x.
Fixed — correctness & robustness
- ≥4 GiB response truncation — closed. libuv's
uv_buf_tcarries its length
in a 32-bit field anduv_write/uv_try_writecount bytes with anint, so a
single response buffer ≥ 4 GiB truncated (silent corruption plus a hung client,
sinceContent-Lengththen lied) and a ≥ 2 GiB completion check wrapped.
Response bodies are app-controlled with no cap, so any payload past this
watermark is now split into ≤ 1 GiB segments handed to oneuv_write— one
write request, terminal bookkeeping still firing exactly once — and every
length/return cast below a segment boundary is exact. Applies across the
synchronous fast paths, the queued path, and the pipelined cork buffer. - WebSocket frames dropped on an async upgrade — fixed. Frames a client sent
before the101was written — bytes buffered in the HTTP parser from the
handshake segment, plus any frames that arrived while an async upgrade handler
had not yet responded (c->pending) — could be silently dropped once the
upgrade completed, desyncing the connection. They are now drained to the
WebSocket parser in receive order (copied out first, since a protocol error may
tear the connection down mid-feed). size_t→intoverflow guards on V8 boundaries. TheString::NewFromUtf8
length, theArray::Newcapacity hint, and the inbound WebSocket message
length — each casts asize_tto anintfor a V8 API — are now clamped to
INT_MAX, closing overflow paths reachable only when an operator raises a limit
past 2 GiB.- Cork-buffer capacity retention. The pipelined cork buffer now
shrink_to_fits once it grows past 64 KiB, so a one-off large batch no longer
retains its peak allocation for the life of the connection. - Connection-recovery invariant corrected. The libuv handle is recovered via
handle.data, never a struct-offset pointer cast, so its position in the
connection struct carries no layout invariant — a stale comment implying
otherwise has been removed.
Types
index.d.tsnow declaresssl.ecdhCurveandssl.ciphersuites. Both
shipped functionally in 1.1.3 (documented there, runtime insrc/tls.h) but
were missing from the type surface; TypeScript consumers can now set them
without a cast.
Compatibility
- No API or behavior changes for existing code — everything here is an
internal robustness fix or an additive type declaration. Drop-in for 1.1.x;
MoroJS picks it up via^1.1.0.
Verification
- Full socket-level wire matrix green: HTTP conformance + edge + hardening +
regression, WebSocket, limits, TLS + TLS-hardening, permessage-deflate —
154node --test, 0 failures. - C++ unit suites under the standalone harness: 593 checks (141 HTTP parser +
352 WebSocket + 100 permessage-deflate). - Native build clean on ABI 137 (Node 24, darwin/arm64); the full 6-ABI matrix
(Node 20–26) builds in CI with npm provenance. - libFuzzer HTTP/WS/TLS/deflate corpora run in CI; the permessage-deflate harness
gains a persisted seed corpus this release.
See docs/THREAT_MODEL.md for the full defense inventory.
v1.1.3
@morojs/engine 1.1.3
Security-hardening release. Closes a third-party defensive audit and a
follow-up review of the new defenses. No breaking changes — every new knob
is additive and off/opt-in by default, so this is a drop-in for 1.1.x.
Security hardening
The receive side was already well defended (request smuggling, Content-Length
overflow, slowloris, header/body caps, WS zip-bombs). This release closes the
gaps the audit found — most importantly the response-delivery side.
- Slow-read response DoS — closed. In-flight responses used to be exempt
from every timeout, so a client that sent a valid request and then stopped
reading (or pinned a zero TCP receive window) could hold its queued response
buffer, kernel socket buffer, and fd indefinitely — the receive-side mirror
of slowloris. A newresponseTimeoutMsbudget (default 300 s, the
response-side twin ofrequestTimeoutMs) sheds a connection whose outbound
queue makes no drain progress for the whole budget. Progress is measured by
the write queue shrinking between sweeps, not by write completion, so a
genuinely slow-but-steady reader of a large download or SSE stream is never
cut off — only a stalled/zero-window peer is. An opt-in
responseBackpressureLimitadditionally hard-caps the outbound queue (the
HTTP mirror ofwsBackpressureLimit). The same delivery deadline now also
bounds a stalled WebSocket consumer and a stalled close-frame flush. - Host header discipline (RFC 9112 §3.2). An HTTP/1.1 request with zero or
more than oneHostheader is now rejected400before any routing sees it —
absent/duplicateHostis a building block for host-confusion and cache
poisoning behind aHost-routing proxy. HTTP/1.0 (which predatesHost) is
unaffected. - Request-target and header-value byte hygiene. Raw control bytes (C0 other
than HTAB in values, and DEL) in the request target or any header value are
now rejected400instead of being handed to the app verbatim — closing a
log-injection / downstream-desync vector. Raw UTF-8 (high bytes) and
absolute-form targets stay accepted, matching Node. Opt-inmaxUriSize
answers414for over-long targets. Sec-WebSocket-Keyvalidation (RFC 6455 §4.1). Malformed keys (not the
base64 of a 16-byte nonce) are refused at the handshake instead of being fed
into the accept-key computation.- Numeric-limit clamp. Operator-supplied size limits are clamped to a sane
ceiling (2^48) so no absurd value can overflow the engine's internal size
arithmetic; the process degrades predictably instead of wrapping. - N-API robustness. V8
Set/Get/string-creation paths that previously
used.Check()(which hard-aborts the Node process on failure under
allocation pressure) now degrade gracefully. - TLS transport. The
SSL_select_next_protoALPN path is documented as not
reaching the CVE-2024-5535 dangling-pointer case, guarded so a future refactor
can't regress it.
New options (all additive)
ssl.ciphers/ssl.ciphersuites/ssl.ecdhCurve— explicit
cipher-list, TLS 1.3 ciphersuite, and key-share group policy for
compliance baselines (PCI/FIPS/hardened profiles). Unset, the host Node's
OpenSSL defaults apply, exactly as before; invalid values throw fromserve()
rather than booting a lax server.responseTimeoutMs,responseBackpressureLimit,maxUriSize—
the response-side / target-size knobs above (see docs/API.md).probe().capabilitiesgainsresponseLimitsandtlsPolicyso consumers
can feature-gate the new options instead of version-sniffing.
MoroJS passes only capability-gated options, so an older framework build simply
doesn't set the new keys — full forward/backward compatibility.
Compatibility
- No API or behavior changes for existing code; all new surface is
additive. Drop-in for 1.1.x; MoroJS picks it up via^1.1.0. - Platform/ABI matrix now includes Linux musl arm64 (Alpine on Graviton no
longer falls back tonode:http): macOS arm64/x64, Linux glibc x64/arm64,
Linux musl x64/arm64, Windows x64 × Node 20–26, prebuilt with npm provenance.
Verification
- Full socket-level wire matrix green: HTTP conformance + edge + hardening +
regression, WebSocket, limits, TLS + TLS-hardening, permessage-deflate
(154 tests), with new regression tests per audit finding — missing/duplicate
Host, control-byte target/value,maxUriSize→ 414, malformed WS key, the
TLS cipher/group knobs, and the slow-read deadline (a steady slow reader
receives the full body over both single-respond()and streamedwrite()
paths, while a zero-window client is still shed). - C++ unit suites under the standalone harness: 141-check HTTP parser +
WebSocket + permessage-deflate. - ASan/UBSan legs and the libFuzzer HTTP/WS/TLS/deflate corpora run in CI;
the new Host/target/value/WS-key validators were re-fuzzed clean. - MoroJS engine integration tests green against this build.
See docs/THREAT_MODEL.md for the full defense inventory and the honest
residuals (e.g. a per-sweep drip peer, bounded by responseBackpressureLimit +
maxConnections).
v1.1.2
@morojs/engine 1.2.0
Hardening + feature release from a full engine audit: every finding was
verified against source before fixing, and the two most serious bugs were
reproduced before and after the fix. New opt-in API (TLS ticket keys, shared
WebSocket compressor), one new platform, and measurable WebSocket throughput
gains. Backwards-compatible: minor bump.
Fixed — correctness & security
- permessage-deflate silent corruption (critical) — a compressed message
whose DEFLATE stream ended withBFINAL=1(explicitly permitted by
RFC 7692 §7.2.3.6) left the shared inflate stream finished; every later
compressed message on that connection decoded as an empty payload with a
success status. The stream now resets onZ_STREAM_ENDand keeps consuming
(multiple back-to-back streams in one message decode correctly), and the
flush tail is skipped when the stream already ended. Reproduced, fixed,
regression-tested. - mTLS session resumption — servers with
requestCert: truefatally
rejected every session-resumption attempt (missing SSL session-id context);
resumption now works, andrequestCertservers advertise their acceptable
client CAs (matching Node'stls.Serverbehavior) so certificate-picking
clients can respond. - Request smuggling / response-splitting hardening — a
0x00byte in an
app-supplied response header name slipped through validation (astrchr
set-terminator match); rejected now. - Worker-thread safety — the request/WebSocket registries were
process-global; withreusePortworkers (an advertised pattern) two loops
could race on them. All registries are now per-thread. - Spurious 431 on pipelined bodies — the head-size guard counted buffered
body bytes when a fully-buffered pipelined request replayed through one
parse call, rejecting valid requests with bodies overmaxHeadSize. Head
size is now measured on head bytes only (exact old boundary semantics kept). - WebSocket / permessage-deflate conformance — RSV1 on a control frame now
fails the connection when compression is negotiated (RFC 7692 §6.1);
*_max_window_bits=8offers are declined instead of illegally echoed as 9
(§7.1.2.1);client_max_window_bitsis never emitted unless the client
offered it (§7.1.2.2); extension-parameter parsing no longer has an integer
overflow on hostile digit strings. Connection: UPGRADE(any token case) now upgrades; non-token
substrings likenot-upgradeno longer match.- TLS robustness — a truncated/corrupt certificate in an inline PEM chain
orcabundle now failsserve()loudly instead of booting a broken chain;
writes ≥ 2 GiB no longer kill the connection on an integer cast; allocation
failure during session setup sheds the connection instead of crashing.
New
ssl.ticketKeys— Node-compatible 48-byte session-ticket key
pass-through. Give everyreusePortworker (or every process behind one
address) the same keys and TLS sessions resume across all of them. Same
layout and semantics as Node'stls.ServerticketKeys; wrong-length input
throws fromserve(). Key generation/rotation stays the caller's job.wsDeflate.sharedCompressor(opt-in) — one server-owned deflate stream
(reset per message) instead of ~262 KB of deflate state per connection;
per-connection contexts become inflate-only. Forces
server_no_context_takeoverin the negotiated response (RFC 7692 §7.1.1.1);
clients cappingserver_max_window_bitsbelow the shared window
transparently fall back to a per-connection context.- Linux arm64 musl — new
@morojs/engine-linux-arm64-muslpackage and CI
leg: Alpine containers on AWS Graviton / Apple-Silicon Docker now load the
native engine instead of silently falling back tonode:http.
Performance
- WebSocket receive: masked payloads are unmasked with word-wide XOR (was
byte-at-a-time behind a redundant zero-fill), and complete single-frame
messages — the common case — are now unmasked in place in the wire buffer
and delivered zero-copy. Measured: +23% echo throughput at 16 KB payloads
(17.9k → 22.1k msg/s on the reference machine). - Text messages fail fast: UTF-8 is validated incrementally per chunk, so
an invalid 16 MiB text message dies at its first bad byte instead of after
full buffering (close 1007 either way). - Response path: string/Buffer bodies cross the JS boundary with one less
full copy per respond/write/end/wsSend; WebSocket sends reuse the
per-connection scratch buffer; TLS output drains in one copy instead of
16 KB hops. - Memory: parser and message buffers no longer pin huge capacities to idle
keep-alive connections after a large request/message. - Builds: release binaries are compiled with
-fno-exceptions -fno-rtti
and anx86-64-v2baseline on x64 targets.
Behavior changes to note
- Corrupt PEM chain/CA material now throws from
serve()(previously booted
with a broken chain). - Clients offering
*_max_window_bits=8get compression declined (previously
a non-conforming response; conforming clients had to fail the handshake). - HTTP throughput is unchanged (~97k req/s reference, no regression).
Verification
- Full wire-test matrix green: HTTP conformance + edge + hardening +
regression, WebSocket, limits, TLS + TLS-hardening, permessage-deflate —
134 tests, plus 546 C++ unit checks (all also clean under ASan/UBSan). - All four fuzz targets (HTTP parser, WebSocket parser, permessage-deflate,
TLS transport — the TLS harness now completes a real in-memory handshake and
fuzzes the established state) run crash-free. - CI matrix extended: Node 23 smoke, Node 26 integration, macOS conformance,
linux-arm64-musl build/smoke legs.
Platform/ABI matrix: macOS arm64/x64, Linux glibc x64/arm64, Linux musl
x64/arm64, Windows x64 × Node 20–26, prebuilt with npm provenance.
v1.1.1
@morojs/engine 1.1.1
Hot-path performance patch — no API changes, no behavior changes. Every change
removes work the engine was doing on each request:
What changed
- Interned path strings — the binding allocated a fresh V8 string for the
request path on every request. Paths are now interned per server (real apps
route a bounded set of paths), so the string is built once per unique path
instead of once per request — removing the last per-request JS allocation and
its GC pressure. The cache is strictly bounded (≤512 entries, ≤128 bytes per
path; past the cap requests simply build the string per call), so unique-URL
floods can't grow it. - Responses build directly into the cork buffer on the pipelined plaintext
path — previously each response was built in a scratch buffer and then copied
into the batch; that full-frame memcpy per response is gone. (TLS responses
keep the encrypt-then-cork path.) - Request registry pre-sized — the per-request id map no longer rehashes or
chains on the hot path (pre-reserved, 0.5 max load factor).
Verification
- Full wire-test matrix green: HTTP conformance + edge + hardening +
regression, WebSocket, limits, TLS + TLS-hardening, permessage-deflate. - 234 MoroJS integration tests green against this build.
- Measured comparisons live in the
MoroJS Benchmark repo.
Drop-in for 1.1.x. Same platform/ABI matrix (macOS arm64/x64, Linux glibc
x64/arm64, Linux musl x64, Windows x64 × Node 20–26), prebuilt with npm
provenance.
v1.1.0
@morojs/engine 1.1.0
Performance release. The engine now outperforms uWebSockets.js in both benchmark profiles — real-world (no pipelining) and pipelined — on a single thread, with no API changes and no behavior changes.
Numbers
Alternating best-of-rounds vs uWebSockets.js through the full MoroJS stack (wrk, 100 connections, Node 24, Apple M2 Ultra):
| Profile | @morojs/engine | uWebSockets.js |
|---|---|---|
| Real-world (no pipelining) | 94,216 req/s | 91,247 req/s |
| Pipelined ×10 (TechEmpower-style) | 495,440 req/s | 470,520 req/s |
The engine won every round of every profile. Versus 1.0.0, pipelined throughput is 3.4× higher (145k → ~495k) and the real-world path gained ~5%.
What changed
- Pipelined response corking — synchronous responses to pipelined requests now accumulate in a per-connection cork buffer and hit the socket as one write per batch instead of one queued write + event-loop deferral per response. Handlers still run strictly one-at-a-time on a clean stack (request N+1 is surfaced only after handler N returns); async handlers, upgrades, errors, and
Connection: closekeep their existing paths. The buffer is bounded by the write high-water mark. - Zero-allocation hot path on warm connections
- Header parsing reuses header slots (and their string capacities) across requests instead of reallocating and destroying them each request.
- The request snapshot (path, query, method, headers, body) is swapped with the parser rather than copied — the previous request's buffers become the parser's scratch for the next one.
- Responses build into a reusable per-connection buffer and are written straight from it on the fast path — no per-response allocation, no intermediate copy. Oversized buffers (>64 KB) are released so a single large response can't pin memory.
- Prebuilt status lines for hot statuses; the
Connection:header no longer allocates.
- Bounded as before — all caps (
maxHeaders, body/head sizes, write high-water mark, pending-bytes flood cap) apply unchanged; large bodies and responses do not stay pinned to idle keep-alive connections.
Compatibility
- No API changes. Drop-in for 1.0.0; MoroJS picks it up via
^1.0.0. - Same platform/ABI matrix as 1.0.0 (macOS arm64/x64, Linux glibc x64/arm64, Linux musl x64, Windows x64 × Node 20–26), prebuilt with npm provenance.
Verification
- 128 socket-level wire tests (HTTP conformance + edge + hardening + regression, WebSocket, limits, TLS + TLS-hardening, permessage-deflate) — all passing.
- 95-check parser unit suite + WebSocket/deflate unit suites under the C++ harness.
- 234 MoroJS integration tests against this build.
- libFuzzer corpus runs on the modified parser; ASan/UBSan legs run in CI.