npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

svelte-adapter-uws

v0.5.1

Published

SvelteKit adapter for uWebSockets.js - high-performance C++ HTTP server with built-in WebSocket support

Readme

svelte-adapter-uws

A SvelteKit adapter powered by uWebSockets.js - the fastest HTTP/WebSocket server available for Node.js, written in C++ and exposed through V8.

I've been loving Svelte and SvelteKit for a long time. I always wanted to expand on the standard adapters, sifting through the internet from time to time, never finding what I was searching for - a proper high-performance adapter with first-class WebSocket support, native TLS, pub/sub built in, and a client library that just works. So I'm doing it myself.

What you get

  • HTTP & HTTPS - native TLS via uWebSockets.js SSLApp, no reverse proxy needed
  • WebSocket & WSS - built-in pub/sub with a reactive Svelte client store
  • In-memory static file cache - assets loaded once at startup, served from RAM with precompressed brotli/gzip variants
  • Dynamic response compression - SSR HTML and API JSON compressed on the fly with brotli or gzip
  • Backpressure handling - streaming responses that won't blow up memory
  • Graceful shutdown - waits for in-flight requests before exiting
  • Health check endpoint - /healthz out of the box
  • Zero-config WebSocket - just set websocket: true and go

Upgrading from 0.4.x? See the migration guide for every breaking change between 0.4.x and 0.5.x.


Table of contents

Getting started

Configuration

WebSocket deep dive

Plugins

Deployment & scaling

Examples

Help


Getting started

Version compatibility

The three ecosystem packages move together. Bump them as a group:

| svelte-adapter-uws | svelte-realtime | svelte-adapter-uws-extensions | Notes | |---|---|---|---| | ^0.4.x | ^0.4.x | ^0.4.x | Legacy stable | | ^0.5.0 | ^0.5.0 | ^0.5.0 | Current. Node 22+ required. See MIGRATION.md if upgrading from 0.4. |

Mixed-version installs are rejected at install time with a peer-dep warning.

Installation

Starting from scratch

If you don't have a SvelteKit project yet:

npx sv create my-app
cd my-app
npm install

Adding the adapter

npm install svelte-adapter-uws
npm install uNetworking/uWebSockets.js#v20.60.0

Note: uWebSockets.js is a native C++ addon installed directly from GitHub, not from npm. It may not compile on all platforms. Check the uWebSockets.js README if you have issues.

Docker: Use node:22-trixie-slim or another glibc >= 2.38 image. Bookworm-based images and Alpine won't work. See Deploying with Docker.

If you plan to use WebSockets during development, also install ws:

npm install -D ws

Quick start: HTTP

The simplest setup - just swap the adapter and you're done.

svelte.config.js

import adapter from 'svelte-adapter-uws';

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

Build and run:

npm run build
node build

Your app is now running on http://localhost:3000.

To change the host or port:

HOST=0.0.0.0 PORT=8080 node build

Quick start: HTTPS

No reverse proxy needed. uWebSockets.js handles TLS natively with its SSLApp.

svelte.config.js - same as HTTP, no changes needed:

import adapter from 'svelte-adapter-uws';

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

Build and run with TLS:

npm run build
SSL_CERT=/path/to/cert.pem SSL_KEY=/path/to/key.pem node build

Your app is now running on https://localhost:3000.

Both SSL_CERT and SSL_KEY must be set. Setting only one will throw an error.

Behind a reverse proxy (nginx, Caddy, etc.)

If your proxy terminates TLS and forwards to HTTP:

ORIGIN=https://example.com node build

Or if you want flexible header-based detection:

PROTOCOL_HEADER=x-forwarded-proto HOST_HEADER=x-forwarded-host node build

Important: PROTOCOL_HEADER, HOST_HEADER, PORT_HEADER, and ADDRESS_HEADER are trusted verbatim. Only set these when running behind a reverse proxy that overwrites the corresponding headers on every request. If the server is directly internet-facing, clients can spoof these values. When in doubt, use a fixed ORIGIN instead.


Quick start: WebSocket

Three things to do:

  1. Enable WebSocket in the adapter
  2. Add the Vite plugin (for dev mode)
  3. Use the client store in your Svelte components

Step 1: Enable WebSocket

svelte.config.js

import adapter from 'svelte-adapter-uws';

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

That's it. This gives you a pub/sub WebSocket server at /ws with no authentication. Any client can connect, subscribe to topics, and receive messages.

Step 2: Add the Vite plugin (required)

The Vite plugin is required when using WebSockets. It does two things:

  1. Dev mode - spins up a WebSocket server so event.platform works during npm run dev
  2. Production builds - runs your hooks.ws file through Vite's pipeline so $lib, $env, and $app imports resolve correctly

Without it, your hooks.ws file won't be able to import from $lib or use $env variables, and event.platform won't work in dev.

vite.config.js

import { sveltekit } from '@sveltejs/kit/vite';
import uws from 'svelte-adapter-uws/vite';

export default {
  plugins: [sveltekit(), uws()]
};

Step 3: Use the client store

src/routes/+page.svelte

<script>
  import { on, status } from 'svelte-adapter-uws/client';

  // Subscribe to the 'notifications' topic
  // Auto-connects, auto-subscribes, auto-reconnects
  const notifications = on('notifications');
</script>

{#if $status === 'open'}
  <span>Connected</span>
{/if}

{#if $notifications}
  <p>Event: {$notifications.event}</p>
  <p>Data: {JSON.stringify($notifications.data)}</p>
{/if}

Step 4: Publish from the server

src/routes/api/notify/+server.js

export async function POST({ request, platform }) {
  const data = await request.json();

  // This sends to ALL clients subscribed to 'notifications'
  platform.publish('notifications', 'new-message', data);

  return new Response('OK');
}

Build and run:

npm run build
node build

Quick start: WSS (secure WebSocket)

WSS works automatically when you enable TLS. WebSocket connections upgrade over the same HTTPS port.

svelte.config.js

import adapter from 'svelte-adapter-uws';

export default {
  kit: {
    adapter: adapter({
      websocket: true
    })
  }
};
npm run build
SSL_CERT=/path/to/cert.pem SSL_KEY=/path/to/key.pem node build

The client store automatically uses wss:// when the page is served over HTTPS - no configuration needed on the client side.


Development, Preview & Production

npm run dev - works (with the Vite plugin)

The Vite plugin is required for WebSocket support in both dev and production (see Step 2). It spins up a ws WebSocket server alongside Vite's dev server, so your client store and event.platform work identically to production.

Changes to your hooks.ws file are picked up automatically - the plugin reloads the handler on save and closes existing connections so they reconnect with the new code. No dev server restart needed.

Note: The dev plugin enforces allowedOrigins on WebSocket upgrades the same way the production handler does. For local dev scenarios that need to accept arbitrary origins (e.g. WSS from a staging client during integration), pass devSkipOriginCheck: true to the plugin: uws({ devSkipOriginCheck: true }).

vite.config.js

import { sveltekit } from '@sveltejs/kit/vite';
import uws from 'svelte-adapter-uws/vite';

export default {
  plugins: [sveltekit(), uws()]
};

npm run preview - WebSockets don't work

SvelteKit's preview server is Vite's built-in HTTP server. It doesn't know about uWebSockets.js or WebSocket upgrades. Your HTTP routes and SSR will work, but WebSocket connections will fail.

Use node build instead of preview for testing WebSocket features.

node build - production, everything works

This is the real deal. uWebSockets.js handles everything:

npm run build
node build

Or with environment variables:

PORT=8080 HOST=0.0.0.0 node build

Or with TLS:

SSL_CERT=./cert.pem SSL_KEY=./key.pem PORT=443 node build

Configuration

Adapter options

adapter({
  // Output directory for the build
  out: 'build', // default: 'build'

  // Precompress static assets with brotli and gzip
  precompress: true, // default: true

  // Prefix for environment variables (e.g. 'MY_APP_' -> MY_APP_PORT)
  envPrefix: '', // default: ''

  // Health check endpoint (set to false to disable)
  healthCheckPath: '/healthz', // default: '/healthz'

  // WebSocket configuration
  websocket: true // or false, or an options object (see below)
})

WebSocket options

adapter({
  websocket: {
    // Path for WebSocket connections
    path: '/ws', // default: '/ws'

    // Path to your custom handler module (auto-discovers src/hooks.ws.js if omitted)
    handler: './src/lib/server/websocket.js', // default: auto-discover

    // Max message size in bytes (connections sending larger messages are closed)
    maxPayloadLength: 1024 * 1024, // default: 1 MB

    // Seconds of inactivity before the connection is closed
    idleTimeout: 120, // default: 120

    // Max bytes of backpressure per connection before messages are dropped.
    // uWS defaults to 64 KB; this adapter uses 1 MB to handle pub/sub spikes.
    // Lower this if you expect many slow consumers.
    maxBackpressure: 1024 * 1024, // default: 1 MB

    // Enable per-message deflate compression
    compression: false, // default: false

    // Automatically send pings to keep the connection alive
    sendPingsAutomatically: true, // default: true

    // Seconds before an async upgrade handler is rejected with 504 (0 to disable)
    upgradeTimeout: 10, // default: 10

    // Sliding-window rate limit: max WebSocket upgrade requests per IP per window.
    // Prevents connection flood attacks. Uses a sliding window so a client cannot
    // double the effective rate by placing requests at a fixed-window boundary.
    // Set to 0 to disable.
    upgradeRateLimit: 10,       // default: 10
    upgradeRateLimitWindow: 10, // window size in seconds, default: 10

    // Allowed origins for WebSocket connections
    // 'same-origin' - only accept where Origin matches Host and scheme (default)
    // '*' - accept from any origin
    // ['https://example.com'] - whitelist specific origins
    // Requests without an Origin header (non-browser clients) are rejected
    // unless an upgrade handler is configured to authenticate them.
    allowedOrigins: 'same-origin' // default: 'same-origin'
  }
})

Backpressure and connection limits

These options control how the server handles misbehaving or slow clients at the WebSocket level:

maxPayloadLength (default: 1 MB) - the maximum size of a single incoming WebSocket message. If a client sends a message larger than this, uWS closes the connection immediately (not just the message - the entire connection is dropped). Set this based on the largest message your application expects to receive. uWS itself defaults to 16 MB; this adapter sets 1 MB as a balanced default that handles typical app payloads in a single frame without forcing chunked-upload frameworks into ~12 KB chunks (which the previous 16 KB default did). For a stricter cap, pin an explicit value (e.g. 16 * 1024 for 16 KB).

maxBackpressure (default: 1 MB) - the per-connection outbound send buffer, AND the threshold above which publish / send / publishBatched silently skip a subscriber. When a specific subscriber's buffer is over this size, uWS drops that frame for that subscriber only while continuing to deliver to every non-backpressured subscriber. This makes publish / send / publishBatched volatile-by-default for slow consumers (the right behavior for cursor positions, typing indicators, presence pings - see "Volatile / fire-and-forget delivery" below). The drain hook fires per-connection when the buffer empties again. Lower this if you want subscribers shed sooner; raise it if you prefer to keep the connection queued and absorb temporary slowness. uWS's own default is 64 KB; this adapter sets 1 MB to favor keeping the connection alive under pub/sub spikes.

upgradeRateLimit (default: 10 per 10s window) - sliding-window rate limit on WebSocket upgrade requests per client IP. Clients exceeding the limit get a 429 Too Many Requests response. The IP rate map is capped at 10,000 entries with LRU eviction by activity score, so sustained connection floods from many IPs don't cause unbounded memory growth.

upgradeAdmission (default: disabled) - two-layer admission control on the upgrade path, both opt-in:

  • maxConcurrent caps how many upgrades may be in flight at once. Crossed requests get a fast 503 Service Unavailable before any per-request work, so a connection storm can be shed without spending CPU on TLS, header parsing, or cookie decoding. Set this just above your steady-state in-flight count to act as a circuit breaker.
  • perTickBudget caps how many actual res.upgrade() calls run per Node.js event-loop tick. Once the budget is spent, subsequent calls are deferred via setImmediate so the loop is not starved by 10K synchronous handshakes from one I/O batch. Pre-upgrade work (rate limit, origin check, hook dispatch) still runs in the original tick; only the hand-off to the C++ upgrade path is paced. Start with 64 and adjust based on your peak burst envelope.
adapter({
  websocket: {
    upgradeAdmission: { maxConcurrent: 1000, perTickBudget: 64 }
  }
});

The two layers are independent: each works without the other. Both default to 0 (disabled) so the upgrade path stays unchanged unless you opt in.

Layered admission: upgrade-path + message-path

upgradeAdmission operates at the WebSocket handshake. It sheds connection attempts before TLS work and before any per-request CPU is spent. That is the right primitive when the threat is "too many clients are trying to connect" - a connection flood, a thundering herd after a deploy, a runaway client retry loop.

It is NOT the right primitive when the threat is "established connections are sending too many RPCs" - a chatty client, an abusive presence ping loop, a misbehaving game tick. Those calls have already passed the handshake; the connection is open; you want to shed at the message dispatch layer instead.

For that second layer, svelte-adapter-uws-extensions ships createAdmissionControl, an opt-in message-path admission wrapper that runs against already-accepted connections. The two stack naturally:

// Production wiring sketch
import { createAdmissionControl } from 'svelte-adapter-uws-extensions';

const messageAdmission = createAdmissionControl({ /* RPC concurrency, per-key buckets, ... */ });

// In hooks.ws.js
export function message(ws, ctx) {
  messageAdmission.run(ws, ctx, async () => {
    // ... your message handler ...
  });
}

// In svelte.config.js
adapter({
  websocket: {
    upgradeAdmission: { maxConcurrent: 1000, perTickBudget: 64 }  // handshake layer
  }
});

The two layers do not share state, configuration, or call sites. They cannot drift apart because the WebSocket lifecycle enforces the ordering: a connection that fails upgradeAdmission never reaches the message handler at all, so createAdmissionControl only ever sees connections that were already admitted at the handshake. The layering is a structural property, not a runtime one.

Security configuration

Defense-in-depth opt-ins layered on top of allowedOrigins. All default to safe values; flip them only after the documented audit step.

  • websocket.authPathRequireOrigin (default true) - the /__ws/auth POST endpoint requires x-requested-with: XMLHttpRequest, Sec-Fetch-Site: same-origin, or an Origin matching allowedOrigins. The adapter client always stamps x-requested-with so the browser path is unaffected. Set false to accept native (non-browser) clients without those headers.
  • websocket.compressCredentialedResponses (default false) - requests carrying Cookie or Authorization skip dynamic brotli/gzip compression to defend against the BREACH attack (compressed length leaks attacker-influenced reflected input alongside a secret). Set true only after auditing the page surface for BREACH defenses (random per-response masking, prefix randomization, no secrets reflected with attacker input). Build-time precompressed static files are unaffected.
  • websocket.unsafeSameOriginWithoutHostPin (default false) - when allowedOrigins: 'same-origin' is paired with no fronting trust (no ORIGIN env, no HOST_HEADER env, no native TLS, no upgrade() hook), the runtime throws at startup because the same-origin check then compares two attacker-controlled headers (Origin vs Host). Set true to restore the previous warn-only behavior. Pin the deployment shape first (ORIGIN, HOST_HEADER, native TLS, or an upgrade() hook).

websocket.allowSystemTopicSubscribe (default false) and websocket.allowNonAsciiTopics (default false) are documented in Topic validation. The Vite plugin mirrors all of these flags; devSkipOriginCheck (default false) on the plugin disables the dev-mode allowedOrigins enforcement for local-only scenarios.

Capacity model

Every internal Map / Set that grows with client behaviour or topic cardinality has an explicit upper bound and a defined behaviour at saturation. The defaults are deliberately generous (1,000,000 across the board) - far above any healthy single-connection use, even at uWS's million-connection scale - so the cap catches obvious bugs and runaway clients without ever biting real apps. Aggregate memory at extreme scale is bounded separately by upgradeAdmission.maxConcurrent; per-connection caps are not the right place to defend against a 1M-connection DoS.

| Site | Default cap | Behaviour at saturation | Override | |------|-------------|-------------------------|----------| | Subscriptions per connection | 1,000,000 | subscribe-denied with reason 'RATE_LIMITED' | not exposed | | Pending platform.request calls per connection | 1,000,000 | promise rejects with "pending requests exceeded" | not exposed | | sendCoalesced keys per connection | 1,000,000 | drop oldest insertion-order entry on insert | not exposed | | Topic seq registry (topicSeqs) | 1,000,000 | one structured console.warn with topN publishers; publish continues | not exposed (resume protocol depends on persistence) | | Runaway-publisher warn dedup | 1,000,000 | FIFO-evict oldest entry on insert | not exposed | | envelopePrefixCache | 256 | FIFO half-evict | not exposed | | decodeCache | 256 | FIFO half-evict | not exposed | | SSR dedup in-flight | 500 | new request bypasses dedup | not exposed | | SSR dedup body buffer per request | 512 KB | response replays without dedup | not exposed | | Upgrade rate-limit IP map | 10,000 | LRU on 60s sweep | not exposed | | Aggregate live connections | unbounded by default | reject upgrade with 503 once maxConcurrent set | upgradeAdmission.maxConcurrent | | Outbound buffer per connection | 1 MB | uWS drops the frame for that subscriber only | wsOptions.maxBackpressure |

Plugin caps all default to 1,000,000 with the same idiot-proof bias:

| Plugin | Cap | Behaviour at saturation | Override | |--------|-----|-------------------------|----------| | replay | maxTopics: 100, ring size: 1000 | LRU evict / ring overwrite | per-topic options | | presence | maxConnections: 1_000_000, maxTopics: 1_000_000 | drop oldest insertion-order entry | constructor options | | cursor | maxConnections: 1_000_000, maxTopics: 1_000_000 | drop oldest insertion-order entry; pending throttle timers cleared | constructor options | | throttle / debounce | maxTopics: 1_000_000 | flush pending then drop oldest topic | second arg to throttle(interval, options) / debounce(...) | | lock | maxKeys: 1_000_000 | new-key withLock rejects with "active key count exceeded" | constructor options | | ratelimit | maxBuckets: 1_000_000 | drop oldest insertion-order bucket on insert | constructor options | | queue | maxSize: 1_000_000 per key | push rejects, onDrop callback fires | constructor options (pass Infinity to opt out) | | dedup | maxEntries: 10_000 | soft + hard cap, oldest insertion-order evicted | constructor options | | session | maxEntries: 10_000 | soft + hard cap, oldest insertion-order evicted | constructor options | | groups | maxMembers (per group, required) | join returns false, onFull callback fires | required option |

Two policy notes:

  • Per-conn cap math at uWS scale. 1,000,000 subscriptions × 1,000,000 connections is more than any realistic process can handle. The per-conn caps catch single-connection bugs (a for (i=0; i<N; i++) ws.subscribe('topic-' + i) loop, a misbehaving extension); they do not pretend to OOM-protect a 1M-connection server. Set upgradeAdmission.maxConcurrent for that.
  • topicSeqs is warn-only. The seq registry cannot evict entries - the resume protocol depends on each topic's monotonic counter persisting for the process lifetime, and dropping a row would corrupt any reconnecting client trying to resume that topic. The cap fires a single structured console.warn with the topN recent publishers when the threshold is first crossed; ops sees the leak shape and can reduce topic cardinality (or opt out with { seq: false } per publish) before OOM.

Static file behavior

All static assets (from the client/ and prerendered/ output directories) are loaded once at startup and served directly from RAM. Each response automatically includes:

  • Content-Type: detected from the file extension
  • Vary: Accept-Encoding: required for correct CDN/proxy caching when serving precompressed variants
  • Accept-Ranges: bytes: enables partial content requests (e.g. for download resume)
  • X-Content-Type-Options: nosniff: prevents MIME-type sniffing in browsers
  • ETag: derived from the file's modification time and size; enables 304 Not Modified responses
  • Cache-Control: public, max-age=31536000, immutable: for versioned assets under /_app/immutable/
  • Cache-Control: no-cache: for all other assets (forces ETag revalidation)

Range requests (HTTP 206): The server handles Range: bytes=start-end requests for static files. Single byte ranges are supported (bytes=0-499, bytes=-500, bytes=500-). Multi-range requests (comma-separated) are served as full 200 responses. An unsatisfiable range returns 416 Range Not Satisfiable. When a Range header is present, the response is always served uncompressed so byte offsets are correct. The If-Range header is respected: if it doesn't match the file's ETag, the full file is returned.

Files with extensions that browsers cannot render inline (.zip, .tar, .tgz, .exe, .dmg, .pkg, .deb, .apk, .iso, .img, .bin, etc.) automatically receive Content-Disposition: attachment so browsers prompt a download dialog instead of attempting to display them.

If precompress: true is set in the adapter options, brotli (.br) and gzip (.gz) precompressed variants are loaded at startup and served when the client's Accept-Encoding header includes br or gzip. Precompressed variants are only used when they are smaller than the original file.


Environment variables

All variables are set at runtime (when you run node build), not at build time.

If you set envPrefix: 'MY_APP_' in the adapter config, all variables are prefixed (e.g. MY_APP_PORT instead of PORT).

| Variable | Default | Description | |---|---|---| | HOST | 0.0.0.0 | Bind address | | PORT | 3000 | Listen port | | ORIGIN | (derived) | Fixed origin (e.g. https://example.com) | | SSL_CERT | - | Path to TLS certificate file | | SSL_KEY | - | Path to TLS private key file | | PROTOCOL_HEADER | - | Header for protocol detection (e.g. x-forwarded-proto) | | HOST_HEADER | - | Header for host detection (e.g. x-forwarded-host) | | PORT_HEADER | - | Header for port override (e.g. x-forwarded-port) | | ADDRESS_HEADER | - | Header for client IP (e.g. x-forwarded-for) | | XFF_DEPTH | 1 | Position from right in X-Forwarded-For | | BODY_SIZE_LIMIT | 512K | Max request body size (supports K, M, G suffixes) | | SHUTDOWN_TIMEOUT | 30 | Seconds to wait during graceful shutdown | | CLUSTER_WORKERS | - | Number of worker threads (or auto for CPU count) | | CLUSTER_MODE | (auto) | reuseport (Linux default) or acceptor (other platforms) | | WS_DEBUG | - | Set to 1 to enable structured WebSocket debug logging (open, close, subscribe, publish) |

Graceful shutdown

On SIGTERM or SIGINT, the server:

  1. Stops accepting new connections
  2. Waits for in-flight SSR requests to complete (up to SHUTDOWN_TIMEOUT seconds)
  3. Emits a sveltekit:shutdown event on process (for cleanup hooks like closing database connections)
  4. Exits
// Listen for shutdown in your server code (e.g. hooks.server.js)
process.on('sveltekit:shutdown', async (reason) => {
  console.log(`Shutting down: ${reason}`);
  await db.close();
});

Examples

# Simple HTTP
node build

# Custom port
PORT=8080 node build

# Behind nginx
ORIGIN=https://example.com node build

# Behind a proxy with forwarded headers
PROTOCOL_HEADER=x-forwarded-proto HOST_HEADER=x-forwarded-host ADDRESS_HEADER=x-forwarded-for node build

# Native TLS
SSL_CERT=./cert.pem SSL_KEY=./key.pem node build

# Everything at once
SSL_CERT=./cert.pem SSL_KEY=./key.pem PORT=443 HOST=0.0.0.0 BODY_SIZE_LIMIT=10M SHUTDOWN_TIMEOUT=60 node build

TypeScript setup

Add the platform type to your src/app.d.ts:

import type { Platform as AdapterPlatform } from 'svelte-adapter-uws';

declare global {
  namespace App {
    interface Platform extends AdapterPlatform {}
  }
}

export {};

Now event.platform.publish(), event.platform.topic(), etc. are fully typed.


Svelte 4 support

This adapter supports both Svelte 4 and Svelte 5. All examples in this README use Svelte 5 syntax ($props(), runes). If you're on Svelte 4, here's how to translate:

Svelte 5 (used in examples)

<script>
  import { crud } from 'svelte-adapter-uws/client';

  let { data } = $props();
  const todos = crud('todos', data.todos);
</script>

Svelte 4 equivalent

<script>
  import { crud } from 'svelte-adapter-uws/client';

  export let data;
  const todos = crud('todos', data.todos);
</script>

The only difference is how you receive props. The client store API (on, crud, lookup, latest, count, once, status, connect) works identically in both versions - it uses svelte/store which hasn't changed.


WebSocket deep dive

WebSocket handler (hooks.ws)

No handler needed (simplest)

With websocket: true, a built-in handler accepts all connections and handles subscribe/unsubscribe messages from the client store. No file needed.

Note: websocket: true only sets up the server side. To actually receive messages in the browser, you need to import the client store (on, crud, etc.) in your Svelte components. Without the client store, the WebSocket endpoint exists but nothing connects to it.

Auto-discovered handler

Create src/hooks.ws.js (or .ts, .mjs) and it will be automatically discovered - no config needed:

src/hooks.ws.js

// Called during the HTTP -> WebSocket upgrade handshake.
// Return an object to accept (becomes ws.getUserData()).
// Return false to reject with 401.
// Omit this export to accept all connections.
export async function upgrade({ headers, cookies, url, remoteAddress }) {
  const sessionId = cookies.session_id;
  if (!sessionId) return false;

  const user = await validateSession(sessionId);
  if (!user) return false;

  // Whatever you return here is available as ws.getUserData()
  return { userId: user.id, name: user.name };
}

// Called when a connection is established
export function open(ws, { platform }) {
  const { userId } = ws.getUserData();
  console.log(`User ${userId} connected`);

  // Subscribe this connection to a user-specific topic
  ws.subscribe(`user:${userId}`);
}

// Called when a message is received
// Note: subscribe/unsubscribe messages from the client store are
// handled automatically BEFORE this function is called
export function message(ws, { data, isBinary }) {
  const msg = JSON.parse(Buffer.from(data).toString());
  console.log('Got message:', msg);
}

// Called when a client tries to subscribe to a topic (optional)
// Return false to deny the subscription
export function subscribe(ws, topic, { platform }) {
  const { role } = ws.getUserData();
  // Only admins can subscribe to admin topics
  if (topic.startsWith('admin') && role !== 'admin') return false;
}

// Called when a client unsubscribes from a topic (optional)
// Use this to clean up per-topic state (presence, groups, etc.)
export function unsubscribe(ws, topic, { platform }) {
  console.log(`Unsubscribed from ${topic}`);
}

// Called when the connection closes. The context carries per-connection
// stats (id / duration / messagesIn / messagesOut / bytesIn / bytesOut)
// alongside `code` / `message` / `subscriptions`. Counters are only
// populated when this hook is exported - the adapter skips the
// per-connection bookkeeping otherwise to keep the hot path zero-cost.
export function close(ws, { code, id, duration, messagesIn, messagesOut, bytesIn, bytesOut, subscriptions }) {
  const { userId } = ws.getUserData();
  console.log(
    `User ${userId} (session ${id}) disconnected after ${duration}ms ` +
    `(${messagesIn} in / ${messagesOut} out, ${bytesIn} / ${bytesOut} bytes, ` +
    `topics: ${[...subscriptions].join(', ')})`
  );
}

// Called when backpressure has drained (optional, for flow control)
export function drain(ws, { platform }) {
  // You can resume sending large messages here
}

// Called when a reconnecting client presents the previous session id
// plus the per-topic seq numbers it last saw. Use this to fill the
// disconnect gap, typically by replaying buffered events. Optional -
// without this hook, reconnects still work; the client just falls
// through to live mode without a gap fill.
export function resume(ws, { sessionId, lastSeenSeqs, platform }) {
  for (const [topic, sinceSeq] of Object.entries(lastSeenSeqs)) {
    replay.replay(ws, topic, sinceSeq, platform);
  }
}

Session resume

On every WS open, the server stamps a session id and announces it to the client ({"type":"welcome","sessionId":"..."}). The client stores the id in sessionStorage (keyed per ws path) and tracks the highest seq it has seen for each topic.

When the connection drops and the client reconnects, it presents the previous session id plus the per-topic last-seen seqs in a {"type":"resume", sessionId, lastSeenSeqs} frame, sent before subscribe-batch. If you export a resume hook, you receive (ws, { sessionId, lastSeenSeqs, platform }) and can replay any events the client missed during the disconnect window. The server acks with {"type":"resumed"} once your hook returns; the client then resubscribes and live messages resume.

Without a resume hook the protocol is still safe: the server acks the resume frame, the client falls through to live mode, and your app behaves the same as a cold connect.

The session id is per-process and per-connection. It does not persist across server restarts; a client presenting a session id the server has never seen receives the same resumed ack and falls through.

Subscribe acknowledgements

When the client subscribes, it includes a numeric ref so the server can ack with the result:

  • {"type":"subscribed", topic, ref} - subscription accepted.
  • {"type":"subscribe-denied", topic, ref, reason} - subscription rejected. reason is one of the canonical codes 'UNAUTHENTICATED', 'FORBIDDEN', 'INVALID_TOPIC', 'RATE_LIMITED', or any custom string the server's subscribe hook returned.

The denial is surfaced on the client through the denials store. Show it as a banner, route to a login page, anything you like:

<script>
  import { denials } from 'svelte-adapter-uws/client';
</script>

{#if $denials}
  <p class="error">Cannot subscribe to {$denials.topic}: {$denials.reason}</p>
{/if}

The server's subscribe hook controls denial reasons:

export function subscribe(ws, topic, { platform }) {
  const { userId, role } = ws.getUserData();
  if (!userId) return 'UNAUTHENTICATED';                  // -> subscribe-denied
  if (topic.startsWith('admin') && role !== 'admin') {
    return 'FORBIDDEN';
  }
  // omit / return undefined / return true -> subscribed
}

Old clients that send subscribe without a ref get no ack frame (silent allow / silent deny, as before). Old servers that ignore ref don't break new clients - they just don't emit acks; the client sees no entry in denials and treats the subscription as active.

subscribe-batch works the same way: one ack frame per topic in the batch, all sharing the batch's single ref.

For batch subscribes (typically the resubscribe-on-reconnect path) you can opt into a single-call subscribeBatch hook instead of paying N subscribe calls. The framework calls it once with all pre-validated topics and applies the returned per-topic decisions:

export async function subscribeBatch(ws, topics, { platform }) {
  const { userId } = ws.getUserData();
  // One DB query for all topics instead of N
  const allowed = await db.allowedTopics(userId, topics);
  const allowedSet = new Set(allowed);
  const denials = {};
  for (const topic of topics) {
    if (!allowedSet.has(topic)) denials[topic] = 'FORBIDDEN';
  }
  return denials; // omit a topic -> allow; false -> FORBIDDEN; string -> that reason
}

If you only export subscribe, the framework still loops it per topic for batch-subscribes (no behaviour change). Export subscribeBatch only when you need the single-query optimization. The hook is sync in this version; for async lookups, pre-cache user grants on userData during upgrade.

Message protocol

The adapter uses a JSON envelope format for all pub/sub messages: { topic, event, data, seq? }. Control messages from the client store (subscribe, unsubscribe, subscribe-batch, resume) use { type, topic, ref? }, { type, topics, ref? }, or { type, sessionId, lastSeenSeqs }. The server emits {"type":"welcome","sessionId":"..."} on open, {"type":"resumed"} after a resume frame, and {"type":"subscribed",...} / {"type":"subscribe-denied",...} per topic when the client supplied a ref.

To avoid JSON-parsing every incoming message, the handler uses a byte-prefix discriminator: control messages start with {"type" (byte 3 is y), while user envelopes start with {"topic" (byte 3 is o). A single byte comparison skips JSON.parse entirely for user messages. Messages over 8 KB are also skipped (generous ceiling for subscribe-batch with many topics, well above any realistic control message).

Topic validation

Topics submitted by clients are validated before being accepted:

  • Must be between 1 and 256 characters
  • Default accept set is printable ASCII (0x20-0x7E) excluding " and \. Control bytes, line separators (U+2028/U+2029), bidirectional overrides (U+202E), the byte-order mark, and other non-ASCII runes are rejected at the wire boundary so log dashboards and admin UIs see a clean, greppable topic name. Apps that legitimately accept non-ASCII topic names from clients can opt in via websocket.allowNonAsciiTopics: true (always-illegal " and \ remain rejected).
  • subscribe-batch accepts at most 256 topics per message (the client only sends what it was subscribed to before a reconnect)

Topics prefixed with __ are reserved for framework-internal channels (presence uses __presence:*, replay uses __replay:*, plus __signal:*, __group:*, __rpc, etc.). Wire-level subscribes to __-prefixed topics are rejected with INVALID_TOPIC, so a client cannot intercept signals routed to other users or plugin broadcasts. Server-side platform.subscribe(ws, '__signal:userId') (the legitimate pattern that enableSignals uses) still works because the block is on the wire layer only. Advanced apps that intentionally route public topics through the __ prefix can opt out via websocket.allowSystemTopicSubscribe: true.

Explicit handler path

If your handler is somewhere other than src/hooks.ws.js:

adapter({
  websocket: {
    handler: './src/lib/server/websocket.js'
  }
})

What the handler gets

The upgrade function receives an UpgradeContext:

{
  headers: { 'cookie': '...', 'host': 'localhost:3000', ... },  // all lowercase
  cookies: { session_id: 'abc123', theme: 'dark' },             // parsed from Cookie header
  url: '/ws?token=abc',                                           // request path + query string
  remoteAddress: '127.0.0.1'                                     // client IP
}

The subscribe function receives (ws, topic) and can return false to deny a client's subscription request. Omit it to allow all subscriptions.

The ws object in open, message, close, and drain is a uWebSockets.js WebSocket. Key methods:

  • ws.getUserData() - returns whatever upgrade returned
  • ws.subscribe(topic) - subscribe to a topic for app.publish()
  • ws.unsubscribe(topic) - unsubscribe from a topic
  • ws.send(data) - send a message to this connection
  • ws.close() - close the connection

Authentication

WebSocket authentication uses the exact same cookies as your SvelteKit app. When the browser opens a WebSocket connection, it sends all cookies for the domain - including session cookies set by SvelteKit's cookies.set(). No tokens, no query parameters, no extra client-side code.

Here's the full flow from login to authenticated WebSocket:

Step 1: Login sets a cookie (standard SvelteKit)

src/routes/login/+page.server.js

import { authenticate, createSession } from '$lib/server/auth.js';

export const actions = {
  default: async ({ request, cookies }) => {
    const form = await request.formData();
    const email = form.get('email');
    const password = form.get('password');

    const user = await authenticate(email, password);
    if (!user) return { error: 'Invalid credentials' };

    const sessionId = await createSession(user.id);

    // This cookie is automatically sent on WebSocket upgrade requests
    cookies.set('session', sessionId, {
      path: '/',
      httpOnly: true,
      sameSite: 'strict',
      secure: true,
      maxAge: 60 * 60 * 24 * 7 // 1 week
    });

    return { success: true };
  }
};

Step 2: WebSocket handler reads the same cookie

src/hooks.ws.js

import { getSession } from '$lib/server/auth.js';

export async function upgrade({ cookies }) {
  // Same cookie that SvelteKit set during login
  const sessionId = cookies.session;
  if (!sessionId) return false; // -> 401, connection rejected

  const user = await getSession(sessionId);
  if (!user) return false; // -> 401, expired or invalid session

  // Attach user data to the socket - available via ws.getUserData()
  // To refresh the session cookie on connect, use the `authenticate` hook
  // (see "Refreshing session cookies on WebSocket connect" below).
  // `upgradeResponse()` with custom non-cookie headers is also supported:
  // return upgradeResponse({ userId: user.id }, { 'x-session-version': '2' });
  return { userId: user.id, name: user.name, role: user.role };
}

export function open(ws, { platform }) {
  const { userId, role } = ws.getUserData();
  console.log(`${userId} connected (${role})`);

  // Subscribe to user-specific and role-based topics
  ws.subscribe(`user:${userId}`);
  if (role === 'admin') ws.subscribe('admin');
}

export function close(ws, { platform }) {
  const { userId } = ws.getUserData();
  console.log(`${userId} disconnected`);
}

Step 3: Client - nothing special needed

src/routes/dashboard/+page.svelte

<script>
  import { on, status } from 'svelte-adapter-uws/client';

  // The browser sends cookies automatically on the upgrade request.
  // If the session is invalid, the connection is rejected and
  // auto-reconnect will retry (useful if the user logs in later).
  const notifications = on('notifications');
  const userMessages = on('user-messages');
</script>

{#if $status === 'open'}
  <span>Authenticated & connected</span>
{:else if $status === 'connecting'}
  <span>Connecting...</span>
{:else}
  <span>Disconnected (not logged in?)</span>
{/if}

Step 4: Send messages to specific users from anywhere

src/routes/api/notify/+server.js

import { json } from '@sveltejs/kit';

export async function POST({ request, platform }) {
  const { userId, message } = await request.json();

  // Only that user receives this (they subscribed in open())
  platform.publish(`user:${userId}`, 'notification', { message });

  return json({ sent: true });
}

Why this works

The WebSocket upgrade is an HTTP request. The browser treats it like any other request to your domain - it includes all cookies, follows the same-origin policy, and respects httpOnly/secure/sameSite flags. There's no difference between how cookies reach a +page.server.js load function and how they reach the upgrade handler.

| What | Where | Same cookies? | |---|---|---| | Page load | +page.server.js load() | Yes | | Form action | +page.server.js actions | Yes | | API route | +server.js | Yes | | Server hook | hooks.server.js handle() | Yes | | WebSocket upgrade | hooks.ws.js upgrade() | Yes |

Refreshing session cookies on WebSocket connect

For short-lived sessions you often want to rotate the session cookie every time a client connects. The obvious approach - attaching Set-Cookie to the 101 Switching Protocols response via upgradeResponse() - is RFC-compliant but is silently rejected by Cloudflare Tunnel, Cloudflare's proxy, and some other strict edge proxies. The symptom is that the WebSocket open handler fires server-side, then the connection closes with code 1006 (Received TCP FIN before WebSocket close frame) before any frames are exchanged. The adapter emits a build-time warning when it detects this pattern.

The adapter ships a first-class solution: the optional authenticate hook runs as a normal HTTP POST before the WebSocket upgrade. Set-Cookie rides on a standard 2xx response, which every proxy handles correctly; the browser then attaches the refreshed cookie to the upgrade request that follows.

Step 1: add an authenticate export to hooks.ws.js

// src/hooks.ws.js
import { getSession, renewSession } from '$lib/server/auth.js';

// Runs as POST /__ws/auth, before the WebSocket upgrade.
// cookies.set() becomes Set-Cookie on a standard 204 response.
export async function authenticate({ cookies }) {
  const session = await getSession(cookies.get('session'));
  if (!session) return false; // -> 401, client does not open the WebSocket

  const renewed = await renewSession(session);
  cookies.set('session', renewed.token, {
    path: '/',
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7
  });
}

// Your existing upgrade() hook stays unchanged - it reads the now-fresh cookie.
export async function upgrade({ cookies }) {
  const session = await getSession(cookies.session);
  if (!session) return false;
  return { userId: session.userId, role: session.role };
}

The authenticate event exposes the SvelteKit event shape you already know: { request, headers, cookies, url, remoteAddress, getClientAddress, platform }. Return values:

  • undefined / nothing - success, responds 204 No Content with any Set-Cookie headers from cookies.set() (recommended).
  • false - responds 401 Unauthorized. The client does not open the WebSocket.
  • A full Response - used as-is; any cookies.set() calls are merged in.

Step 2: opt in from the client

import { connect } from 'svelte-adapter-uws/client';

// Hit /__ws/auth before every WebSocket connect (including reconnects)
connect({ auth: true });

// Or point at a custom path (e.g. behind a Cloudflare Access rule)
connect({ auth: '/api/ws-auth' });

With auth: true the client stores runs fetch('/__ws/auth', { method: 'POST', credentials: 'include' }) before every new WebSocket(...) call, including after automatic reconnects. Concurrent connect attempts share a single in-flight preflight. A 4xx response is treated as terminal (the user is not authenticated); 5xx and network errors fall back to the normal reconnect backoff.

Configuration

  • The default auth path is /__ws/auth. Override with adapter({ websocket: { authPath: '/api/ws-auth' } }).
  • The hook is only mounted when authenticate is exported from hooks.ws - no runtime cost when unused.
  • Dev mode (Vite plugin) mirrors the production route on the same path.
  • The endpoint requires x-requested-with: XMLHttpRequest, Sec-Fetch-Site: same-origin, or an Origin matching allowedOrigins (CSRF defense). The adapter client always stamps x-requested-with. Native (non-browser) clients that need to reach this endpoint without those headers can opt out via websocket.authPathRequireOrigin: false. See Security configuration.

Why not put Set-Cookie on the 101?

Cloudflare's HTTP/2 WebSocket bridging rewrites 101 responses, and Set-Cookie on the 101 trips the edge into tearing the connection down. This is undocumented Cloudflare behavior, but reproducible on every tunnel and proxy connector. The authenticate hook sidesteps it entirely by using a standard HTTP response.


Platform API (event.platform)

Available in server hooks, load functions, form actions, API routes, and WebSocket hooks (hooks.ws).

platform.publish(topic, event, data, options?)

Send a message to all WebSocket clients subscribed to a topic.

Topic and event names are validated before being written into the JSON envelope - quotes, backslashes, and control characters will throw. This prevents JSON injection when names are built from dynamic values like user IDs (platform.publish(\user:${id}`, ...)`). The validation is a single-pass char scan and adds no measurable overhead.

In cluster mode, the message is automatically relayed to all other workers. Pass { relay: false } to skip the relay when the message originates from an external pub/sub source (Redis, Postgres LISTEN/NOTIFY, etc.) that already delivers to every process:

// Redis subscriber running on every worker - relay would cause duplicates
sub.on('message', (channel, payload) => {
  platform.publish(channel, 'update', JSON.parse(payload), { relay: false });
});

Every published frame is also stamped with a monotonic per-topic seq field in the envelope (first publish to a topic is seq: 1, then 2, 3, ...). Reconnecting clients can use this to detect dropped frames and resume from where they left off. Pass { seq: false } to skip stamping for ephemeral or high-cardinality topics where the counter map would grow unbounded:

// Skip seq for per-user cursor topics: counter map would grow with users
platform.publish(`cursor:${userId}`, 'move', pos, { seq: false });
// src/routes/todos/+page.server.js
export const actions = {
  create: async ({ request, platform }) => {
    const formData = await request.formData();
    const todo = await db.createTodo(formData.get('text'));

    // Every client subscribed to 'todos' receives this
    platform.publish('todos', 'created', todo);

    return { success: true };
  }
};

platform.send(ws, topic, event, data)

Send a message to a single WebSocket connection. Wraps in the same { topic, event, data } envelope as publish().

This is useful when you store WebSocket references (e.g. in a Map) and need to message specific connections from SvelteKit handlers:

// src/hooks.ws.js - store connections by user ID
const userSockets = new Map();

export function open(ws, { platform }) {
  const { userId } = ws.getUserData();
  userSockets.set(userId, ws);
}

export function close(ws, { platform }) {
  const { userId } = ws.getUserData();
  userSockets.delete(userId);
}

// Export the map so SvelteKit handlers can access it
export { userSockets };
// src/routes/api/dm/+server.js - send to a specific user
import { userSockets } from '../../hooks.ws.js';

export async function POST({ request, platform }) {
  const { targetUserId, message } = await request.json();
  const ws = userSockets.get(targetUserId);
  if (ws) {
    platform.send(ws, 'dm', 'new-message', { message });
  }
  return new Response('OK');
}

You can also reply directly from inside hooks.ws.js using platform.send() or ws.send() with the envelope format:

// src/hooks.ws.js
export function message(ws, { data, platform }) {
  const msg = JSON.parse(Buffer.from(data).toString());
  // Using platform.send (recommended):
  platform.send(ws, 'echo', 'reply', { got: msg });
  // Or using ws.send with manual envelope:
  ws.send(JSON.stringify({ topic: 'echo', event: 'reply', data: { got: msg } }));
}

platform.sendCoalesced(ws, { key, topic, event, data })

Send a message to a single connection with coalesce-by-key semantics. Each (connection, key) pair holds at most one pending message; if a newer call for the same key arrives before the previous frame drains to the wire, the older value is replaced in place.

Use this for latest-value streams where intermediate values are noise - price ticks, cursor positions, presence state, typing indicators, scroll position. Under load, this is the difference between the client lagging by a thousand stale frames and the client always seeing the most recent value.

If you want a backpressured subscriber to keep eventually receiving the latest value (the queue-and-drain shape), sendCoalesced is the right primitive. If you want backpressured subscribers skipped entirely so the wire stays current for everyone else, use platform.publish / platform.send instead - those drop on backpressure (see the "Volatile / fire-and-forget delivery" section below). sendCoalesced is explicitly drop-the-middle, keep-the-latest; publish / send are explicitly drop-the-laggard, keep-everyone-else-current.

// src/hooks.ws.js - cursor positions during a collaborative edit
export function message(ws, { data, platform }) {
  const msg = JSON.parse(Buffer.from(data).toString());
  if (msg.event === 'cursor') {
    const { docId, userId } = ws.getUserData();
    // Coalesce per (connection, user) - one pending cursor frame per peer.
    // High-frequency mousemove updates collapse cleanly under backpressure.
    for (const peer of getPeersOf(docId)) {
      platform.sendCoalesced(peer, {
        key: 'cursor:' + userId,
        topic: 'doc:' + docId,
        event: 'cursor',
        data: { userId, x: msg.data.x, y: msg.data.y }
      });
    }
  }
}

Three properties worth knowing:

  • Latest value wins. set on an existing key replaces the value but keeps the original slot, so coalescing one key never reorders the rest of the queue.
  • Lazy serialization. data is held as-is in the per-connection buffer and only JSON.stringify'd at flush time. A stream that overwrites the same key 1000 times before a single drain pays one serialization, not 1000.
  • Auto-resume on drain. When maxBackpressure is hit, pumping stops and resumes on the next uWS drain event automatically. No manual flow control.

platform.sendTo(filter, topic, event, data)

Send a message to all connections whose userData matches a filter function. Returns the number of connections the message was sent to.

This is simpler than manually maintaining a Map of connections - no hooks.ws.js needed:

// src/routes/api/dm/+server.js - send to a specific user
export async function POST({ request, platform }) {
  const { targetUserId, message } = await request.json();
  const count = platform.sendTo(
    (userData) => userData.userId === targetUserId,
    'dm', 'new-message', { message }
  );
  return new Response(count > 0 ? 'Sent' : 'User offline');
}
// Send to all admins
platform.sendTo(
  (userData) => userData.role === 'admin',
  'alerts', 'warning', { message: 'Server load high' }
);

Performance: sendTo iterates every open connection and runs your filter function against each one. It's fine for low-frequency operations like sending a DM or notifying admins, but don't use it in a hot loop. If you're broadcasting to a known group of users, subscribe them to a shared topic and use platform.publish() instead - topic-based pub/sub is handled natively by uWS in C++ and doesn't touch the JS event loop.

platform.connections

Number of active WebSocket connections:

// src/routes/api/stats/+server.js
import { json } from '@sveltejs/kit';

export async function GET({ platform }) {
  return json({ online: platform.connections });
}

platform.subscribers(topic)

Number of clients subscribed to a specific topic:

export async function GET({ platform, params }) {
  return json({
    viewers: platform.subscribers(`page:${params.id}`)
  });
}

platform.assertions

Per-category counter of framework invariant violations. The adapter ships internal hard-asserts at ~30 invariant sites (envelope build, WebSocket lifecycle, subscription bookkeeping, cross-worker IPC payloads, server-initiated request entry shape, sendCoalesced state). When one fires, the counter for that category increments and a structured [adapter-uws/assert] line is logged.

Most apps will never see a non-empty entry here. A non-zero counter indicates a regression in the framework or a third-party plugin and should be reported as a GitHub issue with the category string and accompanying log context.

export async function GET({ platform }) {
  // Surface the counters in your /healthz or ops dashboard
  const assertions = {};
  for (const [category, count] of platform.assertions) {
    assertions[category] = count;
  }
  return json({ healthy: Object.keys(assertions).length === 0, assertions });
}

The returned Map is the live module-level instance - read-only, do not mutate. In test mode (process.env.VITEST set, or NODE_ENV === 'test') the assert helper additionally throws so test runners surface the failure; in production it logs and counts but does not throw, so a violation inside a uWS callback frame cannot crash the worker.

platform.pressure and platform.onPressure(cb)

Worker-local backpressure signal. The adapter samples once per second (configurable) and reports the most urgent active stress as a single reason enum, so user code can degrade with intent instead of generic panic.

platform.pressure
// {
//   active: false,
//   subscriberRatio: 12.4,    // total subscriptions / connections, on this worker
//   publishRate: 240,         // platform.publish() calls/sec, last sample
//   memoryMB: 128,            // process.memoryUsage().rss in MB
//   reason: 'NONE'            // 'NONE' | 'PUBLISH_RATE' | 'SUBSCRIBERS' | 'MEMORY'
// }

Reading platform.pressure is a property access - safe in hot paths, no I/O. Use it for synchronous shed decisions in request handlers:

// src/routes/api/heavy-write/+server.js
export async function POST({ platform, request }) {
  if (platform.pressure.reason === 'MEMORY') {
    return new Response('Try again shortly', { status: 503 });
  }
  // ... normal write path
}

platform.onPressure(cb) fires only on transitions (when reason changes between samples), not on every tick. Returns an unsubscribe function:

// src/hooks.ws.js - notify the connected client when pressure state changes
export function open(ws, { platform }) {
  const off = platform.onPressure(({ reason, active }) => {
    platform.send(ws, '__pressure', reason, { active });
  });
  ws.getUserData().__offPressure = off;
}

export function close(ws) {
  ws.getUserData().__offPressure?.();
}

Reason precedence is fixed: MEMORY > PUBLISH_RATE > SUBSCRIBERS. A worker under multiple stresses reports the most urgent one. Memory wins because the worker is approaching OOM and nothing else matters; publish rate is next because CPU saturation cascades fastest; subscriber ratio is last because heavy fan-out degrades gracefully.

Thresholds are configurable per-deployment. Defaults are conservative - a healthy small app should never trip them in steady state. Override via WebSocketOptions.pressure:

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

export default {
  kit: {
    adapter: adapter({
      websocket: {
        pressure: {
          memoryHeapUsedRatio: 0.9,         // default 0.85
          publishRatePerSec: 50000,          // default 10000 (aggregate)
          subscriberRatio: false,            // disable this signal
          sampleIntervalMs: 500,             // default 1000; clamped to >=100
          topicPublishRatePerSec: 10000,     // default 5000 (per topic)
          topicPublishBytesPerSec: 5_000_000 // default 10485760 (10 MB/s per topic)
        }
      }
    })
  }
};

Set any individual threshold to false to disable that signal. sampleIntervalMs is clamped to a minimum of 100 ms.

Clustering: platform.pressure is per-worker. Each worker samples its own counters and reports its own snapshot. There is no aggregate "cluster pressure" - a hot worker should shed its own load without waiting for the rest of the cluster.

Per-topic publish-rate detection

Beyond the aggregate publishRatePerSec signal, the sampler also tracks per-topic publish rates and surfaces the top 5 each tick:

platform.pressure.topPublishers
// [
//   { topic: 'cursor:room-42', messagesPerSec: 8500, bytesPerSec: 1234567 },
//   { topic: 'audit:org-1',    messagesPerSec: 1200, bytesPerSec:  234567 },
//   ...
// ]

When a topic crosses topicPublishRatePerSec or topicPublishBytesPerSec in a sample window, the adapter flags it as a runaway publisher. By default this prints a throttled console.warn (one per topic per minute). For programmatic handling, register platform.onPublishRate(cb) - doing so suppresses the default warning so you own the surface:

platform.onPublishRate((events) => {
  for (const e of events) {
    metrics.record('runaway_publisher', {
      topic: e.topic,
      msgRate: e.messagesPerSec,
      byteRate: e.bytesPerSec
    });
  }
});

The bookkeeping is cheap: the per-topic counter mutates two integer fields in place per platform.publish() call (one entry allocated the first time a topic is published to, then zero allocations forever after). Set topicPublishRatePerSec: false and topicPublishBytesPerSec: false to disable per-topic tracking entirely if you do not want it.

platform.topic(name) - scoped helper

Reduces repetition when publishing multiple events to the same topic:

// src/routes/todos/+page.server.js
export const actions = {
  create: async ({ request, platform }) => {
    const todos = platform.topic('todos');
    const todo = await db.create(await request.formData());
    todos.created(todo);  // shorthand for platform.publish('todos', 'created', todo)
  },

  update: async ({ request, platform }) => {
    const todos = platform.topic('todos');
    const todo = await db.update(await request.formData());
    todos.updated(todo);
  },

  delete: async ({ request, platform }) => {
    const todos = platform.topic('todos');
    const id = (await request.formData()).get('id');
    await db.delete(id);
    todos.deleted({ id });
  }
};

The topic helper also has counter methods:

const online = platform.topic('online-users');
online.set(42);         // -> { event: 'set', data: 42 }
online.increment();     // -> { event: 'increment', data: 1 }
online.increment(5);    // -> { event: 'increment', data: 5 }
online.decrement();     // -> { event: 'decrement', data: 1 }

platform.batch(messages)

Publish multiple messages in a single call. Useful when an action updates several topics at once:

platform.batch([
  { topic: 'todos', event: 'created', data: todo },
  { topic: `user:${userId}`, event: 'activity', data: { action: 'create' } },
  { topic: 'stats', event: 'increment', data: { key: 'todos_created' } }
]);

Each entry is published with platform.publish(). Cross-worker relay is batched automatically, so this is more efficient than three separate publish() calls from a relay overhead perspective.

platform.request(ws, event, data, options?)

Send a request to one connection and await its reply. Use this for server-driven confirmations, capability challenges, or any flow where the server needs an answer from a specific client.

// In a hook on the server
const reply = await platform.request(ws, 'confirm-action', { op: 'delete' }, {
  timeoutMs: 5000
});
if (reply.confirmed) {
  await actuallyDelete();
}

The framework picks a fresh ref, sends {type:'request', ref, event, data}, and the returned Promise resolves with whatever the client's onRequest handler returned. Rejects with Error('request timed out') after timeoutMs (