svelte-adapter-uws 0.4.0 → 0.4.2

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 CHANGED
@@ -2008,12 +2008,26 @@ The client store is a `Readable<Map<string, { user, data }>>`. The Map updates w
2008
2008
 
2009
2009
  **Initial sync and reconnect.** The `cursor(topic)` store sends a `{ type: 'cursor-snapshot', topic }` message every time the WebSocket connection opens -- both on first connect and on every reconnect. The server calls `cursors.snapshot(ws, topic, platform)` in its `message` handler, which sends a `snapshot` event back with the current cursor state (or an empty array if nobody is active). The client replaces its entire cursor map with the snapshot contents, clearing any stale entries from before the disconnect. Wire `cursors.snapshot()` in your message handler as shown in the server example above.
2010
2010
 
2011
- The `cursor()` function accepts an optional second argument with a `maxAge` option (in milliseconds). When set, cursor entries that haven't received an update within that window are automatically removed. This makes clients self-healing when the server fails to broadcast `remove` events under load:
2011
+ The `cursor()` function accepts an optional second argument:
2012
2012
 
2013
2013
  ```js
2014
2014
  const positions = cursor('canvas', { maxAge: 30_000 });
2015
2015
  ```
2016
2016
 
2017
+ `maxAge` (milliseconds) -- cursor entries that haven't received an update within that window are automatically removed. Makes clients self-healing when the server fails to broadcast `remove` events under load.
2018
+
2019
+ `interpolate` -- enables smooth cursor rendering via `requestAnimationFrame`. Incoming `update` events set a lerp target and the displayed position moves toward it at 30% of the remaining distance per frame, eliminating visual jitter from network timing. The first update for each cursor snaps immediately (no lerp from 0,0). `snapshot`, `bulk`, and `remove` events always snap. Only applies to cursor data with numeric `x` and `y` fields -- non-numeric data falls back to direct assignment.
2020
+
2021
+ ```js
2022
+ const positions = cursor('canvas', { interpolate: true });
2023
+ ```
2024
+
2025
+ Options can be combined:
2026
+
2027
+ ```js
2028
+ const positions = cursor('canvas', { maxAge: 30_000, interpolate: true });
2029
+ ```
2030
+
2017
2031
  #### Server API
2018
2032
 
2019
2033
  | Method | Description |
package/files/handler.js CHANGED
@@ -1285,7 +1285,7 @@ function handleRequest(res, req) {
1285
1285
  // WS_ENABLED is set by the adapter at build time - no inference from exports needed
1286
1286
  if (WS_ENABLED) {
1287
1287
  // Warn about unrecognized exports - catches typos like "mesage" or "opn"
1288
- const knownWsExports = new Set(['open', 'message', 'upgrade', 'close', 'drain', 'subscribe']);
1288
+ const knownWsExports = new Set(['open', 'message', 'upgrade', 'close', 'drain', 'subscribe', 'unsubscribe']);
1289
1289
  for (const name of Object.keys(wsModule)) {
1290
1290
  if (!knownWsExports.has(name)) {
1291
1291
  console.warn(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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",
@@ -30,5 +30,14 @@ export interface CursorPosition<UserInfo = unknown, Data = unknown> {
30
30
  */
31
31
  export function cursor<UserInfo = unknown, Data = unknown>(
32
32
  topic: string,
33
- options?: { maxAge?: number }
33
+ options?: {
34
+ maxAge?: number;
35
+ /**
36
+ * Enable smooth lerp interpolation for cursor movement.
37
+ * When enabled, `update` events with numeric `x` and `y` fields
38
+ * are interpolated via requestAnimationFrame instead of snapping.
39
+ * `snapshot`, `bulk`, and `remove` events always snap immediately.
40
+ */
41
+ interpolate?: boolean;
42
+ }
34
43
  ): Readable<Map<string, CursorPosition<UserInfo, Data>>>;
@@ -28,7 +28,7 @@ const cursorStores = new Map();
28
28
  *
29
29
  * @template UserInfo, Data
30
30
  * @param {string} topic - Topic to track cursors on
31
- * @param {{ maxAge?: number }} [options] - Options
31
+ * @param {{ maxAge?: number, interpolate?: boolean }} [options] - Options
32
32
  * @returns {import('svelte/store').Readable<Map<string, { user: UserInfo, data: Data }>>}
33
33
  *
34
34
  * @example
@@ -56,7 +56,10 @@ const cursorStores = new Map();
56
56
  */
57
57
  export function cursor(topic, options) {
58
58
  const maxAge = options?.maxAge;
59
- const cacheKey = maxAge > 0 ? topic + '\0' + maxAge : topic;
59
+ const interpolate = options?.interpolate === true;
60
+ let cacheKey = topic;
61
+ if (maxAge > 0) cacheKey += '\0' + maxAge;
62
+ if (interpolate) cacheKey += '\0lerp';
60
63
 
61
64
  const cached = cursorStores.get(cacheKey);
62
65
  if (cached) return cached;
@@ -73,9 +76,38 @@ export function cursor(topic, options) {
73
76
  let statusUnsub = /** @type {(() => void) | null} */ (null);
74
77
  /** @type {ReturnType<typeof setInterval> | null} */
75
78
  let sweepTimer = null;
79
+ /** @type {Map<string, { x: number, y: number }>} */
80
+ const targets = new Map();
81
+ let rafId = /** @type {number | null} */ (null);
76
82
  let refCount = 0;
77
83
  let cancelled = false;
78
84
 
85
+ function tick() {
86
+ let changed = false;
87
+ for (const [key, target] of targets) {
88
+ const entry = cursorMap.get(key);
89
+ if (!entry) { targets.delete(key); continue; }
90
+ const dx = target.x - entry.data.x;
91
+ const dy = target.y - entry.data.y;
92
+ if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) {
93
+ if (entry.data.x !== target.x || entry.data.y !== target.y) {
94
+ entry.data = { ...entry.data, x: target.x, y: target.y };
95
+ changed = true;
96
+ }
97
+ targets.delete(key);
98
+ continue;
99
+ }
100
+ entry.data = { ...entry.data, x: entry.data.x + dx * 0.3, y: entry.data.y + dy * 0.3 };
101
+ changed = true;
102
+ }
103
+ if (changed) output.set(new Map(cursorMap));
104
+ if (targets.size > 0) {
105
+ rafId = requestAnimationFrame(tick);
106
+ } else {
107
+ rafId = null;
108
+ }
109
+ }
110
+
79
111
  function sweep() {
80
112
  if (!maxAge || maxAge <= 0) return;
81
113
  const cutoff = Date.now() - maxAge;
@@ -97,15 +129,27 @@ export function cursor(topic, options) {
97
129
 
98
130
  if (event.event === 'update' && event.data != null) {
99
131
  const { key, user, data } = event.data;
100
- cursorMap.set(key, { user, data });
101
132
  timestamps.set(key, Date.now());
102
- output.set(new Map(cursorMap));
133
+ if (interpolate && typeof data?.x === 'number' && typeof data?.y === 'number') {
134
+ if (!cursorMap.has(key)) {
135
+ cursorMap.set(key, { user, data });
136
+ output.set(new Map(cursorMap));
137
+ } else {
138
+ cursorMap.get(key).user = user;
139
+ }
140
+ targets.set(key, { x: data.x, y: data.y });
141
+ if (rafId === null) rafId = requestAnimationFrame(tick);
142
+ } else {
143
+ cursorMap.set(key, { user, data });
144
+ output.set(new Map(cursorMap));
145
+ }
103
146
  return;
104
147
  }
105
148
 
106
149
  if (event.event === 'snapshot' && Array.isArray(event.data)) {
107
150
  cursorMap = new Map();
108
151
  timestamps.clear();
152
+ targets.clear();
109
153
  const now = Date.now();
110
154
  for (const entry of event.data) {
111
155
  const { key, user, data } = entry;
@@ -117,6 +161,7 @@ export function cursor(topic, options) {
117
161
  }
118
162
 
119
163
  if (event.event === 'bulk' && Array.isArray(event.data)) {
164
+ targets.clear();
120
165
  const now = Date.now();
121
166
  for (const entry of event.data) {
122
167
  const { key, user, data } = entry;
@@ -130,6 +175,7 @@ export function cursor(topic, options) {
130
175
  if (event.event === 'remove' && event.data != null) {
131
176
  const { key } = event.data;
132
177
  timestamps.delete(key);
178
+ targets.delete(key);
133
179
  if (cursorMap.delete(key)) {
134
180
  output.set(new Map(cursorMap));
135
181
  }
@@ -164,6 +210,11 @@ export function cursor(topic, options) {
164
210
  clearInterval(sweepTimer);
165
211
  sweepTimer = null;
166
212
  }
213
+ if (rafId !== null) {
214
+ cancelAnimationFrame(rafId);
215
+ rafId = null;
216
+ }
217
+ targets.clear();
167
218
  cursorMap = new Map();
168
219
  timestamps.clear();
169
220
  // Push the cleared state to the output store so a new subscriber does