void 0.7.3 → 0.7.5

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.
Files changed (34) hide show
  1. package/dist/{better-auth-shared-APuDaPqW.mjs → better-auth-shared-CjnJZFC0.mjs} +51 -5
  2. package/dist/cli/cli.mjs +8 -8
  3. package/dist/{db-ClNu7vYQ.mjs → db-RqxhOT2E.mjs} +1 -1
  4. package/dist/{deploy-BkjqNk9U.mjs → deploy-CRIz77mw.mjs} +243 -56
  5. package/dist/index.mjs +56 -22
  6. package/dist/{init-CPny6w9D.mjs → init-FMZ29UZW.mjs} +2 -2
  7. package/dist/pages/client.d.mts +4 -3
  8. package/dist/pages/client.mjs +18 -6
  9. package/dist/pages/protocol.d.mts +1 -1
  10. package/dist/pages/protocol.mjs +5 -0
  11. package/dist/{plugin-inference-oZ6Ybu2_.mjs → plugin-inference-CCtRkt9O.mjs} +41 -12
  12. package/dist/{prepare-DKkx-2Kt.mjs → prepare-PqAbhLte.mjs} +2 -2
  13. package/dist/{preset-DFvePt0l.mjs → preset-jKn1hQwP.mjs} +1 -1
  14. package/dist/{protocol-CK4OFwfR.d.mts → protocol-DxGzKEz2.d.mts} +1 -0
  15. package/dist/runtime/better-auth-pg.mjs +1 -1
  16. package/dist/runtime/better-auth.mjs +1 -1
  17. package/dist/runtime/live-client.d.mts +34 -0
  18. package/dist/runtime/live-client.mjs +283 -0
  19. package/dist/runtime/live-server.d.mts +13 -0
  20. package/dist/runtime/live-server.mjs +417 -0
  21. package/dist/runtime/live.d.mts +107 -0
  22. package/dist/runtime/live.mjs +409 -0
  23. package/dist/runtime/migration-handler.mjs +34 -1
  24. package/dist/runtime/sse-client.d.mts +22 -0
  25. package/dist/runtime/sse-client.mjs +36 -0
  26. package/dist/runtime/sse.d.mts +35 -0
  27. package/dist/runtime/sse.mjs +172 -0
  28. package/dist/{scan-C6HMEIdW.mjs → scan-QtRTdh94.mjs} +1 -1
  29. package/package.json +27 -2
  30. package/skills/void/docs/guide/live.md +230 -0
  31. package/skills/void/docs/guide/pages-routing/overview.md +11 -0
  32. package/skills/void/docs/guide/sse.md +187 -0
  33. package/skills/void/docs/index.md +12 -15
  34. package/skills/void/docs/reference/api.md +12 -4
@@ -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 isIdentifier, f as isLiteral, m as isObjectExpression, u as isCallExpression } from "./plugin-inference-oZ6Ybu2_.mjs";
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",
3
+ "version": "0.7.5",
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.3"
335
+ "@void/md": "0.7.5"
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.
@@ -195,6 +195,17 @@ router.refresh(); // re-fetch current page props
195
195
  router.visit('/logout', { method: 'POST' }); // non-GET navigation
196
196
  ```
197
197
 
198
+ For dynamic route params, use `useParams()` from the same adapter package:
199
+
200
+ ```tsx
201
+ import { useParams } from '@void/react';
202
+
203
+ export default function PostPage() {
204
+ const { id } = useParams<{ id: string }>();
205
+ return <h1>Post {id}</h1>;
206
+ }
207
+ ```
208
+
198
209
  ## Scroll Restoration
199
210
 
200
211
  The Void Router automatically saves and restores scroll position during client-side navigation:
@@ -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.