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 +50 -2
- package/files/handler.js +76 -13
- package/index.d.ts +27 -1
- package/package.json +12 -1
- package/testing.d.ts +77 -0
- package/testing.js +267 -0
- package/upgrade-response.js +12 -0
- package/vite.js +8 -1
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 #
|
|
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,
|
|
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
|
-
|
|
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((
|
|
1547
|
+
.then((result) => {
|
|
1501
1548
|
clearTimeout(timer);
|
|
1502
1549
|
if (aborted || timedOut) return;
|
|
1503
|
-
if (
|
|
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
|
-
|
|
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) =>
|
|
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.
|
|
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
|
-
|
|
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');
|