svelte-realtime 0.4.21 → 0.4.23

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
@@ -1171,22 +1219,51 @@ export const message = createMessage({
1171
1219
 
1172
1220
  Opt-in instrumentation for RPC calls, stream subscriptions, and cron executions. Zero overhead if not called.
1173
1221
 
1222
+ `live.metrics(registry)` is a one-time setup call. The top of `src/hooks.ws.{js,ts}` is a natural place, since it loads once when the server boots. Pair it with the `createMetrics()` registry from `svelte-adapter-uws-extensions/prometheus`:
1223
+
1174
1224
  ```js
1225
+ // src/hooks.ws.js
1175
1226
  import { live } from 'svelte-realtime/server';
1176
- import { createMetricsRegistry } from 'svelte-adapter-uws-extensions/prometheus';
1227
+ import { createMetrics } from 'svelte-adapter-uws-extensions/prometheus';
1228
+
1229
+ const metrics = createMetrics();
1230
+
1231
+ live.metrics({
1232
+ counter: ({ name, help, labelNames }) => metrics.counter(name, help, labelNames),
1233
+ histogram: ({ name, help, labelNames }) => metrics.histogram(name, help, labelNames),
1234
+ gauge: ({ name, help, labelNames }) => metrics.gauge(name, help, labelNames)
1235
+ });
1177
1236
 
1178
- const registry = createMetricsRegistry();
1179
- live.metrics(registry);
1237
+ export { message, close, unsubscribe } from 'svelte-realtime/server';
1238
+ ```
1239
+
1240
+ Mount the metrics endpoint on your uWS app (typically in `svelte.config.js` or wherever you build the app):
1241
+
1242
+ ```js
1243
+ app.get('/metrics', metrics.handler);
1180
1244
  ```
1181
1245
 
1182
- This registers counters/histograms for:
1246
+ The six-line shim adapts realtime's options-object call shape to the extensions registry's positional create methods. Once a metric is registered, every increment, observation, and gauge update flows directly to the extensions registry, so the emitted Prometheus output is exactly what `metrics.serialize()` produces.
1247
+
1248
+ ### Registered metrics
1249
+
1183
1250
  - `svelte_realtime_rpc_total` -- RPC call count by path and status
1184
1251
  - `svelte_realtime_rpc_duration_seconds` -- RPC latency by path
1185
1252
  - `svelte_realtime_rpc_errors_total` -- RPC errors by path and code
1186
- - `svelte_realtime_stream_subscriptions` -- active stream subscription gauge by topic
1253
+ - `svelte_realtime_stream_subscriptions` -- active stream subscription gauge
1187
1254
  - `svelte_realtime_cron_total` -- cron execution count by path and status
1188
1255
  - `svelte_realtime_cron_errors_total` -- cron errors by path
1189
1256
 
1257
+ ### Registry shape
1258
+
1259
+ `live.metrics()` accepts any object exposing:
1260
+
1261
+ - `counter({ name, help, labelNames }) -> { inc(labels?) }`
1262
+ - `histogram({ name, help, labelNames }) -> { observe(labels, valueSeconds) }`
1263
+ - `gauge({ name, help }) -> { inc(), dec() }`
1264
+
1265
+ If you prefer `prom-client`, wire it the same way: `counter: ({ name, help, labelNames }) => new client.Counter({ name, help, labelNames, registers: [register] })`, and likewise for `Histogram` and `Gauge`.
1266
+
1190
1267
  ---
1191
1268
 
1192
1269
  ## Circuit breaker
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.23",
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,9 +69,10 @@
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
+ "svelte-adapter-uws-extensions": "^0.4.2",
75
76
  "vitest": "^4.0.18"
76
77
  },
77
78
  "keywords": [
package/server.d.ts CHANGED
@@ -182,6 +182,25 @@ export interface CreateMessageOptions {
182
182
  onUnhandled?(ws: WebSocket<any>, data: ArrayBuffer, platform: Platform): void;
183
183
  }
184
184
 
185
+ /**
186
+ * Shape accepted by `live.metrics()`. Any object matching this contract works
187
+ * (hand-rolled, prom-client adapter, or the `createMetrics()` registry from
188
+ * `svelte-adapter-uws-extensions/prometheus` wrapped to forward options as
189
+ * positional args -- see the README "Prometheus metrics" section).
190
+ */
191
+ export interface MetricsRegistry {
192
+ counter(opts: { name: string; help: string; labelNames?: string[] }): {
193
+ inc(labels?: Record<string, string | number>): void;
194
+ };
195
+ histogram(opts: { name: string; help: string; labelNames?: string[]; buckets?: number[] }): {
196
+ observe(labels: Record<string, string | number>, valueSeconds: number): void;
197
+ };
198
+ gauge(opts: { name: string; help: string; labelNames?: string[] }): {
199
+ inc(): void;
200
+ dec(): void;
201
+ };
202
+ }
203
+
185
204
  /**
186
205
  * Mark a function as RPC-callable over WebSocket.
187
206
  *
@@ -561,21 +580,29 @@ export namespace live {
561
580
  function webhook(topic: string, config: WebhookConfig): WebhookHandler;
562
581
 
563
582
  /**
564
- * Opt-in Prometheus metrics integration.
565
- * Accepts a MetricsRegistry from `svelte-adapter-uws-extensions/prometheus`
566
- * and instruments RPC calls, stream subscriptions, and cron executions.
583
+ * Opt-in Prometheus metrics integration. Instruments RPC calls, stream
584
+ * subscriptions, and cron executions. Zero overhead if never called.
567
585
  *
568
- * Zero overhead if never called.
586
+ * Call once at server start (e.g. the top of `src/hooks.ws.{js,ts}`).
587
+ * See the README "Prometheus metrics" section for a working example
588
+ * that pairs this with `createMetrics()` from
589
+ * `svelte-adapter-uws-extensions/prometheus`.
569
590
  *
570
- * @param registry - A MetricsRegistry instance with counter(), histogram(), gauge()
591
+ * @param registry - Object matching the {@link MetricsRegistry} shape
571
592
  *
572
593
  * @example
573
594
  * ```js
574
- * import { createRegistry } from 'svelte-adapter-uws-extensions/prometheus';
575
- * live.metrics(createRegistry());
595
+ * import { createMetrics } from 'svelte-adapter-uws-extensions/prometheus';
596
+ *
597
+ * const metrics = createMetrics();
598
+ * live.metrics({
599
+ * counter: ({ name, help, labelNames }) => metrics.counter(name, help, labelNames),
600
+ * histogram: ({ name, help, labelNames }) => metrics.histogram(name, help, labelNames),
601
+ * gauge: ({ name, help, labelNames }) => metrics.gauge(name, help, labelNames)
602
+ * });
576
603
  * ```
577
604
  */
578
- function metrics(registry: any): void;
605
+ function metrics(registry: MetricsRegistry): void;
579
606
 
580
607
  /**
581
608
  * Wrap a stream initFn call with a circuit breaker.
package/server.js CHANGED
@@ -1338,13 +1338,20 @@ live.room = function room(config) {
1338
1338
  let _metricsInstruments = null;
1339
1339
 
1340
1340
  /**
1341
- * Opt-in Prometheus metrics integration.
1342
- * Accepts a MetricsRegistry from `svelte-adapter-uws-extensions/prometheus`
1343
- * and instruments RPC calls, stream subscriptions, and cron executions.
1341
+ * Opt-in Prometheus metrics integration. Instruments RPC calls, stream
1342
+ * subscriptions, and cron executions. Zero overhead if never called.
1344
1343
  *
1345
- * Zero overhead if never called.
1344
+ * Call once at server start (e.g. the top of `src/hooks.ws.{js,ts}`).
1346
1345
  *
1347
- * @param {any} registry - A MetricsRegistry instance with counter(), histogram(), gauge()
1346
+ * The registry is any object exposing:
1347
+ * counter({ name, help, labelNames }) -> { inc(labels?) }
1348
+ * histogram({ name, help, labelNames }) -> { observe(labels, valueSeconds) }
1349
+ * gauge({ name, help }) -> { inc(), dec() }
1350
+ *
1351
+ * See the README "Prometheus metrics" section for a working example that
1352
+ * pairs this with `createMetrics()` from `svelte-adapter-uws-extensions/prometheus`.
1353
+ *
1354
+ * @param {any} registry - Object with counter, histogram, and gauge factories
1348
1355
  */
1349
1356
  live.metrics = function metrics(registry) {
1350
1357
  _metricsInstruments = {