svelte-adapter-uws 0.4.6 → 0.4.7

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
@@ -1237,7 +1237,7 @@ await ready();
1237
1237
  // connection is now open, safe to send messages
1238
1238
  ```
1239
1239
 
1240
- In SSR (no browser WebSocket), `ready()` resolves immediately and is a no-op.
1240
+ In SSR (no browser WebSocket and no explicit `url`), `ready()` resolves immediately and is a no-op. In native app environments where `window` doesn't exist but you passed a `url` to `connect()`, `ready()` correctly waits for the connection to open.
1241
1241
 
1242
1242
  `ready()` rejects if the connection is permanently closed before it opens. This happens when the server sends a terminal close code (1008/4401/4403), retries are exhausted, or `close()` is called explicitly. If you call `ready()` in a context where permanent closure is possible, add a `.catch()` handler or use `try/await/catch`.
1243
1243
 
@@ -1251,6 +1251,7 @@ Most users don't need this - `on()` and `status` auto-connect. Use `connect()` w
1251
1251
  import { connect } from 'svelte-adapter-uws/client';
1252
1252
 
1253
1253
  const ws = connect({
1254
+ url: 'wss://my-app.com/ws', // full URL for cross-origin / native app usage (overrides path)
1254
1255
  path: '/ws', // default: '/ws'
1255
1256
  reconnectInterval: 3000, // default: 3000 ms
1256
1257
  maxReconnectInterval: 30000, // default: 30000 ms
@@ -1294,6 +1295,22 @@ The client handles several edge cases automatically, with no configuration requi
1294
1295
 
1295
1296
  **Zombie detection**: the client checks every 30 seconds whether the server has been completely silent for more than 150 seconds (2.5x the server's idle timeout). If so, it forces a close and reconnects. This catches connections that appear open but were silently dropped by the server, which is common on mobile after wake from sleep.
1296
1297
 
1298
+ ### Cross-origin and native app usage
1299
+
1300
+ By default, the client derives the WebSocket URL from `window.location`. If your client runs on a different origin -- a mobile app (Svelte Native, React Native), a standalone Node.js script, or any context where the backend lives elsewhere -- pass a `url` to connect to it directly:
1301
+
1302
+ ```js
1303
+ import { connect, on } from 'svelte-adapter-uws/client';
1304
+
1305
+ connect({ url: 'wss://my-app.com/ws' });
1306
+
1307
+ const todos = on('todos');
1308
+ ```
1309
+
1310
+ When `url` is set, `path` is ignored and the `window` check is bypassed, so the client works in environments without a browser DOM. All other features (reconnect, backoff, batch resubscription, topic stores) work the same way.
1311
+
1312
+ > **Note:** Your server's `allowedOrigins` config must include the origin your client connects from (or `'*'` during development). See the [origin validation](#origin-validation) section.
1313
+
1297
1314
  ---
1298
1315
 
1299
1316
  ## Seeding initial state
@@ -2870,6 +2887,32 @@ Or if you're using `on()` directly (which auto-connects), call `connect()` first
2870
2887
 
2871
2888
  ---
2872
2889
 
2890
+ ## Testing
2891
+
2892
+ ```bash
2893
+ npm test # 711 unit tests (vitest, ~2s)
2894
+ npm run test:e2e # 25 e2e tests (playwright, ~13s)
2895
+ npm run test:coverage # both + coverage reports (~30s)
2896
+ ```
2897
+
2898
+ Unit tests cover store patterns, adapter options, plugin logic, and client behavior using mocked WebSocket globals. They run in vitest with the `vmForks` pool.
2899
+
2900
+ E2e tests start a real SvelteKit app (`test/fixture/`) with the adapter installed via `file:../..`. Playwright runs two projects:
2901
+
2902
+ - **dev** -- `vite dev` with the Vite plugin. Tests SSR, static files, WebSocket pub/sub (via `ws` clients), and the real `client.js` running in Chromium.
2903
+ - **prod** -- `vite build` + `node build/index.js` through uWebSockets.js. Tests the same surface against the production runtime, plus the health check endpoint and 404 handling.
2904
+
2905
+ The coverage script collects V8 coverage from both the Playwright server processes (vite.js, handler.js) and the browser (client.js via Chrome DevTools Protocol), then reports them alongside the vitest unit coverage.
2906
+
2907
+ First-time setup for e2e:
2908
+
2909
+ ```bash
2910
+ cd test/fixture && npm install && cd ../..
2911
+ npx playwright install chromium
2912
+ ```
2913
+
2914
+ ---
2915
+
2873
2916
  ## Related projects
2874
2917
 
2875
2918
  - [svelte-adapter-uws-extensions](https://github.com/lanteanio/svelte-adapter-uws-extensions) -- Redis-backed extensions for multi-server deployments: persistent presence, distributed pub/sub, session storage, and more.
package/client.d.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import type { Readable } from 'svelte/store';
2
2
 
3
3
  export interface ConnectOptions {
4
+ /**
5
+ * Full WebSocket URL to connect to.
6
+ * When set, `path` is ignored and the client connects to this URL directly.
7
+ * Enables cross-origin usage (e.g. svelte-native, React Native, standalone clients).
8
+ * @example 'wss://my-app.com/ws'
9
+ */
10
+ url?: string;
11
+
4
12
  /**
5
13
  * WebSocket endpoint path. Must match the adapter config.
6
14
  * @default '/ws'
package/client.js CHANGED
@@ -132,9 +132,7 @@ export const status = {
132
132
  * @returns {Promise<void>}
133
133
  */
134
134
  export function ready() {
135
- // In non-browser environments (SSR) there is no WebSocket and status
136
- // will never reach 'open'. Resolve immediately so await ready() is a no-op.
137
- if (typeof window === 'undefined') return Promise.resolve();
135
+ if (typeof window === 'undefined' && !(singleton && singleton._hasUrl)) return Promise.resolve();
138
136
 
139
137
  const conn = ensureConnection();
140
138
  return new Promise((resolve, reject) => {
@@ -506,6 +504,7 @@ const THROTTLE_CLOSE_CODES = new Set([
506
504
  */
507
505
  function createConnection(options) {
508
506
  const {
507
+ url,
509
508
  path = '/ws',
510
509
  reconnectInterval = 3000,
511
510
  maxReconnectInterval = 30000,
@@ -565,13 +564,14 @@ function createConnection(options) {
565
564
  const permaClosedStore = writable(false);
566
565
 
567
566
  function getUrl() {
567
+ if (url) return url;
568
568
  if (typeof window === 'undefined') return '';
569
569
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
570
570
  return `${protocol}//${window.location.host}${path}`;
571
571
  }
572
572
 
573
573
  function doConnect() {
574
- if (typeof window === 'undefined') return;
574
+ if (!url && typeof window === 'undefined') return;
575
575
  if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) return;
576
576
 
577
577
  statusStore.set('connecting');
@@ -1006,6 +1006,7 @@ function createConnection(options) {
1006
1006
  events: { subscribe: eventsStore.subscribe },
1007
1007
  status: { subscribe: statusStore.subscribe },
1008
1008
  _permaClosed: { subscribe: permaClosedStore.subscribe },
1009
+ _hasUrl: !!url,
1009
1010
  on: onTopic,
1010
1011
  _onEvent: onEvent,
1011
1012
  _release: release,
package/files/handler.js CHANGED
@@ -408,7 +408,7 @@ const platform = {
408
408
  * No-op if no clients are subscribed - safe to call unconditionally.
409
409
  */
410
410
  publish(topic, event, data, options) {
411
- const envelope = envelopePrefix(topic, event) + JSON.stringify(data) + '}';
411
+ const envelope = envelopePrefix(topic, event) + JSON.stringify(data ?? null) + '}';
412
412
  const result = app.publish(topic, envelope, false, false);
413
413
  // Relay to other workers via main thread (no-op in single-process mode).
414
414
  // Pass { relay: false } when the message originates from an external
@@ -433,7 +433,7 @@ const platform = {
433
433
  * Wraps in the same { topic, event, data } envelope as publish().
434
434
  */
435
435
  send(ws, topic, event, data) {
436
- return ws.send(envelopePrefix(topic, event) + JSON.stringify(data) + '}', false, false);
436
+ return ws.send(envelopePrefix(topic, event) + JSON.stringify(data ?? null) + '}', false, false);
437
437
  },
438
438
 
439
439
  /**
@@ -442,7 +442,7 @@ const platform = {
442
442
  * Returns the number of connections the message was sent to.
443
443
  */
444
444
  sendTo(filter, topic, event, data) {
445
- const envelope = envelopePrefix(topic, event) + JSON.stringify(data) + '}';
445
+ const envelope = envelopePrefix(topic, event) + JSON.stringify(data ?? null) + '}';
446
446
  let count = 0;
447
447
  for (const ws of wsConnections) {
448
448
  if (filter(ws.getUserData())) {
@@ -1381,8 +1381,11 @@ if (WS_ENABLED) {
1381
1381
  upgradeRateMap.set(clientIp, rateEntry);
1382
1382
  } else {
1383
1383
  const elapsed = now - rateEntry.windowStart;
1384
- if (elapsed >= UPGRADE_WINDOW_MS) {
1385
- // Current window is complete - rotate it into the previous slot
1384
+ if (elapsed >= 2 * UPGRADE_WINDOW_MS) {
1385
+ rateEntry.prev = 0;
1386
+ rateEntry.curr = 0;
1387
+ rateEntry.windowStart = now;
1388
+ } else if (elapsed >= UPGRADE_WINDOW_MS) {
1386
1389
  rateEntry.prev = rateEntry.curr;
1387
1390
  rateEntry.curr = 0;
1388
1391
  rateEntry.windowStart = now;
@@ -1423,7 +1426,7 @@ if (WS_ENABLED) {
1423
1426
  const parsed = new URL(reqOrigin);
1424
1427
  const requestHost = (host_header && headers[host_header]) || headers['host'];
1425
1428
  if (!requestHost) {
1426
- allowed = true;
1429
+ allowed = false;
1427
1430
  } else {
1428
1431
  const requestScheme = protocol_header
1429
1432
  ? (headers[protocol_header] || (is_tls ? 'https' : 'http'))
package/files/index.js CHANGED
@@ -4,9 +4,19 @@ import { fileURLToPath } from 'node:url';
4
4
  import { env } from 'ENV';
5
5
 
6
6
  const host = env('HOST', '0.0.0.0');
7
- const port = env('PORT', '3000');
8
- const shutdown_timeout = parseInt(env('SHUTDOWN_TIMEOUT', '30'), 10);
9
- const shutdown_delay = parseInt(env('SHUTDOWN_DELAY_MS', '0'), 10);
7
+ const port_raw = env('PORT', '3000');
8
+
9
+ function parseIntEnv(name, raw, min) {
10
+ const trimmed = raw.trim();
11
+ const n = Number(trimmed);
12
+ if (trimmed === '' || !Number.isInteger(n)) throw new Error(`${name} must be a valid integer, got "${raw}"`);
13
+ if (n < min) throw new Error(`${name} must be >= ${min}, got ${n}`);
14
+ return n;
15
+ }
16
+
17
+ const port = parseIntEnv('PORT', port_raw, 0);
18
+ const shutdown_timeout = parseIntEnv('SHUTDOWN_TIMEOUT', env('SHUTDOWN_TIMEOUT', '30'), 0);
19
+ const shutdown_delay = parseIntEnv('SHUTDOWN_DELAY_MS', env('SHUTDOWN_DELAY_MS', '0'), 0);
10
20
  const cluster_workers = env('CLUSTER_WORKERS', '');
11
21
 
12
22
  const is_primary = cluster_workers && isMainThread;
@@ -126,7 +136,7 @@ if (is_primary) {
126
136
  // Start (or resume) listening once a worker is ready to handle requests
127
137
  if (!listening) {
128
138
  listening = true;
129
- const portNum = parseInt(port, 10);
139
+ const portNum = port;
130
140
  acceptorApp.listen(host, portNum, (socket) => {
131
141
  if (socket) {
132
142
  listen_socket = socket;
@@ -256,13 +266,13 @@ if (is_primary) {
256
266
 
257
267
  if (isMainThread) {
258
268
  // Single-process mode (no clustering)
259
- start(host, parseInt(port, 10));
269
+ start(host, port);
260
270
  } else {
261
271
  // Worker thread startup depends on clustering mode
262
272
  if (workerData?.mode === 'reuseport') {
263
273
  // Reuseport: each worker listens on the shared port directly.
264
274
  // The kernel distributes incoming connections via SO_REUSEPORT.
265
- start(host, parseInt(port, 10));
275
+ start(host, port);
266
276
  parentPort.postMessage({ type: 'ready' });
267
277
  } else {
268
278
  // Acceptor: register with the main thread's acceptor app
package/index.js CHANGED
@@ -102,13 +102,23 @@ export default function (opts = {}) {
102
102
  const allEnv = loadEnv('production', process.cwd(), '');
103
103
  const version = builder.config.kit.version?.name ?? '';
104
104
 
105
+ const aliasMap = { '$lib': libDir };
106
+ const kitAliases = builder.config.kit.alias;
107
+ if (kitAliases) {
108
+ for (const [key, value] of Object.entries(kitAliases)) {
109
+ if (!(key in aliasMap)) {
110
+ aliasMap[key] = path.resolve(value);
111
+ }
112
+ }
113
+ }
114
+
105
115
  await esbuild.build({
106
116
  entryPoints: [path.resolve(handlerFile)],
107
117
  bundle: true,
108
118
  format: 'esm',
109
119
  platform: 'node',
110
120
  outfile: `${tmp}/ws-handler.js`,
111
- alias: { '$lib': libDir },
121
+ alias: aliasMap,
112
122
  packages: 'external',
113
123
  plugins: [{
114
124
  name: 'sveltekit-virtual-modules',
@@ -124,6 +134,9 @@ export default function (opts = {}) {
124
134
  const isPublic = args.path.includes('/public');
125
135
  const isStatic = args.path.includes('/static');
126
136
  if (!isStatic) {
137
+ if (isPublic) {
138
+ return { contents: `export const env = new Proxy(process.env, { get(t, k) { return typeof k === 'string' && k.startsWith(${JSON.stringify(publicPrefix)}) ? t[k] : undefined; }, ownKeys(t) { return Object.keys(t).filter(k => k.startsWith(${JSON.stringify(publicPrefix)})); }, has(t, k) { return typeof k === 'string' && k.startsWith(${JSON.stringify(publicPrefix)}) && k in t; }, getOwnPropertyDescriptor(t, k) { if (typeof k === 'string' && k.startsWith(${JSON.stringify(publicPrefix)}) && k in t) return { value: t[k], enumerable: true, configurable: true }; return undefined; } });` };
139
+ }
127
140
  return { contents: 'export const env = process.env;' };
128
141
  }
129
142
  const entries = Object.entries(allEnv).filter(([k]) =>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
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",
@@ -98,7 +98,9 @@
98
98
  ],
99
99
  "scripts": {
100
100
  "test": "vitest run",
101
- "test:watch": "vitest"
101
+ "test:watch": "vitest",
102
+ "test:e2e": "npx playwright test --config test/e2e/playwright.config.js",
103
+ "test:coverage": "node scripts/coverage.js"
102
104
  },
103
105
  "engines": {
104
106
  "node": ">=20.0.0"
@@ -132,8 +134,10 @@
132
134
  "websocket"
133
135
  ],
134
136
  "devDependencies": {
137
+ "@playwright/test": "^1.59.1",
135
138
  "@vitest/coverage-v8": "^4.0.18",
136
139
  "autocannon": "^8.0.0",
140
+ "c8": "^11.0.0",
137
141
  "polka": "^0.5.2",
138
142
  "socket.io": "^4.8.3",
139
143
  "socket.io-client": "^4.8.3",
@@ -275,6 +275,11 @@ export function createPresence(options = {}) {
275
275
  if (connTopics && connTopics.has(topic)) return;
276
276
 
277
277
  const data = select(ws.getUserData());
278
+ if (!data || typeof data !== 'object') {
279
+ throw new TypeError(
280
+ `presence select() must return a plain object, got ${data === null ? 'null' : typeof data}`
281
+ );
282
+ }
278
283
  const key = resolveKey(data);
279
284
 
280
285
  // Track per-connection
@@ -205,7 +205,11 @@ export function createReplay(options = {}) {
205
205
  publish(platform, topic, event, data) {
206
206
  const state = getTopic(topic);
207
207
  state.seq++;
208
- pushMessage(state, { seq: state.seq, topic, event, data });
208
+ let snapshot = data;
209
+ if (data != null && typeof data === 'object') {
210
+ try { snapshot = structuredClone(data); } catch { snapshot = JSON.parse(JSON.stringify(data)); }
211
+ }
212
+ pushMessage(state, { seq: state.seq, topic, event, data: snapshot });
209
213
  return platform.publish(topic, event, data);
210
214
  },
211
215
 
package/vite.js CHANGED
@@ -80,12 +80,22 @@ export default function uws(options = {}) {
80
80
  getBufferedAmount() { return rawWs.bufferedAmount || 0; },
81
81
  getRemoteAddress() {
82
82
  // uWS returns raw binary bytes (4 for IPv4, 16 for IPv6).
83
- // Dev only handles IPv4; exotic addresses fall back to text encoding.
84
83
  const ip = rawWs._socket?.remoteAddress || '127.0.0.1';
85
84
  const v4 = ip.replace(/^::ffff:/, '');
86
85
  const parts = v4.split('.');
87
86
  if (parts.length === 4) return new Uint8Array(parts.map(Number)).buffer;
88
- return new TextEncoder().encode(ip).buffer;
87
+ // IPv6: expand :: into zeroes, pack 8 groups into 16 bytes
88
+ const halves = v4.split('::');
89
+ const left = halves[0] ? halves[0].split(':') : [];
90
+ const right = halves.length > 1 && halves[1] ? halves[1].split(':') : [];
91
+ const pad = Array(8 - left.length - right.length).fill('0');
92
+ const groups = [...left, ...pad, ...right].map(g => parseInt(g, 16));
93
+ const buf = new Uint8Array(16);
94
+ for (let i = 0; i < 8; i++) {
95
+ buf[i * 2] = (groups[i] >> 8) & 0xff;
96
+ buf[i * 2 + 1] = groups[i] & 0xff;
97
+ }
98
+ return buf.buffer;
89
99
  },
90
100
  getRemoteAddressAsText() {
91
101
  return new TextEncoder().encode(rawWs._socket?.remoteAddress || '127.0.0.1').buffer;
@@ -103,7 +113,7 @@ export default function uws(options = {}) {
103
113
  * @returns {boolean}
104
114
  */
105
115
  function publish(topic, event, data, _options) {
106
- const envelope = '{"topic":' + esc(topic) + ',"event":' + esc(event) + ',"data":' + JSON.stringify(data) + '}';
116
+ const envelope = '{"topic":' + esc(topic) + ',"event":' + esc(event) + ',"data":' + JSON.stringify(data ?? null) + '}';
107
117
  let sent = false;
108
118
  for (const [ws, topics] of subscriptions) {
109
119
  if (topics.has(topic) && ws.readyState === 1) {
@@ -123,7 +133,7 @@ export default function uws(options = {}) {
123
133
  * @returns {number}
124
134
  */
125
135
  function send(ws, topic, event, data) {
126
- return ws.send('{"topic":' + esc(topic) + ',"event":' + esc(event) + ',"data":' + JSON.stringify(data) + '}', false, false) ?? 1;
136
+ return ws.send('{"topic":' + esc(topic) + ',"event":' + esc(event) + ',"data":' + JSON.stringify(data ?? null) + '}', false, false) ?? 1;
127
137
  }
128
138
 
129
139
  /**
@@ -135,7 +145,7 @@ export default function uws(options = {}) {
135
145
  * @returns {number}
136
146
  */
137
147
  function sendTo(filter, topic, event, data) {
138
- const envelope = '{"topic":' + esc(topic) + ',"event":' + esc(event) + ',"data":' + JSON.stringify(data) + '}';
148
+ const envelope = '{"topic":' + esc(topic) + ',"event":' + esc(event) + ',"data":' + JSON.stringify(data ?? null) + '}';
139
149
  let count = 0;
140
150
  for (const [, wrapped] of wsWrappers) {
141
151
  if (filter(wrapped.getUserData())) {
@@ -198,7 +208,8 @@ export default function uws(options = {}) {
198
208
  message: mod.message,
199
209
  close: mod.close,
200
210
  drain: mod.drain,
201
- subscribe: mod.subscribe
211
+ subscribe: mod.subscribe,
212
+ unsubscribe: mod.unsubscribe
202
213
  };
203
214
  }
204
215
 
@@ -480,7 +491,8 @@ export default function uws(options = {}) {
480
491
  mod.message !== userHandlers.message ||
481
492
  mod.close !== userHandlers.close ||
482
493
  mod.drain !== userHandlers.drain ||
483
- mod.subscribe !== userHandlers.subscribe) {
494
+ mod.subscribe !== userHandlers.subscribe ||
495
+ mod.unsubscribe !== userHandlers.unsubscribe) {
484
496
  applyHandlers(mod);
485
497
  // Close existing connections so they reconnect with the new handler.
486
498
  // 1012 = "Service Restart" - clients with auto-reconnect will reconnect.