svelte-adapter-uws 0.4.9 → 0.4.10

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
@@ -9,6 +9,7 @@ I've been loving Svelte and SvelteKit for a long time. I always wanted to expand
9
9
  - **HTTP & HTTPS** - native TLS via uWebSockets.js `SSLApp`, no reverse proxy needed
10
10
  - **WebSocket & WSS** - built-in pub/sub with a reactive Svelte client store
11
11
  - **In-memory static file cache** - assets loaded once at startup, served from RAM with precompressed brotli/gzip variants
12
+ - **Dynamic response compression** - SSR HTML and API JSON compressed on the fly with brotli or gzip
12
13
  - **Backpressure handling** - streaming responses that won't blow up memory
13
14
  - **Graceful shutdown** - waits for in-flight requests before exiting
14
15
  - **Health check endpoint** - `/healthz` out of the box
@@ -725,6 +726,8 @@ export async function upgrade({ cookies }) {
725
726
  if (!user) return false; // -> 401, expired or invalid session
726
727
 
727
728
  // 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': '...' });
728
731
  return { userId: user.id, name: user.name, role: user.role };
729
732
  }
730
733
 
@@ -2925,12 +2928,12 @@ Or if you're using `on()` directly (which auto-connects), call `connect()` first
2925
2928
  ## Testing
2926
2929
 
2927
2930
  ```bash
2928
- npm test # 711 unit tests (vitest, ~2s)
2931
+ npm test # 777 unit tests (vitest, ~2s)
2929
2932
  npm run test:e2e # 25 e2e tests (playwright, ~13s)
2930
2933
  npm run test:coverage # both + coverage reports (~30s)
2931
2934
  ```
2932
2935
 
2933
- Unit tests cover store patterns, adapter options, plugin logic, and client behavior using mocked WebSocket globals. They run in vitest with the `vmForks` pool.
2936
+ Unit tests cover store patterns, adapter options, plugin logic, client behavior, and the WebSocket test harness. They run in vitest with the `vmForks` pool.
2934
2937
 
2935
2938
  E2e tests start a real SvelteKit app (`test/fixture/`) with the adapter installed via `file:../..`. Playwright runs two projects:
2936
2939
 
@@ -2946,6 +2949,51 @@ cd test/fixture && npm install && cd ../..
2946
2949
  npx playwright install chromium
2947
2950
  ```
2948
2951
 
2952
+ ### Test harness for WebSocket handlers
2953
+
2954
+ The `svelte-adapter-uws/testing` entry point provides `createTestServer()` for integration-testing your `hooks.ws` handlers against a real uWebSockets.js server:
2955
+
2956
+ ```js
2957
+ import { createTestServer } from 'svelte-adapter-uws/testing';
2958
+ import { WebSocket } from 'ws';
2959
+ import { describe, it, expect, afterEach } from 'vitest';
2960
+ import * as myHandler from '../src/hooks.ws.js';
2961
+
2962
+ let server;
2963
+ afterEach(() => server?.close());
2964
+
2965
+ it('rejects unauthenticated upgrades', async () => {
2966
+ server = await createTestServer({ handler: myHandler });
2967
+
2968
+ const ws = new WebSocket(server.wsUrl);
2969
+ const code = await new Promise((resolve) => {
2970
+ ws.on('unexpected-response', (_, res) => resolve(res.statusCode));
2971
+ ws.on('open', () => resolve('open'));
2972
+ });
2973
+ expect(code).toBe(401);
2974
+ });
2975
+
2976
+ it('publishes to subscribers', async () => {
2977
+ server = await createTestServer({ handler: myHandler });
2978
+
2979
+ const ws = new WebSocket(server.wsUrl, {
2980
+ headers: { cookie: 'session=valid-token' }
2981
+ });
2982
+ await new Promise(r => ws.on('open', r));
2983
+
2984
+ ws.send(JSON.stringify({ type: 'subscribe', topic: 'todos' }));
2985
+ await new Promise(r => setTimeout(r, 10));
2986
+
2987
+ const msg = new Promise(r => ws.on('message', d => r(JSON.parse(d.toString()))));
2988
+ server.platform.publish('todos', 'created', { id: 1 });
2989
+ expect(await msg).toMatchObject({ topic: 'todos', event: 'created' });
2990
+
2991
+ ws.close();
2992
+ });
2993
+ ```
2994
+
2995
+ The test server starts on a random port (typically in ~2ms), uses the same subscribe/unsubscribe protocol as production, and exposes the full Platform API (`publish`, `send`, `sendTo`, `topic`, `connections`, `subscribers`).
2996
+
2949
2997
  ---
2950
2998
 
2951
2999
  ## Related projects
package/files/handler.js CHANGED
@@ -3,6 +3,8 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { Readable } from 'node:stream';
6
+ import { performance } from 'node:perf_hooks';
7
+ import { brotliCompressSync, gzipSync, constants as zlibConstants } from 'node:zlib';
6
8
  import { parentPort } from 'node:worker_threads';
7
9
  import uWS from 'uWebSockets.js';
8
10
  import { Server } from 'SERVER';
@@ -281,8 +283,10 @@ function cacheDir(dir, urlPrefix, immutable) {
281
283
  const clientDir = path.join(__dirname, 'client');
282
284
  const prerenderedDir = path.join(__dirname, 'prerendered');
283
285
 
286
+ const _t_static = performance.now();
284
287
  cacheDir(path.join(clientDir, base), base, true);
285
288
  cacheDir(path.join(prerenderedDir, base), base, false);
289
+ console.log(`Static files indexed in ${(performance.now() - _t_static).toFixed(1)}ms (${staticCache.size} entries)`);
286
290
 
287
291
  // -- TLS config (must be before origin warning) ------------------------------
288
292
 
@@ -354,14 +358,17 @@ function resolveClientIp(rawIp, headers) {
354
358
 
355
359
  const asset_dir = `${__dirname}/client${base}`;
356
360
 
361
+ const _t_init = performance.now();
357
362
  const server = new Server(manifest);
358
363
  await server.init({
359
364
  env: /** @type {Record<string, string>} */ (process.env),
360
365
  read: (file) => /** @type {ReadableStream} */ (Readable.toWeb(fs.createReadStream(`${asset_dir}/${file}`)))
361
366
  });
367
+ console.log(`SvelteKit server initialized in ${(performance.now() - _t_init).toFixed(1)}ms`);
362
368
 
363
369
  // -- uWS App -----------------------------------------------------------------
364
370
 
371
+ const _t_app = performance.now();
365
372
  const app = is_tls
366
373
  ? uWS.SSLApp({ cert_file_name: ssl_cert, key_file_name: ssl_key })
367
374
  : uWS.App();
@@ -578,6 +585,18 @@ const ssrInflight = new Map();
578
585
  // separate Buffer per chunk. Reduces GC pressure for typical form/JSON bodies.
579
586
  const SMALL_BODY_THRESHOLD = 65536; // 64 KB
580
587
 
588
+ // Dynamic response compression: only compress text content types above a threshold.
589
+ // Static files use build-time precompression and are never affected by this.
590
+ const COMPRESS_MIN_SIZE = 1024;
591
+ const COMPRESSIBLE_TYPES = new Set([
592
+ 'text/html', 'text/css', 'text/plain', 'text/xml', 'text/javascript',
593
+ 'text/csv', 'text/markdown',
594
+ 'application/json', 'application/xml', 'application/javascript',
595
+ 'application/xhtml+xml', 'application/ld+json', 'application/manifest+json',
596
+ 'application/rss+xml', 'application/atom+xml',
597
+ 'image/svg+xml'
598
+ ]);
599
+
581
600
  /**
582
601
  * @param {import('uWebSockets.js').HttpResponse} res
583
602
  * @param {number} limit
@@ -998,7 +1017,8 @@ async function handleSSR(res, method, url, headers, remoteAddress, state) {
998
1017
  statusText: shared.statusText,
999
1018
  headers: shared.headers
1000
1019
  }),
1001
- state
1020
+ state,
1021
+ headers['accept-encoding']
1002
1022
  );
1003
1023
  return;
1004
1024
  }
@@ -1024,7 +1044,7 @@ async function handleSSR(res, method, url, headers, remoteAddress, state) {
1024
1044
  // leader's content to waiters that may legitimately differ.
1025
1045
  if (response.headers.has('set-cookie') || !response.body) {
1026
1046
  resolveShared(null);
1027
- await writeResponse(res, response, state);
1047
+ await writeResponse(res, response, state, headers['accept-encoding']);
1028
1048
  return;
1029
1049
  }
1030
1050
  const varyHeader = response.headers.get('vary');
@@ -1034,7 +1054,7 @@ async function handleSSR(res, method, url, headers, remoteAddress, state) {
1034
1054
  );
1035
1055
  if (personalized) {
1036
1056
  resolveShared(null);
1037
- await writeResponse(res, response, state);
1057
+ await writeResponse(res, response, state, headers['accept-encoding']);
1038
1058
  return;
1039
1059
  }
1040
1060
  }
@@ -1062,7 +1082,8 @@ async function handleSSR(res, method, url, headers, remoteAddress, state) {
1062
1082
  statusText: response.statusText,
1063
1083
  headers: response.headers
1064
1084
  }),
1065
- state
1085
+ state,
1086
+ headers['accept-encoding']
1066
1087
  );
1067
1088
  } catch (err) {
1068
1089
  resolveShared(null);
@@ -1075,7 +1096,7 @@ async function handleSSR(res, method, url, headers, remoteAddress, state) {
1075
1096
  // Normal (non-dedup) path
1076
1097
  const response = await server.respond(request, { platform, getClientAddress });
1077
1098
  if (state.aborted) return;
1078
- await writeResponse(res, response, state);
1099
+ await writeResponse(res, response, state, headers['accept-encoding']);
1079
1100
  } catch (err) {
1080
1101
  if (state.aborted) return;
1081
1102
  if (err instanceof PayloadTooLargeError) {
@@ -1109,8 +1130,9 @@ function writeHeaders(res, response) {
1109
1130
  * @param {import('uWebSockets.js').HttpResponse} res
1110
1131
  * @param {Response} response
1111
1132
  * @param {{ aborted: boolean }} state
1133
+ * @param {string} [acceptEncoding]
1112
1134
  */
1113
- async function writeResponse(res, response, state) {
1135
+ async function writeResponse(res, response, state, acceptEncoding) {
1114
1136
  // No body - write headers + end in a single cork (one syscall).
1115
1137
  // For HEAD responses SvelteKit sets Content-Length to the full body size;
1116
1138
  // pass it to endWithoutBody() so the client knows the entity size.
@@ -1153,9 +1175,34 @@ async function writeResponse(res, response, state) {
1153
1175
  if (second.done || state.aborted) {
1154
1176
  // Single-chunk response (common for SSR) - one cork, one syscall
1155
1177
  if (!state.aborted) {
1178
+ let body = first.value;
1179
+ let encoding = '';
1180
+ if (acceptEncoding && body.byteLength >= COMPRESS_MIN_SIZE &&
1181
+ !response.headers.has('content-encoding')) {
1182
+ const ctRaw = response.headers.get('content-type') || '';
1183
+ const semi = ctRaw.indexOf(';');
1184
+ const ct = semi === -1 ? ctRaw : ctRaw.slice(0, semi).trimEnd();
1185
+ if (COMPRESSIBLE_TYPES.has(ct)) {
1186
+ const useBr = acceptEncoding.includes('br');
1187
+ const useGz = !useBr && acceptEncoding.includes('gzip');
1188
+ if (useBr || useGz) {
1189
+ const compressed = useBr
1190
+ ? brotliCompressSync(body, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 } })
1191
+ : gzipSync(body, { level: 6 });
1192
+ if (compressed.byteLength < body.byteLength) {
1193
+ body = compressed;
1194
+ encoding = useBr ? 'br' : 'gzip';
1195
+ }
1196
+ }
1197
+ }
1198
+ }
1156
1199
  res.cork(() => {
1157
1200
  writeHeaders(res, response);
1158
- res.end(first.value);
1201
+ if (encoding) {
1202
+ res.writeHeader('content-encoding', encoding);
1203
+ res.writeHeader('vary', 'Accept-Encoding');
1204
+ }
1205
+ res.end(body);
1159
1206
  });
1160
1207
  }
1161
1208
  return;
@@ -1497,10 +1544,10 @@ if (WS_ENABLED) {
1497
1544
  }
1498
1545
 
1499
1546
  Promise.resolve(wsModule.upgrade({ headers, cookies, url, remoteAddress: clientIp }))
1500
- .then((userData) => {
1547
+ .then((result) => {
1501
1548
  clearTimeout(timer);
1502
1549
  if (aborted || timedOut) return;
1503
- if (userData === false) {
1550
+ if (result === false) {
1504
1551
  res.cork(() => {
1505
1552
  res.writeStatus('401 Unauthorized');
1506
1553
  res.writeHeader('content-type', 'text/plain');
@@ -1508,6 +1555,15 @@ if (WS_ENABLED) {
1508
1555
  });
1509
1556
  return;
1510
1557
  }
1558
+ // Unpack upgradeResponse() wrapper if present
1559
+ let responseHeaders = null;
1560
+ let userData;
1561
+ if (result && result.__upgradeResponse === true) {
1562
+ userData = result.userData || {};
1563
+ responseHeaders = result.headers;
1564
+ } else {
1565
+ userData = result || {};
1566
+ }
1511
1567
  // Warn once per unique key name about potentially sensitive data in userData.
1512
1568
  // userData is readable by every server-side handler via ws.getUserData().
1513
1569
  if (userData && typeof userData === 'object') {
@@ -1525,12 +1581,18 @@ if (WS_ENABLED) {
1525
1581
  }
1526
1582
  }
1527
1583
  }
1528
- // Ensure remoteAddress is in userData so plugins (e.g. ratelimit)
1529
- // can key on the real client IP without requiring the app to
1530
- // manually copy it from the upgrade callback arguments.
1531
1584
  const ud = userData || {};
1532
1585
  if (!ud.remoteAddress) ud.remoteAddress = clientIp;
1533
1586
  res.cork(() => {
1587
+ if (responseHeaders) {
1588
+ for (const [hk, hv] of Object.entries(responseHeaders)) {
1589
+ if (Array.isArray(hv)) {
1590
+ for (const v of hv) res.writeHeader(hk, v);
1591
+ } else {
1592
+ res.writeHeader(hk, hv);
1593
+ }
1594
+ }
1595
+ }
1534
1596
  res.upgrade(
1535
1597
  ud,
1536
1598
  secKey,
@@ -1705,7 +1767,8 @@ export function start(host, port) {
1705
1767
  app.listen(host, port, (socket) => {
1706
1768
  if (socket) {
1707
1769
  listenSocket = socket;
1708
- console.log(`Listening on ${is_tls ? 'https' : 'http'}://${host}:${port}`);
1770
+ const startup = (performance.now() - _t_app).toFixed(0);
1771
+ console.log(`Listening on ${is_tls ? 'https' : 'http'}://${host}:${port} (ready in ${startup}ms)`);
1709
1772
  } else {
1710
1773
  console.error(`Failed to listen on ${host}:${port}`);
1711
1774
  process.exit(1);
package/index.d.ts CHANGED
@@ -303,7 +303,10 @@ export interface WebSocketHandler<UserData = unknown> {
303
303
  *
304
304
  * May be async.
305
305
  */
306
- upgrade?: (ctx: UpgradeContext) => UserData | false | Promise<UserData | false>;
306
+ upgrade?: (ctx: UpgradeContext) =>
307
+ | UserData | false
308
+ | ReturnType<typeof upgradeResponse<UserData>>
309
+ | Promise<UserData | false | ReturnType<typeof upgradeResponse<UserData>>>;
307
310
 
308
311
  /** Called when a WebSocket connection is established. */
309
312
  open?: (ws: WebSocket<UserData>, ctx: OpenContext) => void;
@@ -533,4 +536,27 @@ export interface TopicHelper {
533
536
  decrement(amount?: number): void;
534
537
  }
535
538
 
539
+ /**
540
+ * Wrap upgrade hook return value to include response headers on the 101
541
+ * Switching Protocols response (e.g. `Set-Cookie` for session refresh).
542
+ *
543
+ * @example
544
+ * ```js
545
+ * import { upgradeResponse } from 'svelte-adapter-uws';
546
+ *
547
+ * export function upgrade({ cookies }) {
548
+ * const session = validateSession(cookies.session_id);
549
+ * if (!session) return false;
550
+ * return upgradeResponse(
551
+ * { userId: session.userId },
552
+ * { 'set-cookie': refreshSessionCookie(session) }
553
+ * );
554
+ * }
555
+ * ```
556
+ */
557
+ export function upgradeResponse<UserData>(
558
+ userData: UserData,
559
+ headers: Record<string, string | string[]>
560
+ ): { __upgradeResponse: true; userData: UserData; headers: Record<string, string | string[]> };
561
+
536
562
  export default function adapter(options?: AdapterOptions): Adapter;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.4.9",
3
+ "version": "0.4.10",
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",
@@ -18,6 +18,10 @@
18
18
  "types": "./index.d.ts",
19
19
  "default": "./index.js"
20
20
  },
21
+ "./upgrade-response": {
22
+ "types": "./index.d.ts",
23
+ "default": "./upgrade-response.js"
24
+ },
21
25
  "./client": {
22
26
  "types": "./client.d.ts",
23
27
  "default": "./client.js"
@@ -26,6 +30,10 @@
26
30
  "types": "./vite.d.ts",
27
31
  "default": "./vite.js"
28
32
  },
33
+ "./testing": {
34
+ "types": "./testing.d.ts",
35
+ "default": "./testing.js"
36
+ },
29
37
  "./plugins/replay": {
30
38
  "types": "./plugins/replay/server.d.ts",
31
39
  "default": "./plugins/replay/server.js"
@@ -87,6 +95,9 @@
87
95
  "files": [
88
96
  "index.js",
89
97
  "index.d.ts",
98
+ "upgrade-response.js",
99
+ "testing.js",
100
+ "testing.d.ts",
90
101
  "client.js",
91
102
  "client.d.ts",
92
103
  "vite.js",
package/testing.d.ts ADDED
@@ -0,0 +1,77 @@
1
+ import type { WebSocket } from 'uWebSockets.js';
2
+ import type { Platform, WebSocketHandler, UpgradeContext } from './index.js';
3
+
4
+ export interface TestServerOptions {
5
+ /** Port to listen on. Defaults to 0 (random available port). */
6
+ port?: number;
7
+ /** WebSocket endpoint path. @default '/ws' */
8
+ wsPath?: string;
9
+ /** WebSocket handler hooks (same shape as hooks.ws.ts exports). */
10
+ handler?: Partial<WebSocketHandler>;
11
+ }
12
+
13
+ export interface TestServer {
14
+ /** HTTP URL of the test server (e.g. 'http://localhost:12345'). */
15
+ url: string;
16
+ /** WebSocket URL of the test server (e.g. 'ws://localhost:12345/ws'). */
17
+ wsUrl: string;
18
+ /** The port the server is listening on. */
19
+ port: number;
20
+ /** Platform API for publishing, sending, and querying connections. */
21
+ platform: Platform;
22
+ /** Stop the server and close all connections. */
23
+ close(): void;
24
+ /** Wait for a WebSocket client to connect. */
25
+ waitForConnection(timeout?: number): Promise<void>;
26
+ /** Wait for the next WebSocket message (after subscribe/unsubscribe handling). */
27
+ waitForMessage(timeout?: number): Promise<{ data: string; isBinary: boolean }>;
28
+ }
29
+
30
+ /**
31
+ * Create a lightweight test server backed by a real uWebSockets.js instance.
32
+ *
33
+ * Starts on a random port and provides a Platform-compatible API for
34
+ * publishing, sending, and asserting on WebSocket behavior. The server
35
+ * uses the same subscribe/unsubscribe protocol as the production handler.
36
+ *
37
+ * @example
38
+ * ```js
39
+ * import { createTestServer } from 'svelte-adapter-uws/testing';
40
+ * import { describe, it, expect, afterEach } from 'vitest';
41
+ *
42
+ * let server;
43
+ * afterEach(() => server?.close());
44
+ *
45
+ * it('rejects unauthenticated upgrades', async () => {
46
+ * server = await createTestServer({
47
+ * handler: {
48
+ * upgrade({ cookies }) {
49
+ * return cookies.session ? { id: 'user-1' } : false;
50
+ * }
51
+ * }
52
+ * });
53
+ *
54
+ * const res = await fetch(server.wsUrl, {
55
+ * headers: { upgrade: 'websocket', connection: 'upgrade' }
56
+ * });
57
+ * expect(res.status).toBe(401);
58
+ * });
59
+ *
60
+ * it('broadcasts to subscribers', async () => {
61
+ * server = await createTestServer();
62
+ * const ws = new WebSocket(server.wsUrl);
63
+ * await server.waitForConnection();
64
+ *
65
+ * ws.send(JSON.stringify({ type: 'subscribe', topic: 'chat' }));
66
+ * // small delay for subscribe to process
67
+ * await new Promise(r => setTimeout(r, 10));
68
+ *
69
+ * server.platform.publish('chat', 'new-message', { text: 'hello' });
70
+ * const msg = await server.waitForMessage();
71
+ * expect(JSON.parse(msg.data)).toMatchObject({
72
+ * topic: 'chat', event: 'new-message', data: { text: 'hello' }
73
+ * });
74
+ * });
75
+ * ```
76
+ */
77
+ export function createTestServer(options?: TestServerOptions): Promise<TestServer>;
package/testing.js ADDED
@@ -0,0 +1,267 @@
1
+ import { parseCookies } from './files/cookies.js';
2
+
3
+ /**
4
+ * Safely quote a string for JSON embedding. Throws on invalid characters.
5
+ * @param {string} s
6
+ * @returns {string}
7
+ */
8
+ function esc(s) {
9
+ for (let i = 0; i < s.length; i++) {
10
+ const c = s.charCodeAt(i);
11
+ if (c < 32 || c === 34 || c === 92) {
12
+ throw new Error(
13
+ `Topic/event name contains invalid character at index ${i}: '${s}'. ` +
14
+ 'Names must not contain quotes, backslashes, or control characters.'
15
+ );
16
+ }
17
+ }
18
+ return '"' + s + '"';
19
+ }
20
+
21
+ /**
22
+ * Build a JSON envelope string matching the production wire format.
23
+ * @param {string} topic
24
+ * @param {string} event
25
+ * @param {unknown} [data]
26
+ * @returns {string}
27
+ */
28
+ function envelope(topic, event, data) {
29
+ return '{"topic":' + esc(topic) + ',"event":' + esc(event) + ',"data":' + JSON.stringify(data ?? null) + '}';
30
+ }
31
+
32
+ /**
33
+ * Create a lightweight test server backed by a real uWebSockets.js instance.
34
+ *
35
+ * Starts on a random port and provides a Platform-compatible API for
36
+ * publishing, sending, and asserting on WebSocket behavior.
37
+ *
38
+ * @param {import('./testing.js').TestServerOptions} [options]
39
+ * @returns {Promise<import('./testing.js').TestServer>}
40
+ */
41
+ export async function createTestServer(options = {}) {
42
+ const { port = 0, wsPath = '/ws', handler = {} } = options;
43
+
44
+ let uWS;
45
+ try {
46
+ uWS = (await import('uWebSockets.js')).default;
47
+ } catch {
48
+ throw new Error(
49
+ 'createTestServer requires uWebSockets.js to be installed.\n' +
50
+ ' npm install uNetworking/uWebSockets.js#v20.60.0'
51
+ );
52
+ }
53
+
54
+ const app = uWS.App();
55
+
56
+ /** @type {Set<import('uWebSockets.js').WebSocket<any>>} */
57
+ const wsConnections = new Set();
58
+
59
+ /** @type {Array<(value: any) => void>} */
60
+ let connectionWaiters = [];
61
+
62
+ /** @type {Array<{ resolve: (value: any) => void, timer: ReturnType<typeof setTimeout> }>} */
63
+ let messageWaiters = [];
64
+
65
+ const platform = {
66
+ publish(topic, event, data) {
67
+ const msg = envelope(topic, event, data);
68
+ return app.publish(topic, msg, false, false);
69
+ },
70
+ send(ws, topic, event, data) {
71
+ return ws.send(envelope(topic, event, data), false, false);
72
+ },
73
+ sendTo(filter, topic, event, data) {
74
+ const msg = envelope(topic, event, data);
75
+ let count = 0;
76
+ for (const ws of wsConnections) {
77
+ if (filter(ws.getUserData())) {
78
+ ws.send(msg, false, false);
79
+ count++;
80
+ }
81
+ }
82
+ return count;
83
+ },
84
+ get connections() { return wsConnections.size; },
85
+ subscribers(topic) { return app.numSubscribers(topic); },
86
+ batch(messages) {
87
+ return messages.map(({ topic, event, data }) => platform.publish(topic, event, data));
88
+ },
89
+ topic(name) {
90
+ return {
91
+ publish: (event, data) => platform.publish(name, event, data),
92
+ created: (data) => platform.publish(name, 'created', data),
93
+ updated: (data) => platform.publish(name, 'updated', data),
94
+ deleted: (data) => platform.publish(name, 'deleted', data),
95
+ set: (value) => platform.publish(name, 'set', value),
96
+ increment: (amount = 1) => platform.publish(name, 'increment', amount),
97
+ decrement: (amount = 1) => platform.publish(name, 'decrement', amount)
98
+ };
99
+ }
100
+ };
101
+
102
+ app.ws(wsPath, {
103
+ maxPayloadLength: 64 * 1024,
104
+ idleTimeout: 120,
105
+ sendPingsAutomatically: true,
106
+
107
+ upgrade(res, req, context) {
108
+ const headers = {};
109
+ req.forEach((k, v) => { headers[k] = v; });
110
+ const secKey = req.getHeader('sec-websocket-key');
111
+ const secProtocol = req.getHeader('sec-websocket-protocol');
112
+ const secExtensions = req.getHeader('sec-websocket-extensions');
113
+ const query = req.getQuery();
114
+ const url = query ? req.getUrl() + '?' + query : req.getUrl();
115
+ const rawIp = new TextDecoder().decode(res.getRemoteAddressAsText());
116
+
117
+ if (!handler.upgrade) {
118
+ res.cork(() => {
119
+ res.upgrade({ remoteAddress: rawIp }, secKey, secProtocol, secExtensions, context);
120
+ });
121
+ return;
122
+ }
123
+
124
+ let aborted = false;
125
+ res.onAborted(() => { aborted = true; });
126
+
127
+ const cookies = parseCookies(headers['cookie']);
128
+ Promise.resolve(handler.upgrade({ headers, cookies, url, remoteAddress: rawIp }))
129
+ .then((result) => {
130
+ if (aborted) return;
131
+ if (result === false) {
132
+ res.cork(() => {
133
+ res.writeStatus('401 Unauthorized');
134
+ res.writeHeader('content-type', 'text/plain');
135
+ res.end('Unauthorized');
136
+ });
137
+ return;
138
+ }
139
+ let userData;
140
+ let responseHeaders = null;
141
+ if (result && result.__upgradeResponse === true) {
142
+ userData = result.userData || {};
143
+ responseHeaders = result.headers;
144
+ } else {
145
+ userData = result || {};
146
+ }
147
+ if (!userData.remoteAddress) userData.remoteAddress = rawIp;
148
+ res.cork(() => {
149
+ if (responseHeaders) {
150
+ for (const [hk, hv] of Object.entries(responseHeaders)) {
151
+ if (Array.isArray(hv)) {
152
+ for (const v of hv) res.writeHeader(hk, v);
153
+ } else {
154
+ res.writeHeader(hk, hv);
155
+ }
156
+ }
157
+ }
158
+ res.upgrade(userData, secKey, secProtocol, secExtensions, context);
159
+ });
160
+ })
161
+ .catch((err) => {
162
+ if (!aborted) {
163
+ res.cork(() => {
164
+ res.writeStatus('500 Internal Server Error');
165
+ res.writeHeader('content-type', 'text/plain');
166
+ res.end('Internal Server Error');
167
+ });
168
+ }
169
+ });
170
+ },
171
+
172
+ open(ws) {
173
+ ws.getUserData().__subscriptions = new Set();
174
+ wsConnections.add(ws);
175
+ handler.open?.(ws, { platform });
176
+ for (const resolve of connectionWaiters) resolve(undefined);
177
+ connectionWaiters = [];
178
+ },
179
+
180
+ message(ws, message, isBinary) {
181
+ // Handle subscribe/unsubscribe from client store
182
+ if (!isBinary && message.byteLength < 8192) {
183
+ const bytes = new Uint8Array(message);
184
+ if (bytes[3] === 0x79) {
185
+ try {
186
+ const msg = JSON.parse(Buffer.from(message).toString());
187
+ if (msg.type === 'subscribe' && typeof msg.topic === 'string') {
188
+ if (msg.topic.length === 0 || msg.topic.length > 256) return;
189
+ if (handler.subscribe && handler.subscribe(ws, msg.topic, { platform }) === false) return;
190
+ ws.subscribe(msg.topic);
191
+ ws.getUserData().__subscriptions?.add(msg.topic);
192
+ return;
193
+ }
194
+ if (msg.type === 'unsubscribe' && typeof msg.topic === 'string') {
195
+ ws.unsubscribe(msg.topic);
196
+ ws.getUserData().__subscriptions?.delete(msg.topic);
197
+ handler.unsubscribe?.(ws, msg.topic, { platform });
198
+ return;
199
+ }
200
+ if (msg.type === 'subscribe-batch' && Array.isArray(msg.topics)) {
201
+ for (const topic of msg.topics.slice(0, 256)) {
202
+ if (typeof topic !== 'string' || topic.length === 0 || topic.length > 256) continue;
203
+ if (handler.subscribe && handler.subscribe(ws, topic, { platform }) === false) continue;
204
+ ws.subscribe(topic);
205
+ ws.getUserData().__subscriptions?.add(topic);
206
+ }
207
+ return;
208
+ }
209
+ } catch {}
210
+ }
211
+ }
212
+
213
+ for (const waiter of messageWaiters) {
214
+ clearTimeout(waiter.timer);
215
+ waiter.resolve({ data: Buffer.from(message).toString(), isBinary });
216
+ }
217
+ messageWaiters = [];
218
+
219
+ handler.message?.(ws, { data: message, isBinary, platform });
220
+ },
221
+
222
+ close(ws, code, message) {
223
+ const subs = ws.getUserData()?.__subscriptions || new Set();
224
+ handler.close?.(ws, { code, message, platform, subscriptions: subs });
225
+ wsConnections.delete(ws);
226
+ }
227
+ });
228
+
229
+ return new Promise((resolve, reject) => {
230
+ app.listen(port, (listenSocket) => {
231
+ if (!listenSocket) return reject(new Error('Failed to listen'));
232
+ const boundPort = uWS.us_socket_local_port(listenSocket);
233
+ resolve({
234
+ url: `http://localhost:${boundPort}`,
235
+ wsUrl: `ws://localhost:${boundPort}${wsPath}`,
236
+ port: boundPort,
237
+ platform,
238
+ close() {
239
+ for (const ws of wsConnections) ws.close(1001, 'Test server closing');
240
+ wsConnections.clear();
241
+ uWS.us_listen_socket_close(listenSocket);
242
+ },
243
+ waitForConnection(timeout = 5000) {
244
+ return new Promise((resolve, reject) => {
245
+ const timer = setTimeout(
246
+ () => reject(new Error('waitForConnection timed out')),
247
+ timeout
248
+ );
249
+ connectionWaiters.push(() => { clearTimeout(timer); resolve(undefined); });
250
+ });
251
+ },
252
+ waitForMessage(timeout = 5000) {
253
+ return new Promise((resolve, reject) => {
254
+ const timer = setTimeout(
255
+ () => {
256
+ messageWaiters = messageWaiters.filter(w => w.timer !== timer);
257
+ reject(new Error('waitForMessage timed out'));
258
+ },
259
+ timeout
260
+ );
261
+ messageWaiters.push({ resolve(v) { clearTimeout(timer); resolve(v); }, timer });
262
+ });
263
+ }
264
+ });
265
+ });
266
+ });
267
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Wrap upgrade hook return value to include response headers on the 101
3
+ * Switching Protocols response (e.g. Set-Cookie for session refresh).
4
+ *
5
+ * @template T
6
+ * @param {T} userData - Data attached to ws.getUserData()
7
+ * @param {Record<string, string | string[]>} headers - Headers for the 101 response
8
+ * @returns {{ __upgradeResponse: true, userData: T, headers: Record<string, string | string[]> }}
9
+ */
10
+ export function upgradeResponse(userData, headers) {
11
+ return { __upgradeResponse: true, userData, headers };
12
+ }
package/vite.js CHANGED
@@ -367,7 +367,14 @@ export default function uws(options = {}) {
367
367
  socket.destroy();
368
368
  return;
369
369
  }
370
- userData = result || {};
370
+ if (result && result.__upgradeResponse === true) {
371
+ userData = result.userData || {};
372
+ 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.');
374
+ }
375
+ } else {
376
+ userData = result || {};
377
+ }
371
378
  } catch (err) {
372
379
  console.error('[adapter-uws] WebSocket upgrade error:', err);
373
380
  socket.write('HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\n\r\nInternal Server Error');