Migration 0.4 to 0.5

The 0.5 release is a coordinated bump across all three ecosystem packages. The headline themes are async predicate fail-closed, full upload primitives, cluster-aware lifecycle hooks, and a wider security-hardening pass. Most apps will upgrade with no source changes; a handful of patterns need attention.

This page is the executive summary and migration checklist. The full per-package detail lives in:

TL;DR

- "svelte-adapter-uws": "^0.4.x",
- "svelte-realtime": "^0.4.x",
- "svelte-adapter-uws-extensions": "^0.4.x"
+ "svelte-adapter-uws": "^0.5.0",
+ "svelte-realtime": "^0.5.0",
+ "svelte-adapter-uws-extensions": "^0.5.0"
- npm install uNetworking/uWebSockets.js#v20.60.0
+ npm install uNetworking/uWebSockets.js#v20.67.0
- "engines": { "node": ">=20.0.0" }
+ "engines": { "node": ">=22.0.0" }

Bump the three packages together. The peer-dep chain rejects mismatched versions with a clear install warning. The @sveltejs/kit peer-dep range stays at ^2.0.0 for compatibility with existing apps.

Runtime requirements

Node.js 22+ (was Node 20+). Tracks uWebSockets.js v20.67.0 dropping Node 20 support upstream. Node 22 LTS, Node 24, and Node 26 are supported.

  • Bump CI matrix: node-version: '20' -> '22' (and optionally '24').
  • Bump node:20-* Docker images to node:22-* or later. Trixie / Ubuntu 24.04+ for the glibc >= 2.38 native addon requires.

Redis 7+ required for createShardedBus and createFunctionLibrary (uses SPUBLISH and Redis Functions). On Redis 6, use createPubSubBus and direct redis.eval instead.

Critical security fixes that may change behavior

Three changes are not backward compatible. They close real bypass classes; audit your code for the patterns described.

Async predicate fail-closed

access, filter, and live.gate predicates that returned a Promise<false> were silently allowing every request. The runtime now awaits the predicate before the truthiness check.

// Was silently allowing every request before 0.5
export const adminFeed = live.stream('admin-feed', loader, {
  access: async (ctx) => {
    const session = await readSession(ctx.user?.id);
    return session?.role === 'admin';
  }
});

After upgrading, expect new FORBIDDEN and UNAUTHENTICATED errors on streams that were previously open. The same fix applies to async subscribe / subscribeBatch hooks in hooks.ws.js. platform.subscribe and platform.checkSubscribe are now async; await them at every call site.

live.access.any(...) and live.access.all(...) had the same bug at the composition layer (sub-predicates iterated via Array.prototype.some / every read Promise<false> as truthy). Both helpers now return Promise<boolean> and await each sub-predicate in order. Audit any composed-async predicate the same way you audit a top-level async predicate.

See /docs/auth and /docs/ecosystem/adapter/auth for the full surface.

live.idempotent cache key namespacing

The cache slot for live.idempotent is now 'rpc:' + path + ':' + userKey. In-flight cache entries from before the upgrade become invisible after deploy because the namespaced key does not match the old un-namespaced key.

  • Redis-backed stores: schedule the upgrade for a low-traffic window or accept that any in-flight retry that lands on the new build re-runs the handler. Old keys TTL out within 48 hours.
  • In-memory stores: entries clear on process restart; no action.
  • idempotencyKey longer than 256 chars now throws LiveError('INVALID_REQUEST', ...).
  • Custom keyFrom callbacks must still encode tenant scope explicitly. The framework cannot guess tenant shape; the new namespacing only closes the cross-RPC class.

ctx.publish('__*', ...) throws LiveError('INVALID_TOPIC')

Pre-fix, app code could spoof framework-internal frames by publishing on __signal:* / __rpc / __presence:* / __group:* / __replay:*. ctx.publish() now refuses any topic starting with __. Use platform.publish(...) directly when you legitimately need to broadcast on a system topic.

Wire-level subscribes to __-prefixed topics blocked

Hostile clients could previously subscribe to __signal:victim-userId and intercept every server-issued signal targeted at that user. Wire-level subscribes for __-prefixed topics now reject with INVALID_TOPIC. Server-side platform.subscribe(ws, '__signal:userId') (the legitimate framework pattern) still works.

Opt out via websocket.allowSystemTopicSubscribe: true (rare; only if your app legitimately routes public topics through the __ prefix).

Tasks runner idempotencyKey namespacing

createTaskRunner from svelte-adapter-uws-extensions/postgres/tasks now namespaces the cache key as 'task:' + name + ':' + idempotencyKey. Same in-flight invisibility caveat as live.idempotent. Plus a 256-char cap on idempotencyKey (1024 in the underlying stores as defense-in-depth).

Other security defaults that may need attention

Default flipOpt out
/__ws/auth POST requires Origin / x-requested-with / Sec-Fetch-Sitewebsocket.authPathRequireOrigin: false
Refuse to start on same-origin policy without host pinwebsocket.unsafeSameOriginWithoutHostPin: true
Skip dynamic compression for credentialed responses (BREACH)websocket.compressCredentialedResponses: true
Wire-topic accept set restricted to printable ASCIIwebsocket.allowNonAsciiTopics: true
Dev plugin enforces allowedOrigins on WSS upgradedevSkipOriginCheck: true on the Vite plugin
Bus envelope validation: 1 MB cap, __ topic denylistmaxEnvelopeBytes: <bytes>, allowSystemTopics: true

The bundled adapter client always stamps x-requested-with on its preflight POST, so browser-side flows are unaffected.

Wire format and shape changes

Presence wire shape

createPresence now emits presence_state (one snapshot per subscribe) and presence_diff (microtask-batched joins/leaves). The five-event format (list, join, updated, leave, heartbeat) is gone. Apps using the bundled presence() Svelte store or svelte-realtime get the new shape automatically. Hand-rolled wire decoders need updating; see the per-package MIGRATION.md.

replay() end event data

{event: 'end', data: null} is now {event: 'end', data: {reqId}}. replay() accepts an optional reqId parameter and emits a truncated event before replay messages when the buffer has been trimmed past the client’s sinceSeq. A new denied event lands when subscribe authorization rejects the topic.

Five-state client status

The 'closed' state split into 'disconnected' (transient, will retry), 'failed' (terminal: auth denied, max retries, or close() called), and 'suspended' (open but tab is backgrounded). ready() resolves on either 'open' or 'suspended'.

- if ($status === 'closed') showOffline();
+ if ($status === 'failed') showOffline();
+ if ($status === 'disconnected') showRetrying();

Default maxPayloadLength raised from 16 KB to 1 MB

Pre-0.5 the adapter matched uWS’s 16 KB default, which forced chunked-upload frameworks into ~12 KB chunks. 1 MB handles typical app payloads in a single frame. Pin websocket.maxPayloadLength: 16 * 1024 to restore.

live.upload: chunkSize renamed to frameSize

The upload-tuning option configure({ upload: { chunkSize } }) is now frameSize with corrected wire semantics. Pre-rename, chunkSize was raw payload bytes per chunk with no clamp, and a value set to the adapter cap (e.g. 1024 * 1024) silently built frames slightly over the cap because envelope overhead (12 + argsLen bytes) pushed them past 1 MB - uWS closed the connection with code 1009. frameSize is wire-frame size, hard-clamped at the discovered adapter cap with a one-time dev warn, and envelope overhead is subtracted internally so the value passes through 1:1 with platform.maxPayloadLength. The 0.9 safety factor in the auto path was dropped (envelope subtraction is now correct).

chunkSize is accepted as a deprecated alias with a one-time dev warn pointing at the rename; existing config keeps working. See Uploads -> Tuning the client pump.

live.stream({ replay: true }) auto-routing

The framework now owns publish-time replay-buffer wrapping. Apps that set platform.replay = createReplay(...) get auto-routing for free: every framework publish surface (ctx.publish, cron auto-publish, ctx.publish from inside cron handlers) auto-routes through platform.replay.publish(...) for topics declared with live.stream({ replay: true }). The pre-rename wrapWithReplay custom proxy at every seam is no longer needed.

Apps without a custom proxy: nothing to do. The silent-bypass bug where cron-fed events on a replay: true topic skipped the buffer is fixed for free.

Apps with a custom proxy: either drop it (the framework’s registry-from-declaration approach is more precise than regex matching), or mark it with the new WRAPPED_FOR_REPLAY symbol to defer framework auto-routing:

import { WRAPPED_FOR_REPLAY } from 'svelte-realtime/server';

function wrapWithReplay(platform) {
  return { ...platform, [WRAPPED_FOR_REPLAY]: true, publish(...) { ... } };
}

Without the marker, the framework’s auto-routing runs alongside the user proxy and double-writes to Redis. See Replay & Presence -> Auto-routing.

Adapter security-pass defaults

Several adapter plugins and primitives ship with new defense-in-depth caps. None require source changes for healthy apps; documenting in case operator metrics or test fixtures depend on the previous unbounded behavior:

DefaultPre-0.5Post-0.5
createDedup maxIdLengthunbounded256
createLock maxKeyLengthunbounded256
createLock maxWaitersPerKeyunbounded1000 (new error code: LOCK_QUEUE_FULL)
throttle / debounce maxTopicLengthunbounded256
createQueue maxKeyLengthunbounded256
createCursor maxTopicLengthunbounded256
createCursor maxDataBytesunbounded8192
createPresence default selectidentity (full passthrough)denylists __-prefixed / constructor / prototype / /token\|secret\|password\|auth\|session\|cookie\|jwt\|credential/i
createMiddleware ctx.locals{}Object.create(null) (no prototype pollution)
SSR responsesno default headersx-content-type-options: nosniff (if unset)
HTTP body pre-allocationBuffer.allocUnsafeBuffer.alloc (zero-fill)
Replay since / replay sinceSeqaccepted negative / NaN as “dump everything”strict integer; invalid -> empty result
createIdempotencyStore maxResultBytesunbounded256 * 1024 (new error class: IdempotencyResultTooLargeError)
Prometheus maxBucketsunbounded32
Prometheus maxSeriesunbounded10000
handleRpc maxEnvelopeDepthunbounded64

Each cap is per-instance configurable; pass Infinity to restore unbounded behavior on a per-call basis. The error shapes are documented in the corresponding plugin pages.

createPresence requires Redis 7.4+

The Redis presence extension uses HPEXPIRE for per-field TTL (gives O(1) leave + ~609x faster mass-disconnect at scale). createPresence probes INFO server on first use and throws on older servers with a clear migration message. Other extensions in the package remain Redis 7.0+ compatible; the Redis 7.4 requirement is specific to createPresence.

The storage layout changed from compound-field-name keying ({instanceId}|{userKey}) to a two-hash split. Old keys are ignored by the new code and expire naturally. During a rolling deploy, list/count from new instances miss old-instance presence for one TTL window; subscribers see eventual convergence. The metrics().staleCleanedTotal field is now always 0 and the presence_stale_cleaned_total Prometheus counter is no longer registered.

Stream store error semantics (carryover from 0.4.21)

Store value always holds your data type (or undefined). Errors are surfaced on a separate .error Readable<RpcError | null>; status on .status: Readable<'loading' | 'connected' | 'reconnecting' | 'error'>.

- {#if $messages?.error}
-   

{$messages.error.message}

+ + {#if $err} +

{$err.message}

{/if}

hooks.ws.js re-exports

Re-export unsubscribe alongside message and close. The 0.4.0 unsubscribe hook fires in real time when a client drops a topic; the close signature changed from ({ platform }) to ({ platform, subscriptions }).

- export { message, close } from 'svelte-realtime/server';
+ export { message, close, unsubscribe } from 'svelte-realtime/server';

init({ platform }) lifecycle hook

setCronPlatform and live.configurePush({ remoteRegistry }) should now run from the new init hook rather than open. This eliminates the boot-to-first-connect window where cron ticks were no-ops and live.push could not reach cross-instance users.

// hooks.ws.js
import { setCronPlatform, live, message, close, unsubscribe } from 'svelte-realtime/server';

export function init({ platform }) {
  setCronPlatform(platform);
  live.configurePush({ remoteRegistry: registry });
}

export { message, close, unsubscribe };

The legacy open(ws, platform) call site continues to work as a fallback. The init and shutdown hooks both fire once per worker and are awaited by start() and createTestServer().

realtimeTransport() for typed errors across SSR

Wire from src/hooks.js (NOT hooks.server.js) to preserve RpcError and LiveError instances across the SSR / client boundary:

// src/hooks.js
import { realtimeTransport } from 'svelte-realtime/hooks';
export const transport = realtimeTransport();

Apps that catch LiveError in their error boundary by instanceof or err.code will see plain Error instances until the transport is wired.

Postgres extensions: ws_* to svti_* table prefix

All Postgres factories (createReplay, createIdempotencyStore, createJobQueue, createTaskRunner) default to svti_* table names. The replay timestamp column was renamed created_date to created_at. Pick one:

Option A (zero-downtime, no SQL): override table on each factory to keep the old names.

createReplay(pg, { table: 'ws_replay' });
createIdempotencyStore(pg, { table: 'ws_idempotency' });
createJobQueue(pg, { table: 'ws_jobs' });
createTaskRunner(pg, { table: 'ws_tasks' });

Option B (rename in place): see the SQL block in svelte-adapter-uws-extensions/MIGRATION.md.

After upgrading

Run your test suite. Pay particular attention to:

  • Async subscribe / subscribeBatch hooks and async access / filter / live.gate predicates that may now correctly deny requests they previously allowed.
  • Custom WebSocket clients decoding __presence:{topic} and __replay:{topic} frames.
  • Code that depended on the 'closed' status state.
  • Native (non-browser) clients hitting /__ws/auth directly.
  • Deployments running same-origin without an ORIGIN / HOST_HEADER pin.
  • Custom BODY_SIZE_LIMIT env values (negative or non-finite values now throw at startup).
  • Postgres table prefix svti_* if you query the tables outside the extensions.
  • Bus subscribers receiving envelopes larger than 1 MB.
  • Long idempotencyKey strings (>256 chars now throw).

For the full list of breaking changes (35 in svelte-realtime, 28 in svelte-adapter-uws, 21 in svelte-adapter-uws-extensions), follow the per-package MIGRATION.md links at the top of this page.

Was this page helpful?