GitHub - lanteanio/svelte-adapter-ws: SvelteKit adapter with built-in WebSocket support (ws) · GitHub
Skip to content

About

SvelteKit adapter with built-in WebSocket support (ws)

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

svelte-adapter-ws

A SvelteKit adapter on Node's own http/https server plus the ws library: it follows svelte-adapter-uws and is meant to work with the same ecosystem around it - svelte-realtime for the client stores and svelte-adapter-uws-extensions for clustering, presence and cursors. Same platform.* surface, same plugins, same client. The adapter swap is one line in svelte.config.js; app code is unchanged.

Why this exists

svelte-adapter-uws rides uWebSockets.js, a native N-API addon. It installs from a github: URL and needs a prebuild for the host platform, and some teams will not take a native dependency into their deployment no matter how straightforward it is in practice. This adapter is for them: the same realtime DX on nothing but Node core and a pure-JS WebSocket library, at the cost of peak throughput.

The family, by tier:

package transport tier
svelte-adapter-uws uWebSockets.js on Node maximum performance
svelte-adapter-ws (this repo) node:http + ws maximum portability
svelte-adapter-bunserve Bun.serve Bun, natively

The suffix names the transport, like every member of the family: uws is uWebSockets.js, ws is the ws library, bunserve is Bun.serve.

Public entry points

This catalog is generated from the package export map by node scripts/render-entry-points.js. Every subpath declares its role, execution environment, stability and deprecation state, carried from the lead adapter so the same import means the same thing in either package. The suite fails if the table drifts from the export map, if a declared subpath does not resolve, or if this package declares a subpath the lead does not.

Entry point Role Environment Stability Deprecation
svelte-adapter-ws SvelteKit adapter and build output Node build supported none
svelte-adapter-ws/upgrade-response WebSocket 101 response headers Node runtime supported none
svelte-adapter-ws/connection Stable connection identity Node runtime supported none
svelte-adapter-ws/subscriptions Accounted topic membership for plugins Node runtime supported none
svelte-adapter-ws/client Reactive connection and topic stores Browser supported none
svelte-adapter-ws/vite Development WebSocket and handler build plugin Node build/dev supported none
svelte-adapter-ws/testing In-process handler integration harness Node test supported none
svelte-adapter-ws/sim Deterministic network and cluster simulator Node test experimental none
svelte-adapter-ws/safe-url Outbound SSRF policy and address classification Node runtime supported none
svelte-adapter-ws/observability Signal manifest, diagnostic formatter/parser, and schema validator Universal supported none
svelte-adapter-ws/plugins/replay Server replay buffer Node runtime supported none
svelte-adapter-ws/plugins/replay/client Browser replay client Browser supported none
svelte-adapter-ws/plugins/presence Server presence registry Node runtime supported none
svelte-adapter-ws/plugins/presence/client Reactive presence client Browser supported none
svelte-adapter-ws/plugins/channels Typed server topics Node runtime supported none
svelte-adapter-ws/plugins/channels/client Typed client topics Browser supported none
svelte-adapter-ws/plugins/throttle Topic throttle and debounce Node runtime supported none
svelte-adapter-ws/plugins/ratelimit Message rate limiting Node runtime supported none
svelte-adapter-ws/plugins/cursor Server cursor fan-out Node runtime supported none
svelte-adapter-ws/plugins/cursor/client Reactive cursor client Browser/worker supported none
svelte-adapter-ws/plugins/middleware Message middleware pipeline Node runtime supported none
svelte-adapter-ws/plugins/queue Per-key ordered work queue Node runtime supported none
svelte-adapter-ws/plugins/groups Server broadcast groups Node runtime supported none
svelte-adapter-ws/plugins/groups/client Reactive group client Browser supported none
svelte-adapter-ws/plugins/lock Per-key critical sections Node runtime supported none
svelte-adapter-ws/plugins/session In-process session store Node runtime supported none
svelte-adapter-ws/plugins/dedup Idempotency window Node runtime supported none
svelte-adapter-ws/plugins/crdt CRDT wire codec and authority Node runtime supported none
svelte-adapter-ws/plugins/crdt/client Binary CRDT client sink Browser supported none
svelte-adapter-ws/plugins/crdt/replica Local CRDT replica primitives Browser/Node supported none
svelte-adapter-ws/plugins/crdt/channel Reactive CRDT channel Browser supported none
svelte-adapter-ws/plugins/smooth Server prediction authority and codec Node runtime supported none
svelte-adapter-ws/plugins/smooth/client Prediction and interpolation client Browser supported none
svelte-adapter-ws/plugins/smooth/random Shared deterministic random stream Browser/Node supported none
svelte-adapter-ws/plugins/webhooks SSRF-gated webhook delivery Node runtime supported none

createTestServer from svelte-adapter-ws/testing answers unmatched HTTP routes with 404 File Not Found and an HTML body, including on HEAD. Without an admission limit, a plain GET to the WebSocket path uses the same answer.

Install

npm install svelte-adapter-ws

Install it as a regular dependency, not a devDependency. Unlike most SvelteKit adapters, the built server resolves the ws library at runtime through this package's dependency tree - a production install that prunes devDependencies would prune the WebSocket transport with it. Everything else the runtime needs (the @sveltejs/kit/node primitives included) is bundled into the build output.

Contributing

For a repository checkout on Node 22, use npm 11.8.0 before running npm ci (npm install -g npm@11.8.0). The bundled npm 10 resolver can fail on this repository's local self-dependency. Published-package installs use the normal npm install svelte-adapter-ws command above.

Usage

// svelte.config.js
import adapter from 'svelte-adapter-ws';

export default {
	kit: {
		adapter: adapter()
	}
};

That serves HTTP, static assets and SSR with zero configuration: node build/index.js listens on 0.0.0.0:3000. Realtime is one option away:

adapter({
	websocket: {
		handler: './src/lib/server/ws.js',
		allowedOrigins: ['https://app.example.com']
	}
})

The handler module exports the family's hook set (upgrade, open, message, close, and the rest); every hook receives the same platform surface the lead adapter exposes - publish, send, subscribe, platform.pressure and the rest - so an app written against svelte-adapter-uws runs unchanged, and the svelte-realtime client stores connect to either. Family options whose lanes have not shipped here refuse the build loudly rather than silently no-op'ing.

The bundled client connection is available from svelte-adapter-ws/client. Its send(data) method returns true when the frame is handed to the current open socket, or false when disconnected or paused. This reports local admission; serialization and transport exceptions propagate. A self-initiated socket close reports disconnected before its closing handshake finishes.

connection.sendSerialized(text): boolean sends a complete JSON message that you have already encoded, without parsing or serializing it again. It returns true after the open WebSocket accepts the text and false when the socket is absent, connecting, closing or closed, including during SSR. It never queues a message for reconnect. A true result is local transport acceptance, not confirmation from the server. Pass a primitive string containing valid JSON; non-string input throws TypeError, and errors from WebSocket.send propagate.

Use connection.simulateOffline() and connection.resumeOnline() for temporary offline simulation. Pause cancels socket and authentication attempts and suppresses retries and browser reconnect triggers while preserving subscriptions, listeners, queued messages and the retry budget. Resume uses the configured URL and a fresh authentication preflight, then restores subscriptions and queued sends in FIFO order. Both methods are idempotent and inert during SSR without a URL. Permanent close, server refusal and exhausted retries remain terminal.

Replay truncation, gaps and denial clear the saved sequence offset while retaining its server generation; an explicit rehydrate adopts the announced generation. Both standalone truncation and a truncated replay end follow this rule. While an automatic recovery is unanswered, live delivery continues but its sequence progress is held until the matching acknowledgement, so a dropped socket retries from the last safe offset. Applications need no source change. The onReplay store reports standalone or inline truncation once per outstanding request and keeps separate replay requests correlated.

Module-level topic and diagnostic stores follow an explicit connect() after close(), including accumulated helpers and binary ingress handles. Store access and plugin construction after close do not reopen the socket; call connect() to start a new session. Connection-object stores stay bound to that object. Existing global subscriptions need no manual rebind.

Plugins

Opt-in modules build on the adapter's public API. Each plugin has separate server and client entry points. A plugin that joins or leaves a connection's topics outside the wire protocol should use trackedSubscribe(ws, topic) / trackedUnsubscribe(ws, topic) from svelte-adapter-ws/subscriptions, as the bundled plugins do. Calling ws.subscribe directly or writing the subscription registry by hand bypasses the worker's subscription accounting. For rollback after an admitted loader or setup fails, see subscription rollback.

Authorization model

Plugin action APIs are authorization-free primitives: they do not know roles or ownership, and execute whatever trusted server code passes. Calling withLock(key, fn), replay.replay(ws, topic, since), or idempotency.handle(key, fn) is no more an authorization check than calling Map.set(key, value) is one. The narrow exception is a client-facing observer handshake: presence.sync() and cursor.snapshot() consult platform.checkSubscribe before installing their private tap, because the built-in message hooks feed them a client-named topic. That transport check does not replace your application authorization for mutations or other plugin calls.

Your message handler is the gate. Identity is established at connect time by the upgrade() hook and stashed on the socket via ws.getUserData(). Your message() handler reads that identity, decides whether the action is allowed, and only then invokes the plugin.

Smooth command pacing

createSmoothAuthority from svelte-adapter-ws/plugins/smooth accepts an optional positive integer maxCommandsPerTick:

import { createSmoothAuthority } from 'svelte-adapter-ws/plugins/smooth';

const authority = createSmoothAuthority({
	apply: (state, command) => ({ x: state.x + command.dx }),
	maxCommandsPerTick: 1
});

Each caller-driven drain() applies at most that many client commands per entity, retains the remaining tail in order, and acknowledges only the applied prefix. A limit of one bounds command steps to the tick rate; larger limits allow bounded catch-up. Keep the authoritative tick cadence bounded. Trusted server inject() commands remain unpaced, and queueCap still drops the oldest commands on overflow.

Existing applications need no migration when the option is omitted: draining all queued client commands, including synchronous enqueue from apply, stays the default. Bounded drains own their prefix while callbacks enqueue more work. If apply throws before state commit, the whole prefix is restored ahead of the waiting tail and the normal queue cap is reapplied; callback side effects remain the caller's responsibility. Higher-level consumers must forward the same setting to each authoritative owner, including cluster relay owners. The option requires no wire-format or dependency change.

Configuration

Adapter options (adapter({ ... })): out, precompress, envPrefix, healthCheckPath, readinessCheckPath, staticHeaders, staticCacheControl, staticDotfiles, warmup, tracing, and the websocket block (handler, path, authPath, adminPath, adminAuthAcknowledged, maxPayloadLength, idleTimeout, maxBackpressure, closeOnBackpressureLimit, compression, allowedOrigins, upgradeTimeout, upgradeRateLimit, upgradeAdmission, messageAdmission, maxTopicSeqEntries, pressure, egress, protection, postureExport, consistencyAuditIntervalMs, resourceGrowthAuditIntervalMs, metrics, primaryInit, workers, and the shared policy flags). The typed surface in src/index.d.ts is the reference.

The two probes, like every path the adapter answers ahead of your routes - the auth path, the waiting-room pages, the WebSocket path while an admission ceiling answers its GETs - are matched on the target as sent. A target that resolves or decodes onto one of them without being spelled as it, such as /x/../healthz or /%68ealthz, or that SvelteKit answers as a data or route-resolution request for one of them, such as /healthz/__data.json, is refused with 400 rather than handed to your router. So is any request target carrying a byte outside visible ASCII or a #, which RFC 9112 does not allow in a request line: send such characters percent-encoded, as browsers and HTTP libraries do.

websocket.maxTopicSeqEntries (default 1000000) caps the per-topic sequence registry. Past the cap the least recently published unprotected topic is evicted and its high-water number carried forward, so a re-published topic always resumes above what any forgotten topic reached - a counter may skip numbers, it never repeats one. 0 disables the bound.

websocket.upgradeAdmission gates NEW handshakes; an open connection is never touched. maxConcurrent caps upgrades in flight, maxConnections caps reserved-plus-live sockets with a permit held until close, perTickBudget paces how many handshakes complete per event-loop tick behind a finite maxDeferred queue, and cursorLane.fraction reserves part of the concurrent ceiling for the cursor-only lane so a flood of cursor reconnects cannot starve the main lane. Whenever a ceiling is set the refusal is content-negotiated: a browser navigation to the WebSocket path (or to waitingRoom.path, default /__waiting-room) gets a self-polling holding page that reloads when a slot frees, polling waitingRoom.admitCheckPath (default /__admit-check), while WebSocket clients and non-HTML requests keep 503 with a jittered Retry-After. waitingRoom: false drops the polling page and answers an HTML navigation with a minimal accessible 503 instead. waitingRoom.template takes a full HTML document with {{token}} substitution, and waitingRoom.renderer takes a module path that renders per request for locale-aware pages; the two are mutually exclusive and both are validated when the adapter is configured.

websocket.egress caps publish egress per accounting window: topic and tenant ceilings over messages, bytes and deliveries, with windowMs, maxKeys and evictionSample sizing the ledger. A refused publish returns false (batches refuse atomically), stamps no sequence, and is counted in platform.pressure.egress. Tenants resolve through the WebSocket handler's egressTenantOf(topic) export.

Protocol control frames - welcome, subscribe acks and denials, lease-ok, the flow-control window grants, protocol errors - are charged against no egress budget: those budgets meter application-data fan-out, and charging control frames to them would let an exhausted tenant budget refuse the very grant frames that pace a client down. They carry their own ceiling instead, because the channel amplifies: a client names a topic in a few bytes and is answered with a whole frame. The worst legal shape, an 8 KB subscribe-batch that fills the 256-topic cap and pads the rest with empty entries, each answered with its own denial, comes back as roughly 175 KB across 2,466 frames, twenty-one times what it cost to ask, and an amplifier reaches the ceiling from about 20 KB/s of sustained inbound. Each connection may be sent 4 MiB of control frames per 10-second window, after which it is closed with 4429, the throttle code the bundled client already reconnects on. The ceiling is fixed: a client restoring 256 topics is answered with about 15 KB and a 5,000-topic restore costs about 300 KB, so a healthy burst sits far under it. Application send and publish traffic is never charged here. See ADAPTER-ERR-CONTROL-EGRESS-EXHAUSTED.

websocket.protection (default 'normal') is the graduated protection posture over the 1 Hz pressure signal, and it governs only the admission of NEW upgrades - an open connection is never touched at any level. 'auto' escalates fast and relaxes slow: normal -> elevated after sustained pressure.active, elevated -> siege once over-capacity upgrade rejects run at twice the gate's admit rate, and each step down needs a longer quiet dwell. 'elevated' and 'siege' pin a level for incident response. At elevated every capacity refusal widens its Retry-After jitter; at siege new upgrades are refused at static-serve cost, /__admit-check always answers 202 with a doubled pollAfterMs, and a browser navigation to the WebSocket path gets the capacity page rather than 426. A per-IP 429 never feeds the escalation - that is an attack signal, not capacity. The live level reads back as platform.protection and layers 'CAPACITY' onto platform.pressure (MEMORY still outranks it). Only the siege step needs a ceiling: escalation to siege compares the over-capacity reject rate against the gate's admit rate, so without upgradeAdmission.maxConcurrent or maxConnections there is no rate to compare and 'auto' stops at elevated - which it still reaches on sustained pressure alone.

websocket.postureExport opens a local stream socket - a unix domain socket path, or a \\.\pipe\... named pipe on Windows - and pushes the live posture to every connected consumer as newline-delimited JSON ({"v":1,"posture":"elevated","reason":"PSI","value":0.83,"psi":{...},"cpuThrottle":{...}}): once on connect, once per transition, and once per 1 Hz sample, so a consumer that stops receiving lines knows the adapter is gone. Local-only and payload-free.

websocket.consistencyAuditIntervalMs (default 5000) runs the shared invariant predicates against a bounded, structure-only snapshot of live connections on a slow, jittered, unref'd timer, off the hot path. A violation logs and increments platform.assertions; only a subscription slot that is no longer a Set, persisting across two consecutive audits, escalates to a deferred worker restart. 0 disables it and schedules no timer. websocket.resourceGrowthAuditIntervalMs (default 0, off) is the observe-only counterpart: it trends the SIZE of the live bookkeeping collections and logs one throttled warning when a series climbs monotonically, the signature of a close, unsubscribe or eviction path that stopped shedding.

HTTP response streams

Response streams normalize strings to UTF-8 and preserve the exact bytes of ArrayBuffers and typed-array/DataView slices. Invalid chunks fail the response and cancel the producer. A finite single-string body has the same dynamic compression eligibility as its byte equivalent; credentialed response compression remains disabled unless explicitly enabled.

Deferred response streams flush their first ready bytes without waiting for later producer work. Ready finite bodies retain length framing and compression. Streams waiting for later data use chunked framing without shared rendering or compression. Socket disconnects cancel and release response readers even after a POST body has finished.

Metrics

websocket.metrics is a module path, not a live object - adapter options are serialized into the build, so a registry constructed in svelte.config.js could never reach the production runtime. Point it at a module whose default export (or a named metrics / registry export) is a Prometheus-shaped registry; the adapter populates it and republishes it as platform.metrics.

// src/lib/server/metrics.js
import { createMetrics } from 'svelte-adapter-uws-extensions/prometheus';
export const metrics = createMetrics();

// svelte.config.js
adapter({ websocket: { metrics: './src/lib/server/metrics.js' } })

The adapter serves no scrape route of its own - write an ordinary one:

// src/routes/metrics/+server.js
export const GET = ({ platform }) =>
	new Response(platform.metrics.serialize(), {
		headers: { 'content-type': 'text/plain; version=0.0.4' }
	});

The registry contract is four methods, two of them optional:

Method Required Returns, and what a registry must get right
counter(name, help, labelNames?) yes { inc(labels?, value?) }. Implement value: ws_publishes_total is incremented once per pressure sample with the whole window's publish count, so a registry that ignores value reports roughly one publish per second on a server doing thousands - a wrong number that looks plausible. The relay-gap counter increments in bulk the same way.
gauge(name, help) yes { set(value), remove?() }. Optional remove() retracts an unavailable sample. No label names: every adapter gauge is unlabelled, and set receives the bare number.
histogram(name, help, options?) no { observe(labels?, value?) }. Takes an options object - { labelNames, buckets } - not a positional labelNames, because a registry that cannot be told which buckets to use silently falls back to its own.
serialize() no Prometheus text exposition. Any route that renders platform.metrics itself needs it, and that is always one worker's view; platform.metricsSnapshot() is built from mirrored values and never calls it.

The heap_used_ratio sample is unavailable when neither an engine ceiling nor a finite cgroup limit is known. Pressure decisions use zero then: the MEMORY reason stays silent, the memory contribution to pressure is zero, and send-gate windows do not shrink for memory. The worker metric mirror removes a previous sample when a cgroup-only limit becomes unlimited and resumes when a finite limit returns. External registries can follow these transitions with optional gauge.remove(). Without removal, they receive this gauge only when a latched engine ceiling makes its availability permanent. Empty HELP/TYPE metadata may remain registered. A throwing removal method is contained like other metric failures; the external sample may remain stale, while the worker mirror still clears it. PSI and resident-memory measurements remain independent.

Durations are seconds with fractional bucket bounds; sizes are bytes with a _bytes suffix. That convention matters most for histogram: buckets that start at 1 put a 5 ms call and a 900 ms call in the same bucket and measure nothing. createMetrics() defaults to millisecond-shaped buckets beginning at 1, so every adapter duration histogram passes an explicit seconds-valued bucket list. A registry may omit the optional histogram factory for backward compatibility; counters still register, while duration families are absent and the public manifest says so instead of silently using wrong defaults.

Every metric below declares how it combines across worker threads, and that column is not documentation - it is the law platform.metricsSnapshot() executes when it merges the cluster. sum means the workers hold disjoint parts of one whole; max means they report the same underlying quantity (or the worst one is the useful answer); min is freshness, where the stalest worker is the honest cluster-level reading.

No client identity - address or session - ever reaches a label.

Metric Type Across workers What it charts
http_requests_total{method,outcome} counter sum Completed HTTP requests. method is a bounded verb or other; outcome is ok, client_error, server_error, or aborted.
http_request_duration_seconds{method,outcome} histogram sum HTTP completion duration, with explicit fractional-second buckets from 1 ms through 10 s.
upgrade_admitted_total counter sum Upgrades accepted (the res.upgrade() actually ran).
upgrade_rejected_total{reason} counter sum Upgrades rejected before open. Reasons, in the order the upgrade path can reach them, except ambiguous_framing, which comes first of all when the handshake's own Transfer-Encoding is unreadable and again below for the shapes only the whole header set shows: siege, over_capacity, cursor_lane, connection_capacity, duplicate_header (a repeated Host, Origin, Authorization, Proxy-Authorization or Content-Type, which cannot be given one reading), ambiguous_framing (a header that says where the body ends two ways, or says it twice, on a handshake that carries no body - the connection is closed with the refusal), malformed_handshake (answered before any capacity check: 400 without exactly one Upgrade line naming websocket, a Connection that lists upgrade, exactly one key that is the base64 of 16 bytes, or with a repeated version line or a query outside visible ASCII or carrying a #; 426 with Sec-WebSocket-Version: 13 for any version but 13), ip_rate_limit, bad_origin, deferred_overflow, auth_timeout, auth_rejected, hook_error. One more, auth_rate_limit, is emitted on the auth preflight POST rather than on an upgrade - it shares this counter because it refuses the same client at the door in front of the handshake. That preflight also answers a repeated framing header with a 400, and that rejection is counted on no series, so a dashboard built on this counter sees duplicate-header refusals from the upgrade path only.
upgrade_duration_seconds{outcome} histogram sum Time from upgrade callback entry to admit, reject, abort, or error, with explicit 1 ms through 10 s buckets.
upgrade_rate_map_evicted_total{door} counter sum Rate-limit entries evicted to make room at the map cap. door is upgrade or auth - a sustained rate on either door means rotating client identities are churning that limiter's map faster than the periodic sweep reclaims it.
upgrade_inflight gauge sum Upgrades currently between admission and open (sampled once per pressure interval).
upgrade_deferred_depth gauge sum Upgrade callbacks retained by the bounded per-worker pacing queue.
upgrade_deferred_oldest_age_seconds gauge max Age of the oldest callback retained by any worker's pacing queue; 0 when empty.
upgrade_deferred_rejected_total counter sum Upgrade callbacks shed because the bounded pacing queue was full. The same decisions also increment upgrade_rejected_total{reason="deferred_overflow"}.
ws_connection_headroom gauge sum Remaining per-worker maxConnections permits across reserved upgrades and live sockets. Registered only when the finite ceiling is enabled and updated on every acquire/release.
waiting_room_queue_depth gauge sum Clients currently polling the waiting room (sampled; 0 with the room off).
protection_posture_state gauge max The live posture: 0 normal, 1 elevated, 2 siege (sampled). Levels are severity-ordered, so the cluster reads as the most defensive posture any worker has engaged.
protection_posture_transitions_total{from,to} counter sum Posture level changes - chart it next to the rejected reasons for an incident timeline.
ws_connections gauge sum Live WebSocket connections on this worker (sampled).
ws_connection_duration_seconds{outcome} histogram sum Connection lifetime, split into clean (1000/1001) and abnormal closure, with explicit 1 s through 24 h buckets.
ws_messages_total{kind,outcome} counter sum Completed inbound text/binary messages, split into successful and throwing handler outcomes.
ws_message_admission_rejected_total{reason,scope} counter sum Application messages shed before app-hook, binary-ingress, or game-publish work by established-message admission. reason is rate_limit, concurrency_limit, or queue_full; scope is connection or global.
ws_message_duration_seconds{kind,outcome} histogram sum End-to-end inbound message handling duration, including awaited hooks, with explicit 100 us through 1 s buckets.
ws_subscriptions gauge sum Live topic subscriptions across this worker's connections (sampled). Divide by ws_connections for the subscriber ratio - the two are exported separately rather than as a precomputed ratio, because averaging per-worker ratios is not the cluster ratio, while summing numerator and denominator is.
ws_publishes_total counter sum Publish calls made on this worker. Counts publishes, never per-recipient deliveries: a publish is one logical event however many subscribers it reaches, and what each one cost is the egress ledger's business. A counter rather than the sampler's precomputed rate, so the query picks its own window.
ws_publish_outcomes_total{outcome} counter sum Every fan-out one publish call hands to the transport, classified as delivered or no_subscribers; aggregate only, never a per-recipient breakdown. Per fan-out, not per logical publish: a publish that excludes a socket takes the per-socket walk and is absent from this counter, while a publish on a shared: true codec's topic makes one fan-out per cohort it publishes to and is counted once for each, so a no_subscribers there answers for one cohort and says nothing by itself about what the other fan-out delivered. ws_publishes_total counts the logical publishes.
ws_backpressure_max_bytes gauge max Worst per-connection outbound buffered bytes over the sampled connection set (0 when healthy). Compare against maxBackpressure for headroom before the send lane sheds.
ws_backpressure_connections gauge sum Sampled connections holding a backpressured outbound queue (0 when healthy).
ws_dropped_frames_total counter sum Exact outbound frames the send lane shed at the configured backpressure limit.
ws_dropped_bytes_total counter sum Exact payload bytes in outbound frames the send lane shed at the configured backpressure limit.
egress_refused_total{scope} counter sum Publishes refused pre-hoc by a configured websocket.egress ceiling: nothing was delivered, relayed, or sequence-stamped for them, unlike the backpressure drops above, which shed frames already accepted for delivery. scope is topic or tenant; per-window figures ride platform.pressure.egress.
egress_window_evicted_total{scope} counter sum Live usage windows dropped at the egress ledger key cap, one per evicted key. The evicted key stops being held to its ceiling for the rest of its window, and because the symptom is FEWER refusals, this is the only signal that distinguishes a budget out of ledger room from traffic that simply fits. scope is topic or tenant.
pressure_saturation gauge max Worker saturation scalar, 0 healthy to 1 at the configured thresholds.
pressure_reason gauge max The live pressure reason as a severity-ordered code: 0 none, 1 subscribers, 2 publish rate, 3 psi, 4 cpu quota, 5 capacity, 6 memory. Ordered so the cluster reads as the worst reason any worker reported, matching the precedence the sampler itself applies.
pressure_reason_transitions_total{from,to} counter sum Pressure reason changes, including both incident entry and recovery. The bounded reason vocabulary keeps the transition matrix low-cardinality, and the counter preserves a brief incident that starts and recovers between scrapes.
pressure_sample_timestamp_seconds gauge min Unix time of the most recent completed pressure sample. The sampling timer is unref'd; if it ever stops, every sampled gauge above keeps serving its last value while the target still reports up. Alert on the age of this timestamp - it is the only thing that separates healthy-and-steady from frozen.
resident_memory_bytes gauge max Resident set size of the process. Worker threads share one address space, so every worker reports the same value.
heap_used_ratio gauge max Absent when no memory ceiling is known. Used fraction of the nearest memory wall: heap against the V8 heap_size_limit, resident set against the cgroup memory limit, worst-of. The heap arm is per-isolate; the rss arm is process-wide, so workers converge when the container wall dominates. The worst worker is the one nearest a wall. Above 0.7 the send gate grants flow-controlled connections smaller windows, half-size at the default 0.85 MEMORY threshold.
psi_cpu_some_avg10 gauge max Kernel pressure-stall CPU some avg10: percent of the last 10s any task was stalled on CPU. Registered only where the kernel exposes PSI, probed once at startup, so absent elsewhere rather than reporting a zero that reads as "no pressure". After registration, a transient read failure emits NaN for that sample instead of serving a stale value beside a fresh generic timestamp.
psi_memory_full_avg10 gauge max Kernel pressure-stall memory full avg10. Same startup availability and transient-NaN semantics.
psi_io_full_avg10 gauge max Kernel pressure-stall IO full avg10. Same startup availability and transient-NaN semantics.
cpu_throttled_ratio gauge max Fraction of the sampled window the cgroup CPU quota held this process suspended. Registered only where a cgroup CPU stat is readable, probed once at startup. A transient failure emits NaN; recovery establishes a new delta baseline so a multi-window gap cannot fabricate a throttle spike.
state_divergence_total{role} counter sum Cross-worker state-hash divergence detections (clustered mode, when stateHashIntervalMs is set). role is majority or minority. No topic strings or client identity - the hash is structure-only.
relay_gap_frames_total counter sum Relayed frames proven lost to this worker (interior relay gaps). Counts frames, not incidents, so one lost burst reads as the burst it was. A lower bound: losses inside an already-reported window fold into that report.
relay_spill_quarantines_total{reason} counter sum Lagging cross-worker relay peers quarantined at the finite spill ceiling. reason is bytes or age; the normal clean-exit supervisor replaces the worker.
relay_spill_dropped_bytes_total counter sum Pending relay bytes discarded when the lagging peer is quarantined. Counts only producer spill already outside the shared ring, not an inferred application-delivery total.
relay_spill_pending_age_seconds gauge max Worst oldest-pending relay spill age observed at quarantine; 0 until a worker has seen one, so healthy is queryable as zero rather than absent. The age measures time since the peer last made drain progress - a stall detector - not how long a backlog has merely existed, so a peer draining steadily while behind never accrues it. A peer that has not yet attached its relay reader is handed no frames at all, so it never accrues one either. Compare with CLUSTER_RELAY_MAX_PENDING_MS; no topic or client identity is exposed.
relay_frame_refused_total{lane} counter sum Publishes refused by this worker's sender-side relay frame ceiling (CLUSTER_RELAY_MAX_FRAME_KB): local subscribers received them, only the cross-worker copy was dropped. lane is publish or batched; a batched refusal is wholesale, because the whole publishBatched array travels as one frame.
relay_frame_oversized_total counter sum Relay frames the primary refused to reassemble, decided from the length prefix before allocating for the frame. A frame this far past the sender ceiling means a peer not applying it, or a corrupt stream; the sender's relay stream is stopped and its own spill ceiling then retires it. A primary-side incident, attributed once to a surviving worker's registry like the quarantines.
open_fds gauge max File descriptors currently open by the process (sampled every ~5 pressure intervals). Registered only where an fd directory exists (Linux, macOS). Worker threads share one process-wide table, so every worker reports the same whole-process value - max(), never sum().
fd_soft_limit gauge max The soft file-descriptor limit; new sockets fail with EMFILE at this count. Chart open_fds against it for connection headroom. Registered only where the limit is readable. Same whole-process note as open_fds.
framework_assertion_violations_total{category,severity} counter sum Framework invariant violations, mirroring the queryable platform.assertions Map. severity is soft (a recoverable assert) or fatal (a hard-tier termination). Category cardinality is bounded by the source-declared categories - never user input.
framework_resource_growth_suspected_total{resource} counter sum Sustained-growth suspicions raised by the optional resource-growth auditor. Registered only when resourceGrowthAuditIntervalMs is set (off by default).
metrics_snapshot_workers_expected gauge max Workers a metricsSnapshot() asked for a report. Present only in a snapshot document; no worker registers it.
metrics_snapshot_workers_reporting gauge max Workers that answered before the deadline with every required counter registered and every required worker gauge sampled. Below _expected means the merged document is partial - every summed series may be understated, and a dip must not be read as a real drop in traffic.
metrics_snapshot_degraded gauge max 1 when the collection did not complete at all - the primary could not be reached, or the deadline expired - so the document is one worker rather than the cluster. A worker that never heard back cannot know how many siblings it has, so _expected and _reporting would agree with each other and the document would read as complete. This is the flag to alert on; the expected/reporting difference catches the partial case, this catches the total one.

The compact table below is the canonical machine-checked contract behind the operator notes above. count maps to the manifest's dimensionless null unit; worker and merge in Origin distinguish factories the runtime calls from families written only by metricsSnapshot(). A dash means no labels or no derived formula.

Metric Factory/type Labels Unit Scope Aggregate Origin Formula Help
http_requests_total counter method,outcome count worker sum worker - Completed HTTP requests by bounded method and outcome
http_request_duration_seconds histogram method,outcome seconds worker sum worker - HTTP request completion duration in seconds
upgrade_admitted_total counter - count worker sum worker - WebSocket upgrades accepted
upgrade_rejected_total counter reason count worker sum worker - WebSocket upgrades rejected before open
upgrade_duration_seconds histogram outcome seconds worker sum worker - WebSocket upgrade decision duration in seconds
upgrade_rate_map_evicted_total counter door count worker sum worker - Rate-limit entries evicted at the map cap
upgrade_inflight gauge - count worker sum worker - Upgrades currently between admission and open
upgrade_deferred_depth gauge - count worker sum worker - Upgrade callbacks waiting in the bounded pacing queue
upgrade_deferred_oldest_age_seconds gauge - seconds worker max worker - Age of the oldest callback in the bounded upgrade pacing queue
upgrade_deferred_rejected_total counter - count worker sum worker - Upgrade callbacks shed because the bounded deferral queue was full
ws_connection_headroom gauge - count worker sum worker - Remaining reserved-or-live WebSocket connection permits
waiting_room_queue_depth gauge - count worker sum worker - Clients currently polling the waiting room
protection_posture_transitions_total counter from,to count worker sum worker - Protection posture level changes
protection_posture_state gauge - enum worker max worker - Current protection posture (0 normal, 1 elevated, 2 siege)
ws_connections gauge - count worker sum worker - Live WebSocket connections
ws_connection_duration_seconds histogram outcome seconds worker sum worker - WebSocket connection lifetime in seconds
ws_messages_total counter kind,outcome count worker sum worker - Completed inbound WebSocket messages by kind and outcome
ws_message_admission_rejected_total counter reason,scope count worker sum worker - Application WebSocket messages shed by established-message admission
ws_message_duration_seconds histogram kind,outcome seconds worker sum worker - Inbound WebSocket message handling duration in seconds
ws_subscriptions gauge - count worker sum worker sum(ws_subscriptions) / sum(ws_connections) Live topic subscriptions; divide by ws_connections for the subscriber ratio
ws_publishes_total counter - count worker sum worker - Publish calls made, never per-recipient deliveries
ws_publish_outcomes_total counter outcome count worker sum worker - Publish calls by aggregate delivery outcome
ws_backpressure_max_bytes gauge - bytes worker max worker - Worst per-connection outbound buffered bytes over the sampled set
ws_backpressure_connections gauge - count worker sum worker - Sampled connections holding a backpressured outbound queue
ws_dropped_frames_total counter - count worker sum worker - Outbound WebSocket frames dropped under backpressure
ws_dropped_bytes_total counter - bytes worker sum worker - Outbound WebSocket payload bytes dropped under backpressure
egress_refused_total counter scope count worker sum worker - Publishes refused by a configured egress ceiling; nothing was delivered or relayed for them
egress_window_evicted_total counter scope count worker sum worker - Live usage windows evicted at the ledger cap; each one stops enforcing its ceiling for the rest of its window
pressure_saturation gauge - ratio worker max worker - Worker saturation, 0 healthy to 1 at the configured thresholds
pressure_reason gauge - enum worker max worker - Pressure reason as a severity-ordered code (0 none to 6 memory)
pressure_reason_transitions_total counter from,to count worker sum worker - Pressure reason changes, including incidents and recoveries
pressure_sample_timestamp_seconds gauge - seconds worker min worker - Unix time of the most recent pressure sample; alert on its age
resident_memory_bytes gauge - bytes process max worker - Resident set size of the process
heap_used_ratio gauge - ratio worker max worker - Used fraction of the nearest memory wall (heap vs the V8 limit, resident set vs the cgroup memory limit, worst-of)
psi_cpu_some_avg10 gauge - percent process max worker - Kernel pressure-stall CPU some avg10
psi_memory_full_avg10 gauge - percent process max worker - Kernel pressure-stall memory full avg10
psi_io_full_avg10 gauge - percent process max worker - Kernel pressure-stall IO full avg10
cpu_throttled_ratio gauge - ratio process max worker - Fraction of the window the cgroup CPU quota held the process suspended
open_fds gauge - count process max worker - File descriptors currently open by the process
fd_soft_limit gauge - count process max worker - Soft file-descriptor limit; new sockets fail with EMFILE at this count
state_divergence_total counter role count worker sum worker - Cross-worker state hash divergence detections
relay_gap_frames_total counter - count worker sum worker - Relayed frames proven lost to this worker
relay_spill_quarantines_total counter reason count worker sum worker - Workers quarantined after a relay spill ceiling
relay_spill_dropped_bytes_total counter - bytes worker sum worker - Pending relay bytes discarded when a lagging worker was quarantined
relay_spill_pending_age_seconds gauge - seconds worker max worker - Worst oldest-pending age observed at relay spill quarantine
relay_frame_refused_total counter lane count worker sum worker - Publishes refused by the sender-side relay frame ceiling; local subscribers still received them
relay_frame_oversized_total counter - count worker sum worker - Relay frames refused at the reassembly ceiling; the sending worker relay stream was stopped
framework_assertion_violations_total counter category,severity count worker sum worker - Framework production-assertion violations by category and severity
framework_resource_growth_suspected_total counter resource count worker sum worker - Sustained resource-growth suspicions raised by the optional auditor
metrics_snapshot_workers_expected gauge - count process max merge - Workers the cluster metrics snapshot asked for a report
metrics_snapshot_workers_reporting gauge - count process max merge - Workers with complete metric reports before the deadline
metrics_snapshot_degraded gauge - count process max merge - 1 when the collection did not complete and this document is one worker, not the cluster

await platform.metricsSnapshot() is the cluster-wide read. It is built from the values the adapter wrote rather than from rendered text, so it needs no serialize() and stays on canonical unprefixed manifest names however the registry renders its own output. Under CLUSTER_WORKERS the primary collects every worker and merges.

Two different things can go wrong, and they are reported by different signals. A worker that misses the primary's deadline is simply absent from the merge: the document renders what arrived and metrics_snapshot_workers_reporting falls below ..._expected, with metrics_snapshot_degraded still 0. A scrape that never hears back from the primary at all answers with the requesting worker ALONE and sets metrics_snapshot_degraded 1 - the expected/reporting pair cannot say so, because a worker that got no answer does not know how many siblings it has. Alert on both: degraded means the collection failed, a reporting shortfall means it succeeded and someone was missing.

metrics_snapshot_workers_expected and ..._reporting are how you tell a healthy fleet from a partial one - a worker counts as reporting only once it has registered every required counter family and sampled every required gauge.

With the Vite plugin installed - the standard setup - the registry is bundled into the app's own server graph and deduplicated with every route that imports it, so platform.metrics and a direct import read the same object. Without it the adapter falls back to a standalone bundle, which instantiates the module twice: adapter counters land on a copy only platform.metrics reaches and an app-graph import reads the other, empty one. The build warns when it takes that fallback.

The reserved /__realtime/* admin route

When your WebSocket handler exports an admin(request) function - svelte-realtime's auth-gated observability handler is the canonical one - the adapter mounts it at the reserved /__realtime/* path, matched before the static and SSR lanes. The adapter bridges the node:http request to the framework-agnostic Web Request -> Response contract the handler speaks and writes the response back; it is pure transport plumbing, so all authorization lives in your handler (the adapter never inspects or short-circuits the decision). A handler that throws, rejects, or returns a non-Response yields a generic 500 with no detail leaked. The route is a no-op unless the handler exports admin, so existing apps are unaffected.

The prefix is matched on the target as sent, while the lanes behind it read the target differently - building the Request resolves dot segments, and both the prerendered lookup and SvelteKit's router percent-decode before matching. Where those readings disagree the request is refused with 400 rather than rerouted:

GET /__realtime/../reflect          inside the prefix, resolves out of it
GET /foo/../__realtime/introspect   outside the prefix, resolves into it
GET /%5f%5frealtime/introspect      outside the prefix, decodes into it

Each is routed as one path and read as another, and rerouting would pick one of those readings on the caller's behalf. So while the route is mounted, an app route inside the prefix is unreachable by the encoded spellings as well as the resolved ones. That is worth knowing if the reservation is part of a security boundary: a fronting proxy's ACL written against the literal /__realtime sees none of these spellings, so the guarantee rests on the adapter refusing them rather than on the proxy. Without an admin export there is no mount and no reservation, and the prefix is an ordinary path your own routes can serve. One spelling stays inside the prefix on every reading and is passed through: an encoded separator, as in /__realtime/..%2freflect, reaches your admin() handler with the %2F intact. Split the pathname on / before decoding any segment - as svelte-realtime's admin() does - so a decoded .. can never walk your own routing.

Configure the prefix with websocket.adminPath:

// vite.config.ts - inside sveltekit({ adapter: ... })
adapter({
  websocket: {
    adminPath: "/__ops", // relocate it (default '/__realtime')
    // adminPath: false    // OR disable the auto-mount entirely
  },
});

Set a string to relocate the route (defense-in-depth, or to avoid colliding with an app route), or false to disable the auto-mount entirely - for apps that mount the admin handler themselves through a SvelteKit +server.js route (with their own middleware), so there is no second adapter-owned mount point. It must be an absolute path differing from websocket.path and websocket.authPath; an invalid value fails the build. The svelte-realtime admin handler is mount-prefix agnostic, so the path is configured here in one place.

Because the adapter cannot see whether your handler gates its own requests, it warns once at boot that the mounted route carries no adapter-level authentication. Set websocket.adminAuthAcknowledged: true once the handler validates a session cookie, bearer token or equivalent and that line stops appearing; it changes nothing about routing or authorization.

Tracing is opt-in and vendor-neutral: point tracing at a server module whose default (or named tracing/provider) export implements startSpan(name, options) - an OpenTelemetry adapter fits in a dozen lines. With a provider configured, the runtime opens spans for SSR, static and prerendered serving, WebSocket upgrades and per-message hooks, propagates W3C traceparent/tracestate from the request into the connection, and exposes the active context as platform.trace / platform.traceContext. Without the option the tracing paths cost one boolean test.

Set CANONICAL_HOST_ALIASES to comma-separated, case-insensitive exact authorities (such as www.example.com,preview.example.com:8443) to return a 308 redirect to ORIGIN before static delivery or SSR. Ports match exactly. ORIGIN must be an HTTP(S) origin without credentials, path, query or fragment, and its authority cannot be an alias. Raw paths and queries and the request method are preserved. Mounted health and readiness probes remain available. Unlisted hosts serve normally; configured proxy host, port and protocol headers keep their existing trust behavior. This feature is disabled by default.

Runtime environment (prefix configurable via the envPrefix option):

The Vite plugin reads the same prefix from the adapter configuration. For envPrefix: 'MY_APP_', set MY_APP_ORIGIN to pin the dev authentication request URL and same-origin checks; bare ORIGIN is ignored. An empty prefix keeps using ORIGIN.

Variable Default Meaning
HOST / PORT 0.0.0.0 / 3000 Listen address.
ORIGIN - The app's public origin (behind a TLS-terminating proxy).
CANONICAL_HOST_ALIASES (disabled) Comma-separated host authorities redirected to ORIGIN with 308 before static, prerendered and SSR handling. Example: www.example.com,preview.example.com:8443. Matching ignores case and preserves explicit ports; no wildcards. ORIGIN must be an HTTP(S) origin without credentials, path, query or fragment and cannot be listed as an alias.
PROTOCOL_HEADER / HOST_HEADER / PORT_HEADER - Proxy identity headers when ORIGIN is not pinned. Without ORIGIN, the URL your app, your admin() handler and your authenticate hook see is built from these or from the Host header, and each must be what it names: a host name, IPv4 address or bracketed IPv6 address with an optional port, a port number, and http or https. Anything else is answered 400 before your code runs, and a refused configured header prints one warning naming it.
ADDRESS_HEADER / XFF_DEPTH - / 1 Client IP resolution behind proxies.
TRUSTED_PROXIES - CIDR allowlist; identity headers from peers outside it are ignored.
BODY_SIZE_LIMIT 512K Request body cap (413 above it).
SHUTDOWN_TIMEOUT 30 Seconds for the WHOLE teardown sequence - app shutdown hooks, the WebSocket drain, then the HTTP in-flight drain share this one budget; 0 removes it (live WebSockets still drain within 30s - a socket never ends on its own). A request still open when it runs out has its request.signal aborted before it is dropped.
SHUTDOWN_DELAY_MS 0 Readiness-flip lead time for balancers that poll.
RECONNECT_DISPERSAL_MS 5000 Reconnect-advisory window at drain; 0 disables the advisory.
PROXY_PROTOCOL off 1 uses the PROXY v2 source address, gated by TRUSTED_PROXIES when set.
SSL_CERT / SSL_KEY - Paths to one PEM certificate and private key.
SSL_SNI_HOSTS - Flat comma-separated SNI host override for the certificate's hot-reload; defaults to SAN DNS names, then subject CN.
SSL_WATCH / SSL_RELOAD_DEBOUNCE_MS 1 / 500 Certificate hot-reload watch.
CLUSTER_WORKERS - In-process cluster: worker-thread count or auto (Linux; see Deployment).
CLUSTER_MODE reuseport The one mode this runtime has; acceptor refuses with the reason.
CLUSTER_RELAY_RING_KB 256 Shared-memory relay ring per direction per worker; 0 = postMessage only.
CLUSTER_RELAY_MAX_PENDING_KB / _MAX_PENDING_MS 4096 / 5000 Per-peer relay spill ceilings; a worker past them is quarantined and replaced.
CLUSTER_RELAY_MAX_FRAME_KB pending KB Sender-side ceiling on one relayed envelope; 0 disables.
WORKER_BOOT_TIMEOUT_MS 60000 Boot deadline for a worker that stops acking liveness mid-init; 0 disables.
WS_DEBUG - Set to 1 to enable structured WebSocket debug logging (open, close, subscribe, publish)

A SIGTERM/SIGINT during a shutdown already running changes nothing: that shutdown keeps its SHUTDOWN_TIMEOUT budget. Invalid cluster configurations refuse the boot (see Deployment).

What the portability tier gives up is throughput, not features: peak HTTP and socket rate, idle-connection density, and large-topic JSON fan-out. What it does not give up is capability. In-process TLS is first class through node:https, with the family's surface - SNI and certificate hot reload. Node also brings HTTP/2 and the entire observability ecosystem (APM agents, OpenTelemetry auto-instrumentation, AsyncLocalStorage, --inspect), none of which hooks a native addon.

What ships, mechanically

  1. API probe: probe/ws-api-facts.mjs empirically verifies every node:http and ws behavior the adapter design relies on - send results and backpressure signals, closed-socket behavior, the upgrade flow, payload limits, compression, shutdown drain, listen options - and writes a committed facts report. The adapter is built against what these libraries were measured to do, not against what their documentation says. Run it with npm run probe after any Node or ws upgrade and review the diff.

    What the first run established, and what each fact costs:

    • send() returns nothing and never throws, on an open or a closed socket. The uWS-shaped tri-state return has to be synthesized by the facade from bufferedAmount plus the send callback, which does report WebSocket is not open: readyState 3 (CLOSED) for a dead socket.
    • Nothing on a ws socket throws when it is closed - not send, not ping, not close. The throw-on-closed contract that drives dead-connection cleanup in the extensions package is entirely facade work here.
    • There is no native pub/sub: none of subscribe, unsubscribe, publish, isSubscribed, getTopics, cork or getBufferedAmount exists on a socket, and WebSocketServer has no publish. Topic fan-out is a JS registry walk.
    • bufferedAmount is in bytes and grows under a slow consumer; the send callback fires once the peer drains, which is the drain signal the backpressure pump needs.
    • The handshake survives an await before handleUpgrade, so async admission before completing the upgrade works; destroying the socket mid-upgrade refuses the client cleanly.
    • http.close() does not call back while a WebSocket is open, and live sockets keep working after it. A managed drain is required equipment.
    • server.listen({ reusePort: true }) is ENOTSUP on Windows, which is why the in-process cluster requires Linux and refuses everywhere else instead of booting workers whose binds fail.
    • The public @sveltejs/kit/node primitives (getRequest, setResponse, createReadableStream) are all present, which is what the HTTP half is built on - never adapter-node's private handler.
  2. HTTP half: a built SvelteKit app serves over node:http - or node:https with first-class in-process TLS: a PEM pair (SSL_CERT/ SSL_KEY) and certificate hot-reload (SSL_WATCH, default on; SSL_RELOAD_DEBOUNCE_MS) that registers a renewed certificate under its SAN DNS names (or the flat SSL_SNI_HOSTS override) in place, so a certbot renewal is served on new SNI-matched handshakes without re-binding the listen socket or dropping a live connection. A non-SNI / unmatched-SNI client keeps the boot-time certificate until a restart: the default context is static. The probe TLS section runs unattended against committed fixtures. HTTP serving goes through the public @sveltejs/kit/node primitives - getRequest, setResponse, createReadableStream - bundled into the build output so a production install needs no devDependencies. The in-memory static cache answers with precompressed representations negotiated by the weights in Accept-Encoding (RFC 9110 12.5.3; per-representation weak ETags), single byte ranges cut in the negotiated representation's coordinates (an unsatisfiable one, a zero-length suffix bytes=-0 included, is a 416 quoting that representation's length), If-Match/If-Unmodified-Since/If-None-Match/If-Modified-Since/If-Range preconditions (a 304 repeats the representation's 200 header fields, every configured staticHeaders entry included, except what describes the body: Content-Type, Content-Length, Content-Encoding and Content-Disposition), the dotfile refusal with its .well-known carve-out, and the prerendered trailing-slash alias and 308 rules, the Location percent-encoded so a route with a non-ASCII name redirects to the bytes a browser sends for it. A request line carrying raw non-ASCII bytes is refused 400 by node's own parser before any lane reads it: a request target is ASCII (RFC 9112 3.2) and no node:http option, the insecure parser included, admits one, where the native tier hands the decoded characters to the lookup. SSR gets concurrent-request dedup for anonymous GET/HEAD, single-chunk dynamic compression negotiated the same way, with the BREACH-defense credential skip, a default x-content-type-options: nosniff fill, and the family duplicate-header policy (repeated singleton headers are refused, proxy identity headers keep their last line). Answers are framed by what the runtime writes, not by what the Response declares: a body that ends after its first chunk carries its real length, as does one the dedup leader read whole under its cap, one that keeps coming goes out chunked, a HEAD takes the length the app declared, and a 204 or 304 carries only the length RFC 9110 8.6 allows. Health and readiness probes answering GET and HEAD, readiness-gated SSR warmup, ADDRESS_HEADER/TRUSTED_PROXIES client-IP resolution and a managed drain of in-flight requests are in. Each SSR and admin request's request.signal aborts when its client disconnects before the request body has arrived in full - always, for a request without one - as under SvelteKit's Node adapter, and, with a reason naming the shutdown, when the shutdown budget runs out and the request is about to be dropped, so a handler that passes the signal to a fetch or a query can stop. PROXY v2 metadata is consumed before HTTP on TCP and decrypted TLS streams. PROXY_PROTOCOL=1 substitutes the first claimed client address; TRUSTED_PROXIES, when configured, checks the actual socket peer for both PROXY and ADDRESS_HEADER claims. Repeated preambles retain the first claim.

  3. JSON realtime: the upgrade path with async admission, origin policy, per-IP rate limiting, upgrade timeout and validated custom 101 headers; the socket facade that synthesizes the family tri-state send result (0 enqueued / 1 sent / 2 dropped) from bufferedAmount plus the backpressure ceiling and throws on closed sockets exactly where the sibling packages' liveness sweeps expect it; the JS topic registry with a per-topic reverse index so a publish walks subscribers, not connections; and the full platform surface - publish (seq-stamped per topic), publishBatched with the shared batch frame when every interested subscriber can decode it, send, sendTo, sendCoalesced with the drain pump, request/requestTopic over reply frames, subscribe/checkSubscribe/unsubscribe with the shared authorization-policy predicates and pending-subscribe revocation machinery, the game lane (grantPublish/publishGame), adviseReconnect, hlc, and the extensions-facing symbol slots under the family's shared Symbol.for keys. Control frames: welcome, hello/caps with the lease grant, subscribe and subscribe-batch with resume-on-subscribe gap-fill barriers, unsubscribe, resume, reply, request-n, game, and the oversized-control-frame refusal. The authenticate preflight endpoint has CSRF defense and rate limiting. connect({ auth: true }) sends x-requested-with: XMLHttpRequest before opening the socket. Custom middleware that checks this header must accept XMLHttpRequest. App hooks fire through the same lifecycle as the lead adapter (init before readiness, shutdown inside the drain budget). Every websocket.* option the family declares ships here. The per-connection caps are the family's: 1,000,000 for the large retained-state maps, 65,536 for subscriptions per connection (a landed subscription is a topic-registry entry as well as a Set entry, so one connection must not be able to hold hundreds of megabytes the connection ceiling cannot see), and the small fixed ones for pending subscribes and ingress bindings; the 65,537th subscribe is answered subscribe-denied with RATE_LIMITED and everything held stays. Graceful shutdown drains live sockets itself (http.close() never completes while one is open). Readiness becomes unavailable when drain begins; upgrades on accepted connections still follow ordinary admission. Refused handshakes keep their HTTP connection usable after the listener closes. Every live client gets the reconnect advisory with the RECONNECT_DISPERSAL_MS window (default 5000, 0 disables the advisory) and a 1001 close, and whatever ignores the close frame past the budget is terminated.

  4. Binary wire: the 0x03 frame fan-out for capability-advertising subscribers with the wire-id announce ordered on the same socket before the first frame, per-connection topic ids (monotonic from 1, never reclaimed), per-connection codec state attached once per capability and detached on close, the stateful-drop poison rule (a frame shed past maxBackpressure degrades that capability to JSON until reconnect - a stateful decoder cannot resync in-band), the shared batch encode with per-entry JSON fallback, the client-to-server ingress lane (wire.ingress:1, ingress-bind/ingress-bound, the game:1 twin routed through the publish grant) and the compact game.fanout:1 egress. The frame layout is pinned against the family conformance vector (test-vectors/binary.json), varints decoded with division so shared ids above 2^32 survive. Seq values ride both representations from one stamp.

  5. Pressure: the 1 Hz sampler behind platform.pressure mutates one stable snapshot in place - publish rate, subscriber ratio, the memory-wall ratio (distance to the nearest of the V8 heap limit and the cgroup limit, never arena fullness), the bounded bufferedAmount walk (1024-connection cap, 64 KiB threshold), the exact shed window, kernel PSI and CFS-throttle signals where Linux provides them, and the top publishers by rate. platform.onPressure fires on reason transitions, platform.onPublishRate once per window; lease grants are sized from the live heap ratio and subscriber ratio, and the client-reported send-gate backlog folds into the headline value. websocket.pressure thresholds are honored. npm run bench:slow-consumer proves engagement end to end: a TCP-paused consumer must appear in the sampler, shed at the maxBackpressure ceiling, and recover to a clean snapshot - a zero stub fails the bench.

  6. In-process cluster: CLUSTER_WORKERS=<n|auto> turns the process entry into a primary that runs the app's websocket.primaryInit once (its return value - SharedArrayBuffers included - replays as workerData.app to every worker and respawn) and spawns one worker thread per slot. Each io worker binds the shared port itself with SO_REUSEPORT and the kernel distributes accepts; websocket.workers { compute } slots boot the full app without a listen socket for app-driven shared-memory work. Cross-worker publish fan-out rides two SharedArrayBuffer rings per worker (encode once, primary forwards bytes verbatim, only receivers decode) with postMessage as the fallback and control lane; per-peer spill ceilings quarantine a worker that stops draining, and a sender-side frame ceiling refuses the pathological publish at its source. The primary heartbeats the fleet, escalates a wedged worker through the clean-exit protocol (terminating just that thread after the grace), respawns crashed slots under per-slot exponential backoff with a stable-uptime budget reset, owns the TLS cert-directory watch (workers swap their own contexts on its broadcast), and drains readiness fleet-wide before the shutdown delay. Every invalid configuration refuses before a worker spawns, through the error catalog. In a multi-worker topology sequenced publishes require an explicit external authority ({ seq: <n>, relay: false }) or { seq: false } - per-worker counters cannot preserve one monotonic topic sequence, and the refusal says so at the publish site.

  7. Golden gate: the platform-surface parity site reads both adapters' platform object literals by AST and fails when a key the lead carries is missing here - and it fails loudly when the lead checkout is absent, because a gate that skips is not a gate (UWS_SRC names the checkout). The wire revision is pinned byte-identical against the lead's protocol.schema.json, and the frame layout against the family conformance vector. The deterministic golden trace runs the REAL built runtime platform under a seeded seam and a virtual clock over scripted connections and pins every emitted frame - JSON envelopes, batch frames, wire-id announces, 0x03 bytes, seq continuity across lanes - against a committed corpus, byte-for-byte (test/dst-goldens/; regenerate deliberately with UPDATE_GOLDENS=1 and review the diff).

Deployment

One process serves one core well. On a multi-core Linux host, set CLUSTER_WORKERS=<n|auto> and the built server runs the family's in-process cluster: a primary thread supervises one worker thread per slot, every io worker binds the shared port itself with SO_REUSEPORT so the kernel distributes accepts (no acceptor bottleneck, no single point of failure), and publishes fan out across workers over shared-memory relay rings. Same env, same options, same behavior as the native-tier adapter: websocket.primaryInit seeds shared memory once in the primary, websocket.workers.compute carves out compute workers that boot the app without a socket, crashed workers respawn into their slot under a backoff budget, and one SIGTERM drains the whole fleet through the ordinary readiness-flip/hook/drain sequence.

The cluster is Linux-only because it stands on SO_REUSEPORT accept distribution, which is a Linux kernel behavior; anywhere else the boot refuses loudly. The dev loop does not need it - a single process is the default and serves development fine - and multi-HOST scale-out is unchanged: one instance per host (or per core, where the cluster is not in play) under the platform process manager behind a load balancer, with svelte-adapter-uws-extensions providing the cross-instance relay, presence and clustering primitives over Redis. Container fleets that scale by replicas need neither reusePort nor CLUSTER_WORKERS - the balancer already spreads connections across replicas.

Speak HTTP/1.1 to it, in origin form. The server answers an HTTP/1.1 request whose target is a path: a request in any other version, OPTIONS *, a target in absolute form or a tab or another control character in the method or the target gets 505 and a closed connection, as on the native tier, so nginx before 1.29.7, which proxies with HTTP/1.0 by default, needs proxy_http_version 1.1; on every location that proxies to it, and a load balancer's health check has to probe a path in HTTP/1.1. Pipelined requests are answered while each response is written before the next request on the connection is parsed, which the native tier does once the request ahead has been handed to the application and the ticks and microtasks it queued have run. If its answer is still waiting then - on I/O or a timer, as an endpoint doing I/O does - the connection closes and neither request gets a byte it did not already have, the native tier's rule. A client's Connection: close takes effect after the read's final complete request has finished its answer. A later complete request in the same read replaces that policy while the answer is buffered, while an incomplete following head leaves it in place. A completed closing answer that fills the response buffer ends the connection before later dispatch. This also applies when a fixed-length or chunked body finishes in that read. Parser refusals that end the connection still stop later dispatch. RFC 9112 9.3.2 has a client retry requests a closed connection left unanswered, and browsers do not pipeline. vite dev runs on Vite's own server, which serves HTTP/1.0 and queues pipelined requests instead. test/http-pipelining-real.test.js and test/http-request-close.test.js pin these boundaries on the built runtime. An incomplete following request head does not interrupt an active answer, including an SSE stream. Its 10-second idle deadline starts after that answer finishes. Completed static, SSR and realtime HTTP answers that fill the 16 KiB response buffer for a read are released before a later pipelined refusal; smaller answers still held in that read are discarded.

systemd integration is automatic under a Type=notify unit and a no-op everywhere else: the runtime detects NOTIFY_SOCKET and sends READY once the service accepts traffic (after the app's init hook resolves in single-process mode; on first listen in clustered mode), STOPPING when a graceful shutdown begins, and, when WatchdogSec= is set, a WATCHDOG ping at half the timeout from a main-loop timer. Type=notify is the readiness gate: systemd does not consider the unit started before READY=1, and a frozen event loop stops the watchdog pings. The messages go through the systemd-notify helper, which on systemd 246 and later waits until the manager has processed each one before it exits; an older manager may fail to attribute a datagram whose sender has already exited and silently drop it, so below systemd 246 run the unit as Type=simple without WatchdogSec=.

Deployment examples include a systemd unit and container configuration with readiness, restart policy and shutdown grace. On Linux, run npm run drill:respawner to verify recovery after a worker wedges and after the primary process is killed. The drill uses its own token-protected fixture and checks that each replacement process becomes ready before continuing.

License

MIT

About

SvelteKit adapter with built-in WebSocket support (ws)

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages