void 0.7.3 → 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 +27 -2
- 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",
|
|
@@ -307,7 +332,7 @@
|
|
|
307
332
|
"valibot": ">=1.0.0-beta.7",
|
|
308
333
|
"vite": "^8.0.0",
|
|
309
334
|
"zod": "^3.25.0 || ^4.0.0",
|
|
310
|
-
"@void/md": "0.7.
|
|
335
|
+
"@void/md": "0.7.4"
|
|
311
336
|
},
|
|
312
337
|
"peerDependenciesMeta": {
|
|
313
338
|
"@void/md": {
|
|
@@ -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.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
---
|
|
2
|
+
outline: deep
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Server-Sent Events
|
|
6
|
+
|
|
7
|
+
Void provides `void/sse` for producing and consuming Server-Sent Events from ordinary route handlers. It handles event formatting, response headers, keepalives, stream closure, request aborts, and browser `EventSource` parsing.
|
|
8
|
+
|
|
9
|
+
Use SSE when one HTTP request owns the producer: AI token streaming, progress updates, command output, deployment logs, or incremental status messages. Use `void/live` or an application-level Durable Object when multiple requests need shared fanout, replay, or subscription state.
|
|
10
|
+
|
|
11
|
+
## Server streams
|
|
12
|
+
|
|
13
|
+
Return `eventStream()` from a route handler:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// routes/api/events.ts
|
|
17
|
+
import { defineHandler } from 'void';
|
|
18
|
+
import { eventStream } from 'void/sse';
|
|
19
|
+
|
|
20
|
+
export const GET = defineHandler((c) => {
|
|
21
|
+
return eventStream(
|
|
22
|
+
async (stream) => {
|
|
23
|
+
await stream.comment('connected');
|
|
24
|
+
await stream.send({
|
|
25
|
+
event: 'ready',
|
|
26
|
+
data: { now: Date.now() },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const timer = setInterval(() => {
|
|
30
|
+
void stream.send({ event: 'tick', data: { now: Date.now() } }).catch(() => stream.close());
|
|
31
|
+
}, 1000);
|
|
32
|
+
|
|
33
|
+
await stream.closed;
|
|
34
|
+
clearInterval(timer);
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
signal: c.req.raw.signal,
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`eventStream()` returns a `Response` immediately with these default headers:
|
|
44
|
+
|
|
45
|
+
```http
|
|
46
|
+
Content-Type: text/event-stream; charset=utf-8
|
|
47
|
+
Cache-Control: no-cache, no-transform
|
|
48
|
+
X-Accel-Buffering: no
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
When the `start` callback returns, the stream closes. Long-lived streams should wait on `stream.closed`, a producer promise, or an application cancellation signal before returning.
|
|
52
|
+
|
|
53
|
+
## Sending events
|
|
54
|
+
|
|
55
|
+
Use `send()` for SSE messages and `comment()` for comments:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
await stream.send({
|
|
59
|
+
id: 'evt_1',
|
|
60
|
+
event: 'post.update',
|
|
61
|
+
retry: 1000,
|
|
62
|
+
data: { id: 'post_123', likes: 10 },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await stream.comment('still connected');
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`data` may be a string or JSON-serializable value. Strings are sent as-is; other values are serialized with `JSON.stringify()`. Multi-line strings are split into multiple `data:` lines. Binary data is rejected because SSE is text-only.
|
|
69
|
+
|
|
70
|
+
`event`, `id`, and `retry` are validated before writing so accidental frame injection is rejected. Writes after close reject with `SseStreamClosedError`.
|
|
71
|
+
|
|
72
|
+
If you already serialized the payload, use `formatSseText()` for lower-level formatting while keeping the same `id`, `event`, and `retry` validation:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { formatSseText } from 'void/sse';
|
|
76
|
+
|
|
77
|
+
const frame = formatSseText({
|
|
78
|
+
event: 'post.update',
|
|
79
|
+
data: JSON.stringify({ id: 'post_123', likes: 10 }),
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Keepalives
|
|
84
|
+
|
|
85
|
+
Keepalives are enabled by default every 15 seconds:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
return eventStream(start);
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Disable them or customize the interval and comment text:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
return eventStream(start, { keepAlive: false });
|
|
95
|
+
|
|
96
|
+
return eventStream(start, {
|
|
97
|
+
keepAlive: { intervalMs: 5000, comment: 'ping' },
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The interval must be a positive finite number.
|
|
102
|
+
|
|
103
|
+
## Last Event ID
|
|
104
|
+
|
|
105
|
+
Browsers send `Last-Event-ID` when reconnecting after an event with an `id` field. Use `getLastEventId()` to resume from your own storage:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { getLastEventId } from 'void/sse';
|
|
109
|
+
|
|
110
|
+
export const GET = defineHandler((c) => {
|
|
111
|
+
const lastId = getLastEventId(c.req.raw);
|
|
112
|
+
return eventStream(async (stream) => {
|
|
113
|
+
await stream.send({ id: nextId(lastId), data: await loadNextItem(lastId) });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`void/sse` does not store or replay events. Persist event offsets in your own database, queue, or Durable Object when replay matters.
|
|
119
|
+
|
|
120
|
+
## Client
|
|
121
|
+
|
|
122
|
+
Use `connectEventStream()` from the browser-only `void/sse/client` subpath:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { connectEventStream } from 'void/sse/client';
|
|
126
|
+
|
|
127
|
+
const stream = connectEventStream<{
|
|
128
|
+
ready: { now: number };
|
|
129
|
+
tick: { now: number };
|
|
130
|
+
}>('/api/events', {
|
|
131
|
+
withCredentials: true,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const offReady = stream.on('ready', (event) => {
|
|
135
|
+
console.log(event.data.now);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
stream.on('tick', (event) => {
|
|
139
|
+
console.log(event.data.now);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
offReady();
|
|
143
|
+
stream.close();
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The client wraps native `EventSource`. It supports cookie credentials and typed event handlers, but it does not support request bodies or arbitrary headers. Use cookie auth, signed URLs, or a fetch-based streaming route when you need custom headers.
|
|
147
|
+
|
|
148
|
+
JSON parsing is the default. Set `parse: 'text'` or pass a custom parser for non-JSON payloads:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
const logs = connectEventStream('/api/logs', { parse: 'text' });
|
|
152
|
+
|
|
153
|
+
logs.on('line', (event) => {
|
|
154
|
+
console.log(event.data);
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Auth
|
|
159
|
+
|
|
160
|
+
SSE routes are ordinary route handlers, so use the same auth checks you use for JSON routes:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
import { requireAuth } from 'void/auth';
|
|
164
|
+
|
|
165
|
+
export const GET = defineHandler(async (c) => {
|
|
166
|
+
const user = await requireAuth(c);
|
|
167
|
+
|
|
168
|
+
return eventStream(async (stream) => {
|
|
169
|
+
await stream.send({ event: 'ready', data: { userId: user.id } });
|
|
170
|
+
await stream.closed;
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Native `EventSource` can send cookies with `withCredentials: true`. For non-cookie auth, generate a short-lived signed URL and validate it in the route handler.
|
|
176
|
+
|
|
177
|
+
## When to use SSE
|
|
178
|
+
|
|
179
|
+
Plain SSE is enough when the producer belongs to the same request that opened the stream:
|
|
180
|
+
|
|
181
|
+
- AI token streaming
|
|
182
|
+
- One-off progress updates
|
|
183
|
+
- Command output
|
|
184
|
+
- Per-request deployment or build logs
|
|
185
|
+
- Incremental status for a long-running action
|
|
186
|
+
|
|
187
|
+
Use a higher-level realtime primitive when you need cross-request fanout, rooms, replay buffers, subscriptions, database change streams, or multi-region coordination.
|