svelte-adapter-uws 0.1.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/LICENSE +21 -0
- package/README.md +1543 -0
- package/client.d.ts +356 -0
- package/client.js +571 -0
- package/files/cookies.js +25 -0
- package/files/env.js +41 -0
- package/files/handler.js +898 -0
- package/files/index.js +116 -0
- package/files/shims.js +21 -0
- package/files/utils.js +136 -0
- package/index.d.ts +396 -0
- package/index.js +224 -0
- package/package.json +81 -0
- package/vite.d.ts +48 -0
- package/vite.js +310 -0
package/files/index.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { env } from 'ENV';
|
|
3
|
+
|
|
4
|
+
/* global WS_ENABLED */
|
|
5
|
+
|
|
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 cluster_workers = env('CLUSTER_WORKERS', '');
|
|
10
|
+
|
|
11
|
+
const is_primary = cluster_workers
|
|
12
|
+
&& process.platform === 'linux'
|
|
13
|
+
&& !WS_ENABLED
|
|
14
|
+
&& process.env.__UWS_WORKER !== '1';
|
|
15
|
+
|
|
16
|
+
if (is_primary) {
|
|
17
|
+
// ── Primary process: fork workers, monitor, restart ─────────────────────
|
|
18
|
+
|
|
19
|
+
const { availableParallelism } = await import('node:os');
|
|
20
|
+
const { fork } = await import('node:child_process');
|
|
21
|
+
|
|
22
|
+
const num = cluster_workers === 'auto'
|
|
23
|
+
? availableParallelism()
|
|
24
|
+
: parseInt(cluster_workers, 10);
|
|
25
|
+
|
|
26
|
+
if (isNaN(num) || num < 1) {
|
|
27
|
+
console.error(`Invalid CLUSTER_WORKERS value: '${cluster_workers}'. Use a positive integer or 'auto'.`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.log(`Primary process ${process.pid} starting ${num} workers...`);
|
|
32
|
+
|
|
33
|
+
/** @type {Set<import('node:child_process').ChildProcess>} */
|
|
34
|
+
const workers = new Set();
|
|
35
|
+
let shutting_down = false;
|
|
36
|
+
|
|
37
|
+
function spawn_worker() {
|
|
38
|
+
const worker = fork(process.argv[1], {
|
|
39
|
+
env: { ...process.env, __UWS_WORKER: '1' }
|
|
40
|
+
});
|
|
41
|
+
workers.add(worker);
|
|
42
|
+
worker.on('exit', (code) => {
|
|
43
|
+
workers.delete(worker);
|
|
44
|
+
if (!shutting_down) {
|
|
45
|
+
console.log(`Worker ${worker.pid} exited with code ${code}, restarting...`);
|
|
46
|
+
spawn_worker();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < num; i++) {
|
|
52
|
+
spawn_worker();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @param {'SIGINT' | 'SIGTERM'} reason */
|
|
56
|
+
function graceful_shutdown(reason) {
|
|
57
|
+
if (shutting_down) return;
|
|
58
|
+
shutting_down = true;
|
|
59
|
+
console.log(`Primary received ${reason}, shutting down ${workers.size} workers...`);
|
|
60
|
+
for (const worker of workers) {
|
|
61
|
+
worker.kill(reason);
|
|
62
|
+
}
|
|
63
|
+
setTimeout(() => {
|
|
64
|
+
for (const worker of workers) {
|
|
65
|
+
worker.kill('SIGKILL');
|
|
66
|
+
}
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}, shutdown_timeout * 1000).unref();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
process.on('SIGTERM', () => graceful_shutdown('SIGTERM'));
|
|
72
|
+
process.on('SIGINT', () => graceful_shutdown('SIGINT'));
|
|
73
|
+
} else {
|
|
74
|
+
// ── Worker (single-process or forked child) ─────────────────────────────
|
|
75
|
+
|
|
76
|
+
if (cluster_workers && !WS_ENABLED && process.platform !== 'linux') {
|
|
77
|
+
console.warn(
|
|
78
|
+
`Warning: CLUSTER_WORKERS is only supported on Linux (current platform: ${process.platform}).\n` +
|
|
79
|
+
'Starting in single-process mode.'
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (cluster_workers && WS_ENABLED) {
|
|
83
|
+
console.warn(
|
|
84
|
+
'Warning: CLUSTER_WORKERS is ignored when WebSocket is enabled.\n' +
|
|
85
|
+
'uWS pub/sub is per-process - clustering would cause missed messages.\n' +
|
|
86
|
+
'Starting in single-process mode.'
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const { start, shutdown, drain } = await import('HANDLER');
|
|
91
|
+
|
|
92
|
+
start(host, parseInt(port, 10));
|
|
93
|
+
|
|
94
|
+
let shutting_down = false;
|
|
95
|
+
|
|
96
|
+
/** @param {'SIGINT' | 'SIGTERM'} reason */
|
|
97
|
+
async function graceful_shutdown(reason) {
|
|
98
|
+
if (shutting_down) return;
|
|
99
|
+
shutting_down = true;
|
|
100
|
+
console.log(`Received ${reason}, shutting down gracefully...`);
|
|
101
|
+
shutdown();
|
|
102
|
+
// @ts-expect-error custom events cannot be typed
|
|
103
|
+
process.emit('sveltekit:shutdown', reason);
|
|
104
|
+
await Promise.race([
|
|
105
|
+
drain(),
|
|
106
|
+
new Promise((resolve) => setTimeout(resolve, shutdown_timeout * 1000).unref())
|
|
107
|
+
]);
|
|
108
|
+
console.log('Shutdown complete, exiting.');
|
|
109
|
+
process.exit(0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
process.on('SIGTERM', () => graceful_shutdown('SIGTERM'));
|
|
113
|
+
process.on('SIGINT', () => graceful_shutdown('SIGINT'));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export { host, port };
|
package/files/shims.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import buffer from 'node:buffer';
|
|
2
|
+
import { webcrypto } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
const File = /** @type {import('node:buffer') & { File?: File}} */ (buffer).File;
|
|
5
|
+
|
|
6
|
+
/** @type {Record<string, any>} */
|
|
7
|
+
const globals = {
|
|
8
|
+
crypto: webcrypto,
|
|
9
|
+
File
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
for (const name in globals) {
|
|
13
|
+
if (name in globalThis) continue;
|
|
14
|
+
|
|
15
|
+
Object.defineProperty(globalThis, name, {
|
|
16
|
+
enumerable: true,
|
|
17
|
+
configurable: true,
|
|
18
|
+
writable: true,
|
|
19
|
+
value: globals[name]
|
|
20
|
+
});
|
|
21
|
+
}
|
package/files/utils.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// ── MIME types ──────────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export const mimes = {
|
|
4
|
+
"3g2": "video/3gpp2", "3gp": "video/3gpp", "3gpp": "video/3gpp", "3mf": "model/3mf",
|
|
5
|
+
"aac": "audio/aac", "apng": "image/apng", "avif": "image/avif",
|
|
6
|
+
"bin": "application/octet-stream", "bmp": "image/bmp",
|
|
7
|
+
"cjs": "application/node", "css": "text/css", "csv": "text/csv",
|
|
8
|
+
"eot": "application/vnd.ms-fontobject", "epub": "application/epub+zip",
|
|
9
|
+
"gif": "image/gif", "glb": "model/gltf-binary", "gltf": "model/gltf+json",
|
|
10
|
+
"gz": "application/gzip",
|
|
11
|
+
"heic": "image/heic", "heif": "image/heif", "htm": "text/html", "html": "text/html",
|
|
12
|
+
"ico": "image/x-icon", "ics": "text/calendar",
|
|
13
|
+
"jar": "application/java-archive", "jpeg": "image/jpeg", "jpg": "image/jpeg",
|
|
14
|
+
"js": "text/javascript", "json": "application/json", "jsonld": "application/ld+json",
|
|
15
|
+
"map": "application/json", "md": "text/markdown", "mid": "audio/midi", "midi": "audio/midi",
|
|
16
|
+
"mjs": "text/javascript", "mp3": "audio/mpeg", "mp4": "video/mp4", "mpeg": "video/mpeg",
|
|
17
|
+
"oga": "audio/ogg", "ogg": "audio/ogg", "ogv": "video/ogg", "opus": "audio/ogg",
|
|
18
|
+
"otf": "font/otf",
|
|
19
|
+
"pdf": "application/pdf", "png": "image/png",
|
|
20
|
+
"rtf": "text/rtf",
|
|
21
|
+
"svg": "image/svg+xml", "svgz": "image/svg+xml",
|
|
22
|
+
"tif": "image/tiff", "tiff": "image/tiff", "toml": "application/toml",
|
|
23
|
+
"ts": "video/mp2t", "ttc": "font/collection", "ttf": "font/ttf", "txt": "text/plain",
|
|
24
|
+
"vtt": "text/vtt",
|
|
25
|
+
"wasm": "application/wasm", "wav": "audio/wav", "weba": "audio/webm",
|
|
26
|
+
"webm": "video/webm", "webmanifest": "application/manifest+json", "webp": "image/webp",
|
|
27
|
+
"woff": "font/woff", "woff2": "font/woff2",
|
|
28
|
+
"xhtml": "application/xhtml+xml", "xml": "text/xml",
|
|
29
|
+
"yaml": "text/yaml", "yml": "text/yaml", "zip": "application/zip"
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} name
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
export function mimeLookup(name) {
|
|
37
|
+
const idx = name.lastIndexOf('.');
|
|
38
|
+
return mimes[idx !== -1 ? name.substring(idx + 1).toLowerCase() : ''] || 'application/octet-stream';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── splitCookiesString ───────────────────────────────────────────────────────
|
|
42
|
+
// Adapted from set-cookie-parser (https://github.com/nfriedly/set-cookie-parser)
|
|
43
|
+
// Copyright (c) Nathan Friedly - MIT License
|
|
44
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export function splitCookiesString(cookiesString) {
|
|
47
|
+
if (Array.isArray(cookiesString)) return cookiesString;
|
|
48
|
+
if (typeof cookiesString !== 'string') return [];
|
|
49
|
+
|
|
50
|
+
const cookiesStrings = [];
|
|
51
|
+
let pos = 0;
|
|
52
|
+
let start, ch, lastComma, nextStart, cookiesSeparatorFound;
|
|
53
|
+
|
|
54
|
+
function skipWhitespace() {
|
|
55
|
+
while (pos < cookiesString.length && /\s/.test(cookiesString.charAt(pos))) pos++;
|
|
56
|
+
return pos < cookiesString.length;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function notSpecialChar() {
|
|
60
|
+
ch = cookiesString.charAt(pos);
|
|
61
|
+
return ch !== '=' && ch !== ';' && ch !== ',';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
while (pos < cookiesString.length) {
|
|
65
|
+
start = pos;
|
|
66
|
+
cookiesSeparatorFound = false;
|
|
67
|
+
|
|
68
|
+
while (skipWhitespace()) {
|
|
69
|
+
ch = cookiesString.charAt(pos);
|
|
70
|
+
if (ch === ',') {
|
|
71
|
+
lastComma = pos;
|
|
72
|
+
pos++;
|
|
73
|
+
skipWhitespace();
|
|
74
|
+
nextStart = pos;
|
|
75
|
+
while (pos < cookiesString.length && notSpecialChar()) pos++;
|
|
76
|
+
if (pos < cookiesString.length && cookiesString.charAt(pos) === '=') {
|
|
77
|
+
cookiesSeparatorFound = true;
|
|
78
|
+
pos = nextStart;
|
|
79
|
+
cookiesStrings.push(cookiesString.substring(start, lastComma));
|
|
80
|
+
start = pos;
|
|
81
|
+
} else {
|
|
82
|
+
pos = lastComma + 1;
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
pos++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!cookiesSeparatorFound || pos >= cookiesString.length) {
|
|
90
|
+
cookiesStrings.push(cookiesString.substring(start, cookiesString.length));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return cookiesStrings;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} value
|
|
101
|
+
* @returns {number}
|
|
102
|
+
*/
|
|
103
|
+
export function parse_as_bytes(value) {
|
|
104
|
+
const str = value.trim();
|
|
105
|
+
const last = str[str.length - 1]?.toUpperCase();
|
|
106
|
+
// Strip trailing 'B' (e.g. "512KB" → "512K")
|
|
107
|
+
const normalized = last === 'B' ? str.slice(0, -1) : str;
|
|
108
|
+
const suffix = normalized[normalized.length - 1]?.toUpperCase();
|
|
109
|
+
const multiplier =
|
|
110
|
+
{ K: 1024, M: 1024 * 1024, G: 1024 * 1024 * 1024 }[suffix] ?? 1;
|
|
111
|
+
return Number(multiplier !== 1 ? normalized.slice(0, -1) : normalized) * multiplier;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {string | undefined} value
|
|
116
|
+
* @returns {string | undefined}
|
|
117
|
+
*/
|
|
118
|
+
export function parse_origin(value) {
|
|
119
|
+
if (value === undefined) return undefined;
|
|
120
|
+
const trimmed = value.trim();
|
|
121
|
+
let url;
|
|
122
|
+
try {
|
|
123
|
+
url = new URL(trimmed);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Invalid ORIGIN: '${trimmed}'. ORIGIN must be a valid URL with http:// or https:// protocol.`,
|
|
127
|
+
{ cause: error }
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`Invalid ORIGIN: '${trimmed}'. Only http:// and https:// protocols are supported.`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return url.origin;
|
|
136
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import type { Adapter } from '@sveltejs/kit';
|
|
2
|
+
import type { WebSocket } from 'uWebSockets.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ## Environment variables (runtime)
|
|
6
|
+
*
|
|
7
|
+
* These are set at runtime, not in the adapter config:
|
|
8
|
+
*
|
|
9
|
+
* | Variable | Default | Description |
|
|
10
|
+
* |---|---|---|
|
|
11
|
+
* | `HOST` | `0.0.0.0` | Bind address |
|
|
12
|
+
* | `PORT` | `3000` | Listen port |
|
|
13
|
+
* | `ORIGIN` | *(derived)* | Fixed origin (e.g. `https://example.com`) |
|
|
14
|
+
* | `SSL_CERT` | - | Path to TLS certificate file (enables HTTPS/WSS natively) |
|
|
15
|
+
* | `SSL_KEY` | - | Path to TLS private key file |
|
|
16
|
+
* | `PROTOCOL_HEADER` | - | Header for protocol detection (e.g. `x-forwarded-proto`) |
|
|
17
|
+
* | `HOST_HEADER` | - | Header for host detection (e.g. `x-forwarded-host`) |
|
|
18
|
+
* | `ADDRESS_HEADER` | - | Header for client IP (e.g. `x-forwarded-for`) |
|
|
19
|
+
* | `XFF_DEPTH` | `1` | Position from right in `X-Forwarded-For` |
|
|
20
|
+
* | `BODY_SIZE_LIMIT` | `512K` | Max request body size (`K`, `M`, `G` suffixes) |
|
|
21
|
+
* | `SHUTDOWN_TIMEOUT` | `30` | Seconds to wait during graceful shutdown |
|
|
22
|
+
*
|
|
23
|
+
* All variables respect the `envPrefix` option (e.g. `MY_APP_PORT` if `envPrefix: 'MY_APP_'`).
|
|
24
|
+
*
|
|
25
|
+
* ### Native TLS (no proxy needed)
|
|
26
|
+
*
|
|
27
|
+
* ```sh
|
|
28
|
+
* SSL_CERT=/path/to/cert.pem SSL_KEY=/path/to/key.pem node build
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* This uses uWebSockets.js `SSLApp` - HTTPS and WSS with zero proxy overhead.
|
|
32
|
+
*/
|
|
33
|
+
export interface AdapterOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Output directory for the build.
|
|
36
|
+
* @default 'build'
|
|
37
|
+
*/
|
|
38
|
+
out?: string;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Precompress static assets with gzip and brotli.
|
|
42
|
+
* @default true
|
|
43
|
+
*/
|
|
44
|
+
precompress?: boolean;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Prefix for environment variables.
|
|
48
|
+
* @default ''
|
|
49
|
+
*/
|
|
50
|
+
envPrefix?: string;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Health check endpoint path. Set to `false` to disable.
|
|
54
|
+
* @default '/healthz'
|
|
55
|
+
*/
|
|
56
|
+
healthCheckPath?: string | false;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Enable WebSocket support.
|
|
60
|
+
*
|
|
61
|
+
* - `true` - enable with built-in pub/sub handler (**no auth, no per-topic
|
|
62
|
+
* authorization** - any connected client can subscribe to any topic.
|
|
63
|
+
* Use a custom handler with `upgrade` for auth gating)
|
|
64
|
+
* - `WebSocketOptions` - enable with custom config and/or auth handler
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```js
|
|
68
|
+
* // Simplest - just turn it on:
|
|
69
|
+
* adapter({ websocket: true })
|
|
70
|
+
*
|
|
71
|
+
* // With auth:
|
|
72
|
+
* adapter({
|
|
73
|
+
* websocket: {
|
|
74
|
+
* handler: './src/lib/server/websocket.js'
|
|
75
|
+
* }
|
|
76
|
+
* })
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
websocket?: boolean | WebSocketOptions;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface WebSocketOptions {
|
|
83
|
+
/**
|
|
84
|
+
* Path to a JS module that exports WebSocket handler functions
|
|
85
|
+
* (`upgrade`, `open`, `message`, `close`).
|
|
86
|
+
*
|
|
87
|
+
* **Optional.** The adapter auto-discovers `src/hooks.ws.js` (or `.ts`, `.mjs`)
|
|
88
|
+
* if it exists - no config needed. If neither a handler path nor a hooks file
|
|
89
|
+
* is found, a built-in handler is used that accepts all connections and handles
|
|
90
|
+
* subscribe/unsubscribe messages from the client store.
|
|
91
|
+
*
|
|
92
|
+
* Only specify this if your handler lives at a non-standard path.
|
|
93
|
+
*
|
|
94
|
+
* @example './src/lib/server/websocket.js'
|
|
95
|
+
*/
|
|
96
|
+
handler?: string;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* URL path to serve WebSocket connections on.
|
|
100
|
+
* @default '/ws'
|
|
101
|
+
*/
|
|
102
|
+
path?: string;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Max message size in bytes. Connections sending larger messages are closed.
|
|
106
|
+
* @default 16384 (16 KB)
|
|
107
|
+
*/
|
|
108
|
+
maxPayloadLength?: number;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Seconds of inactivity before the connection is closed.
|
|
112
|
+
* @default 120
|
|
113
|
+
*/
|
|
114
|
+
idleTimeout?: number;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Max bytes of backpressure before messages are dropped.
|
|
118
|
+
* @default 1048576 (1 MB)
|
|
119
|
+
*/
|
|
120
|
+
maxBackpressure?: number;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Enable per-message deflate compression.
|
|
124
|
+
* Pass `true` for `SHARED_COMPRESSOR`, or a uWS compression constant
|
|
125
|
+
* (e.g. `uWS.DEDICATED_COMPRESSOR_4KB`) for finer control.
|
|
126
|
+
* @default false
|
|
127
|
+
*/
|
|
128
|
+
compression?: boolean | number;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Automatically send pings to keep the connection alive.
|
|
132
|
+
* @default true
|
|
133
|
+
*/
|
|
134
|
+
sendPingsAutomatically?: boolean;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Allowed origins for WebSocket connections.
|
|
138
|
+
*
|
|
139
|
+
* - `'same-origin'` - only accept connections where Origin matches Host *(default)*
|
|
140
|
+
* - `'*'` - accept connections from any origin
|
|
141
|
+
* - `string[]` - whitelist of allowed origin URLs (e.g. `['https://example.com']`)
|
|
142
|
+
*
|
|
143
|
+
* Non-browser clients (no Origin header) are always allowed.
|
|
144
|
+
*
|
|
145
|
+
* @default 'same-origin'
|
|
146
|
+
*/
|
|
147
|
+
allowedOrigins?: 'same-origin' | '*' | string[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── User's WebSocket handler module exports ─────────────────────────────────
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Context passed to the `upgrade` handler.
|
|
154
|
+
*/
|
|
155
|
+
export interface UpgradeContext {
|
|
156
|
+
/** Request headers (all lowercase keys). */
|
|
157
|
+
headers: Record<string, string>;
|
|
158
|
+
/** Parsed cookies from the Cookie header. */
|
|
159
|
+
cookies: Record<string, string>;
|
|
160
|
+
/** The request URL path. */
|
|
161
|
+
url: string;
|
|
162
|
+
/** Remote IP address. */
|
|
163
|
+
remoteAddress: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Shape of the user's WebSocket handler module.
|
|
168
|
+
*
|
|
169
|
+
* Create a file (e.g. `src/lib/server/websocket.js`) and export any
|
|
170
|
+
* of these functions. All are optional - the built-in handler already
|
|
171
|
+
* handles subscribe/unsubscribe for the client store.
|
|
172
|
+
*
|
|
173
|
+
* @example
|
|
174
|
+
* ```js
|
|
175
|
+
* // src/hooks.ws.js - auto-discovered, no config needed
|
|
176
|
+
*
|
|
177
|
+
* export function upgrade({ cookies }) {
|
|
178
|
+
* if (!cookies.session_id) return false; // reject with 401
|
|
179
|
+
* const user = await validateSession(cookies.session_id);
|
|
180
|
+
* if (!user) return false;
|
|
181
|
+
* return { userId: user.id }; // attach data to socket
|
|
182
|
+
* }
|
|
183
|
+
*
|
|
184
|
+
* export function open(ws) {
|
|
185
|
+
* ws.subscribe(`user:${ws.getUserData().userId}`);
|
|
186
|
+
* }
|
|
187
|
+
* ```
|
|
188
|
+
*/
|
|
189
|
+
export interface WebSocketHandler<UserData = unknown> {
|
|
190
|
+
/**
|
|
191
|
+
* Called during the HTTP upgrade handshake.
|
|
192
|
+
*
|
|
193
|
+
* - Return an object to accept - it becomes `ws.getUserData()`.
|
|
194
|
+
* - Return `false` to reject with 401.
|
|
195
|
+
* - Omit this export to accept all connections with `{}` as user data.
|
|
196
|
+
*
|
|
197
|
+
* May be async.
|
|
198
|
+
*/
|
|
199
|
+
upgrade?: (ctx: UpgradeContext) => UserData | false | Promise<UserData | false>;
|
|
200
|
+
|
|
201
|
+
/** Called when a WebSocket connection is established. */
|
|
202
|
+
open?: (ws: WebSocket<UserData>) => void;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Called when a message is received.
|
|
206
|
+
*
|
|
207
|
+
* **Note:** subscribe/unsubscribe messages from the client store are
|
|
208
|
+
* handled automatically before this is called. You only need this for
|
|
209
|
+
* custom application-level messages.
|
|
210
|
+
*/
|
|
211
|
+
message?: (ws: WebSocket<UserData>, data: ArrayBuffer, isBinary: boolean) => void;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Called when a client tries to subscribe to a topic.
|
|
215
|
+
*
|
|
216
|
+
* - Return `false` to deny the subscription (silently ignored on the client).
|
|
217
|
+
* - Return anything else (or omit this export) to allow.
|
|
218
|
+
*
|
|
219
|
+
* Use this for per-topic authorization - e.g. only let admins subscribe to `'admin'`.
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```js
|
|
223
|
+
* export function subscribe(ws, topic) {
|
|
224
|
+
* const { role } = ws.getUserData();
|
|
225
|
+
* if (topic.startsWith('admin') && role !== 'admin') return false;
|
|
226
|
+
* }
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
subscribe?: (ws: WebSocket<UserData>, topic: string) => boolean | void;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Called when backpressure has drained (buffered data was sent).
|
|
233
|
+
* Use this for flow control when sending large or frequent messages.
|
|
234
|
+
*/
|
|
235
|
+
drain?: (ws: WebSocket<UserData>) => void;
|
|
236
|
+
|
|
237
|
+
/** Called when the connection closes. */
|
|
238
|
+
close?: (ws: WebSocket<UserData>, code: number, message: ArrayBuffer) => void;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Platform type for event.platform ────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Available on `event.platform` in server hooks, load functions, and actions.
|
|
245
|
+
*
|
|
246
|
+
* To get type-checking, add this to your `src/app.d.ts`:
|
|
247
|
+
*
|
|
248
|
+
* ```ts
|
|
249
|
+
* import type { Platform as AdapterPlatform } from 'svelte-adapter-uws';
|
|
250
|
+
*
|
|
251
|
+
* declare global {
|
|
252
|
+
* namespace App {
|
|
253
|
+
* interface Platform extends AdapterPlatform {}
|
|
254
|
+
* }
|
|
255
|
+
* }
|
|
256
|
+
* ```
|
|
257
|
+
*/
|
|
258
|
+
export interface Platform {
|
|
259
|
+
/**
|
|
260
|
+
* Publish a message to all WebSocket clients subscribed to a topic.
|
|
261
|
+
*
|
|
262
|
+
* The message is automatically wrapped in a `{ topic, event, data }` envelope
|
|
263
|
+
* that the client store (`svelte-adapter-uws/client`) understands.
|
|
264
|
+
*
|
|
265
|
+
* @param topic - Topic string (e.g. `'todos'`, `'user:123'`, `'org:456'`)
|
|
266
|
+
* @param event - Event name (e.g. `'created'`, `'updated'`, `'deleted'`)
|
|
267
|
+
* @param data - Payload (will be JSON-serialized)
|
|
268
|
+
*
|
|
269
|
+
* @example
|
|
270
|
+
* ```js
|
|
271
|
+
* // In a form action or API route:
|
|
272
|
+
* export async function POST({ platform }) {
|
|
273
|
+
* const todo = await db.save(data);
|
|
274
|
+
* platform.publish('todos', 'created', todo);
|
|
275
|
+
* }
|
|
276
|
+
* ```
|
|
277
|
+
*/
|
|
278
|
+
publish(topic: string, event: string, data?: unknown): boolean;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Send a message to a single WebSocket connection.
|
|
282
|
+
* Wraps in the same `{ topic, event, data }` envelope as `publish()`.
|
|
283
|
+
*
|
|
284
|
+
* @example
|
|
285
|
+
* ```js
|
|
286
|
+
* // In hooks.ws.js - reply to sender:
|
|
287
|
+
* export function message(ws, rawData) {
|
|
288
|
+
* const msg = JSON.parse(Buffer.from(rawData).toString());
|
|
289
|
+
* ws.send(JSON.stringify({ topic: 'echo', event: 'reply', data: { got: msg } }));
|
|
290
|
+
* }
|
|
291
|
+
* ```
|
|
292
|
+
*/
|
|
293
|
+
send(ws: WebSocket<any>, topic: string, event: string, data?: unknown): number;
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Send a message to all connections whose userData matches a filter.
|
|
297
|
+
* Returns the number of connections the message was sent to.
|
|
298
|
+
*
|
|
299
|
+
* The filter receives each connection's userData (whatever `upgrade()` returned).
|
|
300
|
+
*
|
|
301
|
+
* @example
|
|
302
|
+
* ```js
|
|
303
|
+
* // Send to a specific user (no need to maintain your own Map):
|
|
304
|
+
* export async function POST({ platform, request }) {
|
|
305
|
+
* const { targetUserId, message } = await request.json();
|
|
306
|
+
* platform.sendTo(
|
|
307
|
+
* (userData) => userData.userId === targetUserId,
|
|
308
|
+
* 'dm', 'new-message', { message }
|
|
309
|
+
* );
|
|
310
|
+
* }
|
|
311
|
+
*
|
|
312
|
+
* // Send to all admins:
|
|
313
|
+
* platform.sendTo(
|
|
314
|
+
* (userData) => userData.role === 'admin',
|
|
315
|
+
* 'alerts', 'warning', { message: 'Server load high' }
|
|
316
|
+
* );
|
|
317
|
+
* ```
|
|
318
|
+
*/
|
|
319
|
+
sendTo(filter: (userData: any) => boolean, topic: string, event: string, data?: unknown): number;
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Number of active WebSocket connections.
|
|
323
|
+
*
|
|
324
|
+
* @example
|
|
325
|
+
* ```js
|
|
326
|
+
* export async function GET({ platform }) {
|
|
327
|
+
* return json({ online: platform.connections });
|
|
328
|
+
* }
|
|
329
|
+
* ```
|
|
330
|
+
*/
|
|
331
|
+
readonly connections: number;
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Number of clients subscribed to a specific topic.
|
|
335
|
+
*
|
|
336
|
+
* @example
|
|
337
|
+
* ```js
|
|
338
|
+
* export async function GET({ platform, params }) {
|
|
339
|
+
* return json({ viewers: platform.subscribers(`page:${params.id}`) });
|
|
340
|
+
* }
|
|
341
|
+
* ```
|
|
342
|
+
*/
|
|
343
|
+
subscribers(topic: string): number;
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Get a scoped helper for a topic. Reduces repetition when publishing
|
|
347
|
+
* multiple events to the same topic, and provides CRUD shorthand methods
|
|
348
|
+
* that pair with the client's `crud()` helper.
|
|
349
|
+
*
|
|
350
|
+
* @param topic - Topic string (e.g. `'todos'`, `'user:123'`)
|
|
351
|
+
*
|
|
352
|
+
* @example
|
|
353
|
+
* ```js
|
|
354
|
+
* // In a form action:
|
|
355
|
+
* export async function POST({ platform, request }) {
|
|
356
|
+
* const todos = platform.topic('todos');
|
|
357
|
+
* const todo = await db.create(await request.formData());
|
|
358
|
+
* todos.created(todo); // clients see 'created' event
|
|
359
|
+
* }
|
|
360
|
+
*
|
|
361
|
+
* export const actions = {
|
|
362
|
+
* update: async ({ platform, request }) => {
|
|
363
|
+
* const todos = platform.topic('todos');
|
|
364
|
+
* const todo = await db.update(await request.formData());
|
|
365
|
+
* todos.updated(todo); // clients see 'updated' event
|
|
366
|
+
* },
|
|
367
|
+
* delete: async ({ platform, request }) => {
|
|
368
|
+
* const todos = platform.topic('todos');
|
|
369
|
+
* const id = (await request.formData()).get('id');
|
|
370
|
+
* await db.delete(id);
|
|
371
|
+
* todos.deleted({ id }); // clients see 'deleted' event
|
|
372
|
+
* }
|
|
373
|
+
* };
|
|
374
|
+
* ```
|
|
375
|
+
*/
|
|
376
|
+
topic(topic: string): TopicHelper;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export interface TopicHelper {
|
|
380
|
+
/** Publish a custom event to this topic. */
|
|
381
|
+
publish(event: string, data?: unknown): void;
|
|
382
|
+
/** Shorthand for `.publish('created', data)`. Pairs with `crud()` / `lookup()`. */
|
|
383
|
+
created(data?: unknown): void;
|
|
384
|
+
/** Shorthand for `.publish('updated', data)`. Pairs with `crud()` / `lookup()`. */
|
|
385
|
+
updated(data?: unknown): void;
|
|
386
|
+
/** Shorthand for `.publish('deleted', data)`. Pairs with `crud()` / `lookup()`. */
|
|
387
|
+
deleted(data?: unknown): void;
|
|
388
|
+
/** Shorthand for `.publish('set', value)`. Pairs with `count()`. */
|
|
389
|
+
set(value: number): void;
|
|
390
|
+
/** Shorthand for `.publish('increment', amount)`. Pairs with `count()`. */
|
|
391
|
+
increment(amount?: number): void;
|
|
392
|
+
/** Shorthand for `.publish('decrement', amount)`. Pairs with `count()`. */
|
|
393
|
+
decrement(amount?: number): void;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export default function adapter(options?: AdapterOptions): Adapter;
|