svelte-adapter-uws 0.2.17 → 0.2.18
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 +372 -0
- package/files/handler.js +4 -0
- package/files/index.js +13 -9
- package/package.json +29 -1
- package/plugins/cursor/client.d.ts +33 -0
- package/plugins/cursor/client.js +89 -0
- package/plugins/cursor/server.d.ts +84 -0
- package/plugins/cursor/server.js +229 -0
- package/plugins/groups/client.d.ts +31 -0
- package/plugins/groups/client.js +116 -0
- package/plugins/groups/server.d.ts +108 -0
- package/plugins/groups/server.js +235 -0
- package/plugins/middleware/server.d.ts +68 -0
- package/plugins/middleware/server.js +132 -0
- package/plugins/queue/server.d.ts +74 -0
- package/plugins/queue/server.js +208 -0
- package/plugins/ratelimit/server.d.ts +94 -0
- package/plugins/ratelimit/server.js +215 -0
- package/vite.js +2 -0
package/README.md
CHANGED
|
@@ -286,6 +286,8 @@ The Vite plugin is required for WebSocket support in both dev and production (se
|
|
|
286
286
|
|
|
287
287
|
Changes to your `hooks.ws` file are picked up automatically — the plugin reloads the handler on save, no dev server restart needed.
|
|
288
288
|
|
|
289
|
+
**Note:** The dev server does not enforce `allowedOrigins`. Origin checks only run in production. A warning is logged at startup as a reminder.
|
|
290
|
+
|
|
289
291
|
**vite.config.js**
|
|
290
292
|
```js
|
|
291
293
|
import { sveltekit } from '@sveltejs/kit/vite';
|
|
@@ -1241,6 +1243,78 @@ export async function subscribe(ws, topic, { platform }) {
|
|
|
1241
1243
|
|
|
1242
1244
|
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.
|
|
1243
1245
|
|
|
1246
|
+
### Middleware
|
|
1247
|
+
|
|
1248
|
+
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.
|
|
1249
|
+
|
|
1250
|
+
#### Setup
|
|
1251
|
+
|
|
1252
|
+
```js
|
|
1253
|
+
// src/lib/server/pipeline.js
|
|
1254
|
+
import { createMiddleware } from 'svelte-adapter-uws/plugins/middleware';
|
|
1255
|
+
|
|
1256
|
+
export const pipeline = createMiddleware(
|
|
1257
|
+
// logging
|
|
1258
|
+
async (ctx, next) => {
|
|
1259
|
+
console.log(`[${ctx.topic}] ${ctx.event}`);
|
|
1260
|
+
await next();
|
|
1261
|
+
},
|
|
1262
|
+
// auth check
|
|
1263
|
+
async (ctx, next) => {
|
|
1264
|
+
const userId = ctx.ws.getUserData()?.userId;
|
|
1265
|
+
if (!userId) return; // stop chain -- unauthenticated
|
|
1266
|
+
ctx.locals.userId = userId;
|
|
1267
|
+
await next();
|
|
1268
|
+
},
|
|
1269
|
+
// data enrichment
|
|
1270
|
+
async (ctx, next) => {
|
|
1271
|
+
ctx.data = { ...ctx.data, processedAt: Date.now() };
|
|
1272
|
+
await next();
|
|
1273
|
+
}
|
|
1274
|
+
);
|
|
1275
|
+
```
|
|
1276
|
+
|
|
1277
|
+
#### Usage
|
|
1278
|
+
|
|
1279
|
+
```js
|
|
1280
|
+
// src/hooks.ws.js
|
|
1281
|
+
import { pipeline } from '$lib/server/pipeline';
|
|
1282
|
+
|
|
1283
|
+
export async function message(ws, data, { platform }) {
|
|
1284
|
+
const msg = JSON.parse(Buffer.from(data).toString());
|
|
1285
|
+
const ctx = await pipeline.run(ws, msg, platform);
|
|
1286
|
+
if (!ctx) return; // chain was stopped (e.g. auth failed)
|
|
1287
|
+
|
|
1288
|
+
// ctx.locals.userId is available here
|
|
1289
|
+
// ctx.data has the enriched data
|
|
1290
|
+
}
|
|
1291
|
+
```
|
|
1292
|
+
|
|
1293
|
+
#### API
|
|
1294
|
+
|
|
1295
|
+
| Method | Description |
|
|
1296
|
+
|---|---|
|
|
1297
|
+
| `pipeline.run(ws, message, platform)` | Execute the chain. Returns context or `null` if stopped |
|
|
1298
|
+
| `pipeline.use(fn)` | Append a middleware at runtime |
|
|
1299
|
+
|
|
1300
|
+
The context object:
|
|
1301
|
+
|
|
1302
|
+
| Field | Description |
|
|
1303
|
+
|---|---|
|
|
1304
|
+
| `ctx.ws` | The WebSocket connection |
|
|
1305
|
+
| `ctx.message` | Original parsed message |
|
|
1306
|
+
| `ctx.topic` | Message topic (mutable) |
|
|
1307
|
+
| `ctx.event` | Message event (mutable) |
|
|
1308
|
+
| `ctx.data` | Message data (mutable) |
|
|
1309
|
+
| `ctx.platform` | Platform reference |
|
|
1310
|
+
| `ctx.locals` | Scratch space for middleware to share data |
|
|
1311
|
+
|
|
1312
|
+
#### Limitations
|
|
1313
|
+
|
|
1314
|
+
- **Server-side only.** No client component.
|
|
1315
|
+
- **No state.** The middleware itself is stateless -- it's a pure pipeline. Use `ctx.locals` to pass data between middlewares within a single message.
|
|
1316
|
+
- **Double `next()` guard.** Calling `next()` twice in the same middleware is a no-op (the second call does nothing).
|
|
1317
|
+
|
|
1244
1318
|
### Replay (SSR gap)
|
|
1245
1319
|
|
|
1246
1320
|
When you combine SSR with WebSocket live updates, there's a gap between server-side data loading and the moment the client's WebSocket connects. Messages published during that window are lost.
|
|
@@ -1636,6 +1710,304 @@ t=260 [timer fires, 100ms] --> sends {q:"hel"}
|
|
|
1636
1710
|
- **Latest value only.** Intermediate values within an interval are discarded, not queued. If you need every message delivered, don't throttle.
|
|
1637
1711
|
- **Timer-based.** Uses `setTimeout` internally. Precision depends on Node.js event loop load (typically < 1ms drift).
|
|
1638
1712
|
|
|
1713
|
+
### Rate limiting
|
|
1714
|
+
|
|
1715
|
+
Token-bucket rate limiter for inbound WebSocket messages. Protects against spam, abuse, and runaway clients. Supports per-IP, per-connection, or custom key extraction, with optional auto-ban when a bucket is exhausted.
|
|
1716
|
+
|
|
1717
|
+
Different from throttle -- throttle shapes **outbound** publish rate, rate limiting protects **inbound** against abuse.
|
|
1718
|
+
|
|
1719
|
+
#### Setup
|
|
1720
|
+
|
|
1721
|
+
```js
|
|
1722
|
+
// src/lib/server/ratelimit.js
|
|
1723
|
+
import { createRateLimit } from 'svelte-adapter-uws/plugins/ratelimit';
|
|
1724
|
+
|
|
1725
|
+
export const limiter = createRateLimit({
|
|
1726
|
+
points: 10, // 10 messages
|
|
1727
|
+
interval: 1000, // per second
|
|
1728
|
+
blockDuration: 30000 // auto-ban for 30s when exhausted
|
|
1729
|
+
});
|
|
1730
|
+
```
|
|
1731
|
+
|
|
1732
|
+
#### Usage
|
|
1733
|
+
|
|
1734
|
+
```js
|
|
1735
|
+
// src/hooks.ws.js
|
|
1736
|
+
import { limiter } from '$lib/server/ratelimit';
|
|
1737
|
+
|
|
1738
|
+
export function message(ws, data, { platform }) {
|
|
1739
|
+
const { allowed, remaining, resetMs } = limiter.consume(ws);
|
|
1740
|
+
if (!allowed) return; // drop the message
|
|
1741
|
+
|
|
1742
|
+
// ... handle message normally
|
|
1743
|
+
}
|
|
1744
|
+
```
|
|
1745
|
+
|
|
1746
|
+
#### API
|
|
1747
|
+
|
|
1748
|
+
| Method | Description |
|
|
1749
|
+
|---|---|
|
|
1750
|
+
| `limiter.consume(ws, cost?)` | Deduct tokens (cost must be >= 0, defaults to 1), returns `{ allowed, remaining, resetMs }` |
|
|
1751
|
+
| `limiter.reset(key)` | Clear the bucket for a key |
|
|
1752
|
+
| `limiter.ban(key, duration?)` | Manually ban a key |
|
|
1753
|
+
| `limiter.unban(key)` | Remove a ban |
|
|
1754
|
+
| `limiter.clear()` | Reset all state |
|
|
1755
|
+
|
|
1756
|
+
#### Options
|
|
1757
|
+
|
|
1758
|
+
| Option | Default | Description |
|
|
1759
|
+
|---|---|---|
|
|
1760
|
+
| `points` | *required* | Tokens per interval (positive integer) |
|
|
1761
|
+
| `interval` | *required* | Refill interval in ms |
|
|
1762
|
+
| `blockDuration` | `0` | Auto-ban duration in ms when exhausted (0 = no auto-ban) |
|
|
1763
|
+
| `keyBy` | `'ip'` | `'ip'`, `'connection'`, or `(ws) => string` |
|
|
1764
|
+
|
|
1765
|
+
With `keyBy: 'ip'` (default), the limiter reads `userData.remoteAddress`, `.ip`, or `.address`. With `keyBy: 'connection'`, each WebSocket gets its own bucket. Pass a function for custom grouping (e.g. by user ID or room).
|
|
1766
|
+
|
|
1767
|
+
#### Limitations
|
|
1768
|
+
|
|
1769
|
+
- **Server-side only.** No client component needed.
|
|
1770
|
+
- **In-memory.** Buckets live in the process. In cluster mode, each worker has independent rate limits (acceptable for most apps -- abusers hit the same worker via the acceptor).
|
|
1771
|
+
- **Lazy cleanup.** Expired buckets are swept when the internal map exceeds 1000 entries.
|
|
1772
|
+
|
|
1773
|
+
### Cursor (ephemeral state)
|
|
1774
|
+
|
|
1775
|
+
Lightweight fire-and-forget broadcasting for transient state -- mouse cursors, text selections, drag positions, drawing strokes. Built-in throttle with trailing edge ensures the final position always arrives. Auto-cleanup on disconnect.
|
|
1776
|
+
|
|
1777
|
+
#### Setup
|
|
1778
|
+
|
|
1779
|
+
```js
|
|
1780
|
+
// src/lib/server/cursors.js
|
|
1781
|
+
import { createCursor } from 'svelte-adapter-uws/plugins/cursor';
|
|
1782
|
+
|
|
1783
|
+
export const cursors = createCursor({
|
|
1784
|
+
throttle: 50, // at most one broadcast per 50ms per user per topic
|
|
1785
|
+
select: (userData) => ({ id: userData.id, name: userData.name, color: userData.color })
|
|
1786
|
+
});
|
|
1787
|
+
```
|
|
1788
|
+
|
|
1789
|
+
#### Server usage
|
|
1790
|
+
|
|
1791
|
+
```js
|
|
1792
|
+
// src/hooks.ws.js
|
|
1793
|
+
import { cursors } from '$lib/server/cursors';
|
|
1794
|
+
|
|
1795
|
+
export function message(ws, data, { platform }) {
|
|
1796
|
+
const msg = JSON.parse(Buffer.from(data).toString());
|
|
1797
|
+
if (msg.type === 'cursor') {
|
|
1798
|
+
cursors.update(ws, msg.topic, { x: msg.x, y: msg.y }, platform);
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
export function close(ws, { platform }) {
|
|
1803
|
+
cursors.remove(ws, platform);
|
|
1804
|
+
}
|
|
1805
|
+
```
|
|
1806
|
+
|
|
1807
|
+
#### Client usage
|
|
1808
|
+
|
|
1809
|
+
```svelte
|
|
1810
|
+
<script>
|
|
1811
|
+
import { cursor } from 'svelte-adapter-uws/plugins/cursor/client';
|
|
1812
|
+
|
|
1813
|
+
const positions = cursor('canvas');
|
|
1814
|
+
</script>
|
|
1815
|
+
|
|
1816
|
+
{#each [...$positions] as [key, { user, data }] (key)}
|
|
1817
|
+
<div
|
|
1818
|
+
class="cursor-dot"
|
|
1819
|
+
style="left: {data.x}px; top: {data.y}px; background: {user.color}"
|
|
1820
|
+
>
|
|
1821
|
+
{user.name}
|
|
1822
|
+
</div>
|
|
1823
|
+
{/each}
|
|
1824
|
+
```
|
|
1825
|
+
|
|
1826
|
+
The client store is a `Readable<Map<string, { user, data }>>`. The Map updates when cursors move or disconnect.
|
|
1827
|
+
|
|
1828
|
+
#### Server API
|
|
1829
|
+
|
|
1830
|
+
| Method | Description |
|
|
1831
|
+
|---|---|
|
|
1832
|
+
| `cursors.update(ws, topic, data, platform)` | Broadcast position (throttled) |
|
|
1833
|
+
| `cursors.remove(ws, platform)` | Remove from all topics, broadcast removal |
|
|
1834
|
+
| `cursors.list(topic)` | Current positions (for SSR) |
|
|
1835
|
+
| `cursors.clear()` | Reset all state and timers |
|
|
1836
|
+
|
|
1837
|
+
#### How throttle works
|
|
1838
|
+
|
|
1839
|
+
The cursor plugin uses leading edge + trailing edge throttle internally:
|
|
1840
|
+
|
|
1841
|
+
```
|
|
1842
|
+
t=0 update({x:0}) --> broadcasts immediately (leading edge)
|
|
1843
|
+
t=20 update({x:5}) --> stored (within 50ms window)
|
|
1844
|
+
t=40 update({x:9}) --> stored (overwrites x:5)
|
|
1845
|
+
t=50 [timer fires] --> broadcasts {x:9} (trailing edge)
|
|
1846
|
+
```
|
|
1847
|
+
|
|
1848
|
+
The trailing edge ensures you always see where the cursor stopped, even if the user stops moving mid-window.
|
|
1849
|
+
|
|
1850
|
+
#### Limitations
|
|
1851
|
+
|
|
1852
|
+
- **In-memory.** Cursor positions live in the process. In cluster mode, each worker tracks its own connections.
|
|
1853
|
+
- **No persistence.** Positions are lost on restart. This is intentional -- cursors are ephemeral.
|
|
1854
|
+
|
|
1855
|
+
### Queue (ordered delivery)
|
|
1856
|
+
|
|
1857
|
+
Per-key async task queue with configurable concurrency and backpressure. Guarantees in-order processing per key -- useful for sequential operations like collaborative editing, turn-based games, or transaction sequences.
|
|
1858
|
+
|
|
1859
|
+
#### Setup
|
|
1860
|
+
|
|
1861
|
+
```js
|
|
1862
|
+
// src/lib/server/queue.js
|
|
1863
|
+
import { createQueue } from 'svelte-adapter-uws/plugins/queue';
|
|
1864
|
+
|
|
1865
|
+
// Sequential processing per key (default concurrency: 1)
|
|
1866
|
+
export const queue = createQueue({ maxSize: 100 });
|
|
1867
|
+
```
|
|
1868
|
+
|
|
1869
|
+
#### Usage
|
|
1870
|
+
|
|
1871
|
+
```js
|
|
1872
|
+
// src/hooks.ws.js
|
|
1873
|
+
import { queue } from '$lib/server/queue';
|
|
1874
|
+
|
|
1875
|
+
export async function message(ws, data, { platform }) {
|
|
1876
|
+
const msg = JSON.parse(Buffer.from(data).toString());
|
|
1877
|
+
|
|
1878
|
+
// Messages for the same topic are processed one at a time
|
|
1879
|
+
const result = await queue.push(msg.topic, async () => {
|
|
1880
|
+
const record = await db.update(msg.data);
|
|
1881
|
+
platform.publish(msg.topic, 'updated', record);
|
|
1882
|
+
return record;
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1885
|
+
```
|
|
1886
|
+
|
|
1887
|
+
#### API
|
|
1888
|
+
|
|
1889
|
+
| Method | Description |
|
|
1890
|
+
|---|---|
|
|
1891
|
+
| `queue.push(key, task)` | Enqueue a task, returns promise with the task's return value |
|
|
1892
|
+
| `queue.size(key?)` | Waiting + running count for a key, or total |
|
|
1893
|
+
| `queue.clear(key?)` | Cancel waiting tasks (running tasks continue) |
|
|
1894
|
+
| `queue.drain(key?)` | Wait for all tasks to complete |
|
|
1895
|
+
|
|
1896
|
+
#### Options
|
|
1897
|
+
|
|
1898
|
+
| Option | Default | Description |
|
|
1899
|
+
|---|---|---|
|
|
1900
|
+
| `concurrency` | `1` | Max concurrent tasks per key |
|
|
1901
|
+
| `maxSize` | `Infinity` | Max waiting tasks per key (rejects when exceeded) |
|
|
1902
|
+
| `onDrop` | `null` | Called with `{ key, task }` when a task is rejected |
|
|
1903
|
+
|
|
1904
|
+
Different keys are independent -- `push('room-a', ...)` and `push('room-b', ...)` run concurrently. Only tasks with the same key are queued.
|
|
1905
|
+
|
|
1906
|
+
#### Limitations
|
|
1907
|
+
|
|
1908
|
+
- **Server-side only.** No client component.
|
|
1909
|
+
- **In-memory.** Queue state lives in the process. Not durable across restarts.
|
|
1910
|
+
- **No cancellation.** Running tasks cannot be aborted. `clear()` only rejects waiting tasks.
|
|
1911
|
+
|
|
1912
|
+
### Broadcast groups
|
|
1913
|
+
|
|
1914
|
+
Named groups with explicit membership, roles, metadata, and lifecycle hooks. Like topics but with access control -- you decide who can join, what role they have, and what happens when the group fills up or closes.
|
|
1915
|
+
|
|
1916
|
+
#### Setup
|
|
1917
|
+
|
|
1918
|
+
```js
|
|
1919
|
+
// src/lib/server/lobby.js
|
|
1920
|
+
import { createGroup } from 'svelte-adapter-uws/plugins/groups';
|
|
1921
|
+
|
|
1922
|
+
export const lobby = createGroup('lobby', {
|
|
1923
|
+
maxMembers: 50,
|
|
1924
|
+
meta: { game: 'chess' },
|
|
1925
|
+
onJoin: (ws, role) => console.log('joined as', role),
|
|
1926
|
+
onFull: (ws, role) => {
|
|
1927
|
+
// optionally notify the rejected client
|
|
1928
|
+
}
|
|
1929
|
+
});
|
|
1930
|
+
```
|
|
1931
|
+
|
|
1932
|
+
#### Server usage
|
|
1933
|
+
|
|
1934
|
+
```js
|
|
1935
|
+
// src/hooks.ws.js
|
|
1936
|
+
import { lobby } from '$lib/server/lobby';
|
|
1937
|
+
|
|
1938
|
+
export function subscribe(ws, topic, { platform }) {
|
|
1939
|
+
if (topic === 'lobby') {
|
|
1940
|
+
const joined = lobby.join(ws, platform, 'member');
|
|
1941
|
+
if (!joined) {
|
|
1942
|
+
platform.send(ws, 'system', 'error', 'Lobby is full');
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
export function close(ws, { platform }) {
|
|
1948
|
+
lobby.leave(ws, platform);
|
|
1949
|
+
}
|
|
1950
|
+
```
|
|
1951
|
+
|
|
1952
|
+
Publish to group members:
|
|
1953
|
+
|
|
1954
|
+
```js
|
|
1955
|
+
// Broadcast to everyone
|
|
1956
|
+
lobby.publish(platform, 'chat', { text: 'hello' });
|
|
1957
|
+
|
|
1958
|
+
// Broadcast only to admins
|
|
1959
|
+
lobby.publish(platform, 'admin-alert', { msg: 'new report' }, 'admin');
|
|
1960
|
+
```
|
|
1961
|
+
|
|
1962
|
+
#### Client usage
|
|
1963
|
+
|
|
1964
|
+
```svelte
|
|
1965
|
+
<script>
|
|
1966
|
+
import { group } from 'svelte-adapter-uws/plugins/groups/client';
|
|
1967
|
+
|
|
1968
|
+
const lobby = group('lobby');
|
|
1969
|
+
const members = lobby.members;
|
|
1970
|
+
</script>
|
|
1971
|
+
|
|
1972
|
+
<p>{$members.length} members</p>
|
|
1973
|
+
```
|
|
1974
|
+
|
|
1975
|
+
The client store exposes two reactive values: the main store for events (`$lobby` -- latest message) and `.members` for the live member list. The member list updates automatically on join, leave, and close events -- no polling needed.
|
|
1976
|
+
|
|
1977
|
+
#### Server API
|
|
1978
|
+
|
|
1979
|
+
| Method | Description |
|
|
1980
|
+
|---|---|
|
|
1981
|
+
| `group.join(ws, platform, role?)` | Add member. Returns `true` or `false` if full/closed |
|
|
1982
|
+
| `group.leave(ws, platform)` | Remove member |
|
|
1983
|
+
| `group.publish(platform, event, data, role?)` | Broadcast (optionally filtered by role) |
|
|
1984
|
+
| `group.send(platform, ws, event, data)` | Send to one member (throws if not a member) |
|
|
1985
|
+
| `group.members()` | Array of `{ ws, role }` |
|
|
1986
|
+
| `group.count()` | Member count |
|
|
1987
|
+
| `group.has(ws)` | Check membership |
|
|
1988
|
+
| `group.close(platform)` | Dissolve group, notify everyone |
|
|
1989
|
+
| `group.name` | Group name (read-only) |
|
|
1990
|
+
| `group.meta` | Metadata (get/set) |
|
|
1991
|
+
|
|
1992
|
+
Roles: `'member'` (default), `'admin'`, `'viewer'`.
|
|
1993
|
+
|
|
1994
|
+
#### Options
|
|
1995
|
+
|
|
1996
|
+
| Option | Default | Description |
|
|
1997
|
+
|---|---|---|
|
|
1998
|
+
| `maxMembers` | `Infinity` | Maximum members |
|
|
1999
|
+
| `meta` | `{}` | Initial metadata (shallow-copied) |
|
|
2000
|
+
| `onJoin` | -- | `(ws, role) => void` |
|
|
2001
|
+
| `onLeave` | -- | `(ws, role) => void` |
|
|
2002
|
+
| `onFull` | -- | `(ws, role) => void` |
|
|
2003
|
+
| `onClose` | -- | `() => void` |
|
|
2004
|
+
|
|
2005
|
+
#### Limitations
|
|
2006
|
+
|
|
2007
|
+
- **In-memory.** Group state lives in the process. In cluster mode, each worker manages its own groups independently.
|
|
2008
|
+
- **No persistence.** Groups are lost on restart. If you need durable rooms, store membership in a database and rebuild on start.
|
|
2009
|
+
- **Role-filtered publish uses `send()`.** When filtering by role, the plugin iterates members and sends individually instead of using the topic broadcast. Fine for typical group sizes, but O(n) with member count.
|
|
2010
|
+
|
|
1639
2011
|
---
|
|
1640
2012
|
|
|
1641
2013
|
**Deployment & scaling**
|
package/files/handler.js
CHANGED
|
@@ -831,6 +831,10 @@ if (WS_ENABLED) {
|
|
|
831
831
|
if (requestPort) {
|
|
832
832
|
expectedHost = requestHost.replace(/:\d+$/, '') + ':' + requestPort;
|
|
833
833
|
}
|
|
834
|
+
// Strip default ports so "example.com" matches "example.com:443"
|
|
835
|
+
// (URL.host omits the port when it is the default for the scheme)
|
|
836
|
+
const defaultPort = requestScheme === 'https' ? '443' : '80';
|
|
837
|
+
expectedHost = expectedHost.replace(':' + defaultPort, '');
|
|
834
838
|
allowed = parsed.host === expectedHost && parsed.protocol === requestScheme + ':';
|
|
835
839
|
}
|
|
836
840
|
} catch {
|
package/files/index.js
CHANGED
|
@@ -45,8 +45,8 @@ if (is_primary) {
|
|
|
45
45
|
// Exponential backoff for crash-looping workers
|
|
46
46
|
let restart_delay = 0;
|
|
47
47
|
const RESTART_DELAY_MAX = 5000;
|
|
48
|
-
/** @type {ReturnType<typeof setTimeout
|
|
49
|
-
|
|
48
|
+
/** @type {Set<ReturnType<typeof setTimeout>>} */
|
|
49
|
+
const restart_timers = new Set();
|
|
50
50
|
|
|
51
51
|
function spawn_worker() {
|
|
52
52
|
const worker = new Worker(fileURLToPath(import.meta.url));
|
|
@@ -59,7 +59,8 @@ if (is_primary) {
|
|
|
59
59
|
console.log(`Worker thread ${worker.threadId} registered`);
|
|
60
60
|
// Worker started successfully - reset backoff
|
|
61
61
|
restart_delay = 0;
|
|
62
|
-
|
|
62
|
+
for (const t of restart_timers) clearTimeout(t);
|
|
63
|
+
restart_timers.clear();
|
|
63
64
|
// Start (or resume) listening once a worker is ready to handle requests
|
|
64
65
|
if (!listening) {
|
|
65
66
|
listening = true;
|
|
@@ -100,7 +101,12 @@ if (is_primary) {
|
|
|
100
101
|
}
|
|
101
102
|
restart_delay = restart_delay ? Math.min(restart_delay * 2, RESTART_DELAY_MAX) : 100;
|
|
102
103
|
console.log(`Worker thread ${worker.threadId} exited with code ${code}, restarting in ${restart_delay}ms...`);
|
|
103
|
-
|
|
104
|
+
const timer = setTimeout(() => {
|
|
105
|
+
restart_timers.delete(timer);
|
|
106
|
+
if (shutting_down) return;
|
|
107
|
+
spawn_worker();
|
|
108
|
+
}, restart_delay);
|
|
109
|
+
restart_timers.add(timer);
|
|
104
110
|
}
|
|
105
111
|
// If shutting down and all workers have exited, exit immediately
|
|
106
112
|
if (shutting_down && workers.size === 0) {
|
|
@@ -121,11 +127,9 @@ if (is_primary) {
|
|
|
121
127
|
shutting_down = true;
|
|
122
128
|
console.log(`Primary received ${reason}, shutting down ${workers.size} workers...`);
|
|
123
129
|
|
|
124
|
-
// Cancel
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
backoff_reset_timer = null;
|
|
128
|
-
}
|
|
130
|
+
// Cancel all pending worker restarts so we don't spawn during shutdown
|
|
131
|
+
for (const t of restart_timers) clearTimeout(t);
|
|
132
|
+
restart_timers.clear();
|
|
129
133
|
|
|
130
134
|
// Stop accepting new connections
|
|
131
135
|
if (listen_socket) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svelte-adapter-uws",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.18",
|
|
4
4
|
"description": "SvelteKit adapter for uWebSockets.js - high-performance C++ HTTP server with built-in WebSocket support",
|
|
5
5
|
"author": "Kevin Radziszewski",
|
|
6
6
|
"license": "MIT",
|
|
@@ -53,6 +53,34 @@
|
|
|
53
53
|
"./plugins/throttle": {
|
|
54
54
|
"types": "./plugins/throttle/server.d.ts",
|
|
55
55
|
"default": "./plugins/throttle/server.js"
|
|
56
|
+
},
|
|
57
|
+
"./plugins/ratelimit": {
|
|
58
|
+
"types": "./plugins/ratelimit/server.d.ts",
|
|
59
|
+
"default": "./plugins/ratelimit/server.js"
|
|
60
|
+
},
|
|
61
|
+
"./plugins/cursor": {
|
|
62
|
+
"types": "./plugins/cursor/server.d.ts",
|
|
63
|
+
"default": "./plugins/cursor/server.js"
|
|
64
|
+
},
|
|
65
|
+
"./plugins/cursor/client": {
|
|
66
|
+
"types": "./plugins/cursor/client.d.ts",
|
|
67
|
+
"default": "./plugins/cursor/client.js"
|
|
68
|
+
},
|
|
69
|
+
"./plugins/middleware": {
|
|
70
|
+
"types": "./plugins/middleware/server.d.ts",
|
|
71
|
+
"default": "./plugins/middleware/server.js"
|
|
72
|
+
},
|
|
73
|
+
"./plugins/queue": {
|
|
74
|
+
"types": "./plugins/queue/server.d.ts",
|
|
75
|
+
"default": "./plugins/queue/server.js"
|
|
76
|
+
},
|
|
77
|
+
"./plugins/groups": {
|
|
78
|
+
"types": "./plugins/groups/server.d.ts",
|
|
79
|
+
"default": "./plugins/groups/server.js"
|
|
80
|
+
},
|
|
81
|
+
"./plugins/groups/client": {
|
|
82
|
+
"types": "./plugins/groups/client.d.ts",
|
|
83
|
+
"default": "./plugins/groups/client.js"
|
|
56
84
|
}
|
|
57
85
|
},
|
|
58
86
|
"types": "./index.d.ts",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Readable } from 'svelte/store';
|
|
2
|
+
|
|
3
|
+
export interface CursorPosition<UserInfo = unknown, Data = unknown> {
|
|
4
|
+
/** User-identifying data from the server's `select` function. */
|
|
5
|
+
user: UserInfo;
|
|
6
|
+
/** Latest cursor/position data. */
|
|
7
|
+
data: Data;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get a reactive store of cursor positions on a topic.
|
|
12
|
+
*
|
|
13
|
+
* Returns a `Readable<Map<string, CursorPosition>>` that updates
|
|
14
|
+
* automatically when cursors move or disconnect.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```svelte
|
|
18
|
+
* <script>
|
|
19
|
+
* import { cursor } from 'svelte-adapter-uws/plugins/cursor/client';
|
|
20
|
+
*
|
|
21
|
+
* const cursors = cursor('canvas');
|
|
22
|
+
* </script>
|
|
23
|
+
*
|
|
24
|
+
* {#each [...$cursors] as [key, { user, data }] (key)}
|
|
25
|
+
* <div style="left: {data.x}px; top: {data.y}px">
|
|
26
|
+
* {user.name}
|
|
27
|
+
* </div>
|
|
28
|
+
* {/each}
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function cursor<UserInfo = unknown, Data = unknown>(
|
|
32
|
+
topic: string
|
|
33
|
+
): Readable<Map<string, CursorPosition<UserInfo, Data>>>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side cursor helper for svelte-adapter-uws.
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to the internal `__cursor:{topic}` channel and maintains
|
|
5
|
+
* a live Map of cursor positions. The server handles throttling and
|
|
6
|
+
* cleanup; this module keeps the client-side state in sync.
|
|
7
|
+
*
|
|
8
|
+
* @module svelte-adapter-uws/plugins/cursor/client
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { on } from '../../client.js';
|
|
12
|
+
import { writable } from 'svelte/store';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get a reactive store of cursor positions on a topic.
|
|
16
|
+
*
|
|
17
|
+
* Returns a readable Svelte store containing a Map of connection keys
|
|
18
|
+
* to `{ user, data }` objects. The Map updates automatically when
|
|
19
|
+
* cursors move or disconnect.
|
|
20
|
+
*
|
|
21
|
+
* @template UserInfo, Data
|
|
22
|
+
* @param {string} topic - Topic to track cursors on
|
|
23
|
+
* @returns {import('svelte/store').Readable<Map<string, { user: UserInfo, data: Data }>>}
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```svelte
|
|
27
|
+
* <script>
|
|
28
|
+
* import { cursor } from 'svelte-adapter-uws/plugins/cursor/client';
|
|
29
|
+
*
|
|
30
|
+
* const cursors = cursor('canvas');
|
|
31
|
+
* </script>
|
|
32
|
+
*
|
|
33
|
+
* {#each [...$cursors] as [key, { user, data }] (key)}
|
|
34
|
+
* <div style="left: {data.x}px; top: {data.y}px" class="cursor">
|
|
35
|
+
* {user.name}
|
|
36
|
+
* </div>
|
|
37
|
+
* {/each}
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function cursor(topic) {
|
|
41
|
+
const cursorTopic = '__cursor:' + topic;
|
|
42
|
+
const source = on(cursorTopic);
|
|
43
|
+
|
|
44
|
+
/** @type {Map<string, { user: any, data: any }>} */
|
|
45
|
+
let cursorMap = new Map();
|
|
46
|
+
const output = writable(/** @type {Map<string, any>} */ (new Map()));
|
|
47
|
+
|
|
48
|
+
let sourceUnsub = /** @type {(() => void) | null} */ (null);
|
|
49
|
+
let refCount = 0;
|
|
50
|
+
|
|
51
|
+
function startListening() {
|
|
52
|
+
sourceUnsub = source.subscribe((event) => {
|
|
53
|
+
if (event === null) return;
|
|
54
|
+
|
|
55
|
+
if (event.event === 'update' && event.data != null) {
|
|
56
|
+
const { key, user, data } = event.data;
|
|
57
|
+
cursorMap.set(key, { user, data });
|
|
58
|
+
output.set(new Map(cursorMap));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (event.event === 'remove' && event.data != null) {
|
|
63
|
+
const { key } = event.data;
|
|
64
|
+
if (cursorMap.delete(key)) {
|
|
65
|
+
output.set(new Map(cursorMap));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function stopListening() {
|
|
72
|
+
if (sourceUnsub) {
|
|
73
|
+
sourceUnsub();
|
|
74
|
+
sourceUnsub = null;
|
|
75
|
+
}
|
|
76
|
+
cursorMap = new Map();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
subscribe(fn) {
|
|
81
|
+
if (refCount++ === 0) startListening();
|
|
82
|
+
const unsub = output.subscribe(fn);
|
|
83
|
+
return () => {
|
|
84
|
+
unsub();
|
|
85
|
+
if (--refCount === 0) stopListening();
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|