svelte-adapter-uws 0.4.10 → 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 +71 -2
- package/client.d.ts +40 -0
- package/client.js +97 -1
- package/files/cookies.js +116 -0
- package/files/handler.js +164 -21
- package/index.d.ts +116 -6
- package/index.js +86 -0
- package/package.json +1 -1
- package/testing.js +9 -13
- package/vite.js +123 -5
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
|
|
730
|
-
//
|
|
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) => {
|
|
@@ -1511,9 +1657,7 @@ if (WS_ENABLED) {
|
|
|
1511
1657
|
// no cookie parsing). Inject remoteAddress so plugins/ratelimit can
|
|
1512
1658
|
// key on the real client IP via ws.getUserData().remoteAddress.
|
|
1513
1659
|
if (!wsModule.upgrade) {
|
|
1514
|
-
res.
|
|
1515
|
-
res.upgrade({ remoteAddress: clientIp }, secKey, secProtocol, secExtensions, context);
|
|
1516
|
-
});
|
|
1660
|
+
res.upgrade({ remoteAddress: clientIp }, secKey, secProtocol, secExtensions, context);
|
|
1517
1661
|
return;
|
|
1518
1662
|
}
|
|
1519
1663
|
|
|
@@ -1583,24 +1727,23 @@ if (WS_ENABLED) {
|
|
|
1583
1727
|
}
|
|
1584
1728
|
const ud = userData || {};
|
|
1585
1729
|
if (!ud.remoteAddress) ud.remoteAddress = clientIp;
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
}
|
|
1730
|
+
if (responseHeaders) {
|
|
1731
|
+
maybeWarnSetCookieOnUpgrade(responseHeaders);
|
|
1732
|
+
for (const [hk, hv] of Object.entries(responseHeaders)) {
|
|
1733
|
+
if (Array.isArray(hv)) {
|
|
1734
|
+
for (const v of hv) res.writeHeader(hk, v);
|
|
1735
|
+
} else {
|
|
1736
|
+
res.writeHeader(hk, hv);
|
|
1594
1737
|
}
|
|
1595
1738
|
}
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1739
|
+
}
|
|
1740
|
+
res.upgrade(
|
|
1741
|
+
ud,
|
|
1742
|
+
secKey,
|
|
1743
|
+
secProtocol,
|
|
1744
|
+
secExtensions,
|
|
1745
|
+
context
|
|
1746
|
+
);
|
|
1604
1747
|
})
|
|
1605
1748
|
.catch((err) => {
|
|
1606
1749
|
clearTimeout(timer);
|
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
|
|
645
|
+
* Switching Protocols response.
|
|
542
646
|
*
|
|
543
|
-
*
|
|
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
package/testing.js
CHANGED
|
@@ -115,9 +115,7 @@ export async function createTestServer(options = {}) {
|
|
|
115
115
|
const rawIp = new TextDecoder().decode(res.getRemoteAddressAsText());
|
|
116
116
|
|
|
117
117
|
if (!handler.upgrade) {
|
|
118
|
-
res.
|
|
119
|
-
res.upgrade({ remoteAddress: rawIp }, secKey, secProtocol, secExtensions, context);
|
|
120
|
-
});
|
|
118
|
+
res.upgrade({ remoteAddress: rawIp }, secKey, secProtocol, secExtensions, context);
|
|
121
119
|
return;
|
|
122
120
|
}
|
|
123
121
|
|
|
@@ -145,18 +143,16 @@ export async function createTestServer(options = {}) {
|
|
|
145
143
|
userData = result || {};
|
|
146
144
|
}
|
|
147
145
|
if (!userData.remoteAddress) userData.remoteAddress = rawIp;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
res.writeHeader(hk, hv);
|
|
155
|
-
}
|
|
146
|
+
if (responseHeaders) {
|
|
147
|
+
for (const [hk, hv] of Object.entries(responseHeaders)) {
|
|
148
|
+
if (Array.isArray(hv)) {
|
|
149
|
+
for (const v of hv) res.writeHeader(hk, v);
|
|
150
|
+
} else {
|
|
151
|
+
res.writeHeader(hk, hv);
|
|
156
152
|
}
|
|
157
153
|
}
|
|
158
|
-
|
|
159
|
-
|
|
154
|
+
}
|
|
155
|
+
res.upgrade(userData, secKey, secProtocol, secExtensions, context);
|
|
160
156
|
})
|
|
161
157
|
.catch((err) => {
|
|
162
158
|
if (!aborted) {
|
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
|
-
|
|
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 || {};
|