svelte-adapter-uws 0.2.0 → 0.2.2

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
@@ -33,7 +33,7 @@ I've been loving Svelte and SvelteKit for a long time. I always wanted to expand
33
33
  - [TypeScript setup](#typescript-setup)
34
34
  - [Svelte 4 support](#svelte-4-support)
35
35
  - [Deploying with Docker](#deploying-with-docker)
36
- - [Clustering (Linux)](#clustering-linux)
36
+ - [Clustering](#clustering)
37
37
  - [Performance](#performance)
38
38
  - [Troubleshooting](#troubleshooting)
39
39
  - [License](#license)
@@ -383,7 +383,7 @@ If you set `envPrefix: 'MY_APP_'` in the adapter config, all variables are prefi
383
383
  | `XFF_DEPTH` | `1` | Position from right in `X-Forwarded-For` |
384
384
  | `BODY_SIZE_LIMIT` | `512K` | Max request body size (supports `K`, `M`, `G` suffixes) |
385
385
  | `SHUTDOWN_TIMEOUT` | `30` | Seconds to wait during graceful shutdown |
386
- | `CLUSTER_WORKERS` | - | Number of worker processes (or `auto` for CPU count). Linux only, HTTP only |
386
+ | `CLUSTER_WORKERS` | - | Number of worker threads (or `auto` for CPU count) |
387
387
 
388
388
  ### Graceful shutdown
389
389
 
@@ -657,7 +657,9 @@ Available in server hooks, load functions, form actions, and API routes.
657
657
 
658
658
  ### `platform.publish(topic, event, data)`
659
659
 
660
- Send a message to all WebSocket clients subscribed to a topic:
660
+ Send a message to all WebSocket clients subscribed to a topic.
661
+
662
+ Topic and event names are validated before being written into the JSON envelope -- quotes, backslashes, and control characters will throw. This prevents JSON injection when names are built from dynamic values like user IDs (`platform.publish(\`user:\${id}\`, ...)`). The validation is a single-pass char scan and adds no measurable overhead.
661
663
 
662
664
  ```js
663
665
  // src/routes/todos/+page.server.js
@@ -1170,9 +1172,9 @@ docker run -p 3000:3000 \
1170
1172
 
1171
1173
  ---
1172
1174
 
1173
- ## Clustering (Linux)
1175
+ ## Clustering
1174
1176
 
1175
- On Linux, multiple processes can share the same port via `SO_REUSEPORT` - the kernel load-balances incoming connections across workers. This gives you near-linear scaling for HTTP/SSR workloads with zero coordination overhead.
1177
+ The adapter supports multi-core scaling via uWebSockets.js worker thread distribution. A primary thread creates an acceptor app that distributes incoming connections across worker threads, each running their own uWS instance. This works on **all platforms** (Linux, macOS, Windows).
1176
1178
 
1177
1179
  Set the `CLUSTER_WORKERS` environment variable to enable it:
1178
1180
 
@@ -1187,12 +1189,16 @@ CLUSTER_WORKERS=4 node build
1187
1189
  CLUSTER_WORKERS=auto PORT=8080 ORIGIN=https://example.com node build
1188
1190
  ```
1189
1191
 
1190
- The primary process forks N workers, each running their own uWS server on the same port. If a worker crashes, it is automatically restarted. On `SIGTERM`/`SIGINT`, the primary forwards the signal to all workers for graceful shutdown.
1192
+ If a worker crashes, it is automatically restarted with exponential backoff. On `SIGTERM`/`SIGINT`, the primary tells all workers to drain in-flight requests and shut down gracefully.
1193
+
1194
+ ### WebSocket + clustering
1191
1195
 
1192
- ### Limitations
1196
+ `platform.publish()` is automatically relayed across all workers via the primary thread, so subscribers on any worker receive the message. This is built in — no external pub/sub needed.
1193
1197
 
1194
- - **Linux only** - `SO_REUSEPORT` is a Linux kernel feature. On other platforms, the variable is ignored with a warning.
1195
- - **HTTP/SSR only** - clustering is automatically disabled when WebSocket is enabled (`websocket: true` or `websocket: { ... }`). uWS pub/sub is per-process, so messages published in one worker would not reach clients connected to another worker. If you need clustered WebSocket, bring an external pub/sub backend (Redis, NATS, etc.) and manage multi-process coordination yourself.
1198
+ Per-worker limitations (acceptable for most apps):
1199
+ - `platform.connections` returns the count for the local worker only
1200
+ - `platform.subscribers(topic)` — returns the count for the local worker only
1201
+ - `platform.sendTo(filter, ...)` — only reaches connections on the local worker
1196
1202
 
1197
1203
  ---
1198
1204
 
@@ -1200,31 +1206,75 @@ The primary process forks N workers, each running their own uWS server on the sa
1200
1206
 
1201
1207
  ### Why uWebSockets.js?
1202
1208
 
1203
- uWebSockets.js is a C++ HTTP and WebSocket server compiled to a native V8 addon. In benchmarks it consistently outperforms Node.js' built-in `http` module, Express, Fastify, and every other JavaScript HTTP server by a significant margin - often 5-10x more requests per second.
1209
+ uWebSockets.js is a C++ HTTP and WebSocket server compiled to a native V8 addon. It consistently outperforms Node.js' built-in `http` module, Express, Fastify, and every other JavaScript HTTP server by a significant margin.
1210
+
1211
+ We ran a comprehensive benchmark suite isolating every layer of overhead - from barebones uWS through the full adapter pipeline - and compared against `@sveltejs/adapter-node` (Node http + Polka + sirv) and the most popular WebSocket libraries (`socket.io`, `ws`). The benchmark code is in the [`bench/`](bench/) directory so you can reproduce it yourself.
1204
1212
 
1205
- ### Our overhead vs barebones uWS
1213
+ ### HTTP: adapter-uws vs adapter-node
1206
1214
 
1207
- A barebones uWebSockets.js "hello world" can handle 500k+ requests per second on a single core. This adapter adds overhead that is unavoidable for what it does:
1215
+ Tested with a trivial SvelteKit handler (isolates adapter overhead from your app code):
1208
1216
 
1209
- 1. **SvelteKit SSR** - every non-static request goes through SvelteKit's `server.respond()`, which runs your load functions, renders components, and produces HTML. This is the biggest cost and it's the whole point of using SvelteKit.
1217
+ | | adapter-uws | adapter-node | Multiplier |
1218
+ |---|---|---|---|
1219
+ | **Static files** | 135,300 req/s | 20,100 req/s | **6.7x faster** |
1220
+ | **SSR** | 125,100 req/s | 53,900 req/s | **2.3x faster** |
1210
1221
 
1211
- 2. **Static file cache** - on startup we walk the build output and load every static file into memory with its precompressed variants. This is a one-time cost. Serving static files is then a single `Map.get()` plus a `res.cork()` + `res.end()` - about as fast as it gets without sendfile.
1222
+ <sup>100 connections, 10 pipelining, 10s, 2 runs averaged. Node v24, Windows 11.</sup>
1212
1223
 
1213
- 3. **Request construction** - we build a standard `Request` object from uWS' stack-allocated `HttpRequest`. This means reading all headers synchronously (uWS requirement) and constructing a URL string. We read headers lazily for static files (only `accept-encoding` and `if-none-match`), but SSR requires the full set.
1224
+ The static file gap is the largest because `adapter-node` uses sirv which calls `fs.createReadStream().pipe(res)` per request, while we serve from an in-memory `Map` with a single `res.cork()` + `res.end()`. The SSR gap comes from uWS's C++ HTTP parsing and batched writes vs Node's async drain event cycle.
1214
1225
 
1215
- 4. **Response streaming** - we read from the `Response.body` ReadableStream and write chunks through uWS with backpressure support (`onWritable`). Single-chunk responses (most SSR pages) are optimized into a single `res.cork()` + `res.end()` call.
1226
+ ### WebSocket: uWS vs socket.io vs ws
1216
1227
 
1217
- 5. **WebSocket envelope** - every pub/sub message is wrapped in `JSON.stringify({ topic, event, data })`. This is a few microseconds per message. The tradeoff is a clean, standardized format that the client store understands without configuration.
1228
+ 50 connected clients, 10 senders, burst mode, 8 seconds:
1229
+
1230
+ | Server | Messages delivered/s | vs adapter-uws |
1231
+ |---|---|---|
1232
+ | **uWS native** (barebones) | 3,625,000 | 1.0x |
1233
+ | **adapter-uws** (full handler) | 3,642,000 | baseline |
1234
+ | **socket.io** | 177,200 | **20.5x slower** |
1235
+ | **ws** library | 164,500 | **22.1x slower** |
1236
+
1237
+ uWS native pub/sub delivered 3.6M messages/s with perfect 50x fan-out. After optimization, the adapter matches it -- the byte-prefix check and string template envelope add near-zero overhead to the hot path. `socket.io` and `ws` both collapsed under the same load, delivering less than 1x fan-out (massive message loss/queueing).
1238
+
1239
+ ### Where the overhead goes
1240
+
1241
+ **HTTP (SSR path) - 23% total overhead vs barebones uWS:**
1242
+
1243
+ | Layer | Cost | Notes |
1244
+ |---|---|---|
1245
+ | `res.cork()` + status + headers | 11.4% | Writing a proper HTTP response - unavoidable |
1246
+ | `new Request()` construction | 9.7% | Required by SvelteKit's `server.respond()` contract |
1247
+ | Response body reader loop | ~2% | `getReader()` + `read()` + async scheduling |
1248
+ | Header collection, AbortController | ~0% | Measured at 0.08us and 0.004us per request |
1249
+
1250
+ **WebSocket - optimized down from 27% to ~4% overhead vs barebones uWS pub/sub:**
1251
+
1252
+ The two largest WebSocket costs were `JSON.parse()` on every message for the subscribe/unsubscribe check (15%) and `JSON.stringify()` for envelope wrapping (8%). Both have been optimized:
1253
+
1254
+ | Layer | Before | After | How |
1255
+ |---|---|---|---|
1256
+ | Subscribe/unsubscribe check | ~15% | ~0% | Byte-prefix discriminator: control messages start with `{"ty` (byte[3]=`y`), user envelopes start with `{"to` (byte[3]=`o`). A single byte comparison skips `JSON.parse` for all regular messages -- from 0.39us to 0.001us per message. |
1257
+ | Envelope wrapping | ~8% | ~4.5% | String template with `esc()` validation instead of `JSON.stringify` on a wrapper object. Topic and event names are validated with a fast char scan (~10ns) that throws on quotes, backslashes, or control characters — only `data` is stringified. From 0.135us to ~0.085us per publish. |
1258
+ | Connection tracking | ~2% | ~2% | Unchanged |
1259
+ | Origin validation, upgrade headers | ~2% | ~2% | Unchanged |
1218
1260
 
1219
1261
  **What we don't add:**
1220
- - No middleware chain
1221
- - No routing layer (uWS' native routing + SvelteKit's router)
1222
- - No per-request allocations beyond what's needed
1262
+ - No middleware chain (no Polka, no Express)
1263
+ - No routing layer (uWS native routing + SvelteKit's router)
1264
+ - No per-request stream allocation for static files (in-memory Buffer, not `fs.createReadStream`)
1223
1265
  - No Node.js `http.IncomingMessage` shim (we construct `Request` directly from uWS)
1224
1266
 
1225
1267
  ### The bottom line
1226
1268
 
1227
- For static files, performance is very close to barebones uWS. For SSR, the bottleneck is your Svelte components and load functions, not the adapter. The adapter's job is to get out of the way as fast as possible - and it does.
1269
+ The adapter retains 77% of raw uWS HTTP throughput and ~96% of raw uWS WebSocket throughput. The HTTP overhead is dominated by things SvelteKit requires (`new Request()`, proper HTTP headers). The WebSocket overhead is now almost entirely the `JSON.stringify` of your `data` payload -- the adapter's own machinery costs near zero. In a real app, your load functions and component rendering will dwarf all of this -- the adapter's job is to get out of the way, and it does.
1270
+
1271
+ To run the benchmarks yourself:
1272
+
1273
+ ```bash
1274
+ npm install # installs uWebSockets.js, autocannon, etc.
1275
+ node bench/run.mjs # adapter overhead breakdown
1276
+ node bench/run-compare.mjs # full comparison vs adapter-node + socket.io
1277
+ ```
1228
1278
 
1229
1279
  ---
1230
1280
 
@@ -1455,7 +1505,7 @@ const todos = on('todo'); // 'todo' - WRONG, singular vs plural
1455
1505
 
1456
1506
  ### "How do I see what the message envelope looks like?"
1457
1507
 
1458
- Every message sent through `platform.publish()` or `platform.topic().created()` arrives as JSON with this shape:
1508
+ Every message sent through `platform.publish()` or `platform.topic().created()` arrives as JSON with this shape. The envelope is constructed with string concatenation for speed, but `topic` and `event` are validated first -- if either contains a quote, backslash, or control character, the call throws instead of producing malformed JSON:
1459
1509
 
1460
1510
  ```json
1461
1511
  {
package/files/handler.js CHANGED
@@ -24,6 +24,26 @@ class PayloadTooLargeError extends Error {
24
24
  constructor() { super('Payload too large'); }
25
25
  }
26
26
 
27
+ /**
28
+ * Safely quote a string for JSON embedding. Topics and events are
29
+ * developer-defined identifiers — a quote, backslash, or control character
30
+ * is always a bug, so we throw instead of silently escaping.
31
+ * @param {string} s
32
+ * @returns {string} JSON-quoted string, e.g. '"todos"'
33
+ */
34
+ function esc(s) {
35
+ for (let i = 0; i < s.length; i++) {
36
+ const c = s.charCodeAt(i);
37
+ if (c < 32 || c === 34 || c === 92) {
38
+ throw new Error(
39
+ `Topic/event name contains invalid character at index ${i}: '${s}'. ` +
40
+ 'Names must not contain quotes, backslashes, or control characters.'
41
+ );
42
+ }
43
+ }
44
+ return '"' + s + '"';
45
+ }
46
+
27
47
  // -- In-memory static file cache ---------------------------------------------
28
48
 
29
49
  /**
@@ -174,7 +194,7 @@ const platform = {
174
194
  * No-op if no clients are subscribed - safe to call unconditionally.
175
195
  */
176
196
  publish(topic, event, data) {
177
- const envelope = JSON.stringify({ topic, event, data });
197
+ const envelope = '{"topic":' + esc(topic) + ',"event":' + esc(event) + ',"data":' + JSON.stringify(data) + '}';
178
198
  const result = app.publish(topic, envelope, false, false);
179
199
  // Relay to other workers via main thread (no-op in single-process mode)
180
200
  if (parentPort) {
@@ -188,7 +208,7 @@ const platform = {
188
208
  * Wraps in the same { topic, event, data } envelope as publish().
189
209
  */
190
210
  send(ws, topic, event, data) {
191
- return ws.send(JSON.stringify({ topic, event, data }), false, false);
211
+ return ws.send('{"topic":' + esc(topic) + ',"event":' + esc(event) + ',"data":' + JSON.stringify(data) + '}', false, false);
192
212
  },
193
213
 
194
214
  /**
@@ -197,7 +217,7 @@ const platform = {
197
217
  * Returns the number of connections the message was sent to.
198
218
  */
199
219
  sendTo(filter, topic, event, data) {
200
- const envelope = JSON.stringify({ topic, event, data });
220
+ const envelope = '{"topic":' + esc(topic) + ',"event":' + esc(event) + ',"data":' + JSON.stringify(data) + '}';
201
221
  let count = 0;
202
222
  for (const ws of wsConnections) {
203
223
  if (filter(ws.getUserData())) {
@@ -382,7 +402,7 @@ function serveStatic(res, entry, acceptEncoding, ifNoneMatch, headOnly = false)
382
402
  * @param {string} ifNoneMatch
383
403
  * @returns {boolean}
384
404
  */
385
- function tryPrerendered(res, pathname, search, acceptEncoding, ifNoneMatch) {
405
+ function tryPrerendered(res, pathname, search, acceptEncoding, ifNoneMatch, headOnly = false) {
386
406
  // Fast path: skip decodeURIComponent when there are no encoded characters
387
407
  const needsDecode = pathname.includes('%');
388
408
  let decoded;
@@ -404,7 +424,7 @@ function tryPrerendered(res, pathname, search, acceptEncoding, ifNoneMatch) {
404
424
  if (prerendered.has(decoded)) {
405
425
  const entry = staticCache.get(decoded);
406
426
  if (entry) {
407
- serveStatic(res, entry, acceptEncoding, ifNoneMatch);
427
+ serveStatic(res, entry, acceptEncoding, ifNoneMatch, headOnly);
408
428
  return true;
409
429
  }
410
430
  }
@@ -651,7 +671,7 @@ function handleRequest(res, req) {
651
671
  // Lightweight: only 2 header reads, no full collection, no remoteAddress decode
652
672
  if (METHOD === 'GET' || METHOD === 'HEAD') {
653
673
  if (tryPrerendered(res, pathname, query ? `?${query}` : '',
654
- req.getHeader('accept-encoding'), req.getHeader('if-none-match'))) {
674
+ req.getHeader('accept-encoding'), req.getHeader('if-none-match'), METHOD === 'HEAD')) {
655
675
  return;
656
676
  }
657
677
  }
@@ -821,8 +841,12 @@ if (WS_ENABLED) {
821
841
 
822
842
  message: (ws, message, isBinary) => {
823
843
  // Built-in: handle subscribe/unsubscribe from the client store.
824
- // Only parse small text messages - sub/unsub envelopes are always < 512 bytes.
825
- if (!isBinary && message.byteLength < 512) {
844
+ // Control messages are JSON text: {"type":"subscribe","topic":"..."}
845
+ // Byte-prefix check: {"type" has byte[3]='y' (0x79), while user
846
+ // envelopes {"topic" have byte[3]='o' (0x6F). Only JSON.parse when
847
+ // the prefix matches - skips parsing for 99%+ of messages.
848
+ if (!isBinary && message.byteLength < 512 &&
849
+ (new Uint8Array(message))[3] === 0x79 /* 'y' in {"type" */) {
826
850
  try {
827
851
  const msg = JSON.parse(Buffer.from(message).toString());
828
852
  if (msg.type === 'subscribe' && typeof msg.topic === 'string') {
package/files/index.js CHANGED
@@ -41,6 +41,12 @@ if (is_primary) {
41
41
  let shutting_down = false;
42
42
  let listen_socket = null;
43
43
 
44
+ // Exponential backoff for crash-looping workers
45
+ let restart_delay = 0;
46
+ const RESTART_DELAY_MAX = 5000;
47
+ /** @type {ReturnType<typeof setTimeout> | null} */
48
+ let backoff_reset_timer = null;
49
+
44
50
  function spawn_worker() {
45
51
  const worker = new Worker(fileURLToPath(import.meta.url));
46
52
  workers.set(worker, null);
@@ -50,6 +56,9 @@ if (is_primary) {
50
56
  workers.set(worker, msg.descriptor);
51
57
  acceptorApp.addChildAppDescriptor(msg.descriptor);
52
58
  console.log(`Worker thread ${worker.threadId} registered`);
59
+ // Worker started successfully — reset backoff
60
+ restart_delay = 0;
61
+ if (backoff_reset_timer) { clearTimeout(backoff_reset_timer); backoff_reset_timer = null; }
53
62
  } else if (msg.type === 'publish') {
54
63
  // Relay pub/sub to all OTHER workers
55
64
  for (const [w] of workers) {
@@ -65,8 +74,13 @@ if (is_primary) {
65
74
  }
66
75
  workers.delete(worker);
67
76
  if (!shutting_down) {
68
- console.log(`Worker thread ${worker.threadId} exited with code ${code}, restarting...`);
69
- spawn_worker();
77
+ restart_delay = restart_delay ? Math.min(restart_delay * 2, RESTART_DELAY_MAX) : 100;
78
+ console.log(`Worker thread ${worker.threadId} exited with code ${code}, restarting in ${restart_delay}ms...`);
79
+ backoff_reset_timer = setTimeout(() => { spawn_worker(); }, restart_delay);
80
+ }
81
+ // If shutting down and all workers have exited, exit immediately
82
+ if (shutting_down && workers.size === 0) {
83
+ process.exit(0);
70
84
  }
71
85
  });
72
86
 
package/index.d.ts CHANGED
@@ -15,6 +15,7 @@ import type { WebSocket } from 'uWebSockets.js';
15
15
  * | `SSL_KEY` | - | Path to TLS private key file |
16
16
  * | `PROTOCOL_HEADER` | - | Header for protocol detection (e.g. `x-forwarded-proto`) |
17
17
  * | `HOST_HEADER` | - | Header for host detection (e.g. `x-forwarded-host`) |
18
+ * | `PORT_HEADER` | - | Header for port override (e.g. `x-forwarded-port`) |
18
19
  * | `ADDRESS_HEADER` | - | Header for client IP (e.g. `x-forwarded-for`) |
19
20
  * | `XFF_DEPTH` | `1` | Position from right in `X-Forwarded-For` |
20
21
  * | `BODY_SIZE_LIMIT` | `512K` | Max request body size (`K`, `M`, `G` suffixes) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",
@@ -49,7 +49,6 @@
49
49
  "peerDependencies": {
50
50
  "@sveltejs/kit": "^2.0.0",
51
51
  "svelte": "^4.0.0 || ^5.0.0",
52
- "uWebSockets.js": "uNetworking/uWebSockets.js#v20.60.0",
53
52
  "ws": "^8.0.0"
54
53
  },
55
54
  "peerDependenciesMeta": {
@@ -76,6 +75,14 @@
76
75
  "websocket"
77
76
  ],
78
77
  "devDependencies": {
79
- "vitest": "^4.0.18"
78
+ "autocannon": "^8.0.0",
79
+ "polka": "^0.5.2",
80
+ "socket.io": "^4.8.3",
81
+ "socket.io-client": "^4.8.3",
82
+ "vitest": "^4.0.18",
83
+ "ws": "^8.19.0"
84
+ },
85
+ "optionalDependencies": {
86
+ "uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.60.0"
80
87
  }
81
88
  }
package/vite.js CHANGED
@@ -3,6 +3,25 @@ import path from 'node:path';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { parseCookies } from './files/cookies.js';
5
5
 
6
+ /**
7
+ * Safely quote a string for JSON embedding. Throws on invalid characters
8
+ * (quotes, backslashes, control chars) — these are always bugs in topic/event names.
9
+ * @param {string} s
10
+ * @returns {string}
11
+ */
12
+ function esc(s) {
13
+ for (let i = 0; i < s.length; i++) {
14
+ const c = s.charCodeAt(i);
15
+ if (c < 32 || c === 34 || c === 92) {
16
+ throw new Error(
17
+ `Topic/event name contains invalid character at index ${i}: '${s}'. ` +
18
+ 'Names must not contain quotes, backslashes, or control characters.'
19
+ );
20
+ }
21
+ }
22
+ return '"' + s + '"';
23
+ }
24
+
6
25
  /**
7
26
  * Vite plugin that provides WebSocket support during development.
8
27
  *
@@ -78,7 +97,7 @@ export default function uwsDev(options = {}) {
78
97
  * @returns {boolean}
79
98
  */
80
99
  function publish(topic, event, data) {
81
- const envelope = JSON.stringify({ topic, event, data });
100
+ const envelope = '{"topic":' + esc(topic) + ',"event":' + esc(event) + ',"data":' + JSON.stringify(data) + '}';
82
101
  let sent = false;
83
102
  for (const [ws, topics] of subscriptions) {
84
103
  if (topics.has(topic) && ws.readyState === 1) {
@@ -98,7 +117,7 @@ export default function uwsDev(options = {}) {
98
117
  * @returns {number}
99
118
  */
100
119
  function send(ws, topic, event, data) {
101
- return ws.send(JSON.stringify({ topic, event, data }), false, false) ?? 1;
120
+ return ws.send('{"topic":' + esc(topic) + ',"event":' + esc(event) + ',"data":' + JSON.stringify(data) + '}', false, false) ?? 1;
102
121
  }
103
122
 
104
123
  /**
@@ -110,7 +129,7 @@ export default function uwsDev(options = {}) {
110
129
  * @returns {number}
111
130
  */
112
131
  function sendTo(filter, topic, event, data) {
113
- const envelope = JSON.stringify({ topic, event, data });
132
+ const envelope = '{"topic":' + esc(topic) + ',"event":' + esc(event) + ',"data":' + JSON.stringify(data) + '}';
114
133
  let count = 0;
115
134
  for (const [, wrapped] of wsWrappers) {
116
135
  if (filter(wrapped.getUserData())) {
@@ -264,7 +283,9 @@ export default function uwsDev(options = {}) {
264
283
  const arrayBuffer = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
265
284
 
266
285
  // Handle subscribe/unsubscribe from client store
267
- if (!isBinary && buf.byteLength < 512) {
286
+ // Byte-prefix check: {"type" has byte[3]='y' (0x79), user envelopes
287
+ // {"topic" have byte[3]='o' - skip JSON.parse for non-control messages.
288
+ if (!isBinary && buf.byteLength < 512 && buf[3] === 0x79) {
268
289
  try {
269
290
  const msg = JSON.parse(buf.toString());
270
291
  if (msg.type === 'subscribe' && typeof msg.topic === 'string') {