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 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, no dev server restart needed.
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
- if (debug) console.warn('[ws] queue full, dropping oldest message');
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('md5').update(buffer).digest('hex').slice(0, 12)}"`;
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
- res.onWritable(() => { resolve(undefined); return true; })
708
- );
709
- if (state.aborted) break;
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
- // Non-browser clients (no Origin header) are always allowed.
817
- const reqOrigin = headers['origin'];
818
- if (reqOrigin && allowedOrigins !== '*') {
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 (allowedOrigins === 'same-origin') {
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
- wsConnections.delete(ws);
968
- wsModule.close?.(ws, { code, message, platform });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.2.25",
3
+ "version": "0.3.0",
4
4
  "description": "SvelteKit adapter for uWebSockets.js - high-performance C++ HTTP server with built-in WebSocket support",
5
5
  "author": "Kevin Radziszewski",
6
6
  "license": "MIT",
package/vite.js CHANGED
@@ -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
- console.log('[adapter-uws] WebSocket handler reloaded');
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;