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 your code relied on
onAborted arriving synchronously inside respond(), it now arrives a turn
later (and isAborted() is already true when it does).