svelte-realtime 0.4.13 → 0.4.15
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 +67 -11
- package/client.d.ts +11 -0
- package/client.js +14 -2
- package/package.json +1 -1
- package/server.d.ts +4 -2
- package/vite.js +5 -1
package/README.md
CHANGED
|
@@ -261,7 +261,7 @@ The `merge` option on `live.stream()` controls how live pub/sub events are appli
|
|
|
261
261
|
|
|
262
262
|
### crud (default)
|
|
263
263
|
|
|
264
|
-
Handles `created`, `updated`, `deleted` events. The store maintains an array, keyed by `id` (configurable with `key`).
|
|
264
|
+
Handles `created`, `updated`, `deleted` events. The store maintains an array, keyed by `id` (configurable with `key`). Set `max` to cap the buffer size and drop the oldest items when exceeded (useful for live feeds with `prepend: true`).
|
|
265
265
|
|
|
266
266
|
```js
|
|
267
267
|
// Server
|
|
@@ -361,7 +361,7 @@ Events: `update` (add/update by key), `remove` (remove by key), `set` (replace a
|
|
|
361
361
|
| `merge` | `'crud'` | Merge strategy: `'crud'`, `'latest'`, `'set'`, `'presence'`, `'cursor'` |
|
|
362
362
|
| `key` | `'id'` | Key field for `crud` mode |
|
|
363
363
|
| `prepend` | `false` | Prepend new items instead of appending (`crud` mode) |
|
|
364
|
-
| `max` | `50` | Max items to keep
|
|
364
|
+
| `max` | `50` / `0` | Max items to keep. Defaults to 50 for `latest`, 0 (unlimited) for `crud`. Oldest items are dropped when exceeded |
|
|
365
365
|
| `replay` | `false` | Enable seq-based replay for gap-free reconnection |
|
|
366
366
|
| `onSubscribe` | -- | Callback `(ctx, topic)` fired when a client subscribes |
|
|
367
367
|
| `onUnsubscribe` | -- | Callback `(ctx, topic)` fired when a client disconnects |
|
|
@@ -1595,6 +1595,18 @@ export const message = createMessage({ platform: (p) => bus.wrap(p) });
|
|
|
1595
1595
|
|
|
1596
1596
|
No changes needed in your live modules. `ctx.publish` delegates to whatever platform was passed in, so Redis wrapping is transparent.
|
|
1597
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
|
+
|
|
1598
1610
|
### Combined: Redis + rate limiting
|
|
1599
1611
|
|
|
1600
1612
|
```js
|
|
@@ -1653,9 +1665,37 @@ export const orders = live.stream('orders', async (ctx) => {
|
|
|
1653
1665
|
|
|
1654
1666
|
---
|
|
1655
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
|
+
|
|
1656
1692
|
## Clustering
|
|
1657
1693
|
|
|
1658
|
-
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.
|
|
1659
1699
|
|
|
1660
1700
|
| Method | Cross-worker? | Safe in `live()`? |
|
|
1661
1701
|
|---|---|---|
|
|
@@ -1669,24 +1709,40 @@ svelte-realtime works with the adapter's `CLUSTER_WORKERS` mode.
|
|
|
1669
1709
|
|
|
1670
1710
|
---
|
|
1671
1711
|
|
|
1672
|
-
##
|
|
1712
|
+
## Production limits
|
|
1673
1713
|
|
|
1674
1714
|
### maxPayloadLength (default: 16KB)
|
|
1675
1715
|
|
|
1676
|
-
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.
|
|
1677
1717
|
|
|
1678
1718
|
### maxBackpressure (default: 1MB)
|
|
1679
1719
|
|
|
1680
|
-
|
|
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.
|
|
1681
1721
|
|
|
1682
|
-
###
|
|
1722
|
+
### Client send queue (max 1000)
|
|
1683
1723
|
|
|
1684
|
-
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.
|
|
1685
1725
|
|
|
1686
|
-
### Batch size (max 50
|
|
1726
|
+
### Batch size (max 50)
|
|
1687
1727
|
|
|
1688
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.
|
|
1689
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
|
+
|
|
1690
1746
|
### ws.subscribe() vs the subscribe hook
|
|
1691
1747
|
|
|
1692
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.
|
|
@@ -1939,7 +1995,7 @@ The plugin resolves `$live/chat` to `src/live/chat.js`, generates client stubs,
|
|
|
1939
1995
|
|
|
1940
1996
|
## Benchmarks
|
|
1941
1997
|
|
|
1942
|
-
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.
|
|
1943
1999
|
|
|
1944
2000
|
Run with:
|
|
1945
2001
|
|
|
@@ -1962,7 +2018,7 @@ With high-frequency streams (e.g. 1000 cursors at 20 updates/sec), this reduces
|
|
|
1962
2018
|
|
|
1963
2019
|
In Node/SSR (tests, `__directCall`, etc.), events apply synchronously -- no batching overhead.
|
|
1964
2020
|
|
|
1965
|
-
|
|
2021
|
+
See [bench/rpc.js](bench/rpc.js) for the full source.
|
|
1966
2022
|
|
|
1967
2023
|
---
|
|
1968
2024
|
|
package/client.d.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import type { Readable } from 'svelte/store';
|
|
2
2
|
import type { WSEvent } from 'svelte-adapter-uws/client';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* A store that always holds `undefined`. Use as a fallback for conditional
|
|
6
|
+
* streams so you don't need to import `readable` from `svelte/store`:
|
|
7
|
+
*
|
|
8
|
+
* ```svelte
|
|
9
|
+
* import { todos, empty } from '$live/todos';
|
|
10
|
+
* const items = $derived(user ? todos(orgId) : empty);
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export const empty: Readable<undefined>;
|
|
14
|
+
|
|
4
15
|
/**
|
|
5
16
|
* Typed error for RPC failures.
|
|
6
17
|
* Contains a `code` field for programmatic handling.
|
package/client.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { connect as _connect, on, status } from 'svelte-adapter-uws/client';
|
|
3
|
-
import { writable } from 'svelte/store';
|
|
3
|
+
import { writable, readable } from 'svelte/store';
|
|
4
|
+
|
|
5
|
+
/** @type {import('svelte/store').Readable<undefined>} */
|
|
6
|
+
export const empty = readable(undefined);
|
|
4
7
|
|
|
5
8
|
const _textEncoder = new TextEncoder();
|
|
6
9
|
|
|
@@ -499,7 +502,7 @@ function _createStream(path, options, dynamicArgs) {
|
|
|
499
502
|
let merge = options?.merge || 'crud';
|
|
500
503
|
let key = options?.key || 'id';
|
|
501
504
|
let prepend = options?.prepend || false;
|
|
502
|
-
let max = options?.max || 50;
|
|
505
|
+
let max = options?.max || (merge === 'latest' ? 50 : 0);
|
|
503
506
|
|
|
504
507
|
/** @type {any} */
|
|
505
508
|
let currentValue;
|
|
@@ -637,9 +640,18 @@ function _createStream(path, options, dynamicArgs) {
|
|
|
637
640
|
currentValue.unshift(data);
|
|
638
641
|
for (const [k, i] of _index) _index.set(k, i + 1);
|
|
639
642
|
_index.set(data[key], 0);
|
|
643
|
+
if (max && currentValue.length > max) {
|
|
644
|
+
const removed = currentValue.splice(max);
|
|
645
|
+
for (const item of removed) _index.delete(item[key]);
|
|
646
|
+
}
|
|
640
647
|
} else {
|
|
641
648
|
_index.set(data[key], currentValue.length);
|
|
642
649
|
currentValue.push(data);
|
|
650
|
+
if (max && currentValue.length > max) {
|
|
651
|
+
const removed = currentValue.splice(0, currentValue.length - max);
|
|
652
|
+
for (const item of removed) _index.delete(item[key]);
|
|
653
|
+
_rebuildIndex();
|
|
654
|
+
}
|
|
643
655
|
}
|
|
644
656
|
} else if (event === 'updated') {
|
|
645
657
|
const idx = _index.get(data[key]);
|
package/package.json
CHANGED
package/server.d.ts
CHANGED
|
@@ -67,8 +67,10 @@ export interface StreamOptions {
|
|
|
67
67
|
prepend?: boolean;
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
|
-
* Maximum items to keep
|
|
71
|
-
*
|
|
70
|
+
* Maximum items to keep. When the buffer exceeds this size, the oldest
|
|
71
|
+
* items are dropped. Works with `crud` and `latest` merge strategies.
|
|
72
|
+
*
|
|
73
|
+
* For `latest`, defaults to 50. For `crud`, defaults to 0 (unlimited).
|
|
72
74
|
*/
|
|
73
75
|
max?: number;
|
|
74
76
|
|
package/vite.js
CHANGED
|
@@ -785,7 +785,9 @@ function _generateClientStubs(filePath, modulePath, dir) {
|
|
|
785
785
|
? `import { ${[...imports].join(', ')} } from 'svelte-realtime/client';\n`
|
|
786
786
|
: '';
|
|
787
787
|
|
|
788
|
-
|
|
788
|
+
const reexport = `export { empty } from 'svelte-realtime/client';\n`;
|
|
789
|
+
|
|
790
|
+
return importLine + reexport + lines.join('\n') + '\n';
|
|
789
791
|
}
|
|
790
792
|
|
|
791
793
|
/**
|
|
@@ -1712,10 +1714,12 @@ function _generateTypeDeclarations(liveDir, dir) {
|
|
|
1712
1714
|
if (needsRpcError) clientImports.push('RpcError');
|
|
1713
1715
|
declarations.push(` import type { ${clientImports.join(', ')} } from 'svelte-realtime/client';`);
|
|
1714
1716
|
}
|
|
1717
|
+
declarations.push(` import type { Readable } from 'svelte/store';`);
|
|
1715
1718
|
if (needsStreamStore || needsRpcError) {
|
|
1716
1719
|
declarations.push('');
|
|
1717
1720
|
}
|
|
1718
1721
|
declarations.push(...exports);
|
|
1722
|
+
declarations.push(` export const empty: Readable<undefined>;`);
|
|
1719
1723
|
declarations.push('}');
|
|
1720
1724
|
declarations.push('');
|
|
1721
1725
|
}
|