void 0.7.2 → 0.7.4
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/dist/{better-auth-shared-APuDaPqW.mjs → better-auth-shared-BrfZ78iY.mjs} +55 -5
- package/dist/cli/cli.mjs +8 -8
- package/dist/{db-ClNu7vYQ.mjs → db-RqxhOT2E.mjs} +1 -1
- package/dist/{deploy-BkjqNk9U.mjs → deploy-CRIz77mw.mjs} +243 -56
- package/dist/index.mjs +56 -22
- package/dist/{init-CPny6w9D.mjs → init-FMZ29UZW.mjs} +2 -2
- package/dist/{plugin-inference-oZ6Ybu2_.mjs → plugin-inference-CCtRkt9O.mjs} +41 -12
- package/dist/{prepare-DKkx-2Kt.mjs → prepare-PqAbhLte.mjs} +2 -2
- package/dist/{preset-DFvePt0l.mjs → preset-jKn1hQwP.mjs} +1 -1
- package/dist/runtime/better-auth-pg.mjs +1 -1
- package/dist/runtime/better-auth.mjs +1 -1
- package/dist/runtime/live-client.d.mts +34 -0
- package/dist/runtime/live-client.mjs +283 -0
- package/dist/runtime/live-server.d.mts +13 -0
- package/dist/runtime/live-server.mjs +417 -0
- package/dist/runtime/live.d.mts +107 -0
- package/dist/runtime/live.mjs +409 -0
- package/dist/runtime/migration-handler.mjs +34 -1
- package/dist/runtime/sse-client.d.mts +22 -0
- package/dist/runtime/sse-client.mjs +36 -0
- package/dist/runtime/sse.d.mts +35 -0
- package/dist/runtime/sse.mjs +172 -0
- package/dist/{scan-C6HMEIdW.mjs → scan-QtRTdh94.mjs} +1 -1
- package/package.json +42 -17
- package/skills/void/docs/guide/live.md +230 -0
- package/skills/void/docs/guide/sse.md +187 -0
- package/skills/void/docs/index.md +12 -15
- package/skills/void/docs/reference/api.md +4 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
//#region src/runtime/sse.ts
|
|
2
|
+
const DEFAULT_KEEP_ALIVE_INTERVAL_MS = 15e3;
|
|
3
|
+
const DEFAULT_KEEP_ALIVE_COMMENT = "keep-alive";
|
|
4
|
+
var SseStreamClosedError = class extends Error {
|
|
5
|
+
constructor(message = "sse: stream is closed.") {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "SseStreamClosedError";
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
function formatSse(message) {
|
|
11
|
+
const textMessage = {
|
|
12
|
+
event: message.event,
|
|
13
|
+
id: message.id,
|
|
14
|
+
retry: message.retry
|
|
15
|
+
};
|
|
16
|
+
if ("data" in message) textMessage.data = serializeData(message.data);
|
|
17
|
+
return formatSseText(textMessage);
|
|
18
|
+
}
|
|
19
|
+
function formatSseText(message) {
|
|
20
|
+
const lines = [];
|
|
21
|
+
if (message.id != null) {
|
|
22
|
+
assertControlField("id", message.id);
|
|
23
|
+
lines.push(`id: ${message.id}`);
|
|
24
|
+
}
|
|
25
|
+
if (message.event != null) {
|
|
26
|
+
assertControlField("event", message.event);
|
|
27
|
+
lines.push(`event: ${message.event}`);
|
|
28
|
+
}
|
|
29
|
+
if (message.retry != null) {
|
|
30
|
+
assertRetry(message.retry);
|
|
31
|
+
lines.push(`retry: ${message.retry}`);
|
|
32
|
+
}
|
|
33
|
+
if ("data" in message) {
|
|
34
|
+
if (typeof message.data !== "string") throw new Error("sse: data must be a string.");
|
|
35
|
+
for (const line of splitLines(message.data)) lines.push(`data: ${line}`);
|
|
36
|
+
}
|
|
37
|
+
return `${lines.join("\n")}\n\n`;
|
|
38
|
+
}
|
|
39
|
+
function getLastEventId(request) {
|
|
40
|
+
return request.headers.get("Last-Event-ID");
|
|
41
|
+
}
|
|
42
|
+
function eventStream(start, options = {}) {
|
|
43
|
+
const encoder = new TextEncoder();
|
|
44
|
+
const { readable, writable } = new TransformStream();
|
|
45
|
+
const writer = writable.getWriter();
|
|
46
|
+
const signalController = new AbortController();
|
|
47
|
+
const headers = buildSseHeaders(options.headers);
|
|
48
|
+
const keepAlive = normalizeKeepAlive(options.keepAlive);
|
|
49
|
+
let keepAliveTimer;
|
|
50
|
+
let closedOnce = false;
|
|
51
|
+
let closeResolve;
|
|
52
|
+
const closed = new Promise((resolve) => {
|
|
53
|
+
closeResolve = resolve;
|
|
54
|
+
});
|
|
55
|
+
const markClosed = () => {
|
|
56
|
+
if (closedOnce) return;
|
|
57
|
+
closedOnce = true;
|
|
58
|
+
if (!signalController.signal.aborted) signalController.abort();
|
|
59
|
+
if (keepAliveTimer != null) {
|
|
60
|
+
clearInterval(keepAliveTimer);
|
|
61
|
+
keepAliveTimer = void 0;
|
|
62
|
+
}
|
|
63
|
+
closeResolve();
|
|
64
|
+
};
|
|
65
|
+
async function close() {
|
|
66
|
+
if (closedOnce) return;
|
|
67
|
+
markClosed();
|
|
68
|
+
await writer.close().catch(() => {});
|
|
69
|
+
}
|
|
70
|
+
async function fail(error) {
|
|
71
|
+
if (closedOnce) return;
|
|
72
|
+
markClosed();
|
|
73
|
+
await writer.abort(error).catch(() => {});
|
|
74
|
+
}
|
|
75
|
+
async function write(text) {
|
|
76
|
+
if (closedOnce) throw new SseStreamClosedError();
|
|
77
|
+
try {
|
|
78
|
+
await writer.write(encoder.encode(text));
|
|
79
|
+
} catch {
|
|
80
|
+
await close();
|
|
81
|
+
throw new SseStreamClosedError();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const stream = {
|
|
85
|
+
signal: signalController.signal,
|
|
86
|
+
closed,
|
|
87
|
+
send(message) {
|
|
88
|
+
return write(formatSse(message));
|
|
89
|
+
},
|
|
90
|
+
comment(text = "") {
|
|
91
|
+
return write(formatComment(text));
|
|
92
|
+
},
|
|
93
|
+
close
|
|
94
|
+
};
|
|
95
|
+
writer.closed.catch(() => {
|
|
96
|
+
close();
|
|
97
|
+
});
|
|
98
|
+
if (options.signal?.aborted) close();
|
|
99
|
+
else options.signal?.addEventListener("abort", () => void close(), { once: true });
|
|
100
|
+
if (keepAlive) keepAliveTimer = setInterval(() => {
|
|
101
|
+
stream.comment(keepAlive.comment).catch(() => close());
|
|
102
|
+
}, keepAlive.intervalMs);
|
|
103
|
+
queueMicrotask(() => {
|
|
104
|
+
(async () => {
|
|
105
|
+
try {
|
|
106
|
+
await start(stream);
|
|
107
|
+
await close();
|
|
108
|
+
} catch (error) {
|
|
109
|
+
await fail(error);
|
|
110
|
+
}
|
|
111
|
+
})();
|
|
112
|
+
});
|
|
113
|
+
return new Response(readable, { headers });
|
|
114
|
+
}
|
|
115
|
+
function buildSseHeaders(headers) {
|
|
116
|
+
const result = new Headers(headers);
|
|
117
|
+
if (!result.has("Content-Type")) result.set("Content-Type", "text/event-stream; charset=utf-8");
|
|
118
|
+
if (!result.has("Cache-Control")) result.set("Cache-Control", "no-cache, no-transform");
|
|
119
|
+
if (!result.has("X-Accel-Buffering")) result.set("X-Accel-Buffering", "no");
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
function normalizeKeepAlive(keepAlive) {
|
|
123
|
+
if (keepAlive === false) return null;
|
|
124
|
+
if (keepAlive === true || keepAlive == null) return {
|
|
125
|
+
intervalMs: DEFAULT_KEEP_ALIVE_INTERVAL_MS,
|
|
126
|
+
comment: DEFAULT_KEEP_ALIVE_COMMENT
|
|
127
|
+
};
|
|
128
|
+
const intervalMs = keepAlive.intervalMs ?? DEFAULT_KEEP_ALIVE_INTERVAL_MS;
|
|
129
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0) throw new Error("sse: keepAlive.intervalMs must be a positive finite number.");
|
|
130
|
+
return {
|
|
131
|
+
intervalMs,
|
|
132
|
+
comment: keepAlive.comment ?? DEFAULT_KEEP_ALIVE_COMMENT
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function formatComment(text) {
|
|
136
|
+
if (typeof text !== "string") throw new Error("sse: comment text must be a string.");
|
|
137
|
+
return `${splitLines(text).map((line) => `: ${line}`).join("\n")}\n\n`;
|
|
138
|
+
}
|
|
139
|
+
function assertControlField(name, value) {
|
|
140
|
+
if (typeof value !== "string") throw new Error(`sse: ${name} must be a string.`);
|
|
141
|
+
if (hasControlFieldSeparator(value)) throw new Error(`sse: ${name} must not contain CR, LF, or NUL characters.`);
|
|
142
|
+
}
|
|
143
|
+
function hasControlFieldSeparator(value) {
|
|
144
|
+
return value.includes("\r") || value.includes("\n") || value.includes("\0");
|
|
145
|
+
}
|
|
146
|
+
function assertRetry(value) {
|
|
147
|
+
if (!Number.isInteger(value) || value < 0) throw new Error("sse: retry must be a non-negative integer.");
|
|
148
|
+
}
|
|
149
|
+
function isBinaryData(data) {
|
|
150
|
+
return data instanceof ArrayBuffer || ArrayBuffer.isView(data) || typeof Blob !== "undefined" && data instanceof Blob || typeof File !== "undefined" && data instanceof File;
|
|
151
|
+
}
|
|
152
|
+
function serializeData(data) {
|
|
153
|
+
if (typeof data === "string") return data;
|
|
154
|
+
if (data == null) return data === null ? "null" : throwDataSerializationError(data);
|
|
155
|
+
if (typeof data === "function" || typeof data === "symbol") return throwDataSerializationError(data);
|
|
156
|
+
if (isBinaryData(data)) throw new Error("sse: binary data is not supported.");
|
|
157
|
+
try {
|
|
158
|
+
const serialized = JSON.stringify(data);
|
|
159
|
+
if (typeof serialized !== "string") return throwDataSerializationError(data);
|
|
160
|
+
return serialized;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
throw new Error(`sse: data must be JSON-serializable. ${error instanceof Error ? error.message : String(error)}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function throwDataSerializationError(data) {
|
|
166
|
+
throw new Error(`sse: unsupported data value ${String(data)}.`);
|
|
167
|
+
}
|
|
168
|
+
function splitLines(value) {
|
|
169
|
+
return value.split(/\r\n|\r|\n/);
|
|
170
|
+
}
|
|
171
|
+
//#endregion
|
|
172
|
+
export { SseStreamClosedError, eventStream, formatSse, formatSseText, getLastEventId };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { a as join, t as basename } from "./pathe.M-eThtNZ-D-kmWkCS.mjs";
|
|
2
2
|
import { n as globSync, t as glob } from "./dist-DUyXJLkq.mjs";
|
|
3
|
-
import { d as
|
|
3
|
+
import { d as isCallExpression, f as isIdentifier, h as isObjectExpression, p as isLiteral } from "./plugin-inference-CCtRkt9O.mjs";
|
|
4
4
|
import { o as parseRouteFilename, s as parseWebSocketFilename, t as scanPages } from "./scan-Ba4hFwlH.mjs";
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
6
|
import { parseSync } from "vite";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "void",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/voidzero-dev/void.git",
|
|
@@ -31,6 +31,11 @@
|
|
|
31
31
|
"#self/remote/binding-handler": "./dist/runtime/remote/binding-handler.mjs",
|
|
32
32
|
"#self/response": "./dist/runtime/response.mjs",
|
|
33
33
|
"#self/sandbox": "./dist/runtime/sandbox.mjs",
|
|
34
|
+
"#self/sse": "./dist/runtime/sse.mjs",
|
|
35
|
+
"#self/sse/client": "./dist/runtime/sse-client.mjs",
|
|
36
|
+
"#self/live": "./dist/runtime/live.mjs",
|
|
37
|
+
"#self/live/client": "./dist/runtime/live-client.mjs",
|
|
38
|
+
"#self/runtime/live-server": "./dist/runtime/live-server.mjs",
|
|
34
39
|
"#self/runtime/fetch": "./dist/runtime/fetch.mjs",
|
|
35
40
|
"#self/runtime/fetch-stream": "./dist/runtime/fetch-stream.mjs",
|
|
36
41
|
"#self/runtime/migration-handler": "./dist/runtime/migration-handler.mjs",
|
|
@@ -86,6 +91,26 @@
|
|
|
86
91
|
"import": "./dist/runtime/fetch-stream.mjs",
|
|
87
92
|
"require": "./dist/runtime/fetch-stream.mjs"
|
|
88
93
|
},
|
|
94
|
+
"./sse": {
|
|
95
|
+
"types": "./dist/runtime/sse.d.mts",
|
|
96
|
+
"import": "./dist/runtime/sse.mjs",
|
|
97
|
+
"require": "./dist/runtime/sse.mjs"
|
|
98
|
+
},
|
|
99
|
+
"./sse/client": {
|
|
100
|
+
"types": "./dist/runtime/sse-client.d.mts",
|
|
101
|
+
"import": "./dist/runtime/sse-client.mjs",
|
|
102
|
+
"require": "./dist/runtime/sse-client.mjs"
|
|
103
|
+
},
|
|
104
|
+
"./live": {
|
|
105
|
+
"types": "./dist/runtime/live.d.mts",
|
|
106
|
+
"import": "./dist/runtime/live.mjs",
|
|
107
|
+
"require": "./dist/runtime/live.mjs"
|
|
108
|
+
},
|
|
109
|
+
"./live/client": {
|
|
110
|
+
"types": "./dist/runtime/live-client.d.mts",
|
|
111
|
+
"import": "./dist/runtime/live-client.mjs",
|
|
112
|
+
"require": "./dist/runtime/live-client.mjs"
|
|
113
|
+
},
|
|
89
114
|
"./ws": {
|
|
90
115
|
"types": "./dist/runtime/ws.d.mts",
|
|
91
116
|
"import": "./dist/runtime/ws.mjs",
|
|
@@ -261,15 +286,10 @@
|
|
|
261
286
|
"require": "./dist/runtime/db-pg.mjs"
|
|
262
287
|
}
|
|
263
288
|
},
|
|
264
|
-
"scripts": {
|
|
265
|
-
"build": "node scripts/sync-skill-docs.mjs && vp pack",
|
|
266
|
-
"dev": "vp pack --watch",
|
|
267
|
-
"typecheck": "tsgo --noEmit"
|
|
268
|
-
},
|
|
269
289
|
"dependencies": {
|
|
270
290
|
"@cloudflare/sandbox": "^0.10.0",
|
|
271
291
|
"@cloudflare/vite-plugin": "^1.36.3",
|
|
272
|
-
"@cloudflare/workers-types": "
|
|
292
|
+
"@cloudflare/workers-types": "^4.20260508.1",
|
|
273
293
|
"@hono/oauth-providers": "^0.8.5",
|
|
274
294
|
"@napi-rs/keyring": "1.3.0",
|
|
275
295
|
"better-auth": "^1.6.9",
|
|
@@ -284,35 +304,35 @@
|
|
|
284
304
|
"ignore": "^7.0.5",
|
|
285
305
|
"jsonc-parser": "3.3.1",
|
|
286
306
|
"pg": "^8.20.0",
|
|
287
|
-
"wrangler": "
|
|
307
|
+
"wrangler": "^4.90.0"
|
|
288
308
|
},
|
|
289
309
|
"devDependencies": {
|
|
290
|
-
"@clack/prompts": "
|
|
310
|
+
"@clack/prompts": "^1.3.0",
|
|
291
311
|
"@electric-sql/pglite": "0.4.5",
|
|
292
312
|
"@types/better-sqlite3": "^7.6.13",
|
|
293
|
-
"@types/node": "
|
|
313
|
+
"@types/node": "^25.6.2",
|
|
294
314
|
"@types/pg": "^8.20.0",
|
|
295
|
-
"@typescript/native-preview": "
|
|
315
|
+
"@typescript/native-preview": "7.0.0-dev.20260507.1",
|
|
296
316
|
"arktype": "^2.2.0",
|
|
297
317
|
"es-module-lexer": "^2.1.0",
|
|
298
318
|
"estree-walker": "^3.0.3",
|
|
299
319
|
"magic-string": "^0.30.21",
|
|
300
320
|
"ofetch": "2.0.0-alpha.3",
|
|
301
|
-
"pathe": "
|
|
321
|
+
"pathe": "^2.0.3",
|
|
302
322
|
"pglite-server": "0.1.5",
|
|
303
323
|
"picocolors": "^1.1.1",
|
|
304
324
|
"tinyglobby": "^0.2.16",
|
|
305
325
|
"valibot": "^1.4.0",
|
|
306
|
-
"vite": "
|
|
307
|
-
"vite-plus": "
|
|
326
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.1.20",
|
|
327
|
+
"vite-plus": "^0.1.20",
|
|
308
328
|
"zod": "^4.4.3"
|
|
309
329
|
},
|
|
310
330
|
"peerDependencies": {
|
|
311
|
-
"@void/md": "workspace:*",
|
|
312
331
|
"arktype": ">=2.0.0",
|
|
313
332
|
"valibot": ">=1.0.0-beta.7",
|
|
314
333
|
"vite": "^8.0.0",
|
|
315
|
-
"zod": "^3.25.0 || ^4.0.0"
|
|
334
|
+
"zod": "^3.25.0 || ^4.0.0",
|
|
335
|
+
"@void/md": "0.7.4"
|
|
316
336
|
},
|
|
317
337
|
"peerDependenciesMeta": {
|
|
318
338
|
"@void/md": {
|
|
@@ -327,5 +347,10 @@
|
|
|
327
347
|
"zod": {
|
|
328
348
|
"optional": true
|
|
329
349
|
}
|
|
350
|
+
},
|
|
351
|
+
"scripts": {
|
|
352
|
+
"build": "node scripts/sync-skill-docs.mjs && vp pack",
|
|
353
|
+
"dev": "vp pack --watch",
|
|
354
|
+
"typecheck": "tsgo --noEmit"
|
|
330
355
|
}
|
|
331
|
-
}
|
|
356
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
---
|
|
2
|
+
outline: deep
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Live Event Streams
|
|
6
|
+
|
|
7
|
+
`void/live` provides Durable Object-backed fanout over Server-Sent Events. Use it
|
|
8
|
+
when one browser tab needs a single SSE connection that can subscribe and
|
|
9
|
+
unsubscribe from many application topics over time.
|
|
10
|
+
|
|
11
|
+
Use `void/sse` for request-owned streams where the producer lives inside the same
|
|
12
|
+
route handler. Use `void/live` when later requests, mutations, scheduled jobs, or
|
|
13
|
+
queue consumers need to publish to clients that are already connected.
|
|
14
|
+
|
|
15
|
+
## Define A Stream
|
|
16
|
+
|
|
17
|
+
Create a server-only live stream descriptor:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// src/live.ts
|
|
21
|
+
import { defineLiveStream } from 'void/live';
|
|
22
|
+
|
|
23
|
+
export const live = defineLiveStream({
|
|
24
|
+
id: 'app',
|
|
25
|
+
allowAnonymousControl: true,
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Expose it from a normal route:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// routes/live.ts
|
|
33
|
+
import { defineHandler } from 'void';
|
|
34
|
+
import { live } from '../src/live';
|
|
35
|
+
|
|
36
|
+
export const GET = defineHandler((c) => live.connect(c));
|
|
37
|
+
export const POST = defineHandler((c) => live.control(c));
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`GET` opens the SSE connection. `POST` accepts subscribe and unsubscribe control
|
|
41
|
+
operations for that connection.
|
|
42
|
+
|
|
43
|
+
## Subscribe From The Browser
|
|
44
|
+
|
|
45
|
+
Use the browser helper from `void/live/client`:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { connectLiveStream } from 'void/live/client';
|
|
49
|
+
|
|
50
|
+
const stream = connectLiveStream('/live', {
|
|
51
|
+
withCredentials: true,
|
|
52
|
+
retryDelay: 1_000,
|
|
53
|
+
onError(error) {
|
|
54
|
+
console.error(error);
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const unsubscribePost = await stream.subscribe({
|
|
59
|
+
id: 'post-card',
|
|
60
|
+
topic: 'post:12',
|
|
61
|
+
onEvent(event) {
|
|
62
|
+
if (event.type === 'updated') {
|
|
63
|
+
console.log(event.data);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const unsubscribeComments = await stream.subscribe({
|
|
69
|
+
id: 'comments',
|
|
70
|
+
topic: 'comment:2323',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await unsubscribeComments();
|
|
74
|
+
await unsubscribePost();
|
|
75
|
+
stream.close();
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The helper opens one native `EventSource`, waits for it to open, then sends
|
|
79
|
+
batched `POST` control operations. Subscription ids are scoped to the
|
|
80
|
+
connection. Reusing an id replaces the previous subscription with that id. If
|
|
81
|
+
the transport drops, the helper reconnects and resubmits active subscriptions
|
|
82
|
+
with their latest `eventId` as `lastEventId`.
|
|
83
|
+
|
|
84
|
+
## Publish
|
|
85
|
+
|
|
86
|
+
Publish from any server-side code that has a Void runtime env:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { live } from '../src/live';
|
|
90
|
+
|
|
91
|
+
await live.publish(
|
|
92
|
+
'post:12',
|
|
93
|
+
{ title: 'Updated title' },
|
|
94
|
+
{ type: 'updated', eventId: 'post-12-v8' },
|
|
95
|
+
);
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Topics are opaque strings. Payloads are application-owned JSON. `type` and
|
|
99
|
+
`eventId` are copied into the JSON envelope; `void/live` does not interpret
|
|
100
|
+
them, persist them, or use native SSE `id` fields.
|
|
101
|
+
|
|
102
|
+
If you are outside an active request/runtime context, pass env explicitly:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
await live.withEnv(env).publish('post:12', { title: 'Updated title' });
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Authorization
|
|
109
|
+
|
|
110
|
+
Every live connection has an owner. The owner is the identity allowed to send
|
|
111
|
+
control operations for that SSE connection. `connect()` resolves and stores the
|
|
112
|
+
owner; `control()` resolves the owner again and rejects the request if it does
|
|
113
|
+
not match.
|
|
114
|
+
|
|
115
|
+
The quickstart uses an anonymous stream:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
export const live = defineLiveStream({
|
|
119
|
+
id: 'app',
|
|
120
|
+
allowAnonymousControl: true,
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
For authenticated streams, pass the same owner key to `connect()` and
|
|
125
|
+
`control()`:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
return live.connect(c, { owner: `user:${session.userId}` });
|
|
129
|
+
return live.control(c, { owner: `user:${session.userId}` });
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
If every route should derive ownership the same way, define it once with
|
|
133
|
+
`identifyConnection`:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
export const live = defineLiveStream({
|
|
137
|
+
id: 'app',
|
|
138
|
+
async identifyConnection(ctx) {
|
|
139
|
+
const session = await getSession(ctx.request);
|
|
140
|
+
return session ? `user:${session.userId}` : null;
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Then the route handlers can stay thin:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
export const GET = defineHandler((c) => live.connect(c));
|
|
149
|
+
export const POST = defineHandler((c) => live.control(c));
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
When `identifyConnection` returns `null` or `undefined`, Void falls back to the
|
|
153
|
+
current authenticated user and uses `user:${user.id}` when the user has a string
|
|
154
|
+
`id`. If neither path produces an owner and `allowAnonymousControl` is not set,
|
|
155
|
+
the request is rejected with `403`.
|
|
156
|
+
|
|
157
|
+
For stream-local subscription rules, use `onSubscribe`:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
export const live = defineLiveStream({
|
|
161
|
+
id: 'app',
|
|
162
|
+
async onSubscribe(ctx) {
|
|
163
|
+
const match = /^post:(.+)$/.exec(ctx.topic);
|
|
164
|
+
if (match && !(await canReadPost(ctx.env, ctx.user, match[1]))) {
|
|
165
|
+
return new Response('Forbidden', { status: 403 });
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Limits
|
|
172
|
+
|
|
173
|
+
`void/live` is designed for small and medium fanout. By default, a stream allows:
|
|
174
|
+
|
|
175
|
+
- `256` active subscriptions per browser connection
|
|
176
|
+
- `256` active subscriptions per topic
|
|
177
|
+
- `100` subscribe or unsubscribe operations per control request
|
|
178
|
+
- `100` queued events per connection
|
|
179
|
+
- `64 KiB` per encoded event envelope
|
|
180
|
+
|
|
181
|
+
A topic is the unit of fanout. For example, if each blog post uses a topic like
|
|
182
|
+
`post:${postId}`, then each post can have up to `256` active live subscribers at
|
|
183
|
+
one time. Other posts use separate topics and have separate limits.
|
|
184
|
+
|
|
185
|
+
Streams can have many possible topics. Topics are created on demand when clients
|
|
186
|
+
subscribe or publishers send events, so an app with thousands of posts, rooms, or
|
|
187
|
+
documents can use one topic per entity without provisioning them ahead of time.
|
|
188
|
+
The limit applies to each active topic, not to the total number of topic names
|
|
189
|
+
your app might use.
|
|
190
|
+
|
|
191
|
+
For example, a blog with `10,000` posts can model its live capacity as
|
|
192
|
+
`10,000 posts × up to 256 active subscribers per post topic`.
|
|
193
|
+
|
|
194
|
+
That shape works because each post topic is independent. A single post with more
|
|
195
|
+
than `256` active live subscribers would need a larger broadcast design.
|
|
196
|
+
|
|
197
|
+
These limits apply to live subscriptions, not total users or total page views.
|
|
198
|
+
One user with two open tabs may count twice. Disconnected subscriptions are
|
|
199
|
+
pruned, but they can count until the runtime observes that they are stale.
|
|
200
|
+
|
|
201
|
+
You can lower limits per stream:
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
export const live = defineLiveStream({
|
|
205
|
+
id: 'app',
|
|
206
|
+
limits: {
|
|
207
|
+
maxSubscriptionsPerTopic: 64,
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`maxSubscriptionsPerTopic` cannot be raised above `256`. For larger broadcast
|
|
213
|
+
workloads, shard topics in userland or use a dedicated realtime system.
|
|
214
|
+
|
|
215
|
+
## Delivery Semantics
|
|
216
|
+
|
|
217
|
+
`void/live` is an at-most-once live fanout primitive:
|
|
218
|
+
|
|
219
|
+
- one SSE connection can hold many topic subscriptions
|
|
220
|
+
- publish cost is proportional to subscribers of the topic
|
|
221
|
+
- events are ordered within one topic
|
|
222
|
+
- clients using the raw HTTP protocol must resubscribe after reconnect
|
|
223
|
+
- deploys, rollbacks, Worker restarts, browser reconnects, and network changes
|
|
224
|
+
can drop live state
|
|
225
|
+
- durable replay, cache invalidation, live queries, and client state management
|
|
226
|
+
belong in userland
|
|
227
|
+
|
|
228
|
+
Use an application database, queue, or custom Durable Object for replay. Use
|
|
229
|
+
`eventId` and caller-provided `lastEventId` values as application protocol
|
|
230
|
+
fields when you build that layer.
|