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/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;