svelte-realtime 0.4.21 → 0.4.22

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
@@ -934,6 +934,7 @@ Call `configure()` once at app startup. The hooks fire on state transitions only
934
934
  | Option | Description |
935
935
  |---|---|
936
936
  | `url` | Full WebSocket URL for cross-origin or native app usage (e.g. `'wss://api.example.com/ws'`) |
937
+ | `auth` | `true` (or a custom path) to enable an HTTP preflight before each WebSocket upgrade so cookies set by the server's `authenticate` hook ride a normal HTTP response. Required behind Cloudflare Tunnel and other proxies that drop `Set-Cookie` on 101 responses. Requires `svelte-adapter-uws` >= 0.4.12. |
937
938
  | `onConnect()` | Called when the WebSocket connection opens after a reconnect |
938
939
  | `onDisconnect()` | Called when the WebSocket connection closes |
939
940
  | `beforeReconnect()` | Called before each reconnection attempt (can be async) |
@@ -985,6 +986,53 @@ The native client passes the token in the URL:
985
986
  configure({ url: 'wss://my-sveltekit-app.com/ws?token=...' });
986
987
  ```
987
988
 
989
+ ### Refreshing session cookies on connect (Cloudflare Tunnel and friends)
990
+
991
+ Cloudflare Tunnel and other strict edge proxies silently drop the `Set-Cookie` header on WebSocket `101 Switching Protocols` responses. The connection appears to open server-side, then the client immediately sees `close 1006` and never receives a single frame. The classic symptom for this in production: WebSockets work locally and on a bare server, then break the moment you put Cloudflare in front.
992
+
993
+ Fix it in three pieces:
994
+
995
+ 1. Export an `authenticate` hook from `src/hooks.ws.{js,ts}`. It runs as a normal HTTP `POST /__ws/auth` before every upgrade (including reconnects), so cookies you set ride a `204 No Content` response that proxies route correctly.
996
+ 2. Opt into the client preflight with `configure({ auth: true })`.
997
+ 3. Use `svelte-adapter-uws` >= 0.4.12.
998
+
999
+ ```js
1000
+ // src/hooks.ws.js
1001
+ export { message, close, unsubscribe } from 'svelte-realtime/server';
1002
+
1003
+ export function upgrade({ cookies }) {
1004
+ const session = validateSession(cookies.session_id);
1005
+ return session ? { id: session.userId, name: session.name } : false;
1006
+ }
1007
+
1008
+ export function authenticate({ cookies }) {
1009
+ const session = validateSession(cookies.get('session_id'));
1010
+ if (!session) return false;
1011
+
1012
+ if (shouldRotate(session)) {
1013
+ cookies.set('session_id', rotate(session), {
1014
+ httpOnly: true,
1015
+ secure: true,
1016
+ sameSite: 'lax',
1017
+ path: '/'
1018
+ });
1019
+ }
1020
+ return { id: session.userId, name: session.name };
1021
+ }
1022
+ ```
1023
+
1024
+ ```svelte
1025
+ <!-- src/routes/+layout.svelte -->
1026
+ <script>
1027
+ import { configure } from 'svelte-realtime/client';
1028
+ configure({ auth: true });
1029
+ </script>
1030
+ ```
1031
+
1032
+ The client coalesces concurrent connects into a single in-flight preflight, treats `4xx` as terminal, and falls back to normal reconnect backoff on `5xx` and network errors.
1033
+
1034
+ > **Detector:** if the client sees two consecutive WebSocket open->close cycles inside one second with no traffic, it logs a one-shot `console.warn` pointing at this section. That is the Cloudflare-Tunnel-eating-cookies fingerprint.
1035
+
988
1036
  ---
989
1037
 
990
1038
  ## Combine stores
package/client.d.ts CHANGED
@@ -181,6 +181,22 @@ export function configure(config: {
181
181
  * @example 'wss://my-app.com/ws'
182
182
  */
183
183
  url?: string;
184
+ /**
185
+ * Run an HTTP preflight before each WebSocket upgrade so cookies set by the
186
+ * server's `authenticate` hook ride a normal HTTP response (not a 101 Switching
187
+ * Protocols frame). Required behind Cloudflare Tunnel and other strict edge
188
+ * proxies that silently drop `Set-Cookie` on WebSocket upgrades.
189
+ *
190
+ * Pass `true` to use the default `/__ws/auth` path, or a string to override it
191
+ * (must match the adapter's `websocket.authPath` option).
192
+ *
193
+ * Requires `svelte-adapter-uws` >= 0.4.12 and an `authenticate` export in
194
+ * `src/hooks.ws.{js,ts}`.
195
+ *
196
+ * @default false
197
+ * @example configure({ auth: true })
198
+ */
199
+ auth?: boolean | string;
184
200
  /** Called when the WebSocket connection opens (not on initial connect, only reconnects). */
185
201
  onConnect?(): void;
186
202
  /** Called when the WebSocket connection closes. */
package/client.js CHANGED
@@ -133,22 +133,50 @@ function ensureListener() {
133
133
  /**
134
134
  * Attach a disconnect listener once.
135
135
  * Rejects all in-flight RPCs (already sent) with DISCONNECTED.
136
+ * Also detects the Cloudflare-Tunnel "Set-Cookie on 101" symptom: repeated
137
+ * fast open->close cycles with no time spent in the open state.
136
138
  */
137
139
  function ensureDisconnectListener() {
138
140
  if (disconnectListenerAttached) return;
139
141
  disconnectListenerAttached = true;
140
142
 
143
+ let lastOpenAt = 0;
144
+ let fastCloseCount = 0;
145
+ let cfTunnelWarned = false;
146
+
141
147
  status.subscribe((s) => {
142
148
  if (s === 'closed') {
143
- const code = s === 'closed' ? 'DISCONNECTED' : 'CONNECTION_CLOSED';
144
149
  for (const [id, entry] of pending) {
145
150
  pending.delete(id);
146
151
  if (entry.timer) clearTimeout(entry.timer);
147
- entry.reject(new RpcError(code, 'WebSocket connection lost'));
152
+ entry.reject(new RpcError('DISCONNECTED', 'WebSocket connection lost'));
153
+ }
154
+
155
+ if (lastOpenAt > 0) {
156
+ const openDuration = Date.now() - lastOpenAt;
157
+ lastOpenAt = 0;
158
+ if (openDuration < 1000) {
159
+ fastCloseCount++;
160
+ if (fastCloseCount >= 2 && !cfTunnelWarned && !_clientConfig.auth) {
161
+ cfTunnelWarned = true;
162
+ console.warn(
163
+ '[svelte-realtime] WebSocket opened then closed in ' + openDuration + 'ms ' +
164
+ 'with no traffic, repeatedly. This is the classic Cloudflare-Tunnel ' +
165
+ '"Set-Cookie on 101" symptom: the proxy silently drops cookies on ' +
166
+ 'WebSocket upgrade responses.\n' +
167
+ ' Fix: add `configure({ auth: true })` on the client and an ' +
168
+ '`authenticate` hook in `hooks.ws.js` (svelte-adapter-uws >= 0.4.12).\n' +
169
+ ' See: https://svti.me/cf-cookies'
170
+ );
171
+ }
172
+ } else {
173
+ fastCloseCount = 0;
174
+ }
148
175
  }
149
176
  }
150
177
  if (s === 'open') {
151
178
  _terminated = false;
179
+ lastOpenAt = Date.now();
152
180
  }
153
181
  });
154
182
 
@@ -1559,7 +1587,7 @@ function _checkArgs(path, args) {
1559
1587
  * @typedef {{ path: string, args: any[], queuedAt: number, resolve: Function, reject: Function }} OfflineEntry
1560
1588
  */
1561
1589
 
1562
- /** @type {{ url?: string, onConnect?: () => void, onDisconnect?: () => void, timeout?: number, offline?: { queue?: boolean, maxQueue?: number, maxAge?: number, replay?: 'sequential' | 'batch' | ((queue: OfflineEntry[]) => OfflineEntry[]), beforeReplay?: (call: { path: string, args: any[], queuedAt: number }) => boolean, onReplayError?: (call: { path: string, args: any[], queuedAt: number }, error: any) => void } }} */
1590
+ /** @type {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, timeout?: number, offline?: { queue?: boolean, maxQueue?: number, maxAge?: number, replay?: 'sequential' | 'batch' | ((queue: OfflineEntry[]) => OfflineEntry[]), beforeReplay?: (call: { path: string, args: any[], queuedAt: number }) => boolean, onReplayError?: (call: { path: string, args: any[], queuedAt: number }, error: any) => void } }} */
1563
1591
  let _clientConfig = {};
1564
1592
 
1565
1593
  /** @type {boolean} */
@@ -1577,13 +1605,17 @@ let _replayingQueue = false;
1577
1605
  /**
1578
1606
  * Configure client-side connection hooks and offline queue.
1579
1607
  *
1580
- * @param {{ url?: string, onConnect?: () => void, onDisconnect?: () => void, offline?: { queue?: boolean, maxQueue?: number, maxAge?: number, replay?: 'sequential' | 'batch' | ((queue: OfflineEntry[]) => OfflineEntry[]), beforeReplay?: (call: { path: string, args: any[], queuedAt: number }) => boolean, onReplayError?: (call: { path: string, args: any[], queuedAt: number }, error: any) => void } }} config
1608
+ * @param {{ url?: string, auth?: boolean | string, onConnect?: () => void, onDisconnect?: () => void, offline?: { queue?: boolean, maxQueue?: number, maxAge?: number, replay?: 'sequential' | 'batch' | ((queue: OfflineEntry[]) => OfflineEntry[]), beforeReplay?: (call: { path: string, args: any[], queuedAt: number }) => boolean, onReplayError?: (call: { path: string, args: any[], queuedAt: number }, error: any) => void } }} config
1581
1609
  */
1582
1610
  export function configure(config) {
1583
1611
  _clientConfig = config;
1584
1612
 
1585
- if (config.url) {
1586
- _connect({ url: config.url });
1613
+ if (config.url !== undefined || config.auth !== undefined) {
1614
+ /** @type {{ url?: string, auth?: boolean | string }} */
1615
+ const connectArgs = {};
1616
+ if (config.url !== undefined) connectArgs.url = config.url;
1617
+ if (config.auth !== undefined) connectArgs.auth = config.auth;
1618
+ _connect(connectArgs);
1587
1619
  }
1588
1620
 
1589
1621
  if (!_configListenerAttached) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-realtime",
3
- "version": "0.4.21",
3
+ "version": "0.4.22",
4
4
  "description": "Realtime RPC and reactive subscriptions for SvelteKit, built on svelte-adapter-uws",
5
5
  "author": "Kevin Radziszewski",
6
6
  "license": "MIT",
@@ -69,7 +69,7 @@
69
69
  "peerDependencies": {
70
70
  "@sveltejs/kit": "^2.0.0",
71
71
  "svelte": "^4.0.0 || ^5.0.0",
72
- "svelte-adapter-uws": ">=0.4.8"
72
+ "svelte-adapter-uws": ">=0.4.12"
73
73
  },
74
74
  "devDependencies": {
75
75
  "vitest": "^4.0.18"