# Migration guide: svelte-realtime 0.4.x to 0.5.x This guide is organized by **tier**. Most apps only need to read the first two sections. - **[Critical](#critical-read-first)** - runtime behavior changed; audit your code or production may break silently. - **[Required source changes](#required-source-changes)** - won't run cleanly without these. - **[Notable defaults and behaviors](#notable-defaults-and-behaviors)** - probably fine, but you may notice. - **[Recommended new patterns](#recommended-new-patterns)** - not required, but better. - **[Cosmetic](#cosmetic)** - type-only, deprecations, dead code removed. `svelte-realtime` 0.5 raises its peerDep on `svelte-adapter-uws` to `^0.5.0`. See the adapter's MIGRATION.md for breaking changes on that side. If you have a small app and want the 5-minute version, see the [docs site upgrade quickstart](https://svelte-realtime.dev/docs/upgrade-quickstart). --- ## Critical (read first) These four changes close real security bugs that were sitting in idiomatic 0.4 code paths. Audit your code for the patterns described; production deploys may surface new `FORBIDDEN` / `UNAUTHENTICATED` errors and that is the framework now correctly doing its job. ### Async `access` / `filter` predicates and `live.gate` predicates no longer fail open **What changed.** Pre-0.5, the wire-RPC stream path read `if (!streamFilter(ctx, ...))` and the SSR mirror read `if (!predicate(ctx, ...))` synchronously. An async predicate returns a Promise, which is truthy, which made the deny branch unreachable: async-deny became async-allow. Every stream guarded by `access: async (ctx) => ...` (the idiomatic shape for predicates that consult a DB / session store) silently bypassed the developer's intended gate. The fix awaits the predicate before the truthiness check. The matching adapter-side fix in `svelte-adapter-uws@^0.5.0` makes `subscribe` / `subscribeBatch` async-safe and `platform.checkSubscribe` returns `Promise`. `live.access.any(...)` and `live.access.all(...)` had the same bug at the composition layer: both used `Array.prototype.some` / `every`, which read a `Promise` as truthy and either short-circuited to allow (`any`) or fell through to allow (`all`). Apps composing async sub-predicates inside `any` / `all` silently bypassed every gate. The helpers now return `Promise` and `await` each sub-predicate in order, preserving correct short-circuit semantics. The leaf helpers (`live.access.org`, `live.access.user`, `live.access.role`, `live.access.owner`, `live.access.team`) remain sync. **How to migrate.** - Upgrade `svelte-adapter-uws` to `^0.5.0` (already required by 0.5). - Audit every async `access` / `filter` predicate, every async `live.gate(...)`, every async `subscribe` / `subscribeBatch` hook, and every `live.access.any(...)` / `live.access.all(...)` whose sub-predicates were async. Predicates that were silently letting requests through will now correctly deny. Expect to see new `FORBIDDEN` / `UNAUTHENTICATED` errors land on streams that were previously open. - Sync predicates and sync `checkSubscribe` returns are unaffected (await unwraps non-Promise values transparently). - Callers that invoked the result of `live.access.any(...)` or `live.access.all(...)` manually (rare; the typical pattern is to pass the returned predicate to `live.stream({ access })` where the runtime awaits it) must `await` the result. ### `live.idempotent` cache key is now namespaced by RPC path **In-flight cache entries become invisible after deploy.** **What changed.** Pre-0.5, the wrapper used the raw client-supplied `idempotencyKey` as the cache slot regardless of which RPC path was registered with it. A privileged `privateRpc.with({ idempotencyKey: 'abc' })` could be replayed by a public `publicRpc.with({ idempotencyKey: 'abc' })` and read the cached private result without invoking the public handler. The cache key sent to `store.acquire(...)` is now `'rpc:' + path + ':' + userKey`. **This is NOT backward compatible.** In-flight cache entries from before this release become invisible after deploy: the namespaced key does not match the old un-namespaced key. **How to migrate.** - For Redis-backed idempotency 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 eventually TTL out (default 48 hours). - For in-memory stores: entries clear on process restart, no action needed. - Custom `keyFrom` callbacks: still encode tenant scope explicitly. The framework cannot guess the app's tenant shape; the new namespacing only closes the cross-RPC class. - New cap: `idempotencyKey` longer than 256 characters now throws `LiveError('INVALID_REQUEST', ...)`. Pre-fix, stores accepted attacker-supplied 200KB keys. ### `ctx.publish('__*', ...)` now throws `LiveError('INVALID_TOPIC')` **What changed.** Pre-fix, app code could call `ctx.publish('__signal:victim', ...)` or `ctx.publish('__rpc', { id: 'guess', ok: true, data })` to spoof framework-internal frames. Combined with the wire-side `__`-subscribe block, only the framework should publish system channels. `ctx.publish()` now throws `LiveError('INVALID_TOPIC', ...)` when the topic begins with `__`. **How to migrate.** - Server-side `live.signal()` and the plugin-side broadcasts (`__presence:*`, `__group:*`, `__replay:*`) are unaffected - they go through `platform.publish()`, not `ctx.publish()`. - Apps that genuinely need to broadcast on a `__`-prefixed topic should reach for `platform.publish(...)` directly so the intent is explicit at the call site. - Otherwise, rename the topic so it does not start with `__`. ### Stream-RPC subscribes consult the adapter's wire-level subscribe gate **What changed.** Pre-0.5, a `live.stream`/`live.room` subscribe ran the loader, delivered its initial data, and (for rooms) published the presence `'join'` event BEFORE the adapter's `subscribe` / `subscribeBatch` hook chain ever fired. Apps gating private rooms via `subscribeBatch` (rather than via `live.stream({ access })` / `live.room({ guard })`) saw their loader output reach denied users. `_executeStreamRpc` now calls `platform.checkSubscribe?.(ws, topic)` after `__streamFilter` and admission checks but before `ws.subscribe(topic)` / `__onSubscribe` / loader. **How to migrate.** - Upgrade `svelte-adapter-uws` to `^0.5.0`. - If your `subscribe` / `subscribeBatch` hook denies access, expect denials to land on the client's `stream.error` store as `RpcError` with the canonical denial code (`UNAUTHENTICATED` / `FORBIDDEN` / `INVALID_TOPIC` / `RATE_LIMITED`, or whatever string your hook returns). Loader output and presence joins for denied users no longer leak. - If you were relying on the leak (you should not be), gate via `live.stream({ access })` or `live.room({ guard })` instead so the deny path runs synchronously at the realtime layer. --- ## Required source changes These won't run cleanly until you make the change. ### Runtime: Node.js 22+ required (was Node 20+) **What changed.** `package.json#engines.node` moved from `>=20.0.0` to `>=22.0.0`. Tracks the adapter's bump, which in turn tracks `uWebSockets.js` v20.67.0 dropping Node 20 support upstream. **How to migrate.** See `svelte-adapter-uws/MIGRATION.md` for the full uWS-side rationale. No realtime-specific action beyond bumping your runtime to Node 22+. ### Bump `svelte-adapter-uws` peerDep **What changed.** The peer-dep floor moved from `^0.4.0` to `^0.5.0`. Most apps will not work with an adapter older than 0.5 after upgrading - new primitives consumed by this package (`platform.checkSubscribe`, `platform.request` / `onRequest`, `platform.publishBatched`, `init` / `shutdown` lifecycle, `platform.maxPayloadLength`, `platform.bufferedAmount`, async-safe subscribe gates) are version-gated. **How to migrate.** Bump both packages together: ```diff - "svelte-adapter-uws": "^0.4.x" - "svelte-realtime": "^0.4.x" + "svelte-adapter-uws": "^0.5.0" + "svelte-realtime": "^0.5.0" ``` Read the adapter's MIGRATION.md for adapter-side changes (subscribe gate semantics, `init`/`shutdown` hook contract, `maxPayloadLength` default raise from 16KB to 1MB). ### `hooks.ws.js` must export `unsubscribe` (and `close` signature changed) **What changed.** The 0.4.0 release introduced an `unsubscribe` hook that fires in real time when a client drops a topic. The `close` hook signature changed from `({ platform })` to `({ platform, subscriptions })` and only fires `onUnsubscribe` for topics still active at disconnect time. **How to migrate.** Re-export all three hooks from `hooks.ws.js`: ```diff - export { message, close } from 'svelte-realtime/server'; + export { message, close, unsubscribe } from 'svelte-realtime/server'; ``` Existing destructures of `{ platform }` keep working; `subscriptions` is additive. ### `live.room()` actions require `topicArgs` **What changed (0.4.0).** `live.room()` with actions previously inferred the argument count from `topicFn.length`. The 0.4.0 release made `topicArgs` explicit. **How to migrate.** ```diff export const board = live.room( (ctx, boardId) => `board:${boardId}`, + { topicArgs: 1 }, { init: async (ctx, boardId) => loadBoard(boardId), actions: { addCard: live(...) } } ); ``` Rooms without `actions` are unaffected. ### `live.room()` `onLeave` and `onJoin` semantics **What changed (0.4.0).** `onLeave` callback signature changed from `(ctx)` to `(ctx, topic)`. `onJoin` now runs after `initFn` succeeds; if `initFn` throws, `onJoin` is not called. **How to migrate.** ```diff - onLeave: (ctx) => { /* ... */ } + onLeave: (ctx, topic) => { /* ... */ } ``` If you relied on `onJoin` running before `initFn`, restructure: side effects that should fire regardless go in `init`'s try/catch; side effects that should only fire on a successful init stay in `onJoin`. ### `live.validated()` rejects unrecognized schema types **What changed (0.4.0).** Previously passed input through with a dev warning. Now returns an error. Standard Schema (Zod, ArkType, Valibot v1+) is the canonical surface as of 0.4.19; legacy `.safeParse` / `._run` paths remain. **How to migrate.** Ensure every schema is Zod, Valibot, ArkType, or another Standard Schema-compatible validator. Async schemas are rejected with a clear error. ### Reserved topic prefix `__` rejected at definition time **What changed (0.4.0).** `live.stream()` and `live.channel()` now reject topics with the `__` prefix at definition time. Async topic functions are also rejected at definition time. Binary RPC headers >65535 bytes return `PAYLOAD_TOO_LARGE`. **How to migrate.** Rename any `__`-prefixed topic in your stream/channel definitions. Use `platform.publish(...)` directly if you have a legitimate framework-level use case (which is rare and typically wrong). --- ## Notable defaults and behaviors These change observable runtime behavior. Most apps are unaffected; a few will notice. ### Stream store errors no longer replace the data value (`.error` is a separate Readable) **What changed.** This change shipped in 0.4.21 and remains the public contract for 0.5. Pre-0.4.21, connection failures, timeouts, and rejected fetches set the store value to `{ error: RpcError }`, replacing whatever data was there. Patterns like `($store ?? []).filter(...)` crashed with `TypeError` because the error object is truthy but not an array. The store value now always holds your data type (or `undefined` while loading). Errors are surfaced on a separate `.error` `Readable`, and `.status` is a `Readable<'loading' | 'connected' | 'reconnecting' | 'error'>`. **How to migrate.** ```diff - {#if $messages?.error} -

{$messages.error.message}

+ const err = messages.error; + {#if $err} +

{$err.message}

``` Code that uses `$store === undefined` for loading and otherwise treats the value as data needs no changes. ### Auto-replay routing for `live.stream({ replay: true })` -- bespoke `wrapWithReplay` proxies must opt out via `WRAPPED_FOR_REPLAY` or be dropped **What changed.** Pre-fix, the user was responsible for wrapping the platform with a `wrapWithReplay` proxy at every seam (`createMessage({ platform: wrapWithReplay })` AND `setCronPlatform(wrapWithReplay(platform))`). The docs showed the wrap only on the RPC seam, so cron-published events to a `replay: true` topic silently bypassed the buffer; reconnecting clients never saw missed cron ticks even when the documented three-tier reconnect (`replay -> delta.fromSeq -> rehydrate`) was correctly declared on the stream. The fix moves replay routing into the framework. `live.stream(topic, loader, { replay: true })` registers the topic at declaration time (or at first-subscribe time for dynamic topic factories). When `platform.replay` is exposed by the adapter, the framework auto-routes every publish to a registered topic through `platform.replay.publish(...)` -- regardless of which seam the publisher sits on. Cron auto-publish, `ctx.publish` from RPC handlers, `ctx.publish` from cron handlers, all flow through the same routing helper. The buffer is populated end-to-end with no user wiring beyond `replay: true`. **This is observable for two user shapes:** 1. **Apps without a custom `wrapWithReplay` proxy:** the framework now Just Works. Cron-published events to replay-eligible topics get buffered automatically; reconnecting clients see missed events on resume. No action required; this fixes the silent bug. 2. **Apps with a custom `wrapWithReplay` proxy:** the framework's auto-routing runs alongside the user proxy's routing and DOUBLE-WRITES to Redis. To preserve old behavior, mark the proxy with `[WRAPPED_FOR_REPLAY] = true` so the framework defers entirely: ```js import { WRAPPED_FOR_REPLAY } from 'svelte-realtime/server'; function wrapWithReplay(p) { const wrapped = new Proxy(p, { /* ...your intercepts... */ }); wrapped[WRAPPED_FOR_REPLAY] = true; // explicit opt-out return wrapped; } ``` Or drop the wrap entirely (recommended -- the framework now owns the same job). Most bespoke `wrapWithReplay` proxies were doing exactly what the framework now does built-in: matching topics against a regex and routing to `replay.publish`. The framework's registry-from-declaration approach is more precise (topics are sourced from `live.stream({ replay: true })`, not regex patterns) and removes the asymmetry between seams. **Dev-mode misconfiguration warning.** If `replay: true` is declared on a stream but `platform.replay` is undefined when the first publish to that topic happens, the framework logs a one-time `console.warn` per topic with the install pointer for the replay extension. Catches the "I declared `replay: true` but never installed the extension" footgun loudly. Production runs silently. **How to migrate.** - Most apps: do nothing. Cron + RPC + derived publishes to `replay: true` topics now reach the buffer automatically. - Apps with a custom `wrapWithReplay` proxy: add `[WRAPPED_FOR_REPLAY] = true` to keep the proxy authoritative, OR drop the proxy and let the framework own routing. - Apps that wired cron-side replay separately via `setCronPlatform(wrapWithReplay(platform))`: drop the cron-side wrap. The framework now routes cron auto-publishes through replay automatically. Bus wrapping still needs `configureCron({ bus })` for cluster fan-out (orthogonal concern). ### `live.upload` aggregate pre-handler buffer cap raised; chunk-0 frames may be rejected with `OVERLOADED` **What changed.** Pre-fix, every concurrent upload stream got its own 16 MB pre-handler-resolution buffer with no aggregate cap. N concurrent connections opening streamId 0 with a 16 MB chunk-0 payload each could allocate `16 * N` MB of worker memory before any handler-side cap could fire. The default aggregate cap is now 64 MB across all in-flight pending uploads; chunk-0 frames that would exceed are rejected with `OVERLOADED` and the streamId is never registered. **How to migrate.** No action required for normal traffic. If you orchestrate many concurrent uploads (batch import, parallel media transcode), surface the `OVERLOADED` code to the client and either retry with backoff or queue uploads. The cap is tunable via `_setCapsForTest({ uploadPendingMaxAggregate: bytes })` for tests; a runtime option is a follow-up. ### Global middleware `next()` is single-call-guarded **What changed.** Pre-fix, a buggy middleware written as `next().then(() => next())` re-entered the chain and executed the downstream handler twice. Side-effecting handlers (charge customer, send email, increment counter) silently doubled. Each middleware frame in `_runWithMiddleware` now creates its own one-shot `next()`. The second call throws `Error('middleware: next() called more than once. ...')`. **How to migrate.** If your middleware was buggy (calling `next()` twice), the throw lands inside your middleware function (typically caught by the surrounding RPC error handler). Fix the bug; the call now fails loud rather than silently doubling. ### `live.push` rejects with structured `LiveError` codes (not message-substring sniffing) **What changed.** Pre-fix, callers had to sniff `err.message.includes('timed out')` to distinguish deadline expiry from other failures. Deadline expiry from the adapter primitive (`Error('request timed out')`) is now translated to `LiveError('TIMEOUT', ...)`; argument-validation throws lift from plain `Error` to `LiveError('VALIDATION', ...)`. Message text is preserved verbatim on `.message` (so existing substring checks keep working) and the original error rides on `.cause`. The full `live.push` failure surface now discriminates via `err.code`: `VALIDATION` / `NOT_FOUND` / `TIMEOUT` / `CONNECTION_CLOSED` / caller-defined. **How to migrate.** Replace `err.message.includes('timed out')` with `err.code === 'TIMEOUT'`. The substring check still works during the transition. ### `configureCron` accepts a partial config (validation message changed) **What changed.** Previous rule was "leader is required". Now: at least one of `leader` or `bus` must be present. The validation error message changed from `"config must include a leader field"` to `"config must include at least one of leader or bus"`. **How to migrate.** No action required for existing `{ leader: ... }` call sites. If you parse the error message in tests, update the substring. ### Vite plugin classifies single-arity `(ctx) => topic` as static streams **What changed.** `_isDynamicExport` previously returned true for any function-form first argument regardless of arity, so `live.stream((ctx) => 'events:' + ctx.user.id, ...)` produced a factory-shaped client stub. The stub is now arity-aware: 0-param topic-fns and 1-param ctx-only topic-fns (named `ctx` / `context` / `_ctx`, or typed as `Ctx` / `Context` / `RequestContext` / `ServerContext` / `LiveContext`) are static; everything else stays dynamic. **How to migrate.** If your client code has `myStream(ctx.user.id).subscribe(...)` for what is now correctly classified as static, drop the call: ```diff - $: items = $myStream(orgId); + $: items = $myStream; ``` Existing 1-arg-non-ctx and 2+arg topic-fns are unaffected. ### Stream reconnect backoff timing changed **What changed (0.4.0).** Old: fixed 50-200ms random delay. New: first two attempts use 20-100ms, then exponential backoff up to 5 minutes with jitter. Observable timing difference, not an API change. **How to migrate.** If you have tests asserting reconnect timings, update them. Production code is unaffected. ### `live.upload` chunk pacing uses `conn.bufferedAmount` **What changed.** The client pump now checks `conn.bufferedAmount` after every chunk and pauses sends when the WS send queue exceeds a high-water mark, resuming when it drops below a low-water mark. Defaults: 4MB high-water, 1MB low-water, 50ms drain-poll interval. Configurable via `configure({ upload: { highWaterMark, lowWaterMark } })`. **How to migrate.** No code change required. Apps that explicitly relied on unbounded queue growth (you should not be) will see paced sends. ### `MAX_PRESENCE_REF` is now an exported tunable cap (default 1,000,000) **What changed.** The in-memory presence-ref map (`_presenceRef`) cap was an unexported `10_000` internal that bounded only refcount bookkeeping. It now backs the zero-config presence-roster fallback (presence stream init reconstructs the roster from `_presenceRef` when `platform.presence.list` isn't wired) and is exported alongside the other documented capacity caps. Default raised to 1,000,000. Saturation behavior: entries with a pending leave timer are evicted first; if still full, the new join is dropped silently and a one-shot warning surfaces. **How to migrate.** If you have a custom build that referenced the unexported `10_000` constant, switch to the exported `MAX_PRESENCE_REF` import. Apps that wire `platform.presence` (Redis-backed) bypass the fallback and are unaffected. ### `live.publishRateWarning` and `live.silentTopicWarning` validation throws at registration **What changed.** Invalid `threshold` / `intervalMs` / `thresholdMs` (non-positive, non-finite, wrong type) now throw `[svelte-realtime]`-prefixed errors at registration time so misconfiguration shows up at boot rather than mid-traffic. **How to migrate.** No action required for valid configs. Audit any dynamic config wiring that could pass `0`, `-1`, `'30s'` (string), etc. ### `live.cron` 6-field expressions and 1Hz tick **What changed.** A leading seconds field unlocks sub-minute granularity. Once any 6-field schedule is registered, the cron tick adapts from 60s to 1Hz (sticky for the process lifetime; cleared by `_clearCron` for HMR). 5-field schedules running under the 1Hz tick fire only at second `:00` of any matching minute, so they keep their once-per-matching-minute semantics. `live.cron` no longer fires concurrently with itself: the tick now skips a path whose previous invocation is still in flight and increments `cronCount{status: 'skipped'}`. **How to migrate.** No action required. If you had a long-running cron job that intentionally relied on parallel invocations (you should not), the next tick is now skipped instead of running in parallel; restructure to either let the previous invocation finish or split the work into independent jobs with different paths. --- ## Recommended new patterns Not required. Adopting these gets you the full 0.5 experience. ### `ctx.skip(key, ms)` for per-key handler gating **What's new.** A per-key gate primitive on `LiveContext`. Returns `true` to skip the call (key is within its cooldown window), `false` to run it. Pairs with `ctx.shed` semantically so call sites read uniformly with an early `return`: ```js export const moveNote = live(async (ctx, noteId, x, y) => { if (ctx.shed('background')) return; // pressure shed if (ctx.skip(`move:${noteId}`, 16)) return; // per-key handler gate await dbUpdateNote(noteId, x, y); ctx.publish(TOPICS.notes, 'updated', { noteId, x, y }); }); ``` State is per-replica (CPU/DB shed, not cluster-wide rate limit; for cross-replica gating use `live.rateLimit({ store: 'redis' })` or the `redis/ratelimit` extension). Capped at 5000 active entries with fail-open semantics on overflow (returns `false`, dev-warns once). Throws `LiveError('INVALID_ARG', ...)` on `key` not a string or `ms` not a positive finite number. This is the primitive developers were reaching for when they wrote `ctx.throttle('move:id', 50)` thinking it gated handler execution. The old `ctx.throttle` / `ctx.debounce` are outbound publish helpers (renamed to `publishThrottled` / `publishDebounced` - see [Cosmetic](#cosmetic)); the new `ctx.skip` is the actual handler gate. ### `createMessage({ onJsonMessage(ws, msg, platform) })` for plugin-layer JSON dispatch **What's new.** A callback on `createMessage` that receives the parsed envelope when a non-RPC text frame parses as a JSON object. Replaces the manual `TextDecoder + JSON.parse + dispatch` pattern that plugins like `cursor.hooks.message` previously required in user `hooks.ws.js`. ```js // Before import { createMessage } from 'svelte-realtime/server'; import { cursor } from '$lib/server/redis'; export const message = createMessage({ onUnhandled(ws, data, platform) { if (!(data instanceof ArrayBuffer) || data.byteLength < 2) return; let msg; try { msg = JSON.parse(new TextDecoder().decode(data)); } catch { return; } if (!msg || typeof msg !== 'object') return; if (msg.type === 'cursor') { cursor.hooks.message(ws, { data: msg, platform }); } } }); // After export const message = createMessage({ onJsonMessage(ws, msg, platform) { if (msg.type === 'cursor') cursor.hooks.message(ws, { data: msg, platform }); } }); ``` Two-tier lookup: (1) fast path uses the `msg` field forwarded by `svelte-adapter-uws@^0.5.3` (one parse total); (2) fallback parses locally for frames the adapter didn't fast-path (older adapter, > 8 KiB frame, or non-`{"ty` prefix). Frames that aren't JSON, can't parse, parse to a non-object, or exceed the depth cap (`maxJsonDepth`, default 64) fall through to `onUnhandled` with the original raw bytes. The adapter's `maxPayloadLength` (default 1 MB) is the structural size ceiling. Both `onJsonMessage` and `onUnhandled` can be set together for mixed JSON / binary frame handling. ### Move `setCronPlatform` and `live.configurePush({ remoteRegistry })` to `init({ platform })` **What changed.** Both functions used to be wired from `open(ws, platform)`. The recommended call site is now the adapter's `init({ platform })` lifecycle hook, which fires once per worker after the listen socket is bound and before any upgrade / open / message hook runs. This eliminates the boot-to-first-connect window where cron ticks were no-ops and `live.push` could not reach cross-instance users. **How to migrate.** Move the calls from `open` to `init`: ```js // 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 }; ``` Requires `svelte-adapter-uws@^0.5.0`. The legacy `open(ws, platform)` call site continues to work as a fallback. ### `live.upload({ reauthEvery })` mid-stream re-auth **What changed.** Pre-fix, `_startUpload` ran the module guard once at chunk-0 arrival; if the user's session was revoked mid-upload (token expiry, explicit logout, role downgrade) the upload kept running with the original auth grant. Passing `live.upload(handler, { reauthEvery: })` re-runs the same guard against the live `ctx` every N bytes received past the last re-auth. If the guard rejects, the upload aborts with `UNAUTHENTICATED` / `FORBIDDEN` and the consumer observes the abort signal. Default is unset (legacy behavior: guard runs once at chunk-0). The option must be opted into per upload because not every upload has a meaningful re-auth boundary. **How to migrate.** No action required for write-once short uploads. For long-tail user uploads (multi-minute, multi-GB) where session revocation matters: ```js export const avatar = live.upload( async (ctx, name, mime) => { /* ... */ }, { reauthEvery: 16 * 1024 * 1024 } // re-auth every 16 MB ); ``` ### Auto-discovery of adapter `maxPayloadLength` for upload frame sizing **What changed.** The server piggybacks `platform.maxPayloadLength` on the first upload-response envelope per WS via a `__cap` field; the client caches the value globally and uses it as the wire frame size for subsequent uploads, subtracting envelope overhead (10 bytes on chunks 1+, `12 + argsLen` on chunk 0) per chunk internally. The first upload uses the conservative 12KB default; the second upload onwards uses the full discovered cap. User-configured `configure({ upload: { frameSize } })` always wins over discovery, but is silently clamped down to the discovered cap with a one-time dev warn if it exceeds the adapter's limit. The framework guarantees no wire frame ever exceeds `platform.maxPayloadLength`; without this clamp the adapter would close the connection with code 1009. **How to migrate.** No action required. If you pinned `chunkSize` to 12KB by hand, drop the override and let auto-discovery upgrade frames on adapters with `maxPayloadLength: 1MB` (the 0.5.x default). ### `configure({ upload: { chunkSize } })` renamed to `frameSize`; `chunkSize` accepted as deprecated alias **Why the rename.** Pre-rename, the `chunkSize` knob was raw payload bytes per chunk with no clamp and no warn. A user reading "the adapter cap is 1MB" and setting `chunkSize: 1024 * 1024` was correctly following the docs and silently built frames slightly over the cap (the envelope overhead added `12 + argsLen` bytes); uWS evaluated frame size on receive and closed the connection with code 1009. The failure was silent: the client error path never fired because the connection close beat the chunk send-ack. **What changed.** - The knob is renamed to `frameSize` and means "max wire frame bytes," matching `platform.maxPayloadLength`'s semantic 1:1. The framework subtracts envelope overhead per chunk internally; user code does no envelope arithmetic. - A hard ceiling clamps user input to the discovered adapter cap, with a one-time dev warn when clamping kicks in. Overflow is now structurally impossible. - The auto path drops the old 0.9 safety factor: frame size auto-defaults to the FULL discovered cap (the safety factor was a workaround for the missing per-chunk overhead subtraction; that's now done correctly). - `chunkSize` is accepted as a deprecated alias for `frameSize`. Existing config still works; a one-time dev warn points at the rename. Both fields take the same numeric value. - When both `frameSize` and `chunkSize` are set, `frameSize` wins and no deprecation warn fires. **How to migrate.** - Existing apps with `configure({ upload: { chunkSize: N } })` keep working. To silence the deprecation warn, rename the field to `frameSize`. The numeric value passes through unchanged. - Existing apps that set `chunkSize` to the adapter's `maxPayloadLength` (the silent-overflow case) are now correctly clamped down by the envelope overhead. Effective payload-per-chunk drops by ~12 + `argsLen` bytes (typically ~30-50 bytes); throughput change is invisible (~0.003% on a 1MB cap). - New apps should use `frameSize`. The mental model is: "this is the wire frame budget; the framework slices payload to fit." ```js // Before configure({ upload: { chunkSize: 1024 * 1024 } }); // silently overflows on 1MB cap // After configure({ upload: { frameSize: 1024 * 1024 } }); // safe; framework subtracts envelope per chunk ``` ### `RpcError` and `LiveError` SvelteKit transport (opt-in) **What changed.** Typed errors thrown during `+page.server.js` `load()` previously arrived at `+error.svelte` as plain `Error` instances. The new `realtimeTransport()` SvelteKit transport hook (from `svelte-realtime/hooks`) auto-registers serialization for `RpcError` and `LiveError` across the SSR / client boundary so the original class with `code` intact is preserved. This is opt-in (additive), but apps that catch `LiveError` in their error boundary by `instanceof` or `err.code` will see the previous behavior (plain Error) until they wire the transport. **How to migrate.** Wire the transport hook from `src/hooks.js` (NOT `hooks.server.js`): ```js // src/hooks.js import { realtimeTransport } from 'svelte-realtime/hooks'; export const transport = realtimeTransport(); ``` --- ## Cosmetic Type-only changes, deprecations, dead code removed. No action required for most apps. ### `ctx.throttle` / `ctx.debounce` renamed to `ctx.publishThrottled` / `ctx.publishDebounced`; old names accepted as soft-deprecated aliases **What changed.** The names `throttle` / `debounce` in JS-land (lodash, RxJS, Underscore) typically mean "gate a function's execution." The realtime helpers actually scheduled outbound publishes - misreading the name as a gate led to calls like `ctx.throttle('move:noteId', 50)` (developer intent: "gate this handler") which silently published junk frames to a topic nobody subscribed to at the full client rate (`event=50` (number), `data=undefined`, `ms=undefined` -> `setTimeout(_, 0)` -> zero-ms window -> next call publishes again). The new names `publishThrottled` / `publishDebounced` put "publish" central so the misread becomes structurally impossible. For the gate-handler use case the developer was actually after, `ctx.skip(key, ms)` is the new primitive (see [Recommended new patterns](#recommended-new-patterns)). **How to migrate.** Optional rename for new code: ```diff - ctx.throttle(topic, event, data, ms) + ctx.publishThrottled(topic, event, data, ms) - ctx.debounce(topic, event, data, ms) + ctx.publishDebounced(topic, event, data, ms) ``` The old names keep working as aliases indefinitely. A one-time dev warning per process per name fires on first call to the old name; production behaviour is unchanged. To silence the dev warning, rename. `live.cron()` and `live()` contexts both gained the new names; both keep the old aliases. If you wrote `ctx.throttle('move:id', 50)` thinking it would gate handler execution, the fix is `if (ctx.skip('move:id', 50)) return` at the top of the handler body. See the `ctx.skip` migration entry in [Recommended new patterns](#recommended-new-patterns). ### `pushHooks.close` now drains stream-subscription bookkeeping when called with `ctx` **What changed.** Pre-0.5, `pushHooks.close` was push-only. Apps following the JSDoc-ordained `export const close = pushHooks.close;` left stream-subscription bookkeeping (`_topicWsCounts`, silent-topic watchdogs, `__onUnsubscribe` callbacks) un-drained. A 30s flurry of `silent topic` warnings fired after every page closed. `pushHooks.close(ws, ctx)` now routes through the realtime `close` when the adapter passes `ctx` (production), so the existing JSDoc-style re-export gets the same full cleanup with no doc-ordained migration. Direct one-arg `pushHooks.close(ws)` calls (tests, custom flows) still work as push-only via a fallback branch. **How to migrate.** Nothing required if you wired `pushHooks.close` per the JSDoc. If you wired both `pushHooks.close` AND `realtime.close` manually (composed), the calls are idempotent across both registries - no double-drain bug. ### `onCronError()` is deprecated; use `onError()` **What changed (0.4.0).** `onError(handler)` is the global error handler for cron, effects, and derived stream errors. `onCronError` still works but delegates. **How to migrate.** ```diff - onCronError((err, jobName) => log.error({ err, jobName })) + onError((err) => log.error({ err })) ``` ### Snapshot hydration for `live.aggregate` skips `__proto__` / `constructor` / `prototype` **What changed.** Pre-fix, `Object.assign(entry.state, snapshotState)` accepted any keys present in the snapshot payload. A snapshot returned from a backend (Redis cache, JSON payload from another service) is a hostile-input boundary - a payload like `JSON.parse('{"__proto__":{"polluted":1}}')` would have stamped `polluted` on `Object.prototype`. Snapshot hydration now copies own enumerable keys via a helper that skips `__proto__`, `constructor`, and `prototype`. **How to migrate.** No action required unless you stored values under literal keys named `__proto__` / `constructor` / `prototype` in your aggregate state (don't). Both the single-snapshot path (`live.aggregate(..., { snapshot })`) and the per-window snapshot path (`live.aggregate(..., { snapshots: { ... } })`) go through the helper. ### Realtime rate-limit identity probes `id`, `user_id`, and `userId` **What changed.** Pre-fix, the default per-handler rate-limit identity key read only `ctx.user.id`. Apps with sessions whose shape uses Postgres-convention `user_id` or camelCase `userId` fell back to the per-connection guest bucket - defeating per-user rate limits exactly when they mattered most. `_getIdentityKey` now reads `ctx.user.id ?? ctx.user.user_id ?? ctx.user.userId`. **How to migrate.** No action required if your session shape exposes any of the three. If your session uses a different field, override: ```js live.rateLimit({ identity: ctx => ctx.user?.tenant_user_id, /* ... */ }) ``` ### Vite codegen path interpolation now JSON-quoted (RCE fix) **What changed.** Pre-fix, the Vite plugin emitted client stubs and server-bundle registrations as `__rpc('${modulePath}/${name}')`. A filename containing a `'` could break out of the generated string literal and inject code. Every interpolation now routes through `JSON.stringify(...)`. **How to migrate.** No action required. Regenerate the build (`vite build`); old generated bundles on disk should be discarded. ### SSR stub generation now uses the same classifier as client stubs **What changed.** An earlier 0.5 prerelease taught the client-side classifier that single-arity ctx-only topic functions are static, but the SSR stub generator (`_generateSsrStubs`) was missed in that pass. Result: client said "static StreamStore", SSR said "factory function", and `$inbox` during SSR rendering called `factory.subscribe(...)` and crashed every affected page with `TypeError: store.subscribe is not a function`. Both paths now go through the canonical `_isDynamicExport` classifier. **How to migrate.** No action required after upgrading to 0.5 final. ### `live.configurePush()` typings: `remoteRegistry` is now accepted, both fields are optional **What changed.** `server.d.ts` typed `identify` as required and omitted `remoteRegistry`. The runtime accepted either field individually and rejected only when neither was present. The typed surface now mirrors that contract via a discriminated union. **How to migrate.** TypeScript users: if you pinned to the broken types via assertions, drop them. `live.configurePush({ remoteRegistry })` and `live.configurePush({ identify })` typecheck cleanly. `live.configurePush({})` is now a compile-time error. ### Removed: dead `pipe.filter().transformEvent` field **What changed.** `pipe.filter()` returned `{ transformInit, transformEvent }` but `pipe()` only ever consumed `transformInit`, so `transformEvent` had no runtime effect. The field is removed from `server.js` and `server.d.ts`. The README pointer for per-event projection now correctly directs to the `transform` option on `live.stream({ transform })`. **How to migrate.** If you set `pipe.filter({ transformEvent: ... })`, the value was being silently discarded - move to `live.stream({ transform })`: ```diff - live.stream(topic, loader, pipe(pipe.filter({ transformEvent: project }))) + live.stream(topic, loader, { transform: project }) ``` ### Default in-process lock backing `live.lock` rewritten as a per-key FIFO waiter queue **What changed.** The prior `Map` chain was replaced so that `maxWaitMs` cancellations skip cancelled waiters cleanly without breaking FIFO. Behavior for existing call sites without `maxWaitMs` is identical: same FIFO, same parallelism across keys, same handler-error propagation that unblocks the next waiter. The adapter's `lock.clear()` now rejects pending waiters with a typed `LOCK_CLEARED` error instead of leaving them hanging. **How to migrate.** If your code calls the adapter's lock plugin directly via `lock.clear()` and ignored `clear()`-driven rejections, add a `catch`: ```diff await someLockedCall().catch(err => { + if (err.code === 'LOCK_CLEARED') return; throw err; }); ``` ### `__binaryRpc` accepts `ArrayBuffer | ArrayBufferView` **What changed (0.4.0).** The TypeScript signature widened from `ArrayBuffer` to `ArrayBuffer | ArrayBufferView`. Not breaking for callers passing `ArrayBuffer`. **How to migrate.** None required for runtime; TypeScript users may need to relax overly-narrow argument types if they were re-typing the function.