svelte-adapter-uws 0.2.25 → 0.3.0
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 +3 -1
- package/client.js +6 -1
- package/files/handler.js +62 -12
- package/files/index.js +10 -2
- package/package.json +1 -1
- package/vite.js +11 -1
package/README.md
CHANGED
|
@@ -289,7 +289,7 @@ The client store automatically uses `wss://` when the page is served over HTTPS
|
|
|
289
289
|
|
|
290
290
|
The Vite plugin is required for WebSocket support in both dev and production (see [Step 2](#step-2-add-the-vite-plugin-required)). It spins up a `ws` WebSocket server alongside Vite's dev server, so your client store and `event.platform` work identically to production.
|
|
291
291
|
|
|
292
|
-
Changes to your `hooks.ws` file are picked up automatically — the plugin reloads the handler on save
|
|
292
|
+
Changes to your `hooks.ws` file are picked up automatically — the plugin reloads the handler on save and closes existing connections so they reconnect with the new code. No dev server restart needed.
|
|
293
293
|
|
|
294
294
|
**Note:** The dev server does not enforce `allowedOrigins`. Origin checks only run in production. A warning is logged at startup as a reminder.
|
|
295
295
|
|
|
@@ -388,6 +388,8 @@ adapter({
|
|
|
388
388
|
// 'same-origin' - only accept where Origin matches Host and scheme (default)
|
|
389
389
|
// '*' - accept from any origin
|
|
390
390
|
// ['https://example.com'] - whitelist specific origins
|
|
391
|
+
// Requests without an Origin header (non-browser clients) are rejected
|
|
392
|
+
// unless an upgrade handler is configured to authenticate them.
|
|
391
393
|
allowedOrigins: 'same-origin' // default: 'same-origin'
|
|
392
394
|
}
|
|
393
395
|
})
|
package/client.js
CHANGED
|
@@ -314,6 +314,11 @@ function createConnection(options) {
|
|
|
314
314
|
|
|
315
315
|
ws.onmessage = (rawEvent) => {
|
|
316
316
|
try {
|
|
317
|
+
// Reject oversized messages to prevent main-thread blocking
|
|
318
|
+
if (typeof rawEvent.data === 'string' && rawEvent.data.length > 1048576) {
|
|
319
|
+
if (debug) console.warn('[ws] message too large, dropped:', rawEvent.data.length, 'bytes');
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
317
322
|
const msg = JSON.parse(rawEvent.data);
|
|
318
323
|
if (msg.topic && msg.event !== undefined) {
|
|
319
324
|
/** @type {import('./client.js').WSEvent} */
|
|
@@ -548,7 +553,7 @@ function createConnection(options) {
|
|
|
548
553
|
ws.send(JSON.stringify(data));
|
|
549
554
|
} else {
|
|
550
555
|
if (sendQueue.length >= MAX_QUEUE_SIZE) {
|
|
551
|
-
|
|
556
|
+
console.warn('[ws] queue full (' + MAX_QUEUE_SIZE + '), dropping oldest message');
|
|
552
557
|
sendQueue.shift();
|
|
553
558
|
}
|
|
554
559
|
if (debug) console.log('[ws] queued ->', data);
|
package/files/handler.js
CHANGED
|
@@ -110,7 +110,7 @@ function cacheDir(dir, urlPrefix, immutable) {
|
|
|
110
110
|
if (immutable && relPath.startsWith(`${manifest.appPath}/immutable/`)) {
|
|
111
111
|
headers.push(['cache-control', 'public, max-age=31536000, immutable']);
|
|
112
112
|
} else {
|
|
113
|
-
etag = `W/"${createHash('
|
|
113
|
+
etag = `W/"${createHash('sha256').update(buffer).digest('hex').slice(0, 16)}"`;
|
|
114
114
|
headers.push(['cache-control', 'no-cache'], ['etag', etag]);
|
|
115
115
|
}
|
|
116
116
|
|
|
@@ -575,6 +575,10 @@ async function handleSSR(res, method, url, headers, remoteAddress, state, abortS
|
|
|
575
575
|
const value = headers[address_header] || '';
|
|
576
576
|
|
|
577
577
|
if (address_header === 'x-forwarded-for') {
|
|
578
|
+
// Reject absurdly long XFF headers (max ~8KB)
|
|
579
|
+
if (value.length > 8192) {
|
|
580
|
+
throw new Error('X-Forwarded-For header too large');
|
|
581
|
+
}
|
|
578
582
|
const addresses = value.split(',');
|
|
579
583
|
|
|
580
584
|
if (xff_depth > addresses.length) {
|
|
@@ -696,17 +700,18 @@ async function writeResponse(res, response, state) {
|
|
|
696
700
|
res.write(second.value);
|
|
697
701
|
});
|
|
698
702
|
|
|
699
|
-
// Stream remaining chunks with backpressure
|
|
703
|
+
// Stream remaining chunks with backpressure (30s timeout per drain)
|
|
700
704
|
for (;;) {
|
|
701
705
|
const { done, value } = await reader.read();
|
|
702
706
|
if (done || state.aborted) break;
|
|
703
707
|
|
|
704
708
|
const ok = res.write(value);
|
|
705
709
|
if (!ok) {
|
|
706
|
-
await new Promise((resolve) =>
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
+
const drained = await new Promise((resolve) => {
|
|
711
|
+
const timer = setTimeout(() => resolve(false), 30000);
|
|
712
|
+
res.onWritable(() => { clearTimeout(timer); resolve(true); return true; });
|
|
713
|
+
});
|
|
714
|
+
if (!drained || state.aborted) break;
|
|
710
715
|
}
|
|
711
716
|
}
|
|
712
717
|
} finally {
|
|
@@ -799,6 +804,19 @@ if (WS_ENABLED) {
|
|
|
799
804
|
const wsOptions = WS_OPTIONS;
|
|
800
805
|
const allowedOrigins = wsOptions.allowedOrigins || 'same-origin';
|
|
801
806
|
|
|
807
|
+
// Per-IP upgrade rate limiter: max 10 upgrades per 10 seconds
|
|
808
|
+
const UPGRADE_WINDOW_MS = 10000;
|
|
809
|
+
const UPGRADE_MAX_PER_WINDOW = 10;
|
|
810
|
+
/** @type {Map<string, { count: number, resetAt: number }>} */
|
|
811
|
+
const upgradeRateMap = new Map();
|
|
812
|
+
// Purge stale entries every 60s to prevent unbounded growth
|
|
813
|
+
setInterval(() => {
|
|
814
|
+
const now = Date.now();
|
|
815
|
+
for (const [ip, entry] of upgradeRateMap) {
|
|
816
|
+
if (now > entry.resetAt) upgradeRateMap.delete(ip);
|
|
817
|
+
}
|
|
818
|
+
}, 60000).unref();
|
|
819
|
+
|
|
802
820
|
app.ws(WS_PATH, {
|
|
803
821
|
// Handle HTTP -> WebSocket upgrade with user-provided auth
|
|
804
822
|
upgrade: (res, req, context) => {
|
|
@@ -808,16 +826,40 @@ if (WS_ENABLED) {
|
|
|
808
826
|
req.forEach((key, value) => {
|
|
809
827
|
headers[key] = value;
|
|
810
828
|
});
|
|
829
|
+
const upgradeIp = textDecoder.decode(res.getRemoteAddressAsText());
|
|
830
|
+
|
|
831
|
+
// Rate limit upgrade requests per IP
|
|
832
|
+
const now = Date.now();
|
|
833
|
+
let rateEntry = upgradeRateMap.get(upgradeIp);
|
|
834
|
+
if (!rateEntry || now > rateEntry.resetAt) {
|
|
835
|
+
rateEntry = { count: 0, resetAt: now + UPGRADE_WINDOW_MS };
|
|
836
|
+
upgradeRateMap.set(upgradeIp, rateEntry);
|
|
837
|
+
}
|
|
838
|
+
rateEntry.count++;
|
|
839
|
+
if (rateEntry.count > UPGRADE_MAX_PER_WINDOW) {
|
|
840
|
+
res.cork(() => {
|
|
841
|
+
res.writeStatus('429 Too Many Requests');
|
|
842
|
+
res.writeHeader('content-type', 'text/plain');
|
|
843
|
+
res.end('Too many upgrade requests');
|
|
844
|
+
});
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
|
|
811
848
|
const secKey = req.getHeader('sec-websocket-key');
|
|
812
849
|
const secProtocol = req.getHeader('sec-websocket-protocol');
|
|
813
850
|
const secExtensions = req.getHeader('sec-websocket-extensions');
|
|
814
851
|
|
|
815
852
|
// Origin validation - reject cross-origin WebSocket connections.
|
|
816
|
-
//
|
|
817
|
-
|
|
818
|
-
if (
|
|
853
|
+
// Requests without an Origin header are also rejected (non-browser
|
|
854
|
+
// clients must be authenticated via the upgrade handler instead).
|
|
855
|
+
if (allowedOrigins !== '*') {
|
|
856
|
+
const reqOrigin = headers['origin'];
|
|
819
857
|
let allowed = false;
|
|
820
|
-
if (
|
|
858
|
+
if (!reqOrigin) {
|
|
859
|
+
// No Origin header - reject unless an upgrade handler is
|
|
860
|
+
// configured (it can authenticate non-browser clients itself)
|
|
861
|
+
allowed = !!wsModule.upgrade;
|
|
862
|
+
} else if (allowedOrigins === 'same-origin') {
|
|
821
863
|
try {
|
|
822
864
|
const parsed = new URL(reqOrigin);
|
|
823
865
|
const requestHost = (host_header && headers[host_header]) || headers['host'];
|
|
@@ -942,6 +984,11 @@ if (WS_ENABLED) {
|
|
|
942
984
|
try {
|
|
943
985
|
const msg = JSON.parse(textDecoder.decode(message));
|
|
944
986
|
if (msg.type === 'subscribe' && typeof msg.topic === 'string') {
|
|
987
|
+
// Validate topic name: max 256 chars, no control characters
|
|
988
|
+
if (msg.topic.length === 0 || msg.topic.length > 256) return;
|
|
989
|
+
for (let i = 0; i < msg.topic.length; i++) {
|
|
990
|
+
if (msg.topic.charCodeAt(i) < 32) return;
|
|
991
|
+
}
|
|
945
992
|
// If a subscribe hook exists, let it gate access
|
|
946
993
|
if (wsModule.subscribe && wsModule.subscribe(ws, msg.topic, { platform }) === false) {
|
|
947
994
|
return;
|
|
@@ -964,8 +1011,11 @@ if (WS_ENABLED) {
|
|
|
964
1011
|
drain: wsModule.drain ? (ws) => wsModule.drain(ws, { platform }) : undefined,
|
|
965
1012
|
|
|
966
1013
|
close: (ws, code, message) => {
|
|
967
|
-
|
|
968
|
-
|
|
1014
|
+
try {
|
|
1015
|
+
wsModule.close?.(ws, { code, message, platform });
|
|
1016
|
+
} finally {
|
|
1017
|
+
wsConnections.delete(ws);
|
|
1018
|
+
}
|
|
969
1019
|
},
|
|
970
1020
|
|
|
971
1021
|
maxPayloadLength: wsOptions.maxPayloadLength,
|
package/files/index.js
CHANGED
|
@@ -45,6 +45,8 @@ if (is_primary) {
|
|
|
45
45
|
// Exponential backoff for crash-looping workers
|
|
46
46
|
let restart_delay = 0;
|
|
47
47
|
const RESTART_DELAY_MAX = 5000;
|
|
48
|
+
const RESTART_MAX_ATTEMPTS = 50;
|
|
49
|
+
let restart_attempts = 0;
|
|
48
50
|
/** @type {Set<ReturnType<typeof setTimeout>>} */
|
|
49
51
|
const restart_timers = new Set();
|
|
50
52
|
|
|
@@ -57,8 +59,9 @@ if (is_primary) {
|
|
|
57
59
|
workers.set(worker, msg.descriptor);
|
|
58
60
|
acceptorApp.addChildAppDescriptor(msg.descriptor);
|
|
59
61
|
console.log(`Worker thread ${worker.threadId} registered`);
|
|
60
|
-
// Worker started successfully - reset backoff
|
|
62
|
+
// Worker started successfully - reset backoff and attempt counter
|
|
61
63
|
restart_delay = 0;
|
|
64
|
+
restart_attempts = 0;
|
|
62
65
|
for (const t of restart_timers) clearTimeout(t);
|
|
63
66
|
restart_timers.clear();
|
|
64
67
|
// Start (or resume) listening once a worker is ready to handle requests
|
|
@@ -99,8 +102,13 @@ if (is_primary) {
|
|
|
99
102
|
listening = false;
|
|
100
103
|
console.log('All workers down, acceptor paused until a replacement is ready');
|
|
101
104
|
}
|
|
105
|
+
restart_attempts++;
|
|
106
|
+
if (restart_attempts > RESTART_MAX_ATTEMPTS) {
|
|
107
|
+
console.error(`Worker restart limit reached (${RESTART_MAX_ATTEMPTS}). Exiting.`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
102
110
|
restart_delay = restart_delay ? Math.min(restart_delay * 2, RESTART_DELAY_MAX) : 100;
|
|
103
|
-
console.log(`Worker thread ${worker.threadId} exited with code ${code}, restarting in ${restart_delay}ms
|
|
111
|
+
console.log(`Worker thread ${worker.threadId} exited with code ${code}, restarting in ${restart_delay}ms... (attempt ${restart_attempts}/${RESTART_MAX_ATTEMPTS})`);
|
|
104
112
|
const timer = setTimeout(() => {
|
|
105
113
|
restart_timers.delete(timer);
|
|
106
114
|
if (shutting_down) return;
|
package/package.json
CHANGED
package/vite.js
CHANGED
|
@@ -364,6 +364,11 @@ export default function uws(options = {}) {
|
|
|
364
364
|
try {
|
|
365
365
|
const msg = JSON.parse(buf.toString());
|
|
366
366
|
if (msg.type === 'subscribe' && typeof msg.topic === 'string') {
|
|
367
|
+
// Validate topic name: max 256 chars, no control characters
|
|
368
|
+
if (msg.topic.length === 0 || msg.topic.length > 256) return;
|
|
369
|
+
for (let ci = 0; ci < msg.topic.length; ci++) {
|
|
370
|
+
if (msg.topic.charCodeAt(ci) < 32) return;
|
|
371
|
+
}
|
|
367
372
|
if (userHandlers.subscribe && userHandlers.subscribe(wrapped, msg.topic, { platform }) === false) {
|
|
368
373
|
return;
|
|
369
374
|
}
|
|
@@ -416,7 +421,12 @@ export default function uws(options = {}) {
|
|
|
416
421
|
mod.drain !== userHandlers.drain ||
|
|
417
422
|
mod.subscribe !== userHandlers.subscribe) {
|
|
418
423
|
applyHandlers(mod);
|
|
419
|
-
|
|
424
|
+
// Close existing connections so they reconnect with the new handler.
|
|
425
|
+
// 1012 = "Service Restart" - clients with auto-reconnect will reconnect.
|
|
426
|
+
for (const ws of connections) {
|
|
427
|
+
ws.close(1012, 'Handler reloaded');
|
|
428
|
+
}
|
|
429
|
+
console.log('[adapter-uws] WebSocket handler reloaded, existing connections closed');
|
|
420
430
|
}
|
|
421
431
|
}).catch((err) => {
|
|
422
432
|
handlerFailed = true;
|