svelte-realtime 0.4.12 → 0.4.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -16
- package/package.json +1 -1
- package/server.js +10 -1
- package/vite.js +5 -5
package/README.md
CHANGED
|
@@ -634,9 +634,9 @@ Call live functions from `+page.server.js` to load data server-side, then hydrat
|
|
|
634
634
|
|
|
635
635
|
```js
|
|
636
636
|
// src/routes/chat/+page.server.js
|
|
637
|
-
export async function load({ platform }) {
|
|
637
|
+
export async function load({ platform, locals }) {
|
|
638
638
|
const { messages } = await import('$live/chat');
|
|
639
|
-
const data = await messages.load(platform);
|
|
639
|
+
const data = await messages.load(platform, { user: locals.user });
|
|
640
640
|
return { messages: data };
|
|
641
641
|
}
|
|
642
642
|
```
|
|
@@ -884,14 +884,47 @@ Call `configure()` once at app startup. The hooks fire on state transitions only
|
|
|
884
884
|
When using svelte-realtime from a client that runs on a different origin (Svelte Native, React Native, or any standalone app), pass the `url` option to point at your SvelteKit backend:
|
|
885
885
|
|
|
886
886
|
```js
|
|
887
|
-
import { configure } from 'svelte-realtime/client';
|
|
887
|
+
import { configure, __rpc, __stream } from 'svelte-realtime/client';
|
|
888
888
|
|
|
889
|
-
configure({
|
|
890
|
-
|
|
891
|
-
|
|
889
|
+
configure({ url: 'wss://my-sveltekit-app.com/ws' });
|
|
890
|
+
|
|
891
|
+
// Call a live function (equivalent to $live/chat.sendMessage, but untyped)
|
|
892
|
+
const sendMessage = __rpc('chat/sendMessage');
|
|
893
|
+
await sendMessage('hello');
|
|
894
|
+
|
|
895
|
+
// Subscribe to a stream (returns a Svelte store)
|
|
896
|
+
const messages = __stream('chat/messages', { merge: 'crud', key: 'id' });
|
|
897
|
+
messages.subscribe((value) => console.log(value));
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
The typed `$live/*` imports and stream hydration are generated by the Vite plugin and only work inside a SvelteKit project. Outside SvelteKit, use `__rpc()` and `__stream()` directly. You get the same reconnection, offline queue, and batching -- just without codegen and types.
|
|
901
|
+
|
|
902
|
+
When `url` is set, the default same-origin WebSocket URL is bypassed entirely. Requires `svelte-adapter-uws` 0.4.8+.
|
|
903
|
+
|
|
904
|
+
Browser clients authenticate via cookies set during login. Native clients typically use a token instead. Your upgrade hook can support both:
|
|
905
|
+
|
|
906
|
+
```js
|
|
907
|
+
// src/hooks.ws.js
|
|
908
|
+
export { message } from 'svelte-realtime/server';
|
|
909
|
+
|
|
910
|
+
export function upgrade({ cookies, url }) {
|
|
911
|
+
// Browser -- cookie auth
|
|
912
|
+
const session = cookies.session_id;
|
|
913
|
+
if (session) return validateSession(session);
|
|
914
|
+
|
|
915
|
+
// Native app -- token auth via query string
|
|
916
|
+
const token = new URL(url, 'http://n').searchParams.get('token');
|
|
917
|
+
if (token) return validateToken(token);
|
|
918
|
+
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
892
921
|
```
|
|
893
922
|
|
|
894
|
-
|
|
923
|
+
The native client passes the token in the URL:
|
|
924
|
+
|
|
925
|
+
```js
|
|
926
|
+
configure({ url: 'wss://my-sveltekit-app.com/ws?token=...' });
|
|
927
|
+
```
|
|
895
928
|
|
|
896
929
|
---
|
|
897
930
|
|
|
@@ -1562,6 +1595,18 @@ export const message = createMessage({ platform: (p) => bus.wrap(p) });
|
|
|
1562
1595
|
|
|
1563
1596
|
No changes needed in your live modules. `ctx.publish` delegates to whatever platform was passed in, so Redis wrapping is transparent.
|
|
1564
1597
|
|
|
1598
|
+
If you already run Postgres and don't need Redis, you can use the [LISTEN/NOTIFY bridge](#postgres-notify) instead for cross-instance pub/sub.
|
|
1599
|
+
|
|
1600
|
+
### What the extensions handle
|
|
1601
|
+
|
|
1602
|
+
When you add the Redis extensions from [svelte-adapter-uws-extensions](https://github.com/lanteanio/svelte-adapter-uws-extensions), you get:
|
|
1603
|
+
|
|
1604
|
+
- **Cross-instance pub/sub** with echo suppression (messages from the same instance are dropped on receive) and microtask-batched Redis pipelines (multiple publishes in one event loop tick become a single Redis roundtrip)
|
|
1605
|
+
- **Distributed presence** with heartbeat-based zombie cleanup -- dead sockets are detected by probing `getBufferedAmount()`, and stale Redis entries are cleaned server-side by a Lua script after a configurable TTL (default 90s)
|
|
1606
|
+
- **Replay buffers** with atomic sequence numbering via Lua `INCR` + sorted sets -- per-topic ordering is strict, and gap detection triggers a truncation event before replaying what's available
|
|
1607
|
+
- **Cross-instance rate limiting** via atomic Lua scripts that use `redis.call('TIME')` to avoid clock skew between app servers
|
|
1608
|
+
- **Circuit breakers** with a three-state machine (healthy / broken / probing) -- when Redis goes down, the breaker trips after a configurable failure threshold, local delivery continues, and a single probe request tests recovery before resuming full traffic
|
|
1609
|
+
|
|
1565
1610
|
### Combined: Redis + rate limiting
|
|
1566
1611
|
|
|
1567
1612
|
```js
|
|
@@ -1620,9 +1665,37 @@ export const orders = live.stream('orders', async (ctx) => {
|
|
|
1620
1665
|
|
|
1621
1666
|
---
|
|
1622
1667
|
|
|
1668
|
+
## Failure modes
|
|
1669
|
+
|
|
1670
|
+
### Redis goes down
|
|
1671
|
+
|
|
1672
|
+
All Redis extensions accept an optional circuit breaker. The breaker trips after a configurable number of consecutive failures (default 5). Once broken, cross-instance pub/sub, presence writes, replay buffering, and distributed rate limiting are skipped entirely -- no retries, no queuing, no thundering herd. Local delivery continues normally: `ctx.publish()` still reaches subscribers on the same instance and across workers. After a configurable timeout (default 30s), the breaker enters a probing state where a single request is allowed through. If it succeeds, the breaker resets to healthy and all extensions resume.
|
|
1673
|
+
|
|
1674
|
+
### Instance crashes mid-session
|
|
1675
|
+
|
|
1676
|
+
The distributed presence extension runs a heartbeat cycle (default 30s) that probes each tracked WebSocket with `getBufferedAmount()`. Under mass disconnect, the runtime may drop close events entirely -- the heartbeat catches these and triggers a synchronous leave. On the Redis side, stale presence entries are cleaned by a server-side Lua script that scans the hash and removes fields older than the configurable TTL (default 90s). The `LEAVE_SCRIPT` atomically checks whether the same user is still connected on another instance before broadcasting a leave event, so users don't appear to leave and rejoin when a single instance restarts.
|
|
1677
|
+
|
|
1678
|
+
### Client reconnects after a long disconnect
|
|
1679
|
+
|
|
1680
|
+
Reconnection uses up to three tiers depending on what's available and how large the gap is. The replay buffer (configurable, default 1000 messages per topic) fills small gaps with strict per-topic ordering via atomic Lua sequence numbering. If the gap is too large for replay, delta sync kicks in -- the client sends its last known version, and the server returns only the changes since that version (or `{unchanged: true}` if nothing changed). If neither replay nor delta sync can cover the gap, the client falls back to a full refetch of the init function. All three paths are automatic and require no client-side code changes.
|
|
1681
|
+
|
|
1682
|
+
### Send buffer overflow
|
|
1683
|
+
|
|
1684
|
+
Each WebSocket connection has a send buffer limit (default 1MB, configurable via `maxBackpressure` in the adapter). When the buffer is full, messages are silently dropped. In dev mode, `handleRpc` logs a warning when a response fails to deliver. For streams that produce high-frequency output, wrap the source with `live.breaker()` or use `live.throttle()` / `live.debounce()` to control the publish rate.
|
|
1685
|
+
|
|
1686
|
+
### Batch and queue limits
|
|
1687
|
+
|
|
1688
|
+
A single `batch()` call is capped at 50 RPC calls -- the client rejects before sending, and the server enforces the same cap as a safety net. The adapter's client-side send queue holds up to 1000 messages; when full, the oldest item is dropped. The adapter rate-limits WebSocket upgrades per IP with a sliding window (default 10 per 10s) to prevent connection floods.
|
|
1689
|
+
|
|
1690
|
+
---
|
|
1691
|
+
|
|
1623
1692
|
## Clustering
|
|
1624
1693
|
|
|
1625
|
-
svelte-realtime works with the adapter's `CLUSTER_WORKERS` mode.
|
|
1694
|
+
svelte-realtime works with the adapter's `CLUSTER_WORKERS` mode. The adapter spawns N worker threads (default: number of CPUs). On Linux, workers share the port via `SO_REUSEPORT` and the kernel distributes incoming connections. On macOS and Windows, a primary thread accepts connections and routes them to workers via uWS child app descriptors.
|
|
1695
|
+
|
|
1696
|
+
Cross-worker `ctx.publish()` calls are batched via microtask coalescing -- all publishes within one event loop tick are bundled into a single `postMessage` to the primary thread, which fans them out to other workers. This keeps IPC overhead constant regardless of publish volume.
|
|
1697
|
+
|
|
1698
|
+
Workers are health-checked every 10 seconds. If a worker fails to respond within 30 seconds, it is terminated and restarted with exponential backoff (starting at 100ms, max 5s, up to 50 restart attempts before the process exits). On graceful shutdown (`SIGTERM` / `SIGINT`), the primary stops accepting connections, sends a shutdown signal to all workers, and waits for them to drain in-flight requests and close WebSocket connections with code 1001 (Going Away) so clients reconnect to another instance.
|
|
1626
1699
|
|
|
1627
1700
|
| Method | Cross-worker? | Safe in `live()`? |
|
|
1628
1701
|
|---|---|---|
|
|
@@ -1636,24 +1709,40 @@ svelte-realtime works with the adapter's `CLUSTER_WORKERS` mode.
|
|
|
1636
1709
|
|
|
1637
1710
|
---
|
|
1638
1711
|
|
|
1639
|
-
##
|
|
1712
|
+
## Production limits
|
|
1640
1713
|
|
|
1641
1714
|
### maxPayloadLength (default: 16KB)
|
|
1642
1715
|
|
|
1643
|
-
If an RPC request exceeds this, the adapter closes the connection
|
|
1716
|
+
Maximum size of a single WebSocket message. If an RPC request exceeds this, the adapter closes the connection (uWS behavior). Increase `maxPayloadLength` in the adapter's websocket config if your app sends large payloads.
|
|
1644
1717
|
|
|
1645
1718
|
### maxBackpressure (default: 1MB)
|
|
1646
1719
|
|
|
1647
|
-
|
|
1720
|
+
Per-connection send buffer. When exceeded, messages are silently dropped. `handleRpc` checks the return value of `platform.send()` and warns in dev mode when a response is not delivered.
|
|
1648
1721
|
|
|
1649
|
-
###
|
|
1722
|
+
### Client send queue (max 1000)
|
|
1650
1723
|
|
|
1651
|
-
The adapter's `sendQueued()` drops the oldest item
|
|
1724
|
+
The adapter's `sendQueued()` drops the oldest item when the queue exceeds 1000 messages. This queue buffers messages while the WebSocket is reconnecting.
|
|
1652
1725
|
|
|
1653
|
-
### Batch size (max 50
|
|
1726
|
+
### Batch size (max 50)
|
|
1654
1727
|
|
|
1655
1728
|
A single `batch()` call is limited to 50 RPC calls. The client rejects before sending if the limit is exceeded, and the server enforces the same limit as a safety net. Split into multiple `batch()` calls if you need more.
|
|
1656
1729
|
|
|
1730
|
+
### Presence refs (max 10,000)
|
|
1731
|
+
|
|
1732
|
+
The server tracks presence join/leave refcounts in memory. When the map reaches 10,000 entries, suspended entries (those with a pending leave timer) are evicted first. If the map is still full after eviction, the join is dropped silently.
|
|
1733
|
+
|
|
1734
|
+
### Rate-limit identities (max 5,000)
|
|
1735
|
+
|
|
1736
|
+
Per-function rate limiting (`live.rateLimit()`) tracks sliding-window buckets in memory. When the bucket map reaches 5,000 entries, stale buckets are swept first. If still full, new identities are rejected with a `RATE_LIMITED` error. Existing identities are unaffected.
|
|
1737
|
+
|
|
1738
|
+
### Throttle/debounce timers (max 5,000)
|
|
1739
|
+
|
|
1740
|
+
The server tracks active throttle and debounce entries globally. When at capacity, new entries bypass the timer and publish immediately so data is never silently dropped.
|
|
1741
|
+
|
|
1742
|
+
### Topic length (max 256 characters)
|
|
1743
|
+
|
|
1744
|
+
The adapter rejects topic names longer than 256 characters or containing control characters (byte value < 32). This applies to subscribe, unsubscribe, and batch-subscribe messages.
|
|
1745
|
+
|
|
1657
1746
|
### ws.subscribe() vs the subscribe hook
|
|
1658
1747
|
|
|
1659
1748
|
`live.stream()` calls `ws.subscribe(topic)` server-side, bypassing the adapter's `subscribe` hook entirely. This is correct -- stream topics are gated by `guard()`, not the subscribe hook.
|
|
@@ -1906,7 +1995,7 @@ The plugin resolves `$live/chat` to `src/live/chat.js`, generates client stubs,
|
|
|
1906
1995
|
|
|
1907
1996
|
## Benchmarks
|
|
1908
1997
|
|
|
1909
|
-
The benchmark suite measures overhead added by svelte-realtime on top of raw WebSocket messaging.
|
|
1998
|
+
The benchmark suite measures the full-stack overhead added by svelte-realtime on top of raw WebSocket messaging: JSON serialization, RPC path resolution, registry lookup, context construction, handler execution, and response encoding. These run in-process with mock objects and isolate the framework cost from network latency.
|
|
1910
1999
|
|
|
1911
2000
|
Run with:
|
|
1912
2001
|
|
|
@@ -1929,7 +2018,7 @@ With high-frequency streams (e.g. 1000 cursors at 20 updates/sec), this reduces
|
|
|
1929
2018
|
|
|
1930
2019
|
In Node/SSR (tests, `__directCall`, etc.), events apply synchronously -- no batching overhead.
|
|
1931
2020
|
|
|
1932
|
-
|
|
2021
|
+
See [bench/rpc.js](bench/rpc.js) for the full source.
|
|
1933
2022
|
|
|
1934
2023
|
---
|
|
1935
2024
|
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -13,6 +13,9 @@ const guards = new Map();
|
|
|
13
13
|
/** @type {Set<Function>} Streams with onUnsubscribe hooks (for iterating static matches in close) */
|
|
14
14
|
const _streamsWithUnsubscribe = new Set();
|
|
15
15
|
|
|
16
|
+
/** @type {Set<string>} Paths that already warned about null ctx.user in __directCall */
|
|
17
|
+
const _directCallWarned = new Set();
|
|
18
|
+
|
|
16
19
|
/**
|
|
17
20
|
* Tag a topic function with __topicUsesCtx by inspecting its first parameter name.
|
|
18
21
|
*
|
|
@@ -2472,7 +2475,13 @@ export async function __directCall(path, args, platform, options) {
|
|
|
2472
2475
|
// Run module guard
|
|
2473
2476
|
const modulePath = /** @type {any} */ (fn).__modulePath || path.substring(0, path.lastIndexOf('/'));
|
|
2474
2477
|
const guardFn = await _resolveGuard(modulePath);
|
|
2475
|
-
if (guardFn)
|
|
2478
|
+
if (guardFn) {
|
|
2479
|
+
if (ctx.user === null && typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production' && !_directCallWarned.has(path)) {
|
|
2480
|
+
_directCallWarned.add(path);
|
|
2481
|
+
console.warn(`[svelte-realtime] .load() is calling guard for '${path}' with ctx.user = null. Pass { user } in the options to provide user data:\n stream.load(platform, { user: locals.user })\n See: https://svti.me/ssr`);
|
|
2482
|
+
}
|
|
2483
|
+
await guardFn(ctx);
|
|
2484
|
+
}
|
|
2476
2485
|
|
|
2477
2486
|
if (/** @type {any} */ (fn).__isStream) {
|
|
2478
2487
|
if (/** @type {any} */ (fn).__isGated) {
|
package/vite.js
CHANGED
|
@@ -1604,7 +1604,7 @@ function _generateTypeDeclarations(liveDir, dir) {
|
|
|
1604
1604
|
if (isTS) {
|
|
1605
1605
|
const returnType = _extractStreamReturnType(source, name);
|
|
1606
1606
|
const storeType = `StreamStore<${returnType} | undefined | { error: RpcError }>`;
|
|
1607
|
-
const loadSig = `{ load(platform: any, options?: { args?: any[] }): Promise<${returnType}> }`;
|
|
1607
|
+
const loadSig = `{ load(platform: any, options?: { args?: any[]; user?: any }): Promise<${returnType}> }`;
|
|
1608
1608
|
if (isDynamic) {
|
|
1609
1609
|
const factoryParams = _extractDynamicFactoryParams(source, name, 'live\\.stream');
|
|
1610
1610
|
exports.push(` export const ${name}: (${factoryParams} => ${storeType}) & ${loadSig};`);
|
|
@@ -1612,7 +1612,7 @@ function _generateTypeDeclarations(liveDir, dir) {
|
|
|
1612
1612
|
exports.push(` export const ${name}: ${storeType} & ${loadSig};`);
|
|
1613
1613
|
}
|
|
1614
1614
|
} else {
|
|
1615
|
-
const loadSig = `{ load(platform: any, options?: { args?: any[] }): Promise<any> }`;
|
|
1615
|
+
const loadSig = `{ load(platform: any, options?: { args?: any[]; user?: any }): Promise<any> }`;
|
|
1616
1616
|
if (isDynamic) {
|
|
1617
1617
|
exports.push(` export const ${name}: ((...args: any[]) => StreamStore<any>) & ${loadSig};`);
|
|
1618
1618
|
} else {
|
|
@@ -1629,7 +1629,7 @@ function _generateTypeDeclarations(liveDir, dir) {
|
|
|
1629
1629
|
if (!exports.some(e => e.includes(`export const ${name}:`))) {
|
|
1630
1630
|
needsStreamStore = true;
|
|
1631
1631
|
const isDynamic = _isDynamicExport(source, name, 'live\\.channel');
|
|
1632
|
-
const loadSig = `{ load(platform: any, options?: { args?: any[] }): Promise<any> }`;
|
|
1632
|
+
const loadSig = `{ load(platform: any, options?: { args?: any[]; user?: any }): Promise<any> }`;
|
|
1633
1633
|
if (isDynamic) {
|
|
1634
1634
|
if (isTS) {
|
|
1635
1635
|
const factoryParams = _extractDynamicFactoryParams(source, name, 'live\\.channel');
|
|
@@ -1650,7 +1650,7 @@ function _generateTypeDeclarations(liveDir, dir) {
|
|
|
1650
1650
|
handledNames.add(name);
|
|
1651
1651
|
if (!exports.some(e => e.includes(`export const ${name}:`))) {
|
|
1652
1652
|
needsStreamStore = true;
|
|
1653
|
-
exports.push(` export const ${name}: StreamStore<any> & { load(platform: any, options?: { args?: any[] }): Promise<any> };`);
|
|
1653
|
+
exports.push(` export const ${name}: StreamStore<any> & { load(platform: any, options?: { args?: any[]; user?: any }): Promise<any> };`);
|
|
1654
1654
|
}
|
|
1655
1655
|
}
|
|
1656
1656
|
|
|
@@ -1661,7 +1661,7 @@ function _generateTypeDeclarations(liveDir, dir) {
|
|
|
1661
1661
|
handledNames.add(name);
|
|
1662
1662
|
if (!exports.some(e => e.includes(`export const ${name}:`))) {
|
|
1663
1663
|
needsStreamStore = true;
|
|
1664
|
-
exports.push(` export const ${name}: StreamStore<any> & { load(platform: any, options?: { args?: any[] }): Promise<any> };`);
|
|
1664
|
+
exports.push(` export const ${name}: StreamStore<any> & { load(platform: any, options?: { args?: any[]; user?: any }): Promise<any> };`);
|
|
1665
1665
|
}
|
|
1666
1666
|
}
|
|
1667
1667
|
|