svelte-adapter-uws 0.4.1 → 0.4.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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.5, y: entry.data.y + dy * 0.5 };
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,35 @@ 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
+ const existing = cursorMap.get(key);
135
+ if (!existing) {
136
+ cursorMap.set(key, { user, data });
137
+ } else {
138
+ const dx = data.x - existing.data.x;
139
+ const dy = data.y - existing.data.y;
140
+ existing.user = user;
141
+ existing.data = {
142
+ ...existing.data,
143
+ x: existing.data.x + dx * 0.5,
144
+ y: existing.data.y + dy * 0.5
145
+ };
146
+ }
147
+ targets.set(key, { x: data.x, y: data.y });
148
+ output.set(new Map(cursorMap));
149
+ if (rafId === null) rafId = requestAnimationFrame(tick);
150
+ } else {
151
+ cursorMap.set(key, { user, data });
152
+ output.set(new Map(cursorMap));
153
+ }
103
154
  return;
104
155
  }
105
156
 
106
157
  if (event.event === 'snapshot' && Array.isArray(event.data)) {
107
158
  cursorMap = new Map();
108
159
  timestamps.clear();
160
+ targets.clear();
109
161
  const now = Date.now();
110
162
  for (const entry of event.data) {
111
163
  const { key, user, data } = entry;
@@ -117,6 +169,7 @@ export function cursor(topic, options) {
117
169
  }
118
170
 
119
171
  if (event.event === 'bulk' && Array.isArray(event.data)) {
172
+ targets.clear();
120
173
  const now = Date.now();
121
174
  for (const entry of event.data) {
122
175
  const { key, user, data } = entry;
@@ -130,6 +183,7 @@ export function cursor(topic, options) {
130
183
  if (event.event === 'remove' && event.data != null) {
131
184
  const { key } = event.data;
132
185
  timestamps.delete(key);
186
+ targets.delete(key);
133
187
  if (cursorMap.delete(key)) {
134
188
  output.set(new Map(cursorMap));
135
189
  }
@@ -164,6 +218,11 @@ export function cursor(topic, options) {
164
218
  clearInterval(sweepTimer);
165
219
  sweepTimer = null;
166
220
  }
221
+ if (rafId !== null) {
222
+ cancelAnimationFrame(rafId);
223
+ rafId = null;
224
+ }
225
+ targets.clear();
167
226
  cursorMap = new Map();
168
227
  timestamps.clear();
169
228
  // Push the cleared state to the output store so a new subscriber does