svelte-adapter-uws 0.2.25 → 0.3.1

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,21 @@ 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 (configurable, 0 = disabled)
808
+ const UPGRADE_MAX_PER_WINDOW = wsOptions.upgradeRateLimit ?? 10;
809
+ const UPGRADE_WINDOW_MS = (wsOptions.upgradeRateLimitWindow ?? 10) * 1000;
810
+ /** @type {Map<string, { count: number, resetAt: number }>} */
811
+ const upgradeRateMap = new Map();
812
+ if (UPGRADE_MAX_PER_WINDOW > 0) {
813
+ // Purge stale entries every 60s to prevent unbounded growth
814
+ setInterval(() => {
815
+ const now = Date.now();
816
+ for (const [ip, entry] of upgradeRateMap) {
817
+ if (now > entry.resetAt) upgradeRateMap.delete(ip);
818
+ }
819
+ }, 60000).unref();
820
+ }
821
+
802
822
  app.ws(WS_PATH, {
803
823
  // Handle HTTP -> WebSocket upgrade with user-provided auth
804
824
  upgrade: (res, req, context) => {
@@ -808,16 +828,42 @@ if (WS_ENABLED) {
808
828
  req.forEach((key, value) => {
809
829
  headers[key] = value;
810
830
  });
831
+ const upgradeIp = textDecoder.decode(res.getRemoteAddressAsText());
832
+
833
+ // Rate limit upgrade requests per IP (0 = disabled)
834
+ if (UPGRADE_MAX_PER_WINDOW > 0) {
835
+ const now = Date.now();
836
+ let rateEntry = upgradeRateMap.get(upgradeIp);
837
+ if (!rateEntry || now > rateEntry.resetAt) {
838
+ rateEntry = { count: 0, resetAt: now + UPGRADE_WINDOW_MS };
839
+ upgradeRateMap.set(upgradeIp, rateEntry);
840
+ }
841
+ rateEntry.count++;
842
+ if (rateEntry.count > UPGRADE_MAX_PER_WINDOW) {
843
+ res.cork(() => {
844
+ res.writeStatus('429 Too Many Requests');
845
+ res.writeHeader('content-type', 'text/plain');
846
+ res.end('Too many upgrade requests');
847
+ });
848
+ return;
849
+ }
850
+ }
851
+
811
852
  const secKey = req.getHeader('sec-websocket-key');
812
853
  const secProtocol = req.getHeader('sec-websocket-protocol');
813
854
  const secExtensions = req.getHeader('sec-websocket-extensions');
814
855
 
815
856
  // 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 !== '*') {
857
+ // Requests without an Origin header are also rejected (non-browser
858
+ // clients must be authenticated via the upgrade handler instead).
859
+ if (allowedOrigins !== '*') {
860
+ const reqOrigin = headers['origin'];
819
861
  let allowed = false;
820
- if (allowedOrigins === 'same-origin') {
862
+ if (!reqOrigin) {
863
+ // No Origin header - reject unless an upgrade handler is
864
+ // configured (it can authenticate non-browser clients itself)
865
+ allowed = !!wsModule.upgrade;
866
+ } else if (allowedOrigins === 'same-origin') {
821
867
  try {
822
868
  const parsed = new URL(reqOrigin);
823
869
  const requestHost = (host_header && headers[host_header]) || headers['host'];
@@ -942,6 +988,11 @@ if (WS_ENABLED) {
942
988
  try {
943
989
  const msg = JSON.parse(textDecoder.decode(message));
944
990
  if (msg.type === 'subscribe' && typeof msg.topic === 'string') {
991
+ // Validate topic name: max 256 chars, no control characters
992
+ if (msg.topic.length === 0 || msg.topic.length > 256) return;
993
+ for (let i = 0; i < msg.topic.length; i++) {
994
+ if (msg.topic.charCodeAt(i) < 32) return;
995
+ }
945
996
  // If a subscribe hook exists, let it gate access
946
997
  if (wsModule.subscribe && wsModule.subscribe(ws, msg.topic, { platform }) === false) {
947
998
  return;
@@ -964,8 +1015,11 @@ if (WS_ENABLED) {
964
1015
  drain: wsModule.drain ? (ws) => wsModule.drain(ws, { platform }) : undefined,
965
1016
 
966
1017
  close: (ws, code, message) => {
967
- wsConnections.delete(ws);
968
- wsModule.close?.(ws, { code, message, platform });
1018
+ try {
1019
+ wsModule.close?.(ws, { code, message, platform });
1020
+ } finally {
1021
+ wsConnections.delete(ws);
1022
+ }
969
1023
  },
970
1024
 
971
1025
  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/index.d.ts CHANGED
@@ -166,11 +166,26 @@ export interface WebSocketOptions {
166
166
  * - `'*'` - accept connections from any origin
167
167
  * - `string[]` - whitelist of allowed origin URLs (e.g. `['https://example.com']`)
168
168
  *
169
- * Non-browser clients (no Origin header) are always allowed.
169
+ * Requests without an Origin header (non-browser clients) are rejected
170
+ * unless an upgrade handler is configured to authenticate them.
170
171
  *
171
172
  * @default 'same-origin'
172
173
  */
173
174
  allowedOrigins?: 'same-origin' | '*' | string[];
175
+
176
+ /**
177
+ * Maximum number of WebSocket upgrade requests allowed per IP address
178
+ * within `upgradeRateLimitWindow` seconds.
179
+ * Set to `0` to disable upgrade rate limiting.
180
+ * @default 10
181
+ */
182
+ upgradeRateLimit?: number;
183
+
184
+ /**
185
+ * Time window in seconds for the upgrade rate limiter.
186
+ * @default 10
187
+ */
188
+ upgradeRateLimitWindow?: number;
174
189
  }
175
190
 
176
191
  // -- User's WebSocket handler module exports ---------------------------------
package/index.js CHANGED
@@ -221,7 +221,9 @@ export default function (opts = {}) {
221
221
  sendPingsAutomatically: websocket?.sendPingsAutomatically ?? true,
222
222
  compression: websocket?.compression ?? false,
223
223
  allowedOrigins: websocket?.allowedOrigins ?? 'same-origin',
224
- upgradeTimeout: websocket?.upgradeTimeout ?? 10
224
+ upgradeTimeout: websocket?.upgradeTimeout ?? 10,
225
+ upgradeRateLimit: websocket?.upgradeRateLimit ?? 10,
226
+ upgradeRateLimitWindow: websocket?.upgradeRateLimitWindow ?? 10
225
227
  };
226
228
 
227
229
  builder.copy(files, out, {
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.1",
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;