Skip to content

Releases: Moro-JS/engine

v1.1.7

Choose a tag to compare

@M-Chris M-Chris released this 15 Sep 22:31

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: true advertises it. A framework that armed
    its own setImmediate drain 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
    settled await and a nextTick each 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 own io_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 extra read per wake, many completions per wake.
  • coop-taskrun (the 1.1.6 mode): kept as the fallback, and selectable
    with MORO_ENGINE_URING_TASKRUN=coop for A/B runs
    (MORO_ENGINE_URING_TASKRUN=defer pins 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_TASKRUN or whose eventfd wake the probe cannot
    prove get coop-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

Choose a tag to compare

@M-Chris M-Chris released this 14 Sep 01:16

@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, writeHeadPrepared and endWith round 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 omits v8-fast-api-calls.h; the build fetches the exact
    per-tag copy (sha256-pinned in tools/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 through String::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 old WriteOneByte bail-out
    on V8 ≥ 14 is replaced by WriteOneByteV2).
  • The FIN travels with the last bytes of a Connection: close response
    (macOS TCP_NOPUSH around the send then shutdown, Linux MSG_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_NODELAY is set once on
    the listening socket and inherited by every accepted socket (Linux and
    XNU both do; test/sockopt-unit.cpp checks the running kernel), instead of
    a setsockopt per 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
    maxConnections and 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 registers onRequestBatch(count) receives complete pipelined requests
    as one call over getBatchBuffers() 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=0 turns 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-flight SEND per 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 blocks io_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 in docs/DESIGN.md and
    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 / onWritable are 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
    responseBackpressureLimit trip inside respond() reached onAborted in the
    middle of that very call. isAborted(reqId) is already true inside
    onAborted. The one exception: close() delivers every pending onAborted
    synchronously before it returns. MORO_ENGINE_NOTIFY=sync restores the old
    delivery for bisecting (fast calls are then disabled).
  • A Connection: close exchange 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_2 for 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() gains notify, transport / transportReason, fastApi { compiled, installed, reason, compiledV8, runtimeV8 }, fastCallStats (with
    MORO_ENGINE_FASTCALL_STATS=1), and the capabilities staticRoutes,
    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_BINARY points the loader at a specific
    .node (the PGO training run uses it).
  • npm run check:exports (tools/check-exports.mjs) diffs the native surface
    across src/binding.cpp, index.js, index.mjs and index.d.ts; it runs
    in CI and in the release gate.
  • npm run test:suites is 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.sh builds 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 ...

Read more

v1.1.5

Choose a tag to compare

@M-Chris M-Chris released this 03 Aug 01:30

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 that std::string::resize had
    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 overlapped WSARecv holds it across the
    callback — but no longer zero-fills it.
  • Pipelined parsing is no longer quadratic. The parser compacted its
    buffer with erase(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 a std::unordered_map paying 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 (new src/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 against std::unordered_map, including adversarial probe-chain
    clustering).
  • V8 boundary: malloc-free header and body encoding. The response header
    block was a fresh std::string per response and every header name, header
    value, and string body crossed via String::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-enter respond(),
    nested calls get a local); ASCII one-byte strings (names/values, and bodies
    up to 64 KiB) are read with WriteOneByte into reused buffers; and
    getHeaders serves 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 old Utf8Value path.
  • Serialization micro-costs. The Date header is cached as the complete
    header line (one append, refreshed per second); Content-Length and
    chunk-size lines use small stack writers instead of std::to_string /
    snprintf; the corked fast path reserves once up front;
    finalizeHeaders runs 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 its index.d.ts contract 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 of getMethod() 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=generate instruments
    a build and writes profiles to MORO_PGO_DIR (default build/pgo); after
    running a representative workload, merge with llvm-profdata and rebuild
    with MORO_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 —
    154 node --test, 0 failures. (One pre-existing stopListening
    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

Choose a tag to compare

@M-Chris M-Chris released this 13 Jul 22:10
bc961de

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_t carries its length
    in a 32-bit field and uv_write/uv_try_write count bytes with an int, so a
    single response buffer ≥ 4 GiB truncated (silent corruption plus a hung client,
    since Content-Length then 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 one uv_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 the 101 was 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_tint overflow guards on V8 boundaries. The String::NewFromUtf8
    length, the Array::New capacity hint, and the inbound WebSocket message
    length — each casts a size_t to an int for 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.ts now declares ssl.ecdhCurve and ssl.ciphersuites. Both
    shipped functionally in 1.1.3 (documented there, runtime in src/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 —
    154 node --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

Choose a tag to compare

@M-Chris M-Chris released this 12 Jul 00:16

@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 new responseTimeoutMs budget (default 300 s, the
    response-side twin of requestTimeoutMs) 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
    responseBackpressureLimit additionally hard-caps the outbound queue (the
    HTTP mirror of wsBackpressureLimit). 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 one Host header is now rejected 400 before any routing sees it —
    absent/duplicate Host is a building block for host-confusion and cache
    poisoning behind a Host-routing proxy. HTTP/1.0 (which predates Host) 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 rejected 400 instead 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-in maxUriSize
    answers 414 for over-long targets.
  • Sec-WebSocket-Key validation (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_proto ALPN 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 from serve()
    rather than booting a lax server.
  • responseTimeoutMs, responseBackpressureLimit, maxUriSize
    the response-side / target-size knobs above (see docs/API.md).
  • probe().capabilities gains responseLimits and tlsPolicy so 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 to node: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 streamed write()
    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

Choose a tag to compare

@M-Chris M-Chris released this 11 Jul 05:16

@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 with BFINAL=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 on Z_STREAM_END and 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: true fatally
    rejected every session-resumption attempt (missing SSL session-id context);
    resumption now works, and requestCert servers advertise their acceptable
    client CAs (matching Node's tls.Server behavior) so certificate-picking
    clients can respond.
  • Request smuggling / response-splitting hardening — a 0x00 byte in an
    app-supplied response header name slipped through validation (a strchr
    set-terminator match); rejected now.
  • Worker-thread safety — the request/WebSocket registries were
    process-global; with reusePort workers (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 over maxHeadSize. 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=8 offers are declined instead of illegally echoed as 9
    (§7.1.2.1); client_max_window_bits is 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 like not-upgrade no longer match.
  • TLS robustness — a truncated/corrupt certificate in an inline PEM chain
    or ca bundle now fails serve() 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 every reusePort worker (or every process behind one
    address) the same keys and TLS sessions resume across all of them. Same
    layout and semantics as Node's tls.Server ticketKeys; wrong-length input
    throws from serve(). 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_takeover in the negotiated response (RFC 7692 §7.1.1.1);
    clients capping server_max_window_bits below the shared window
    transparently fall back to a per-connection context.
  • Linux arm64 musl — new @morojs/engine-linux-arm64-musl package and CI
    leg: Alpine containers on AWS Graviton / Apple-Silicon Docker now load the
    native engine instead of silently falling back to node: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 an x86-64-v2 baseline 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=8 get 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

Choose a tag to compare

@M-Chris M-Chris released this 10 Jul 19:03

@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

Choose a tag to compare

@M-Chris M-Chris released this 10 Jul 04:59

@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: close keep 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.