Skip to content

v1.1.9

Choose a tag to compare

@M-Chris M-Chris released this 24 Sep 21:48
· 18 commits to main since this release

@morojs/engine 1.1.9

A routing release with two new native functions. 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 MoroJS keeps working on this binary; a MoroJS that calls the new
functions checks the flag first.

A TLS release with one new native function. 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
MoroJS keeps working on this binary; a MoroJS that calls the new function
checks the flag first and reports a clear error on 1.1.7.

New — certificate rotation without a restart

updateSsl(serverId, ssl) replaces the certificate and key of a running TLS
server for every handshake that starts afterwards
(capabilities.tlsReload). Until now the material was read once in
serve(), so an ACME renewal or a rotated secret meant a listener restart;
in-process TLS was the only place in the engine where that was still true.

  • ssl is the full serve() ssl shape, not a diff: file paths or inline
    PEM, passphrase, minVersion, requestCert/rejectUnauthorized,
    ciphers/ciphersuites/ecdhCurve, ticketKeys. Inline wins over a
    file path for the same slot, as in serve().
  • Validated before the swap. The new context is built and checked
    (readable files, parsable PEM, key matching certificate, protocol floor)
    while the current one keeps serving; any failure throws with the same
    messages serve() uses and leaves the live context untouched.
  • Established connections are unaffected. A TlsSession holds its own
    reference to the SSL_CTX it handshaked with, so a kept-alive socket or
    an in-flight request finishes on the old context while the next
    handshake gets the new one. The old context is freed when its last
    session closes.
  • Session-ticket keys carry over when the update omits them, so tickets
    minted before a rotation still resume afterwards; an update that names
    new keys ends resumption of the old tickets. The ALPN policy carries
    over the same way.
  • Throws for a server that was not started with ssl (TLS cannot be
    turned on after the fact), for an unknown serverId, and for a missing
    options object.
  • The swap runs on the server's loop thread, the only thread that
    handshakes, so there is no lock and no window in which a handshake sees
    a half-built context. The per-connection path is unchanged: sessions
    still derive from the live context pointer; the rotation itself costs one
    context build.

Internally serve() and updateSsl() share one option parser and one
context builder (parseSslOptions / buildTlsContext in src/binding.cpp),
so the two cannot drift; Server::tls() and the TlsContext accessors for
ticket keys and ALPN exist for the carry-over.

Test: test/tls-reload.test.mjs — a fresh handshake is shown the new
certificate after an update in either shape; a connection opened before the
update keeps answering on its old session while new ones see the new
certificate; five kinds of bad material each throw and the old certificate
keeps serving; a plaintext server and an unknown id are refused; tickets
resume across a rotation that keeps the keys and stop resuming after one
that changes them.

New — parameter routes answered inside the engine

setParamRoute(serverId, method, prefix, suffix, status?, headers?)
registers a route with one variable path segment, and the engine answers a
matching request with that segment as the body, without calling into JS
(capabilities.paramRoutes). setParamRoute(sid, GET, '/user/', '')
answers GET /user/42 with 42; ('/files/', '.json') answers
GET /files/report.json with report.

Static routes (1.1.5) already kept a fixed reply out of JS. A route whose
reply is the URL - an id echo, a name lookup key - could not use them, and
the per-request JS call was the remaining cost on the benchmark shape every
harness runs (GET /user/:id). It is gone: with a static route for /,
one for POST /user and a parameter route for /user/, a server built on
this engine never enters JS for that route table.

  • A request matches when its path is prefix + one non-empty segment with
    no / in it + suffix, on the given method. Everything else - an empty
    segment, a second slash, another method, a different suffix - still
    reaches onRequest exactly as before.
  • The segment goes out as it is on the wire, undecoded (a%20b stays
    a%20b), which is what uWS's writeParameterValue does too. A parameter
    that needs decoding stays a JS route.
  • The reply is byte-identical to respond(reqId, status, headers, segment):
    same status line, Content-Length computed from the segment, Date,
    keep-alive handling, and the optional headers given at registration.
  • Static routes are checked first, then parameter routes, in registration
    order. Registering the same (method, prefix, suffix) again replaces the
    earlier reply; clearParamRoutes(serverId) hands every parameter route
    back to JS. Both are per server, like the static-route calls.
  • method is the same index onRequest reports (GET = 0, POST = 1,
    ...); status defaults to 200; headers is the flat
    [name, value, ...] array setStaticRoute takes, or null.
  • Cost when unused: none. The dispatch sites test one hasEngineRoutes()
    (static or parameter routes present) before looking at either table, so
    a server that registers neither runs the same path as 1.1.8.

Measured on the arm64 Linux VM used for the harness work (2 server cores,
zrk --closed, 6 generator threads, same engine build both sides, the raw
javascript/morojs-engine entry), GET /user/0 went from a JS callback
per request to a parameter route:

connections JS handler (req/s) parameter route (req/s)
64 309–320k 348–384k
512 395–414k 435–441k

GET / (a static route both sides) was unchanged, as expected.

Internally Server::tryStaticRoute() falls through to
tryParamRoute(), which matches against paramRoutes_ and calls the same
respond() the static path uses, with the segment pointing into the
request's own path buffer; nothing is copied before the write.
SetParamRoute in src/binding.cpp shares buildHeaders with
SetStaticRoute, so the two header shapes cannot drift.

Test: test/param-routes.test.mjs — the capability is advertised; a
parameter route echoes the segment with zero onRequest calls, with and
without a suffix and with custom headers; the segment is verbatim
(a%20b); five non-matching shapes each reach JS with the expected method
and path; the reply is byte-identical to respond() with the same segment;
re-registration replaces and clearParamRoutes hands the route back to JS.

Compatibility

  • No wire changes. No removed or renamed exports (tools/check-exports.mjs:
    no drift — 31 native functions, 15 capability flags).
  • New: updateSsl(serverId, ssl), capabilities.tlsReload.
  • New: setParamRoute(serverId, method, prefix, suffix, status?, headers?),
    clearParamRoutes(serverId), capabilities.paramRoutes.
  • Declared in all four places (Initialize, NATIVE_API, index.mjs,
    index.d.ts) and documented in docs/API.md (parameter routes section,
    after static routes).
  • Verified on macOS (libuv) and Linux (io_uring defer-taskrun and libuv):
    218 suite tests (214 from 1.1.8 plus the four above), the C++ unit tests
    and the smoke test.
  • MoroJS uses it from the release after 1.8.12: param('id') in place of a
    handler on a bare single-parameter route registers a parameter route when
    capabilities.paramRoutes is true and runs the equivalent
    res.end(req.params.id) handler otherwise.