An open API service indexing awesome lists of open source software.

https://github.com/lanteanio/svelte-adapter-uws

SvelteKit adapter for uWebSockets.js with built-in WebSocket support
https://github.com/lanteanio/svelte-adapter-uws

adapter svelte sveltekit uwebsockets websocket

Last synced: 28 days ago
JSON representation

SvelteKit adapter for uWebSockets.js with built-in WebSocket support

Awesome Lists containing this project

README

          

# svelte-adapter-uws

A SvelteKit adapter powered by [uWebSockets.js](https://github.com/uNetworking/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](./MIGRATION.md) for every breaking change between 0.4.x and 0.5.x.

---

## Table of contents

**Getting started**
- [Installation](#installation)
- [Quick start: HTTP](#quick-start-http)
- [Quick start: HTTPS](#quick-start-https)
- [Quick start: WebSocket](#quick-start-websocket)
- [Quick start: WSS (secure WebSocket)](#quick-start-wss-secure-websocket)
- [Development, Preview & Production](#development-preview--production)

**Configuration**
- [Adapter options](#adapter-options)
- [Environment variables](#environment-variables)
- [TypeScript setup](#typescript-setup)
- [Svelte 4 support](#svelte-4-support)

**WebSocket deep dive**
- [WebSocket handler (`hooks.ws`)](#websocket-handler-hooksws)
- [Authentication](#authentication)
- [Refreshing session cookies on WebSocket connect](#refreshing-session-cookies-on-websocket-connect)
- [Platform API (`event.platform`)](#platform-api-eventplatform)
- [Client store API](#client-store-api)
- [Seeding initial state](#seeding-initial-state)

**Plugins**
- [Middleware](#middleware)
- [Replay (SSR gap)](#replay-ssr-gap)
- [Dedup (idempotency window)](#dedup-idempotency-window)
- [Presence](#presence)
- [Typed channels](#typed-channels)
- [Throttle/debounce](#throttledebounce)
- [Rate limiting](#rate-limiting)
- [Cursor (ephemeral state)](#cursor-ephemeral-state)
- [Queue (ordered delivery)](#queue-ordered-delivery)
- [Lock (per-key serialization)](#lock-per-key-serialization)
- [Session (in-process store with sliding TTL)](#session-in-process-store-with-sliding-ttl)
- [Broadcast groups](#broadcast-groups)

**Deployment & scaling**
- [Deploying with Docker](#deploying-with-docker)
- [Clustering](#clustering)
- [OS tuning for production](#os-tuning-for-production)
- [Performance](#performance)

**Examples**
- [Full example: real-time todo list](#full-example-real-time-todo-list)

**Help**
- [Troubleshooting](#troubleshooting)
- [Related projects](#related-projects)
- [License](#license)

---

**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:

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

### Adding the adapter

```bash
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](https://github.com/uNetworking/uWebSockets.js) 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](#deploying-with-docker).

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

```bash
npm install -D ws
```

---

## Quick start: HTTP

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

**svelte.config.js**
```js
import adapter from 'svelte-adapter-uws';

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

**Build and run:**
```bash
npm run build
node build
```

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

To change the host or port:
```bash
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:
```js
import adapter from 'svelte-adapter-uws';

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

**Build and run with TLS:**
```bash
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:

```bash
ORIGIN=https://example.com node build
```

Or if you want flexible header-based detection:
```bash
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**
```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**
```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**
```svelte

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

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

{#if $status === 'open'}
Connected
{/if}

{#if $notifications}

Event: {$notifications.event}


Data: {JSON.stringify($notifications.data)}


{/if}
```

### Step 4: Publish from the server

**src/routes/api/notify/+server.js**
```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:**
```bash
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**
```js
import adapter from 'svelte-adapter-uws';

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

```bash
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](#step-2-add-the-vite-plugin-required)). 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**
```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:

```bash
npm run build
node build
```

Or with environment variables:
```bash
PORT=8080 HOST=0.0.0.0 node build
```

Or with TLS:
```bash
SSL_CERT=./cert.pem SSL_KEY=./key.pem PORT=443 node build
```

---

**Configuration**

## Adapter options

```js
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

```js
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's own default is 16 KB, which the adapter previously matched; the 1 MB default ships now to handle typical app payloads in a single frame without forcing chunked-upload frameworks into ~12 KB chunks (which the previous 16 KB cap did). For a stricter cap, pin an explicit value (e.g. `16 * 1024` for the uWS-matching 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.

```js
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`](https://github.com/lanteanio/svelte-adapter-uws-extensions) ships `createAdmissionControl`, an opt-in message-path admission wrapper that runs against already-accepted connections. The two stack naturally:

```js
// 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](https://en.wikipedia.org/wiki/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](#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 {
console.log(`Shutting down: ${reason}`);
await db.close();
});
```

### Examples

```bash
# 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`:

```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)**
```svelte

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

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

```

**Svelte 4 equivalent**
```svelte

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

export let data;
const todos = crud('todos', data.todos);

```

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**
```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.
//
// `msg` is the JSON-parsed envelope when the adapter parsed the frame
// for control-message routing but no control type matched (i.e. it
// looks like `{"type":"",...}` from a plugin). The adapter
// already did `TextDecoder + JSON.parse` once during routing, so this
// avoids a second parse on the dispatch path. `msg` is `undefined`
// for binary frames, prefix-miss frames, parse failures, or frames
// that parse to a non-object.
export function message(ws, { data, isBinary, msg }) {
if (msg) {
// Already-parsed JSON object envelope - dispatch by msg.type
console.log('Got envelope:', msg);
return;
}
// Binary or non-envelope text frame - decode manually
console.log('Got raw frame, byteLength:', data.byteLength);
}

// 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:

```svelte

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

{#if $denials}

Cannot subscribe to {$denials.topic}: {$denials.reason}


{/if}
```

The server's `subscribe` hook controls denial reasons:

```js
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:

```js
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`:

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

### What the handler gets

The `upgrade` function receives an `UpgradeContext`:

```js
{
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](https://github.com/uNetworking/uWebSockets.js). 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**
```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**
```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**
```svelte

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');

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

### Step 4: Send messages to specific users from anywhere

**src/routes/api/notify/+server.js**
```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`**

```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**

```js
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](#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:

```js
// 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:

```js
// Skip seq for per-user cursor topics: counter map would grow with users
platform.publish(`cursor:${userId}`, 'move', pos, { seq: false });
```

```js
// 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:

```js
// 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 };
```

```js
// 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:

```js
// 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.

```js
// 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:

```js
// 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');
}
```

```js
// 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:

```js
// 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:

```js
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.

```js
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.closedWsAborts`

Per-worker count of best-effort uWS operations that aborted because the underlying WebSocket had already closed. Bumped every time `platform.subscribe`, `platform.unsubscribe`, `platform.send`, `platform.sendCoalesced`, `platform.sendTo`, or `platform.request` is called on a `ws` whose native handle has been freed - typically because the caller `await`-ed something (auth, loader, subscribe hook) and the client closed during the wait.

These methods are *closed-WS safe* by contract: they swallow uWS's `Invalid access of closed uWS.WebSocket` exception, return a success-shaped no-op sentinel (`null` for subscribe, `false` for unsubscribe, `2` for send, etc.), and bump this counter. Callers can fire-and-forget without a per-site try/catch.

```js
export async function GET({ platform }) {
return json({ closedWsAborts: platform.closedWsAborts });
}
```

A non-zero value is normal under client churn (tab close, network blips, mass reconnect waves). A rapidly-growing value under steady load indicates either pathological client behaviour or that the server's async setup path is too long for its connect rate. In clustered mode, sum across workers for cluster-wide visibility.

Monotonic, per-worker, reset only on process restart.

### `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.

```js
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:

```js
// 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:

```js
// 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`:

```js
// 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:

```js
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:

```js
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:

```js
// 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:

```js
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:

```js
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.

```js
// 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` (default `5000`) and with `Error('connection closed')` if the WebSocket closes before a reply arrives.

The client side opts in by registering a single handler:

```js
import { onRequest } from 'svelte-adapter-uws/client';

onRequest(async (event, data) => {
if (event === 'confirm-action') {
return { confirmed: confirm(`Are you sure? (${data.op})`) };
}
throw new Error('unknown event: ' + event);
});
```

Throw or reject from the handler to send an error reply; the server's awaiting Promise rejects with the same message. With no handler installed, request frames are dropped silently and the server times out.

### `platform.publishBatched(messages)`

Publish a list of `{topic, event, data}` events as a single `{type:'batch', events:[...]}` WebSocket frame per affected subscriber, instead of one frame per event. Each subscriber receives only the events whose topics are in their subscription set, in submitted order. Subscribers with no overlap with the batch's topics receive nothing.

```js
// Form action publishing several related events:
export const actions = {
closeBook: async ({ platform }) => {
const { items, audit } = await db.closeBook();
platform.publishBatched([
...items.map(i => ({ topic: 'org:42:items', event: 'updated', data: i })),
{ topic: 'org:42:audit', event: 'closed', data: audit }
]);
}
};
```

The frame the client receives:

```json
{ "type": "batch", "events": [
{ "topic": "org:42:items", "event": "updated", "data": ..., "seq": 17 },
{ "topic": "org:42:items", "event": "updated", "data": ..., "seq": 18 },
{ "topic": "org:42:audit", "event": "closed", "data": ..., "seq": 4 }
] }
```

The bundled `svelte-adapter-uws/client` decodes the batch frame and dispatches each contained event through the same per-topic store ladder a single-event frame would take - indistinguishable from N individual frames except for the latency drop and the lower onmessage bill.

**When the win shows up.** Wire-level batching has two characteristic shapes that pay off:

- **Bulk fan-out, single topic.** A bulk import publishing 50 events to a topic with 500 subscribers used to send 25,000 frames (50 events x 500 subs); now it sends 500 frames (1 batch x 500 subs). The publishBatched path benches at ~3.2M events/sec on this profile vs ~840K events/sec for an equivalent `publish()` loop (~3.8x speedup, measured locally).
- **Room-state reset, overlapping topics.** A handler that publishes 5 events across 3 rooms where every viewer is in all 3 rooms benches at ~1.46M events/sec vs ~1.15M events/sec for the loop (~27% speedup).

**When the win does not apply.** Mixed subscriber views (some subs see only a subset of the batch's topics) and small disjoint batches (e.g. 3 events to 3 topics with disjoint subscriber sets) cannot share one frame - the C++ TopicTree fanout used by `publish()` is faster than building per-subscriber payloads in JS for those shapes. `publishBatched` detects these cases and falls back to a per-event `publish()` loop, so calling it is at least as fast as the publish loop the user would have written by hand. Verified on a third bench profile (3 events x 50 subs disjoint topics): delta `0.3%` (within noise).

**Capability handshake.** The client opts in by sending `{type:'hello', caps:['batch']}` after the WebSocket opens. The bundled client does this automatically. When the server detects that any interested subscriber has not advertised the `'batch'` capability, the call falls back to the publish loop so old clients receive plain envelopes they can decode. Mixing old and new clients in the same call is safe; old clients simply do not benefit from the batched optimization.

**Cross-worker relay.** A single `publish-batched` IPC frame carries the full event list to other workers in cluster mode. Each receiving worker re-runs the fast-path detection against ITS local subscriber set and dispatches via either a single batch envelope (fast path) or per-event publishes (slow path). Wire batching is preserved cluster-wide instead of degrading to per-event relays at the worker boundary. Pass `{relay: false}` per-event to skip the relay (use when the messages came from an external pub/sub source already fanning out to every worker).

**Coalesce by key.** Each event takes an optional `coalesceKey?: string` field. Events that share a key collapse so only the latest value survives, with the surviving entry kept at the latest occurrence's position. Events without a key pass through unchanged. Use this for high-frequency batches where intermediate values are noise - a single call carrying 100 cursor positions for the same user delivers only the latest.

```js
platform.publishBatched(positions.map(p => ({
topic: 'cursors',
event: 'move',
data: p,
coalesceKey: 'cursor:' + p.userId // latest position per user wins
})));
```

**Frame-size budget.** A batched frame larger than 256 KB triggers a throttled `console.warn`; uWS per-message-deflate may kick in at large sizes and surprise CPU budgets. Chunk into multiple `publishBatched` calls when the warning fires.

**Order and seq.** Within one batched frame, events appear in call order (after any `coalesceKey` collapsing). Each event is independently stamped with a per-topic monotonic `seq`, identical to `publish()`; pass `{seq: false}` per-event to skip stamping for that one event. The streaming `sendCoalesced` API (per-key replacement on a single connection) is independent of `publishBatched`'s batch-level `coalesceKey`; mixing the two on the same subscriber is supported but produces separate frames.

**Two distinct contracts - pick one.** The existing `platform.batch(messages)` is NOT wire-level batching - it is a `for` loop calling `publish()` once per message, so N submitted messages still produce N WebSocket frames per subscribed connection. The cross-worker relay coalesces per microtask, but the client still pays N onmessage dispatches. Use `batch()` when you want per-message return values; use `publishBatched()` when you want one-frame-per-subscriber wire batching.

### `platform.requestId`

A correlation id you can thread through structured logs to follow a single request across server hooks, load functions, and downstream services.

For HTTP requests, a fresh UUID is generated per request. For WebSocket connections the id is stamped once at upgrade and reused for every hook on that connection (`open`, `subscribe`, `message`, `drain`, `close`). In both cases an inbound `X-Request-ID` header overrides the generated value when present, so callers, gateways, and tracing collectors can supply their own id and have it follow the request through your code.

```js
// src/routes/api/orders/+server.js
export async function POST({ platform, request }) {
const log = logger.child({ requestId: platform.requestId });
log.info('order request received');

const order = await db.create(await request.json());
platform.publish('orders', 'created', order);

log.info({ orderId: order.id }, 'order published');
return json({ ok: true, requestId: platform.requestId });
}
```

```js
// hooks.ws.js - the same id flows through every WS hook on a connection
export function open(ws, { platform }) {
logger.info({ requestId: platform.requestId }, 'ws open');
}

export function close(ws, { platform, code }) {
logger.info({ requestId: platform.requestId, code }, 'ws close');
}
```

The header value is sanitized before being used: only printable ASCII (no whitespace, no control chars) up to 128 chars is honoured. Anything else is ignored and a fresh UUID is generated instead, so the id is always safe to interpolate into log lines.

The adapter never writes `X-Request-ID` on the response automatically - emitting it back is an app-layer choice (usually for outbound observability). Set it explicitly if you want callers to see it:

```js
return new Response(body, {
headers: { 'x-request-id': platform.requestId }
});
```

> **Dev-mode note:** in `vite dev`, the dev server generates a fresh UUID per request but does not honour `X-Request-ID` for HTTP traffic (SvelteKit's `emulate.platform()` runs without access to request headers). Production reads the header. Dev-mode WebSocket connections honour the header normally.

### Volatile / fire-and-forget delivery

`platform.publish`, `platform.send`, and `platform.publishBatched` are **all volatile under backpressure**. When a specific subscriber's outbound buffer is over `maxBackpressure` (default 1 MB, configurable in `websocket.maxBackpressure`), uWS skips that subscriber for that frame while continuing to deliver to every non-backpressured subscriber. The skip is silent, per-subscriber, and does not queue for retry. There is no separate `volatile: true` flag because the volatile semantic is the default.

This is the right behavior for transient state where stale values are worse than dropped ones - cursor positions, typing indicators, presence pings, telemetry pulses, draft auto-saves. Slow / disconnected / backgrounded subscribers fall behind silently while everyone else stays current.

```js
// Cursor broadcast: every reader gets the latest position they can keep up
// with; lagging readers silently lose intermediate values, no queue grows.
platform.publish(`doc:${docId}:cursors`, 'move', { userId, x, y }, { seq: false });
```

Pair with `{ seq: false }` to opt out of seq stamping for these high-cardinality, replay-uninteresting topics. The seq counter map is per-topic and grows with cardinality, so opting out keeps memory bounded for unbounded-cardinality topic spaces like `cursor:${userId}` or `presence:${sessionId}`.

For the **drop-the-middle, keep-the-latest** shape on a single connection (the value still arrives, just collapsed across intermediate frames), use `platform.sendCoalesced(ws, ...)` instead. That path queues per `(connection, key)` and drains on the next uWS `drain` event rather than dropping. Quick comparison:

| Primitive | Behavior under backpressure |
|---|---|
| `platform.publish` / `send` / `publishBatched` | Skip the backpressured subscriber, deliver to others. No retry. |
| `platform.sendCoalesced(ws, { key, ... })` | Queue per `(ws, key)`, latest value wins, drain on next `onWritable`. |

To tune how aggressively backpressured subscribers get skipped, lower `maxBackpressure` in `websocket` options (the smaller the buffer, the sooner uWS starts skipping). The 1 MB default favors keeping the connection alive over shedding load; drop to 64 KB or 256 KB if your workload prefers shedding faster.

For visibility into whether subscribers are actually being skipped at scale, watch `platform.pressure.publishRate` and `platform.pressure.topPublishers` - a topic publishing far above its consumer rate is the canonical signature of a backpressure-shedding workload.

---

## Client store API

Import from `svelte-adapter-uws/client`. Everything auto-connects - you don't need to call `connect()` first.

### `on(topic)` - subscribe to a topic

The main function most users need. Returns a Svelte readable store that updates whenever a message is published to the topic.

> **Important:** The store starts as `null` (no message received yet). Always use `{#if $store}` before accessing properties, or you'll get "Cannot read properties of null".

```svelte

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

// Full event envelope: { topic, event, data }
const todos = on('todos');

{#if $todos}

{$todos.event}: {JSON.stringify($todos.data)}


{/if}

```

### `on(topic, event)` - subscribe to a specific event

Filters to a single event name and wraps the payload in `{ data }`:

```svelte

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

// Only 'created' events, wrapped in { data }
const newTodo = on('todos', 'created');

{#if $newTodo}

New todo: {$newTodo.data.text}


{/if}
```

### `.scan(initial, reducer)` - accumulate state

Like `Array.reduce` but reactive. Each new event feeds through the reducer:

```svelte

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

const todos = on('todos').scan([], (list, { event, data }) => {
if (event === 'created') return [...list, data];
if (event === 'updated') return list.map(t => t.id === data.id ? data : t);
if (event === 'deleted') return list.filter(t => t.id !== data.id);
return list;
});

{#each $todos as todo (todo.id)}

{todo.text}


{/each}
```

### `onDerived(topicFn, store)` - reactive topic subscription

Subscribes to a topic derived from a reactive value. When the source store changes, the old topic is released and the new one is subscribed automatically.

```svelte

import { page } from '$app/stores';
import { onDerived } from 'svelte-adapter-uws/client';
import { derived } from 'svelte/store';

// Subscribe to a different topic based on the current route
const roomId = derived(page, ($page) => $page.params.id);
const messages = onDerived((id) => `room:${id}`, roomId);

{#if $messages}

{$messages.event}: {JSON.stringify($messages.data)}


{/if}
```

Without `onDerived`, you'd need to manually watch the source store and call `connect().subscribe()` / `connect().unsubscribe()` yourself when it changes. `onDerived` handles the full lifecycle: subscribes when the first Svelte subscriber arrives, switches topics when the source changes, and unsubscribes from the server when the last Svelte subscriber leaves.

### `crud(topic, initial?, options?)` - live CRUD list

Subscribes to a topic and handles `created`, `updated`, and `deleted` events automatically:

```svelte

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

let { data } = $props(); // from +page.server.js load()

// $todos auto-updates when server publishes created/updated/deleted
const todos = crud('todos', data.todos);

{#each $todos as todo (todo.id)}

{todo.text}


{/each}
```

Options:
- `key` - property to match items by (default: `'id'`)
- `prepend` - add new items to the beginning instead of end (default: `false`)
- `maxAge` - auto-remove entries that haven't been created/updated within this many milliseconds (see [maxAge](#maxage---client-side-entry-expiry) below)

```js
// Notifications, newest first
const notifications = crud('notifications', [], { prepend: true });

// Items keyed by 'slug' instead of 'id'
const posts = crud('posts', data.posts, { key: 'slug' });
```

Pair with `platform.topic()` on the server:

```js
// Server: +page.server.js
export const actions = {
create: async ({ request, platform }) => {
const todo = await db.create(await request.formData());
platform.topic('todos').created(todo); // client sees 'created'
},
update: async ({ request, platform }) => {
const todo = await db.update(await request.formData());
platform.topic('todos').updated(todo); // client sees 'updated'
},
delete: async ({ request, platform }) => {
await db.delete((await request.formData()).get('id'));
platform.topic('todos').deleted({ id }); // client sees 'deleted'
}
};
```

### `lookup(topic, initial?, options?)` - live keyed object

Like `crud()` but returns a `Record` instead of an array. Better for dashboards and fast lookups:

```svelte

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

let { data } = $props();
const users = lookup('users', data.users);

{#if $users[selectedId]}

{/if}
```

Options:
- `key` - property to match items by (default: `'id'`)
- `maxAge` - auto-remove entries that haven't been created/updated within this many milliseconds (see [maxAge](#maxage---client-side-entry-expiry) below)

### `maxAge` - client-side entry expiry

Both `crud()` and `lookup()` accept a `maxAge` option (in milliseconds). When set, entries that haven't received a `created` or `updated` event within that window are automatically removed from the store. Explicit `deleted` events still remove entries immediately.

This is useful for state backed by an external store with TTL (e.g. Redis). If the server fails to broadcast a removal event (mass disconnects, crashes, Redis TTL expiry without keyspace notifications), clients clean up on their own:

```js
// Presence entries expire after 90s without a refresh
const users = lookup('__presence:board', data.users, { key: 'key', maxAge: 90_000 });

// Sensor readings expire after 30s without an update
const sensors = lookup('sensors', [], { key: 'id', maxAge: 30_000 });

// Same option works on crud()
const items = crud('items', data.items, { maxAge: 60_000 });
```

The sweep runs at `maxAge / 2` intervals (minimum 1 second). The timer is cleaned up automatically when the last subscriber unsubscribes.

### `latest(topic, max?, initial?)` - ring buffer

Keeps the last N events. Perfect for chat, activity feeds, notifications:

```svelte

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

// Keep the last 100 chat messages
const messages = latest('chat', 100);

{#each $messages as msg}

{msg.event}: {msg.data.text}


{/each}
```

### `count(topic, initial?)` - live counter

Handles `set`, `increment`, and `decrement` events:

```svelte

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

const online = count('online-users');

{$online} users online


```

Server (from any hook or handler that has `platform`):
```js
// In hooks.ws.js - track connected users:
export function open(ws, { platform }) {
platform.topic('online-users').increment();
}
export function close(ws, { platform }) {
platform.topic('online-users').decrement();
}

// Or from a SvelteKit handler:
platform.topic('online-users').set(42);
```

> **Heads up:** The increment/decrement pattern above has a subtle race condition - a newly connected client won't see the current count because its `subscribe` message hasn't been processed yet when `open` fires. See [Seeding initial state](#seeding-initial-state) for the fix.

### `once(topic, event?, options?)` - wait for one event

Returns a promise that resolves with the first matching event and then unsubscribes:

```js
import { once } from 'svelte-adapter-uws/client';

// Wait for any event on the 'jobs' topic
const event = await once('jobs');

// Wait for a specific event
const result = await once('jobs', 'completed');

// With a timeout (rejects if no event within 5 seconds)
const result = await once('jobs', 'completed', { timeout: 5000 });

// Timeout without event filter
const event = await once('jobs', { timeout: 5000 });
```

### `status` - connection status

Readable store with the current connection state. Five states drive distinct UI affordances:

- `'connecting'` - establishing a connection (initial attempt or retry)
- `'open'` - connected, live data is flowing
- `'suspended'` - WS is technically open but the tab is in the background; server may close idle backgrounded sockets, so live data is best-effort
- `'disconnected'` - lost connection, will retry automatically
- `'failed'` - terminal: auth denied, max retries exhausted, or `close()` called

```svelte

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

{#if $status === 'open'}
Live
{:else if $status === 'suspended'}
Paused (tab in background)
{:else if $status === 'connecting'}
Connecting...
{:else if $status === 'disconnected'}
Reconnecting...
{:else}
Connection failed
{/if}
```

The `'suspended'` overlay flips back to `'open'` automatically when the tab returns to the foreground (assuming the WebSocket survived the hide period; if it did not, the state machine drives `'connecting'` -> `'open'` via the normal reconnect path).

### `failure` - cause of the most recent disconnect

Sibling Readable to `status`. Use `status` to drive UI state; use `failure` to drive what message you show. Stays at `null` while connected, set when the connection drops, cleared on the next successful `'open'`.

The value is a discriminated union by `kind`:

```ts
type Failure =
| { kind: 'ws-close'; class: 'TERMINAL' | 'EXHAUSTED' | 'THROTTLE' | 'RETRY'; code: number; reason: string }
| { kind: 'auth-preflight'; class: 'AUTH'; status: number; reason: string };
```

Five `class` values let consumers render targeted UI without inspecting raw close codes:

- `'TERMINAL'` - server permanently rejected the client (close codes 1008 / 4401 / 4403). The retry loop is stopped; the user must re-authenticate or refresh.
- `'EXHAUSTED'` - reconnect attempts exceeded `maxReconnectAttempts`. The network never recovered; surface a manual-retry button.
- `'THROTTLE'` - server signalled rate-limiting (close code 4429). Reconnect is still scheduled, jumped ahead in the backoff curve.
- `'RETRY'` - normal transient drop (1006 abnormal closure, network blip, server restart). Reconnect is in progress; usually paired with the `'disconnected'` status.
- `'AUTH'` - the auth preflight (`{ auth: true }`) failed before the WebSocket was opened. 4xx is terminal; 5xx and network errors retry. The HTTP status code is in `status`, not `code`.

`failure === null` while `status === 'failed'` is the deliberately-ended state - the user called `close()`, not a transport-level failure.

```svelte

import { status, failure } from 'svelte-adapter-uws/client';

{#if $failure?.class === 'TERMINAL'}

Session expired. Sign in again


{:else if $failure?.class === 'EXHAUSTED'}

Connection lost. location.reload()}>Reload


{:else if $failure?.class === 'THROTTLE'}

Server is busy. Retrying shortly...


{:else if $failure?.class === 'AUTH'}

Could not authenticate (HTTP {$failure.status}). Sign in


{:else if $status === 'disconnected'}
Reconnecting...
{/if}
```

### `ready()` - wait for connection

Returns a promise that resolves when the WebSocket connection is open:

```js
import { ready } from 'svelte-adapter-uws/client';

await ready();
// connection is now open, safe to send messages
```

In SSR (no browser WebSocket and no explicit `url`), `ready()` resolves immediately and is a no-op. In native app environments where `window` doesn't exist but you passed a `url` to `connect()`, `ready()` correctly waits for the connection to open.

`ready()` rejects if the connection is permanently closed before it opens. This happens when the server sends a terminal close code (1008/4401/4403), retries are exhausted, or `close()` is called explicitly. If you call `ready()` in a context where permanent closure is possible, add a `.catch()` handler or use `try/await/catch`.

### `connect(options?)` - power-user API

Most users don't need this - `on()` and `status` auto-connect. Use `connect()` when you need `close()`, `send()`, or custom options.

**If you pass custom options** (like a non-default `path`), call `connect()` before any `on()`, `status`, `ready()`, or `once()` calls. Those functions auto-connect with defaults, and the connection is locked once created. A console warning will fire if your options are ignored due to ordering:

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

const ws = connect({
url: 'wss://my-app.com/ws', // full URL for cross-origin / native app usage (overrides path)
path: '/ws', // default: '/ws'
reconnectInterval: 3000, // default: 3000 ms
maxReconnectInterval: 30000, // default: 30000 ms
maxReconnectAttempts: Infinity, // default: Infinity
debug: true // default: false - turn this on to see everything!
});

// With debug: true, you'll see every WebSocket event in the browser console:
// [ws] connected
// [ws] subscribe -> todos
// [ws] <- todos created { id: 1, text: "Buy milk" }
// [ws] send -> { type: "ping" }
// [ws] disconnected
// [ws] queued -> { type: "important" }
// [ws] resubscribe-batch -> ['todos', 'chat']
// [ws] flush -> { type: "important" }

// Manual topic management
ws.subscribe('chat');
ws.unsubscribe('chat');

// Send custom messages to the server
ws.send({ type: 'ping' });

// Send with queue (messages queue up while disconnected, flush on reconnect)
ws.sendQueued({ type: 'important', data: '...' });

// Permanent disconnect (won't auto-reconnect)
ws.close();
```

### Automatic connection behaviors

The client handles several edge cases automatically, with no configuration required:

**Exponential backoff with proportional jitter**: each reconnect attempt waits longer than the previous one. The jitter is +-25% of the base delay (not a fixed +-500ms), so at high attempt counts thousands of clients are spread over a wide window rather than clustering.

**Page visibility reconnect**: when a browser tab resumes from background or a phone is unlocked, the client reconnects immediately instead of waiting for the backoff timer. Browsers often close WebSocket connections silently when a tab is hidden.

**Batch resubscription**: on reconnect, all topics are resubscribed in batched `subscribe-batch` messages. Each batch stays under the server's 8 KB control-message ceiling and 256-topic-per-batch cap. For typical apps (under 200 topics with short names) this is a single frame; larger sets are automatically chunked.

**Microtask-batched initial subscribes**: multiple `subscribe(topic)` calls landing in the same microtask coalesce into one `subscribe-batch` wire frame. A page that mounts many topic stores in a tight loop (a multi-stream dashboard, a `svelte-realtime` page initializing 5 stream RPCs) triggers the server's `subscribeBatch` hook ONCE instead of the per-topic `subscribe` hook N times. Single-topic case stays as a plain `subscribe` frame for the minimal-change wire shape. Same chunking limits as the reconnect path. Topics are still added to the local subscription set synchronously, so a disconnect between the call and the microtask flush loses nothing - the reopen path picks them up. **Test-code note**: code asserting on the exact wire shape of two same-microtask subscribes seeing two `subscribe` frames now sees one `subscribe-batch` frame; use `.find(m => m.type === 'subscribe-batch' && m.topics.includes(...))` instead.

**Zombie detection**: the client checks every 30 seconds whether the server has been completely silent for more than 150 seconds (2.5x the server's idle timeout). If so, it forces a close and reconnects. This catches connections that appear open but were silently dropped by the server, which is common on mobile after wake from sleep.

### Cross-origin and native app usage

By default, the client derives the WebSocket URL from `window.location`. If your client runs on a different origin - a mobile app (Svelte Native, React Native), a standalone Node.js script, or any context where the backend lives elsewhere - pass a `url` to connect to it directly:

```js
import { connect, on } from 'svelte-adapter-uws/client';

connect({ url: 'wss://my-app.com/ws' });

const todos = on('todos');
```

When `url` is set, `path` is ignored and the `window` check is bypassed, so the client works in environments without a browser DOM. All other features (reconnect, backoff, batch resubscription, topic stores) work the same way.

> **Note:** Your server's `allowedOrigins` config must include the origin your client connects from (or `'*'` during development). See the [origin validation](#origin-validation) section.

---

## Seeding initial state

When a client connects, there's a window between the WebSocket opening and the client's topic subscriptions being processed. Any `platform.publish()` calls that happen during `open` will be missed by the connecting client, because it hasn't subscribed to those topics yet.

This matters most with `count()`. If your `open` hook does `platform.topic('online').set(total)`, the connecting client won't see it - the `set` event is broadcast before the client's `subscribe` message arrives.

The fix is to use the `subscribe` hook instead of (or alongside) `open` to send the current value directly to the subscribing client:

```js
// src/hooks.ws.js
let online = 0;

export function open(ws, { platform }) {
online++;
platform.topic('online').set(online); // broadcasts to already-subscribed clients
}

export function subscribe(ws, topic, { platform }) {
// When a client subscribes to 'online', send it the current count
if (topic === 'online') {
platform.send(ws, 'online', 'set', online);
}
}

export function close(ws, { platform }) {
online--;
platform.topic('online').set(online);
}
```

```svelte

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

const online = count('online');

{$online} online


```

The `subscribe` hook fires at the right moment - after the client is actually subscribed to the topic. `platform.send()` sends only to that one client, so it gets the current value without waiting for the next broadcast.

This same pattern works for any topic where new subscribers need to see the current state. For a CRUD list, you could send the full dataset in `subscribe`:

```js
// src/hooks.ws.js
export async function subscribe(ws, topic, { platform }) {
if (topic === 'todos') {
const todos = await db.getTodos();
for (const todo of todos) {
platform.send(ws, 'todos', 'created', todo);
}
}
}
```

```svelte

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

// No need for load() data - the subscribe hook seeds the list
const todos = crud('todos');

{#each $todos as todo (todo.id)}

{todo.text}


{/each}
```

---

## Plugins

Opt-in modules that build on top of the adapter's public API. They don't change any core behavior - if you don't import them, they don't exist. Each plugin ships in its own subdirectory under `plugins/` with separate server and client entry points.

### Authorization model

Every plugin in this directory is an **authorization-free primitive**. None of them know who the caller is, what roles the caller has, or whether the requested action is allowed - they execute whatever the caller 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.

Your message handler is the gate. Identity is established at connect time by the [`upgrade()` hook](#authentication) 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:

```js
// hooks.ws.js
import { withLock } from 'svelte-adapter-uws/plugins/lock';

export async function message(ws, { data }) {
const { topic, action, payload } = JSON.parse(Buffer.from(data).toString());
const { userId, role } = ws.getUserData() ?? {};

// 1. Authentication: did upgrade() reject? If not, ws.getUserData() is non-empty.
if (!userId) return;

// 2. Authorization: this handler decides. The plugin does not.
if (action === 'reset-counter' && role !== 'admin') return;

// 3. Only now is the plugin invoked. withLock has no idea who the caller is.
await withLock(`counter:${topic}`, async () => {
// ... critical section
});
}
```

Higher-level frameworks built on this adapter (e.g. [`svelte-realtime`](https://github.com/lanteanio/svelte-realtime)) wrap this pattern: `ctx.user` is the same identity object the `upgrade()` hook returned, and the framework's `_guard` / `live.public()` / `// realtime-allow-public` machinery is the authorization layer at the RPC seam. The plugins below still do not gate anything themselves; the framework's auth lives outside them.

The same pattern applies to every plugin in this section: read identity, decide, then invoke. A plugin that "looks like an auth gate" by virtue of taking a userId-shaped key (e.g. `presence.subscribe(`user:${userId}`)`) is just substituting whatever string the caller hands it - if your handler interpolates `payload.targetUserId` from the wire without checking that the caller owns it, the plugin will happily address a user the caller has no business touching.

### Middleware

Composable message processing pipeline. Chain functions that run on inbound messages before your handler logic. Each middleware receives a context and a `next` function - call `next()` to continue, skip it to stop the chain.

#### Setup

```js
// src/lib/server/pipeline.js
import { createMiddleware } from 'svelte-adapter-uws/plugins/middleware';

export const pipeline = createMiddleware(
// logging
async (ctx, next) => {
console.log(`[${ctx.topic}] ${ctx.event}`);
await next();
},
// auth check
async (ctx, next) => {
const userId = ctx.ws.getUserData()?.userId;
if (!userId) return; // stop chain - unauthenticated
ctx.locals.userId = userId;
await next();
},
// data enrichment
async (ctx, next) => {
ctx.data = { ...ctx.data, processedAt: Date.now() };
await next();
}
);
```

#### Usage

```js
// src/hooks.ws.js
import { pipeline } from '$lib/server/pipeline';

export async function message(ws, { data, platform }) {
const msg = JSON.parse(Buffer.from(data).toString());
const ctx = await pipeline.run(ws, msg, platform);
if (!ctx) return; // chain was stopped (e.g. auth failed)

// ctx.locals.userId is available here
// ctx.data has the enriched data
}
```

#### API

| Method | Description |
|---|---|
| `pipeline.run(ws, message, platform)` | Execute the chain. Returns