@suckless/sse 0.6.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/README.md +80 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +168 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @suckless/sse
|
|
2
|
+
|
|
3
|
+
Server-Sent Events channel for Web API servers. Zero dependencies, runtime-agnostic.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @suckless/sse
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createSSEChannel } from "@suckless/sse"
|
|
15
|
+
|
|
16
|
+
const channel = createSSEChannel({ heartbeat: 15_000, replay: 50 })
|
|
17
|
+
|
|
18
|
+
// In your request handler
|
|
19
|
+
function handler(req: Request): Response {
|
|
20
|
+
return channel.connect(req)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Broadcast from anywhere
|
|
24
|
+
channel.send("message", { text: "hello" })
|
|
25
|
+
channel.send("update", { count: 42 })
|
|
26
|
+
|
|
27
|
+
// Cleanup
|
|
28
|
+
channel.close()
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## How it works
|
|
32
|
+
|
|
33
|
+
Each call to `connect()` creates a `ReadableStream` wired to the client via the standard `Response` constructor. Events are broadcast to all connected clients as SSE-formatted text chunks. Clients that disconnect (via `AbortSignal` or stream cancellation) are cleaned up automatically.
|
|
34
|
+
|
|
35
|
+
Optional keepalive comments (`: keepalive`) prevent proxies and load balancers from closing idle connections. An optional replay buffer stores recent events so reconnecting clients can catch up via the `Last-Event-ID` header.
|
|
36
|
+
|
|
37
|
+
To avoid unbounded memory growth, clients that stop draining their stream are disconnected once their pending buffer exceeds the internal safety limit.
|
|
38
|
+
|
|
39
|
+
## API
|
|
40
|
+
|
|
41
|
+
### `createSSEChannel(options?): SSEChannel`
|
|
42
|
+
|
|
43
|
+
Creates a new SSE channel.
|
|
44
|
+
|
|
45
|
+
| Option | Type | Default | Description |
|
|
46
|
+
| ----------- | -------- | ------- | --------------------------------------------------------- |
|
|
47
|
+
| `heartbeat` | `number` | `15000` | Keepalive interval in ms. Set to `0` to disable. |
|
|
48
|
+
| `replay` | `number` | `0` | Number of recent events to buffer. Set to `0` to disable. |
|
|
49
|
+
|
|
50
|
+
### `channel.connect(req): Response`
|
|
51
|
+
|
|
52
|
+
Accepts a `Request` and returns an SSE `Response`. Honors `Last-Event-ID` for replay. Returns a `503` if the channel is closed.
|
|
53
|
+
|
|
54
|
+
### `channel.send(event, data): void`
|
|
55
|
+
|
|
56
|
+
Broadcasts an event to all connected clients. `event` must not contain CR/LF characters, and `data` must serialize with `JSON.stringify()`. Throws if validation fails or the channel is closed.
|
|
57
|
+
|
|
58
|
+
### `channel.close(): void`
|
|
59
|
+
|
|
60
|
+
Closes the channel, disconnects all clients, and clears the replay buffer. Idempotent.
|
|
61
|
+
|
|
62
|
+
### `channel.clients: number`
|
|
63
|
+
|
|
64
|
+
Number of currently connected clients.
|
|
65
|
+
|
|
66
|
+
### Cleanup
|
|
67
|
+
|
|
68
|
+
The channel implements the standard disposal protocol:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
using channel = createSSEChannel()
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`using` requires toolchain support for Explicit Resource Management (TypeScript 5.2+).
|
|
75
|
+
|
|
76
|
+
You can also call `channel[Symbol.dispose]()` directly.
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** JSON-serializable value. Includes objects with a `toJSON()` method (e.g. `Date`). */
|
|
2
|
+
type JsonValue = string | number | boolean | null | {
|
|
3
|
+
toJSON(): unknown;
|
|
4
|
+
} | JsonValue[] | {
|
|
5
|
+
[key: string]: JsonValue;
|
|
6
|
+
};
|
|
7
|
+
interface SSEChannelOptions {
|
|
8
|
+
/** Keepalive interval in milliseconds. Set to 0 to disable. Default: 15000. */
|
|
9
|
+
heartbeat?: number;
|
|
10
|
+
/** Number of recent events to buffer for Last-Event-ID reconnection. Set to 0 to disable. Default: 0. */
|
|
11
|
+
replay?: number;
|
|
12
|
+
}
|
|
13
|
+
/** Server-Sent Events channel. */
|
|
14
|
+
interface SSEChannel {
|
|
15
|
+
/** Number of currently connected clients. */
|
|
16
|
+
readonly clients: number;
|
|
17
|
+
/** Connect a request and return an SSE response stream. */
|
|
18
|
+
connect(req: Request): Response;
|
|
19
|
+
/** Broadcast an event to all connected clients. */
|
|
20
|
+
send(event: string, data: JsonValue): void;
|
|
21
|
+
/** Close the channel and disconnect all clients. */
|
|
22
|
+
close(): void;
|
|
23
|
+
/** Dispose the channel. Equivalent to calling `close()`. */
|
|
24
|
+
[Symbol.dispose](): void;
|
|
25
|
+
}
|
|
26
|
+
/** Create a Server-Sent Events channel. */
|
|
27
|
+
declare function createSSEChannel(options?: SSEChannelOptions): SSEChannel;
|
|
28
|
+
export { createSSEChannel, SSEChannelOptions, SSEChannel, JsonValue };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// packages/sse/src/index.ts
|
|
2
|
+
var encoder = new TextEncoder;
|
|
3
|
+
var KEEPALIVE_CHUNK = encoder.encode(`: keepalive
|
|
4
|
+
|
|
5
|
+
`);
|
|
6
|
+
var STREAM_HIGH_WATER_MARK = 64 * 1024;
|
|
7
|
+
function createSSEChannel(options) {
|
|
8
|
+
const heartbeatMs = options?.heartbeat ?? 15000;
|
|
9
|
+
const replaySize = options?.replay ?? 0;
|
|
10
|
+
if (heartbeatMs < 0 || !Number.isFinite(heartbeatMs)) {
|
|
11
|
+
throw new RangeError("heartbeat must be a non-negative finite number");
|
|
12
|
+
}
|
|
13
|
+
if (!Number.isInteger(replaySize) || replaySize < 0) {
|
|
14
|
+
throw new RangeError("replay must be a non-negative integer");
|
|
15
|
+
}
|
|
16
|
+
const replayBuffer = [];
|
|
17
|
+
const connections = new Map;
|
|
18
|
+
let nextClientId = 0;
|
|
19
|
+
let nextEventId = 0;
|
|
20
|
+
let closed = false;
|
|
21
|
+
function disconnect(clientId) {
|
|
22
|
+
const client = connections.get(clientId);
|
|
23
|
+
if (!client) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
connections.delete(clientId);
|
|
27
|
+
if (client.heartbeat !== undefined) {
|
|
28
|
+
clearInterval(client.heartbeat);
|
|
29
|
+
}
|
|
30
|
+
client.signal.removeEventListener("abort", client.onAbort);
|
|
31
|
+
try {
|
|
32
|
+
client.controller.close();
|
|
33
|
+
} catch {}
|
|
34
|
+
}
|
|
35
|
+
function enqueue(clientId, chunk) {
|
|
36
|
+
const client = connections.get(clientId);
|
|
37
|
+
if (!client) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const { desiredSize } = client.controller;
|
|
41
|
+
if (desiredSize !== null && desiredSize < chunk.byteLength) {
|
|
42
|
+
disconnect(clientId);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
client.controller.enqueue(chunk);
|
|
47
|
+
} catch {
|
|
48
|
+
disconnect(clientId);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function replay(clientId, lastEventId) {
|
|
52
|
+
if (!/^\d+$/.test(lastEventId)) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const id = Number(lastEventId);
|
|
56
|
+
if (!Number.isSafeInteger(id)) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
for (const entry of replayBuffer) {
|
|
60
|
+
if (entry.id > id) {
|
|
61
|
+
enqueue(clientId, entry.chunk);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function serializeEvent(id, event, data) {
|
|
66
|
+
if (event.includes(`
|
|
67
|
+
`) || event.includes("\r")) {
|
|
68
|
+
throw new RangeError("event must not contain CR or LF");
|
|
69
|
+
}
|
|
70
|
+
const payload = JSON.stringify(data);
|
|
71
|
+
if (payload === undefined) {
|
|
72
|
+
throw new TypeError("data must be JSON-serializable");
|
|
73
|
+
}
|
|
74
|
+
return encoder.encode(`id: ${id}
|
|
75
|
+
event: ${event}
|
|
76
|
+
data: ${payload}
|
|
77
|
+
|
|
78
|
+
`);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
get clients() {
|
|
82
|
+
return connections.size;
|
|
83
|
+
},
|
|
84
|
+
connect(req) {
|
|
85
|
+
if (closed) {
|
|
86
|
+
return new Response("channel closed", { status: 503 });
|
|
87
|
+
}
|
|
88
|
+
const clientId = nextClientId++;
|
|
89
|
+
const lastEventId = req.headers.get("Last-Event-ID");
|
|
90
|
+
const stream = new ReadableStream({
|
|
91
|
+
start: (controller) => {
|
|
92
|
+
let heartbeat;
|
|
93
|
+
const onAbort = () => {
|
|
94
|
+
disconnect(clientId);
|
|
95
|
+
};
|
|
96
|
+
if (heartbeatMs > 0) {
|
|
97
|
+
heartbeat = setInterval(() => {
|
|
98
|
+
enqueue(clientId, KEEPALIVE_CHUNK);
|
|
99
|
+
}, heartbeatMs);
|
|
100
|
+
if (typeof heartbeat === "object" && "unref" in heartbeat) {
|
|
101
|
+
heartbeat.unref();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
connections.set(clientId, {
|
|
105
|
+
controller,
|
|
106
|
+
heartbeat,
|
|
107
|
+
signal: req.signal,
|
|
108
|
+
onAbort
|
|
109
|
+
});
|
|
110
|
+
if (req.signal.aborted) {
|
|
111
|
+
disconnect(clientId);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
req.signal.addEventListener("abort", onAbort, {
|
|
115
|
+
once: true
|
|
116
|
+
});
|
|
117
|
+
if (lastEventId !== null) {
|
|
118
|
+
replay(clientId, lastEventId);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
cancel: () => {
|
|
122
|
+
disconnect(clientId);
|
|
123
|
+
}
|
|
124
|
+
}, {
|
|
125
|
+
highWaterMark: STREAM_HIGH_WATER_MARK,
|
|
126
|
+
size: (chunk) => chunk?.byteLength ?? 0
|
|
127
|
+
});
|
|
128
|
+
return new Response(stream, {
|
|
129
|
+
headers: {
|
|
130
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
131
|
+
"Cache-Control": "no-cache, no-transform",
|
|
132
|
+
"X-Accel-Buffering": "no"
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
send(event, data) {
|
|
137
|
+
if (closed) {
|
|
138
|
+
throw new Error("Channel is closed");
|
|
139
|
+
}
|
|
140
|
+
const id = nextEventId++;
|
|
141
|
+
const chunk = serializeEvent(id, event, data);
|
|
142
|
+
if (replaySize > 0) {
|
|
143
|
+
replayBuffer.push({ id, chunk });
|
|
144
|
+
if (replayBuffer.length > replaySize) {
|
|
145
|
+
replayBuffer.shift();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (const clientId of connections.keys()) {
|
|
149
|
+
enqueue(clientId, chunk);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
close() {
|
|
153
|
+
closed = true;
|
|
154
|
+
for (const clientId of connections.keys()) {
|
|
155
|
+
disconnect(clientId);
|
|
156
|
+
}
|
|
157
|
+
replayBuffer.length = 0;
|
|
158
|
+
},
|
|
159
|
+
[Symbol.dispose]() {
|
|
160
|
+
if (!closed) {
|
|
161
|
+
this.close();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
export {
|
|
167
|
+
createSSEChannel
|
|
168
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@suckless/sse",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Server-Sent Events channel for Web API servers",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"eventsource",
|
|
7
|
+
"realtime",
|
|
8
|
+
"server-sent-events",
|
|
9
|
+
"sse",
|
|
10
|
+
"streaming",
|
|
11
|
+
"suckless",
|
|
12
|
+
"typescript"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/brielov/suckless/tree/master/packages/sse#readme",
|
|
15
|
+
"bugs": "https://github.com/brielov/suckless/issues",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "brielov",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/brielov/suckless.git",
|
|
21
|
+
"directory": "packages/sse"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"module": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"import": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"default": "./dist/index.js"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"./package.json": "./package.json"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"typescript": ">=5.2.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependenciesMeta": {
|
|
43
|
+
"typescript": {
|
|
44
|
+
"optional": true
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|