svelte-adapter-uws 0.4.11 → 0.4.12

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
@@ -36,6 +36,7 @@ I've been loving Svelte and SvelteKit for a long time. I always wanted to expand
36
36
  **WebSocket deep dive**
37
37
  - [WebSocket handler (`hooks.ws`)](#websocket-handler-hooksws)
38
38
  - [Authentication](#authentication)
39
+ - [Refreshing session cookies on WebSocket connect](#refreshing-session-cookies-on-websocket-connect)
39
40
  - [Platform API (`event.platform`)](#platform-api-eventplatform)
40
41
  - [Client store API](#client-store-api)
41
42
  - [Seeding initial state](#seeding-initial-state)
@@ -726,8 +727,10 @@ export async function upgrade({ cookies }) {
726
727
  if (!user) return false; // -> 401, expired or invalid session
727
728
 
728
729
  // Attach user data to the socket - available via ws.getUserData()
729
- // To also set response headers on the 101 (e.g. refresh session cookie):
730
- // return upgradeResponse({ userId: user.id }, { 'set-cookie': '...' });
730
+ // To refresh the session cookie on connect, use the `authenticate` hook
731
+ // (see "Refreshing session cookies on WebSocket connect" below).
732
+ // `upgradeResponse()` with custom non-cookie headers is also supported:
733
+ // return upgradeResponse({ userId: user.id }, { 'x-session-version': '2' });
731
734
  return { userId: user.id, name: user.name, role: user.role };
732
735
  }
733
736
 
@@ -797,6 +800,72 @@ The WebSocket upgrade is an HTTP request. The browser treats it like any other r
797
800
  | Server hook | `hooks.server.js` `handle()` | Yes |
798
801
  | **WebSocket upgrade** | **`hooks.ws.js` `upgrade()`** | **Yes** |
799
802
 
803
+ ### Refreshing session cookies on WebSocket connect
804
+
805
+ For short-lived sessions you often want to rotate the session cookie every time a client connects. The obvious approach -- attaching `Set-Cookie` to the 101 Switching Protocols response via `upgradeResponse()` -- is RFC-compliant but **is silently rejected by Cloudflare Tunnel, Cloudflare's proxy, and some other strict edge proxies**. The symptom is that the WebSocket `open` handler fires server-side, then the connection closes with code 1006 (`Received TCP FIN before WebSocket close frame`) before any frames are exchanged. The adapter emits a build-time warning when it detects this pattern.
806
+
807
+ The adapter ships a first-class solution: the optional `authenticate` hook runs as a normal HTTP POST **before** the WebSocket upgrade. `Set-Cookie` rides on a standard 2xx response, which every proxy handles correctly; the browser then attaches the refreshed cookie to the upgrade request that follows.
808
+
809
+ **Step 1: add an `authenticate` export to `hooks.ws.js`**
810
+
811
+ ```js
812
+ // src/hooks.ws.js
813
+ import { getSession, renewSession } from '$lib/server/auth.js';
814
+
815
+ // Runs as POST /__ws/auth, before the WebSocket upgrade.
816
+ // cookies.set() becomes Set-Cookie on a standard 204 response.
817
+ export async function authenticate({ cookies }) {
818
+ const session = await getSession(cookies.get('session'));
819
+ if (!session) return false; // -> 401, client does not open the WebSocket
820
+
821
+ const renewed = await renewSession(session);
822
+ cookies.set('session', renewed.token, {
823
+ path: '/',
824
+ httpOnly: true,
825
+ secure: true,
826
+ sameSite: 'lax',
827
+ maxAge: 60 * 60 * 24 * 7
828
+ });
829
+ }
830
+
831
+ // Your existing upgrade() hook stays unchanged - it reads the now-fresh cookie.
832
+ export async function upgrade({ cookies }) {
833
+ const session = await getSession(cookies.session);
834
+ if (!session) return false;
835
+ return { userId: session.userId, role: session.role };
836
+ }
837
+ ```
838
+
839
+ The `authenticate` event exposes the SvelteKit event shape you already know: `{ request, headers, cookies, url, remoteAddress, getClientAddress, platform }`. Return values:
840
+
841
+ - `undefined` / nothing - success, responds `204 No Content` with any `Set-Cookie` headers from `cookies.set()` (recommended).
842
+ - `false` - responds `401 Unauthorized`. The client does not open the WebSocket.
843
+ - A full `Response` - used as-is; any `cookies.set()` calls are merged in.
844
+
845
+ **Step 2: opt in from the client**
846
+
847
+ ```js
848
+ import { connect } from 'svelte-adapter-uws/client';
849
+
850
+ // Hit /__ws/auth before every WebSocket connect (including reconnects)
851
+ connect({ auth: true });
852
+
853
+ // Or point at a custom path (e.g. behind a Cloudflare Access rule)
854
+ connect({ auth: '/api/ws-auth' });
855
+ ```
856
+
857
+ With `auth: true` the client stores runs `fetch('/__ws/auth', { method: 'POST', credentials: 'include' })` before every `new WebSocket(...)` call, including after automatic reconnects. Concurrent connect attempts share a single in-flight preflight. A `4xx` response is treated as terminal (the user is not authenticated); `5xx` and network errors fall back to the normal reconnect backoff.
858
+
859
+ **Configuration**
860
+
861
+ - The default auth path is `/__ws/auth`. Override with `adapter({ websocket: { authPath: '/api/ws-auth' } })`.
862
+ - The hook is only mounted when `authenticate` is exported from `hooks.ws` -- no runtime cost when unused.
863
+ - Dev mode (Vite plugin) mirrors the production route on the same path.
864
+
865
+ **Why not put `Set-Cookie` on the 101?**
866
+
867
+ Cloudflare's HTTP/2 WebSocket bridging rewrites 101 responses, and `Set-Cookie` on the 101 trips the edge into tearing the connection down. This is undocumented Cloudflare behavior, but reproducible on every tunnel and proxy connector. The `authenticate` hook sidesteps it entirely by using a standard HTTP response.
868
+
800
869
  ---
801
870
 
802
871
  ## Platform API (`event.platform`)
package/client.d.ts CHANGED
@@ -40,6 +40,46 @@ export interface ConnectOptions {
40
40
  * @default false
41
41
  */
42
42
  debug?: boolean;
43
+
44
+ /**
45
+ * Run a pre-WebSocket HTTP preflight against the adapter's `authenticate`
46
+ * endpoint before opening the socket. Required only when your server's
47
+ * `hooks.ws` exports an `authenticate` hook that refreshes session cookies.
48
+ *
49
+ * - `true` (recommended) - use the default path `/__ws/auth`
50
+ * - `string` - use a custom path, e.g. `'/api/ws/auth'`
51
+ * - `false` / omit - disabled, no preflight (default)
52
+ *
53
+ * The preflight is a `fetch(authPath, { method: 'POST', credentials: 'include' })`
54
+ * that runs before every connect (including reconnects) so rotated session
55
+ * cookies are picked up. Concurrent connect attempts share a single in-flight
56
+ * request. On a non-2xx response, the connection is not opened and the status
57
+ * store transitions to `'closed'` with a permanent rejection.
58
+ *
59
+ * This exists because `Set-Cookie` on a 101 Switching Protocols response is
60
+ * silently dropped by Cloudflare Tunnel and some other strict edge proxies,
61
+ * which closes the WebSocket with code 1006 before any frames are exchanged.
62
+ * Refreshing cookies via a normal HTTP response works behind every proxy.
63
+ *
64
+ * @default false
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * // src/hooks.ws.ts
69
+ * export function authenticate({ cookies }) {
70
+ * const session = validateSession(cookies.get('session'));
71
+ * if (!session) return false;
72
+ * cookies.set('session', renewSession(session), {
73
+ * httpOnly: true, secure: true, sameSite: 'lax', path: '/'
74
+ * });
75
+ * }
76
+ *
77
+ * // src/routes/+layout.svelte
78
+ * import { connect } from 'svelte-adapter-uws/client';
79
+ * connect({ auth: true });
80
+ * ```
81
+ */
82
+ auth?: boolean | string;
43
83
  }
44
84
 
45
85
  /**
package/client.js CHANGED
@@ -509,9 +509,15 @@ function createConnection(options) {
509
509
  reconnectInterval = 3000,
510
510
  maxReconnectInterval = 30000,
511
511
  maxReconnectAttempts = Infinity,
512
- debug = false
512
+ debug = false,
513
+ auth = false
513
514
  } = options;
514
515
 
516
+ // Resolve the auth preflight path. `auth: true` -> default '/__ws/auth',
517
+ // `auth: '/custom'` -> use the provided path, `auth: false` (default) -> disabled.
518
+ /** @type {string | null} */
519
+ const authPath = auth === true ? '/__ws/auth' : (typeof auth === 'string' && auth) ? auth : null;
520
+
515
521
  /** @type {WebSocket | null} */
516
522
  let ws = null;
517
523
 
@@ -520,6 +526,9 @@ function createConnection(options) {
520
526
  /** @type {ReturnType<typeof setInterval> | null} */
521
527
  let activityTimer = null;
522
528
 
529
+ /** @type {Promise<boolean> | null} deduped in-flight auth preflight */
530
+ let authInFlight = null;
531
+
523
532
  let attempt = 0;
524
533
  let intentionallyClosed = false;
525
534
  // Set when the server permanently rejects us (terminal close code) or when
@@ -570,12 +579,99 @@ function createConnection(options) {
570
579
  return `${protocol}//${window.location.host}${path}`;
571
580
  }
572
581
 
582
+ /**
583
+ * Build the HTTP URL for the auth preflight. Mirrors getUrl() but emits
584
+ * http/https instead of ws/wss so same-origin cookies flow correctly.
585
+ * Returns null in SSR or when auth is disabled.
586
+ */
587
+ function getAuthUrl() {
588
+ if (!authPath) return null;
589
+ if (url) {
590
+ try {
591
+ const wsUrl = new URL(url);
592
+ const httpScheme = wsUrl.protocol === 'wss:' ? 'https:' : 'http:';
593
+ return httpScheme + '//' + wsUrl.host + authPath;
594
+ } catch {
595
+ return null;
596
+ }
597
+ }
598
+ if (typeof window === 'undefined') return null;
599
+ return window.location.origin + authPath;
600
+ }
601
+
602
+ /**
603
+ * Run the auth preflight. Returns one of:
604
+ * - `'ok'` - request accepted (2xx). Open the socket.
605
+ * - `'unauthorized'` - server rejected with 4xx. Terminal: the user is
606
+ * not authenticated and retrying won't help without new credentials.
607
+ * - `'transient'` - 5xx or network error. Fall back to normal reconnect
608
+ * backoff so the preflight retries alongside the socket.
609
+ *
610
+ * Deduped: concurrent doConnect() calls share a single in-flight fetch.
611
+ *
612
+ * @returns {Promise<'ok' | 'unauthorized' | 'transient'>}
613
+ */
614
+ function runAuth() {
615
+ if (!authPath) return Promise.resolve('ok');
616
+ if (authInFlight) return authInFlight;
617
+ const target = getAuthUrl();
618
+ if (!target) return Promise.resolve('ok');
619
+
620
+ authInFlight = (async () => {
621
+ try {
622
+ const resp = await fetch(target, {
623
+ method: 'POST',
624
+ credentials: 'include',
625
+ headers: { 'x-requested-with': 'svelte-adapter-uws' }
626
+ });
627
+ if (debug) console.log('[ws] auth preflight status=%d', resp.status);
628
+ if (resp.ok) return 'ok';
629
+ if (resp.status >= 400 && resp.status < 500) return 'unauthorized';
630
+ return 'transient';
631
+ } catch (err) {
632
+ if (debug) console.warn('[ws] auth preflight network error:', err);
633
+ return 'transient';
634
+ } finally {
635
+ authInFlight = null;
636
+ }
637
+ })();
638
+ return authInFlight;
639
+ }
640
+
573
641
  function doConnect() {
574
642
  if (!url && typeof window === 'undefined') return;
575
643
  if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) return;
576
644
 
577
645
  statusStore.set('connecting');
578
646
 
647
+ if (authPath) {
648
+ runAuth().then((outcome) => {
649
+ if (intentionallyClosed || terminalClosed) return;
650
+ if (outcome === 'unauthorized') {
651
+ // Server rejected the request with a 4xx. The user is not
652
+ // authenticated and retrying won't help until they log in.
653
+ if (debug) console.warn('[ws] auth preflight rejected (4xx), not opening WebSocket');
654
+ statusStore.set('closed');
655
+ terminalClosed = true;
656
+ permaClosedStore.set(true);
657
+ return;
658
+ }
659
+ if (outcome === 'transient') {
660
+ // Network error or 5xx. Retry via the normal backoff loop so
661
+ // the preflight automatically re-runs on the next attempt.
662
+ if (debug) console.warn('[ws] auth preflight transient failure, scheduling reconnect');
663
+ statusStore.set('closed');
664
+ scheduleReconnect();
665
+ return;
666
+ }
667
+ openSocket();
668
+ });
669
+ return;
670
+ }
671
+ openSocket();
672
+ }
673
+
674
+ function openSocket() {
579
675
  try {
580
676
  ws = new WebSocket(getUrl());
581
677
  } catch {
package/files/cookies.js CHANGED
@@ -23,3 +23,119 @@ export function parseCookies(cookieHeader) {
23
23
  }
24
24
  return cookies;
25
25
  }
26
+
27
+ const COOKIE_NAME_INVALID = /[\s"(),/:;<=>?@[\\\]{}\u0000-\u001f\u007f]/;
28
+ const COOKIE_VALUE_INVALID = /[,;\s\u0000-\u001f\u007f]/;
29
+ const VALID_SAMESITE = new Set(['strict', 'lax', 'none']);
30
+
31
+ /**
32
+ * @typedef {object} CookieSerializeOptions
33
+ * @property {string} [path]
34
+ * @property {string} [domain]
35
+ * @property {Date} [expires]
36
+ * @property {number} [maxAge] - seconds
37
+ * @property {boolean} [httpOnly]
38
+ * @property {boolean} [secure]
39
+ * @property {boolean} [partitioned]
40
+ * @property {'strict' | 'lax' | 'none' | boolean} [sameSite]
41
+ * @property {boolean} [encode] - default true; pass false to skip URI encoding
42
+ */
43
+
44
+ /**
45
+ * Serialize a cookie name/value/options triple into a Set-Cookie header string.
46
+ * Mirrors the cookie semantics SvelteKit applies via its cookies.set() API so
47
+ * users writing an authenticate hook get the same behavior as in +server.js.
48
+ *
49
+ * @param {string} name
50
+ * @param {string} value
51
+ * @param {CookieSerializeOptions} [options]
52
+ * @returns {string}
53
+ */
54
+ export function serializeCookie(name, value, options = {}) {
55
+ if (typeof name !== 'string' || name.length === 0 || COOKIE_NAME_INVALID.test(name)) {
56
+ throw new Error(`Invalid cookie name: '${name}'`);
57
+ }
58
+ const encoded = options.encode === false ? value : encodeURIComponent(value);
59
+ if (COOKIE_VALUE_INVALID.test(encoded)) {
60
+ throw new Error(`Invalid cookie value for '${name}'`);
61
+ }
62
+ let out = name + '=' + encoded;
63
+ if (options.domain !== undefined) out += '; Domain=' + options.domain;
64
+ if (options.path !== undefined) out += '; Path=' + options.path;
65
+ if (options.expires !== undefined) out += '; Expires=' + options.expires.toUTCString();
66
+ if (options.maxAge !== undefined) {
67
+ if (!Number.isFinite(options.maxAge)) {
68
+ throw new Error(`Invalid Max-Age for cookie '${name}': ${options.maxAge}`);
69
+ }
70
+ out += '; Max-Age=' + Math.floor(options.maxAge);
71
+ }
72
+ if (options.httpOnly) out += '; HttpOnly';
73
+ if (options.secure) out += '; Secure';
74
+ if (options.partitioned) out += '; Partitioned';
75
+ if (options.sameSite !== undefined) {
76
+ const raw = options.sameSite === true ? 'strict' : options.sameSite === false ? 'lax' : options.sameSite;
77
+ const normalized = String(raw).toLowerCase();
78
+ if (!VALID_SAMESITE.has(normalized)) {
79
+ throw new Error(`Invalid SameSite for cookie '${name}': ${options.sameSite}`);
80
+ }
81
+ out += '; SameSite=' + normalized[0].toUpperCase() + normalized.slice(1);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * Create a SvelteKit-like cookies API for use in the authenticate hook.
88
+ * Reads from the incoming request's Cookie header and accumulates Set-Cookie
89
+ * strings that the caller writes onto the response.
90
+ *
91
+ * @param {string} [cookieHeader] - raw Cookie header from the request
92
+ */
93
+ export function createCookies(cookieHeader) {
94
+ const parsed = parseCookies(cookieHeader);
95
+ /** @type {Map<string, string>} keyed by name + path + domain so repeated set() with the same scope overwrites */
96
+ const outgoing = new Map();
97
+
98
+ function key(name, path, domain) {
99
+ return name + '\0' + (path || '') + '\0' + (domain || '');
100
+ }
101
+
102
+ const api = {
103
+ /** @param {string} name */
104
+ get(name) {
105
+ return parsed[name];
106
+ },
107
+ /** @returns {Record<string, string>} */
108
+ getAll() {
109
+ return { ...parsed };
110
+ },
111
+ /**
112
+ * @param {string} name
113
+ * @param {string} value
114
+ * @param {CookieSerializeOptions} [options]
115
+ */
116
+ set(name, value, options = {}) {
117
+ outgoing.set(key(name, options.path, options.domain), serializeCookie(name, value, options));
118
+ parsed[name] = value;
119
+ },
120
+ /**
121
+ * @param {string} name
122
+ * @param {Pick<CookieSerializeOptions, 'path' | 'domain'>} [options]
123
+ */
124
+ delete(name, options = {}) {
125
+ api.set(name, '', {
126
+ ...options,
127
+ expires: new Date(0),
128
+ maxAge: 0
129
+ });
130
+ delete parsed[name];
131
+ },
132
+ /**
133
+ * Drain accumulated Set-Cookie headers. Called by the adapter, not the user.
134
+ * @returns {string[]}
135
+ */
136
+ _serialize() {
137
+ return [...outgoing.values()];
138
+ }
139
+ };
140
+ return api;
141
+ }
package/files/handler.js CHANGED
@@ -11,7 +11,7 @@ import { Server } from 'SERVER';
11
11
  import { manifest, prerendered, base } from 'MANIFEST';
12
12
  import { env } from 'ENV';
13
13
  import * as wsModule from 'WS_HANDLER';
14
- import { parseCookies } from './cookies.js';
14
+ import { parseCookies, createCookies } from './cookies.js';
15
15
  import { mimeLookup, parse_as_bytes, parse_origin } from './utils.js';
16
16
 
17
17
  /* global ENV_PREFIX */
@@ -19,6 +19,7 @@ import { mimeLookup, parse_as_bytes, parse_origin } from './utils.js';
19
19
  /* global WS_ENABLED */
20
20
  /* global WS_PATH */
21
21
  /* global WS_OPTIONS */
22
+ /* global WS_AUTH_PATH */
22
23
  /* global HEALTH_CHECK_PATH */
23
24
 
24
25
  class PayloadTooLargeError extends Error {
@@ -1332,7 +1333,7 @@ function handleRequest(res, req) {
1332
1333
  // WS_ENABLED is set by the adapter at build time - no inference from exports needed
1333
1334
  if (WS_ENABLED) {
1334
1335
  // Warn about unrecognized exports - catches typos like "mesage" or "opn"
1335
- const knownWsExports = new Set(['open', 'message', 'upgrade', 'close', 'drain', 'subscribe', 'unsubscribe']);
1336
+ const knownWsExports = new Set(['open', 'message', 'upgrade', 'close', 'drain', 'subscribe', 'unsubscribe', 'authenticate']);
1336
1337
  for (const name of Object.keys(wsModule)) {
1337
1338
  if (!knownWsExports.has(name)) {
1338
1339
  console.warn(
@@ -1342,6 +1343,30 @@ if (WS_ENABLED) {
1342
1343
  }
1343
1344
  }
1344
1345
 
1346
+ // One-shot runtime warning when a user upgrade handler attaches Set-Cookie
1347
+ // to the 101 Switching Protocols response. Cloudflare Tunnel and some other
1348
+ // strict edge proxies silently close WebSocket connections with 1006 when
1349
+ // the 101 carries Set-Cookie. The `authenticate` hook refreshes cookies
1350
+ // over a normal HTTP response and works behind every proxy.
1351
+ let warnedSetCookieOnUpgrade = false;
1352
+ /** @param {Record<string, string | string[]> | null | undefined} responseHeaders */
1353
+ function maybeWarnSetCookieOnUpgrade(responseHeaders) {
1354
+ if (warnedSetCookieOnUpgrade || !responseHeaders) return;
1355
+ for (const k of Object.keys(responseHeaders)) {
1356
+ if (k.toLowerCase() === 'set-cookie') {
1357
+ warnedSetCookieOnUpgrade = true;
1358
+ console.warn(
1359
+ '[adapter-uws] Set-Cookie on the 101 upgrade response is rejected by ' +
1360
+ 'Cloudflare Tunnel and some other edge proxies (WebSocket opens, then ' +
1361
+ 'closes with 1006 TCP FIN). Migrate to the `authenticate` hook to ' +
1362
+ 'refresh session cookies over a normal HTTP response: ' +
1363
+ 'export function authenticate({ cookies }) { cookies.set(...); }'
1364
+ );
1365
+ return;
1366
+ }
1367
+ }
1368
+ }
1369
+
1345
1370
  const wsOptions = WS_OPTIONS;
1346
1371
  const allowedOrigins = wsOptions.allowedOrigins || 'same-origin';
1347
1372
 
@@ -1403,6 +1428,127 @@ if (WS_ENABLED) {
1403
1428
  }
1404
1429
  }, 60000).unref();
1405
1430
 
1431
+ // -- Authenticate endpoint (pre-upgrade HTTP hook) ---------------------
1432
+ // Optional `authenticate` export in hooks.ws.ts runs as a normal HTTP POST
1433
+ // so session cookies can be refreshed via a standard Set-Cookie on a 200
1434
+ // response. This works behind Cloudflare Tunnel and other strict edge
1435
+ // proxies that silently drop WebSocket connections whose 101 response
1436
+ // carries Set-Cookie. The client store POSTs here before opening the WS
1437
+ // when `connect({ auth: true })` is used.
1438
+ if (typeof wsModule.authenticate === 'function') {
1439
+ const authPath = WS_AUTH_PATH;
1440
+ // Body size cap for the authenticate endpoint. Most requests have no
1441
+ // body at all -- the hook reads cookies from the Cookie header. Cap at
1442
+ // a small value to make malicious payloads cheap to reject.
1443
+ const AUTH_BODY_LIMIT = 64 * 1024;
1444
+
1445
+ app.post(authPath, (res, req) => {
1446
+ /** @type {Record<string, string>} */
1447
+ const authHeaders = {};
1448
+ req.forEach((k, v) => { authHeaders[k] = v; });
1449
+ const method = 'POST';
1450
+ const url = req.getUrl() + (req.getQuery() ? '?' + req.getQuery() : '');
1451
+ const clientIp = resolveClientIp(textDecoder.decode(res.getRemoteAddressAsText()), authHeaders);
1452
+
1453
+ const state = acquireState();
1454
+ res.onAborted(() => { state.aborted = true; });
1455
+
1456
+ const contentLength = parseInt(authHeaders['content-length'], 10);
1457
+ if (!isNaN(contentLength) && contentLength > AUTH_BODY_LIMIT) {
1458
+ send413(res);
1459
+ releaseState(state);
1460
+ return;
1461
+ }
1462
+
1463
+ const body = readBody(res, AUTH_BODY_LIMIT, state, isNaN(contentLength) ? -1 : contentLength);
1464
+
1465
+ const base_origin = origin || get_origin(authHeaders);
1466
+ const request = new Request(base_origin + url, {
1467
+ method,
1468
+ headers: authHeaders,
1469
+ body,
1470
+ // @ts-expect-error
1471
+ duplex: 'half'
1472
+ });
1473
+
1474
+ const cookies = createCookies(authHeaders['cookie']);
1475
+
1476
+ const event = {
1477
+ request,
1478
+ headers: authHeaders,
1479
+ cookies,
1480
+ url,
1481
+ remoteAddress: clientIp,
1482
+ getClientAddress: () => clientIp,
1483
+ platform
1484
+ };
1485
+
1486
+ Promise.resolve()
1487
+ .then(() => wsModule.authenticate(event))
1488
+ .then(async (result) => {
1489
+ if (state.aborted) return;
1490
+
1491
+ if (result === false) {
1492
+ res.cork(() => {
1493
+ res.writeStatus('401 Unauthorized');
1494
+ res.writeHeader('content-type', 'text/plain');
1495
+ res.end('Unauthorized');
1496
+ });
1497
+ return;
1498
+ }
1499
+
1500
+ if (result instanceof Response) {
1501
+ // User returned a full Response -- honour it, but merge any
1502
+ // cookies set via cookies.set() so both APIs work together.
1503
+ const buf = result.body ? Buffer.from(await result.arrayBuffer()) : null;
1504
+ if (state.aborted) return;
1505
+ res.cork(() => {
1506
+ res.writeStatus(String(result.status));
1507
+ for (const [hk, hv] of result.headers) {
1508
+ if (hk === 'set-cookie' || hk === 'content-length') continue;
1509
+ res.writeHeader(hk, hv);
1510
+ }
1511
+ for (const c of result.headers.getSetCookie()) res.writeHeader('set-cookie', c);
1512
+ for (const c of cookies._serialize()) res.writeHeader('set-cookie', c);
1513
+ if (buf) res.end(buf);
1514
+ else res.end();
1515
+ });
1516
+ return;
1517
+ }
1518
+
1519
+ // Implicit success: 204 No Content with any Set-Cookie headers
1520
+ res.cork(() => {
1521
+ res.writeStatus('204 No Content');
1522
+ for (const c of cookies._serialize()) res.writeHeader('set-cookie', c);
1523
+ res.endWithoutBody(0);
1524
+ });
1525
+ })
1526
+ .catch((err) => {
1527
+ if (state.aborted) return;
1528
+ if (err instanceof PayloadTooLargeError) {
1529
+ send413(res);
1530
+ return;
1531
+ }
1532
+ console.error('[adapter-uws] authenticate error:', err);
1533
+ if (!state.aborted) send500(res);
1534
+ })
1535
+ .finally(() => { releaseState(state); });
1536
+ });
1537
+
1538
+ // Reject non-POST verbs on the auth path so GET/HEAD do not fall through
1539
+ // to the SSR catch-all (which would try to render a SvelteKit route).
1540
+ app.any(authPath, (res) => {
1541
+ res.cork(() => {
1542
+ res.writeStatus('405 Method Not Allowed');
1543
+ res.writeHeader('allow', 'POST');
1544
+ res.writeHeader('content-type', 'text/plain');
1545
+ res.end('Method Not Allowed');
1546
+ });
1547
+ });
1548
+
1549
+ console.log(`WebSocket auth endpoint registered at ${authPath}`);
1550
+ }
1551
+
1406
1552
  app.ws(WS_PATH, {
1407
1553
  // Handle HTTP -> WebSocket upgrade with user-provided auth
1408
1554
  upgrade: (res, req, context) => {
@@ -1582,6 +1728,7 @@ if (WS_ENABLED) {
1582
1728
  const ud = userData || {};
1583
1729
  if (!ud.remoteAddress) ud.remoteAddress = clientIp;
1584
1730
  if (responseHeaders) {
1731
+ maybeWarnSetCookieOnUpgrade(responseHeaders);
1585
1732
  for (const [hk, hv] of Object.entries(responseHeaders)) {
1586
1733
  if (Array.isArray(hv)) {
1587
1734
  for (const v of hv) res.writeHeader(hk, v);
package/index.d.ts CHANGED
@@ -127,6 +127,20 @@ export interface WebSocketOptions {
127
127
  */
128
128
  path?: string;
129
129
 
130
+ /**
131
+ * URL path for the `authenticate` preflight endpoint.
132
+ *
133
+ * The adapter auto-mounts a `POST` endpoint here when your `hooks.ws` file
134
+ * exports an `authenticate` function. The client store hits it before
135
+ * opening a WebSocket when `connect({ auth: true })` is used.
136
+ *
137
+ * Must differ from `path`. Change this only if the default collides with
138
+ * your routing or if Cloudflare Access requires a non-`__`-prefixed path.
139
+ *
140
+ * @default '/__ws/auth'
141
+ */
142
+ authPath?: string;
143
+
130
144
  /**
131
145
  * Max message size in bytes. Connections sending larger messages are closed.
132
146
  * @default 16384 (16 KB)
@@ -201,6 +215,36 @@ export interface WebSocketOptions {
201
215
 
202
216
  // -- User's WebSocket handler module exports ---------------------------------
203
217
 
218
+ /**
219
+ * Options accepted by `authenticateCookies.set()` and `.delete()`. Matches the
220
+ * shape SvelteKit uses for `cookies.set()`.
221
+ */
222
+ export interface CookieSerializeOptions {
223
+ path?: string;
224
+ domain?: string;
225
+ expires?: Date;
226
+ /** In seconds. */
227
+ maxAge?: number;
228
+ httpOnly?: boolean;
229
+ secure?: boolean;
230
+ partitioned?: boolean;
231
+ sameSite?: 'strict' | 'lax' | 'none' | boolean;
232
+ /** Defaults to `true`. Set to `false` to skip URI-encoding the value. */
233
+ encode?: boolean;
234
+ }
235
+
236
+ /**
237
+ * SvelteKit-like cookies API available inside the `authenticate` hook.
238
+ * Mutations via `.set()` and `.delete()` become `Set-Cookie` headers on the
239
+ * HTTP response returned from the endpoint.
240
+ */
241
+ export interface AuthenticateCookies {
242
+ get(name: string): string | undefined;
243
+ getAll(): Record<string, string>;
244
+ set(name: string, value: string, options?: CookieSerializeOptions): void;
245
+ delete(name: string, options?: Pick<CookieSerializeOptions, 'path' | 'domain'>): void;
246
+ }
247
+
204
248
  /**
205
249
  * Context passed to the `upgrade` handler.
206
250
  */
@@ -215,6 +259,31 @@ export interface UpgradeContext {
215
259
  remoteAddress: string;
216
260
  }
217
261
 
262
+ /**
263
+ * Context passed to the optional `authenticate` handler.
264
+ *
265
+ * `authenticate` runs as a normal HTTP POST before the WebSocket upgrade, so
266
+ * any `Set-Cookie` headers from `cookies.set()` ride on a standard response
267
+ * and work behind every proxy (unlike `Set-Cookie` on the 101 upgrade, which
268
+ * Cloudflare Tunnel and some other strict edge proxies silently drop).
269
+ */
270
+ export interface AuthenticateContext {
271
+ /** The incoming request (standard `Request` object, with body). */
272
+ request: Request;
273
+ /** Request headers (all lowercase keys). */
274
+ headers: Record<string, string>;
275
+ /** SvelteKit-like cookies API. Mutations become Set-Cookie on the response. */
276
+ cookies: AuthenticateCookies;
277
+ /** The request URL path, including query string if present. */
278
+ url: string;
279
+ /** Remote IP address (honoring `ADDRESS_HEADER` / `XFF_DEPTH`). */
280
+ remoteAddress: string;
281
+ /** Shorthand for returning `remoteAddress`. Matches the SvelteKit event shape. */
282
+ getClientAddress: () => string;
283
+ /** The platform API (publish, send, topic helpers, etc.). */
284
+ platform: Platform;
285
+ }
286
+
218
287
  /**
219
288
  * Context passed to `open` and `drain` handlers.
220
289
  */
@@ -294,6 +363,41 @@ export interface SubscribeContext {
294
363
  * ```
295
364
  */
296
365
  export interface WebSocketHandler<UserData = unknown> {
366
+ /**
367
+ * Optional HTTP preflight that runs before the WebSocket upgrade.
368
+ *
369
+ * Recommended for any flow that needs to refresh a session cookie on WS
370
+ * connect. Returning cookies from this hook goes out via a standard HTTP
371
+ * response, which works behind every proxy. Setting `Set-Cookie` on the
372
+ * 101 upgrade response (via `upgradeResponse()`) is silently dropped by
373
+ * Cloudflare Tunnel and some other strict edge proxies.
374
+ *
375
+ * Triggered by the client store via `connect({ auth: true })`, which
376
+ * POSTs to `/__ws/auth` (configurable via `websocket.authPath`) before
377
+ * opening every WebSocket - including after reconnects.
378
+ *
379
+ * Return values:
380
+ * - `undefined` / `void` - success, responds 204 with any cookies set via `cookies.set()`.
381
+ * - `false` - respond 401 Unauthorized.
382
+ * - `Response` - use the returned response directly; any `cookies.set()` calls are merged in.
383
+ *
384
+ * May be async.
385
+ *
386
+ * @example
387
+ * ```js
388
+ * export function authenticate({ cookies }) {
389
+ * const session = validateSessionToken(cookies.get('session'));
390
+ * if (!session) return false;
391
+ * cookies.set('session', renewSession(session), {
392
+ * httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 7
393
+ * });
394
+ * }
395
+ * ```
396
+ */
397
+ authenticate?: (ctx: AuthenticateContext) =>
398
+ | Response | false | void
399
+ | Promise<Response | false | void>;
400
+
297
401
  /**
298
402
  * Called during the HTTP upgrade handshake.
299
403
  *
@@ -538,19 +642,25 @@ export interface TopicHelper {
538
642
 
539
643
  /**
540
644
  * Wrap upgrade hook return value to include response headers on the 101
541
- * Switching Protocols response (e.g. `Set-Cookie` for session refresh).
645
+ * Switching Protocols response.
542
646
  *
543
- * @example
647
+ * **Warning (Cloudflare):** attaching `Set-Cookie` to the 101 response is
648
+ * rejected by Cloudflare Tunnel and some other strict edge proxies. The
649
+ * WebSocket opens, then closes with code 1006 before any frames are exchanged.
650
+ * For session-cookie refresh use the `authenticate` hook instead, which
651
+ * refreshes cookies over a normal HTTP response and works behind every proxy.
652
+ *
653
+ * This helper remains supported for non-cookie response headers and for
654
+ * deployments that do not sit behind strict proxies.
655
+ *
656
+ * @example Custom non-cookie headers (safe):
544
657
  * ```js
545
658
  * import { upgradeResponse } from 'svelte-adapter-uws';
546
659
  *
547
660
  * export function upgrade({ cookies }) {
548
661
  * const session = validateSession(cookies.session_id);
549
662
  * if (!session) return false;
550
- * return upgradeResponse(
551
- * { userId: session.userId },
552
- * { 'set-cookie': refreshSessionCookie(session) }
553
- * );
663
+ * return upgradeResponse({ userId: session.userId }, { 'x-session-version': '2' });
554
664
  * }
555
665
  * ```
556
666
  */
package/index.js CHANGED
@@ -12,6 +12,47 @@ const files = fileURLToPath(new URL('./files', import.meta.url).href);
12
12
  // by handler.js for ALL messages regardless of user handler.
13
13
  const DEFAULT_WS_HANDLER = '// Built-in: subscribe/unsubscribe handled by the runtime\n';
14
14
 
15
+ /**
16
+ * Scan a bundled WS handler for `upgradeResponse(..., { 'set-cookie': ... })`
17
+ * usage. Emits a loud warning at build time because Cloudflare Tunnel and some
18
+ * other strict edge proxies silently drop WebSocket connections whose 101
19
+ * response carries Set-Cookie -- symptom is 1006 TCP FIN immediately after
20
+ * open fires server-side. The recommended fix is the `authenticate` hook.
21
+ *
22
+ * @param {string} source
23
+ * @returns {boolean}
24
+ */
25
+ function detectSetCookieOnUpgrade(source) {
26
+ // Scan each upgradeResponse( call for a 'set-cookie' / "Set-Cookie" literal
27
+ // inside its arguments. Works against bundler output (esbuild/rollup/Vite),
28
+ // which preserves these as literals even after minification rewrites the
29
+ // surrounding identifiers.
30
+ const re = /upgradeResponse\s*\(/gi;
31
+ let match;
32
+ while ((match = re.exec(source)) !== null) {
33
+ // Walk forward matching parens to find the end of the call
34
+ let depth = 1;
35
+ let i = match.index + match[0].length;
36
+ let inStr = '';
37
+ let esc = false;
38
+ for (; i < source.length && depth > 0; i++) {
39
+ const c = source[i];
40
+ if (esc) { esc = false; continue; }
41
+ if (inStr) {
42
+ if (c === '\\') esc = true;
43
+ else if (c === inStr) inStr = '';
44
+ continue;
45
+ }
46
+ if (c === '"' || c === "'" || c === '`') { inStr = c; continue; }
47
+ if (c === '(') depth++;
48
+ else if (c === ')') depth--;
49
+ }
50
+ const args = source.slice(match.index + match[0].length, i - 1);
51
+ if (/['"`]\s*set-cookie\s*['"`]/i.test(args)) return true;
52
+ }
53
+ return false;
54
+ }
55
+
15
56
  /** @type {import('./index.js').default} */
16
57
  export default function (opts = {}) {
17
58
  const { out = 'build', precompress = true, envPrefix = '', healthCheckPath = '/healthz' } = opts;
@@ -227,6 +268,18 @@ export default function (opts = {}) {
227
268
  `Use '/${wsPath}' instead.`
228
269
  );
229
270
  }
271
+ const wsAuthPath = websocket?.authPath ?? '/__ws/auth';
272
+ if (wsAuthPath[0] !== '/') {
273
+ throw new Error(
274
+ `websocket.authPath must start with '/' - got '${wsAuthPath}'. ` +
275
+ `Use '/${wsAuthPath}' instead.`
276
+ );
277
+ }
278
+ if (wsAuthPath === wsPath) {
279
+ throw new Error(
280
+ `websocket.authPath ('${wsAuthPath}') must differ from websocket.path ('${wsPath}').`
281
+ );
282
+ }
230
283
  const wsOpts = {
231
284
  maxPayloadLength: websocket?.maxPayloadLength ?? 16 * 1024,
232
285
  idleTimeout: websocket?.idleTimeout ?? 120,
@@ -239,6 +292,38 @@ export default function (opts = {}) {
239
292
  upgradeRateLimitWindow: websocket?.upgradeRateLimitWindow ?? 10
240
293
  };
241
294
 
295
+ // Scan the bundled WS handler for `upgradeResponse(..., { 'set-cookie': ... })`
296
+ // and warn loudly. Cloudflare Tunnel and some other strict edge proxies
297
+ // silently close WebSocket connections whose 101 response carries
298
+ // Set-Cookie (1006 TCP FIN immediately after the server-side open fires).
299
+ if (websocket && existsSync(`${tmp}/ws-handler.js`)) {
300
+ try {
301
+ const handlerSrc = readFileSync(`${tmp}/ws-handler.js`, 'utf8');
302
+ if (detectSetCookieOnUpgrade(handlerSrc)) {
303
+ builder.log.warn(
304
+ '[adapter-uws] Your upgrade() hook attaches Set-Cookie to the 101 response ' +
305
+ 'via upgradeResponse(). This fails silently behind Cloudflare Tunnel, ' +
306
+ "Cloudflare's proxy, and some other strict edge proxies: the WebSocket " +
307
+ 'opens, then closes with code 1006 before any frames are exchanged.\n' +
308
+ '\n' +
309
+ 'Migrate to the `authenticate` hook to refresh session cookies over a ' +
310
+ 'normal HTTP response that works behind every proxy:\n' +
311
+ '\n' +
312
+ ' export function authenticate({ cookies }) {\n' +
313
+ " const session = validateSession(cookies.get('session'));\n" +
314
+ ' if (!session) return false;\n' +
315
+ " cookies.set('session', renewSession(session), { httpOnly: true, secure: true, sameSite: 'lax', path: '/' });\n" +
316
+ ' }\n' +
317
+ '\n' +
318
+ 'Then opt in from the client: connect({ auth: true }).\n' +
319
+ 'This warning is safe to ignore if you do not deploy behind Cloudflare.'
320
+ );
321
+ }
322
+ } catch {
323
+ // Scanner is best-effort; ignore IO errors
324
+ }
325
+ }
326
+
242
327
  builder.copy(files, out, {
243
328
  replace: {
244
329
  ENV: './env.js',
@@ -252,6 +337,7 @@ export default function (opts = {}) {
252
337
  WS_ENABLED: JSON.stringify(!!websocket),
253
338
  WS_PATH: JSON.stringify(wsPath),
254
339
  WS_OPTIONS: JSON.stringify(wsOpts),
340
+ WS_AUTH_PATH: JSON.stringify(wsAuthPath),
255
341
  HEALTH_CHECK_PATH: JSON.stringify(healthCheckPath)
256
342
  }
257
343
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.4.11",
3
+ "version": "0.4.12",
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",
package/vite.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { existsSync } from 'node:fs';
3
- import { parseCookies } from './files/cookies.js';
3
+ import { parseCookies, createCookies } from './files/cookies.js';
4
4
 
5
5
  /**
6
6
  * Safely quote a string for JSON embedding. Throws on invalid characters
@@ -27,11 +27,12 @@ function esc(s) {
27
27
  * Uses the same subscribe/unsubscribe/publish protocol as the production
28
28
  * uWS handler, so the client store works identically in dev and prod.
29
29
  *
30
- * @param {{ path?: string, handler?: string }} [options]
30
+ * @param {{ path?: string, handler?: string, authPath?: string }} [options]
31
31
  * @returns {import('vite').Plugin}
32
32
  */
33
33
  export default function uws(options = {}) {
34
34
  const wsPath = options.path || '/ws';
35
+ const wsAuthPath = options.authPath || '/__ws/auth';
35
36
 
36
37
  /** @type {import('ws').WebSocketServer | undefined} */
37
38
  let wss;
@@ -45,7 +46,7 @@ export default function uws(options = {}) {
45
46
  /** @type {Map<import('ws').WebSocket, object>} */
46
47
  const wsWrappers = new Map();
47
48
 
48
- /** @type {{ upgrade?: Function, open?: Function, message?: Function, close?: Function, drain?: Function, subscribe?: Function }} */
49
+ /** @type {{ upgrade?: Function, open?: Function, message?: Function, close?: Function, drain?: Function, subscribe?: Function, unsubscribe?: Function, authenticate?: Function }} */
49
50
  let userHandlers = {};
50
51
 
51
52
  /**
@@ -209,7 +210,8 @@ export default function uws(options = {}) {
209
210
  close: mod.close,
210
211
  drain: mod.drain,
211
212
  subscribe: mod.subscribe,
212
- unsubscribe: mod.unsubscribe
213
+ unsubscribe: mod.unsubscribe,
214
+ authenticate: mod.authenticate
213
215
  };
214
216
  }
215
217
 
@@ -324,6 +326,110 @@ export default function uws(options = {}) {
324
326
  })();
325
327
  }
326
328
 
329
+ // /__ws/auth middleware: runs the user's `authenticate` hook as a normal
330
+ // HTTP POST so session cookies are refreshed via a standard Set-Cookie
331
+ // on a 200-series response. Mirrors the production handler in dev.
332
+ server.middlewares.use(wsAuthPath, async (req, res, next) => {
333
+ await handlerReady;
334
+ if (!userHandlers.authenticate) { next(); return; }
335
+ if (req.method !== 'POST') {
336
+ res.statusCode = 405;
337
+ res.setHeader('allow', 'POST');
338
+ res.setHeader('content-type', 'text/plain');
339
+ res.end('Method Not Allowed');
340
+ return;
341
+ }
342
+
343
+ /** @type {Record<string, string>} */
344
+ const headers = {};
345
+ for (const [k, v] of Object.entries(req.headers)) {
346
+ if (typeof v === 'string') headers[k] = v;
347
+ else if (Array.isArray(v)) headers[k] = v.join(', ');
348
+ }
349
+
350
+ // Read body (capped at 64 KB; the hook rarely needs it).
351
+ const AUTH_BODY_LIMIT = 64 * 1024;
352
+ /** @type {Buffer[]} */
353
+ const chunks = [];
354
+ let total = 0;
355
+ let oversized = false;
356
+ for await (const chunk of req) {
357
+ total += chunk.length;
358
+ if (total > AUTH_BODY_LIMIT) { oversized = true; break; }
359
+ chunks.push(chunk);
360
+ }
361
+ if (oversized) {
362
+ res.statusCode = 413;
363
+ res.setHeader('content-type', 'text/plain');
364
+ res.end('Content Too Large');
365
+ return;
366
+ }
367
+ const bodyBuf = Buffer.concat(chunks);
368
+
369
+ const origin = 'http://' + (headers['host'] || 'localhost');
370
+ const url = req.url || wsAuthPath;
371
+ const request = new Request(origin + url, {
372
+ method: 'POST',
373
+ headers,
374
+ body: bodyBuf.length > 0 ? bodyBuf : undefined,
375
+ // @ts-expect-error
376
+ duplex: 'half'
377
+ });
378
+
379
+ const cookies = createCookies(headers['cookie']);
380
+ const clientIp = req.socket?.remoteAddress || '';
381
+ const event = {
382
+ request,
383
+ headers,
384
+ cookies,
385
+ url,
386
+ remoteAddress: clientIp,
387
+ getClientAddress: () => clientIp,
388
+ platform
389
+ };
390
+
391
+ try {
392
+ const result = await Promise.resolve(userHandlers.authenticate(event));
393
+
394
+ if (result === false) {
395
+ res.statusCode = 401;
396
+ res.setHeader('content-type', 'text/plain');
397
+ res.end('Unauthorized');
398
+ return;
399
+ }
400
+
401
+ if (result instanceof Response) {
402
+ res.statusCode = result.status;
403
+ for (const [hk, hv] of result.headers) {
404
+ if (hk === 'set-cookie' || hk === 'content-length') continue;
405
+ res.setHeader(hk, hv);
406
+ }
407
+ const outCookies = [
408
+ ...result.headers.getSetCookie(),
409
+ ...cookies._serialize()
410
+ ];
411
+ if (outCookies.length > 0) res.setHeader('set-cookie', outCookies);
412
+ if (result.body) {
413
+ const buf = Buffer.from(await result.arrayBuffer());
414
+ res.end(buf);
415
+ } else {
416
+ res.end();
417
+ }
418
+ return;
419
+ }
420
+
421
+ res.statusCode = 204;
422
+ const outCookies = cookies._serialize();
423
+ if (outCookies.length > 0) res.setHeader('set-cookie', outCookies);
424
+ res.end();
425
+ } catch (err) {
426
+ console.error('[adapter-uws] authenticate error:', err);
427
+ res.statusCode = 500;
428
+ res.setHeader('content-type', 'text/plain');
429
+ res.end('Internal Server Error');
430
+ }
431
+ });
432
+
327
433
  server.httpServer?.on('upgrade', async (req, socket, head) => {
328
434
  const { pathname } = new URL(req.url || '', 'http://localhost');
329
435
  if (pathname !== wsPath) return;
@@ -370,7 +476,19 @@ export default function uws(options = {}) {
370
476
  if (result && result.__upgradeResponse === true) {
371
477
  userData = result.userData || {};
372
478
  if (result.headers && Object.keys(result.headers).length > 0) {
373
- console.warn('[adapter-uws] upgrade() returned response headers — these are only applied in production (uWS). The ws library used in dev does not support custom 101 headers.');
479
+ const hasSetCookie = Object.keys(result.headers).some(
480
+ (k) => k.toLowerCase() === 'set-cookie'
481
+ );
482
+ if (hasSetCookie) {
483
+ console.warn(
484
+ '[adapter-uws] upgradeResponse() attaches Set-Cookie to the 101 response. ' +
485
+ 'This fails silently behind Cloudflare Tunnel and some other strict edge proxies ' +
486
+ '(WebSocket opens, then closes with 1006). Use the `authenticate` hook to ' +
487
+ 'refresh session cookies over a normal HTTP response.'
488
+ );
489
+ } else {
490
+ console.warn('[adapter-uws] upgrade() returned response headers. These are only applied in production (uWS); the ws library used in dev does not support custom 101 headers.');
491
+ }
374
492
  }
375
493
  } else {
376
494
  userData = result || {};