@parsrun/realtime 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/README.md ADDED
@@ -0,0 +1,176 @@
1
+ # @parsrun/realtime
2
+
3
+ Edge-compatible realtime features for Pars with SSE and Durable Objects support.
4
+
5
+ ## Features
6
+
7
+ - **SSE (Server-Sent Events)**: Simple real-time streaming
8
+ - **Durable Objects**: Cloudflare stateful WebSockets
9
+ - **Pub/Sub**: Channel-based messaging
10
+ - **Presence**: Online user tracking
11
+ - **Hono Integration**: Ready-to-use middleware
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pnpm add @parsrun/realtime
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ```typescript
22
+ import { createSSEStream } from '@parsrun/realtime';
23
+
24
+ // In Hono route
25
+ app.get('/events', (c) => {
26
+ const stream = createSSEStream(c);
27
+
28
+ // Send events
29
+ stream.send({ type: 'connected', data: { userId: '123' } });
30
+
31
+ // Subscribe to events
32
+ events.on('update', (data) => {
33
+ stream.send({ type: 'update', data });
34
+ });
35
+
36
+ return stream.response();
37
+ });
38
+ ```
39
+
40
+ ## API Overview
41
+
42
+ ### SSE (Server-Sent Events)
43
+
44
+ ```typescript
45
+ import { createSSEAdapter, createSSEStream } from '@parsrun/realtime/adapters/sse';
46
+
47
+ // Create SSE stream
48
+ app.get('/events/:channel', async (c) => {
49
+ const channel = c.req.param('channel');
50
+ const stream = createSSEStream(c);
51
+
52
+ // Send initial data
53
+ stream.send({
54
+ event: 'connected',
55
+ data: { channel },
56
+ });
57
+
58
+ // Subscribe to channel
59
+ const unsubscribe = pubsub.subscribe(channel, (message) => {
60
+ stream.send({
61
+ event: 'message',
62
+ data: message,
63
+ });
64
+ });
65
+
66
+ // Cleanup on disconnect
67
+ c.req.raw.signal.addEventListener('abort', () => {
68
+ unsubscribe();
69
+ });
70
+
71
+ return stream.response();
72
+ });
73
+ ```
74
+
75
+ ### Durable Objects (Cloudflare)
76
+
77
+ ```typescript
78
+ import { createDurableObjectRoom } from '@parsrun/realtime/adapters/durable-objects';
79
+
80
+ // Define room class
81
+ export class ChatRoom extends createDurableObjectRoom({
82
+ onConnect: async (ws, state) => {
83
+ ws.send(JSON.stringify({ type: 'connected' }));
84
+ },
85
+
86
+ onMessage: async (ws, message, state) => {
87
+ // Broadcast to all connections
88
+ state.broadcast(message);
89
+ },
90
+
91
+ onDisconnect: async (ws, state) => {
92
+ // Cleanup
93
+ },
94
+ }) {}
95
+
96
+ // In worker
97
+ app.get('/ws/:roomId', async (c) => {
98
+ const roomId = c.req.param('roomId');
99
+ const id = env.CHAT_ROOM.idFromName(roomId);
100
+ const room = env.CHAT_ROOM.get(id);
101
+
102
+ return room.fetch(c.req.raw);
103
+ });
104
+ ```
105
+
106
+ ### Pub/Sub
107
+
108
+ ```typescript
109
+ import { createPubSub } from '@parsrun/realtime';
110
+
111
+ const pubsub = createPubSub();
112
+
113
+ // Subscribe
114
+ const unsubscribe = pubsub.subscribe('channel', (message) => {
115
+ console.log('Received:', message);
116
+ });
117
+
118
+ // Publish
119
+ await pubsub.publish('channel', { type: 'update', data: {} });
120
+
121
+ // Unsubscribe
122
+ unsubscribe();
123
+ ```
124
+
125
+ ### Presence
126
+
127
+ ```typescript
128
+ import { createPresence } from '@parsrun/realtime';
129
+
130
+ const presence = createPresence({
131
+ heartbeatInterval: 30000,
132
+ });
133
+
134
+ // Join
135
+ await presence.join('room:123', {
136
+ userId: 'user:1',
137
+ data: { name: 'John' },
138
+ });
139
+
140
+ // Get online users
141
+ const users = await presence.getMembers('room:123');
142
+
143
+ // Leave
144
+ await presence.leave('room:123', 'user:1');
145
+ ```
146
+
147
+ ### Hono Integration
148
+
149
+ ```typescript
150
+ import { createRealtimeMiddleware } from '@parsrun/realtime/hono';
151
+
152
+ const realtime = createRealtimeMiddleware({
153
+ pubsub,
154
+ presence,
155
+ });
156
+
157
+ app.use('/realtime/*', realtime);
158
+
159
+ // Routes automatically created:
160
+ // GET /realtime/events/:channel - SSE stream
161
+ // POST /realtime/publish/:channel - Publish message
162
+ // GET /realtime/presence/:room - Get presence
163
+ ```
164
+
165
+ ## Exports
166
+
167
+ ```typescript
168
+ import { ... } from '@parsrun/realtime'; // Main exports
169
+ import { ... } from '@parsrun/realtime/adapters/sse'; // SSE adapter
170
+ import { ... } from '@parsrun/realtime/adapters/durable-objects'; // Durable Objects
171
+ import { ... } from '@parsrun/realtime/hono'; // Hono integration
172
+ ```
173
+
174
+ ## License
175
+
176
+ MIT
@@ -0,0 +1,89 @@
1
+ import { DurableObjectsAdapterOptions, RealtimeAdapter, MessageHandler, RealtimeMessage, PresenceUser } from '../types.js';
2
+
3
+ /**
4
+ * @parsrun/realtime - Durable Objects Adapter
5
+ * Cloudflare Durable Objects adapter for WebSocket-based realtime
6
+ */
7
+
8
+ /**
9
+ * RealtimeChannel Durable Object
10
+ * Manages a single realtime channel with WebSocket connections
11
+ *
12
+ * Usage in wrangler.toml:
13
+ * ```toml
14
+ * [durable_objects]
15
+ * bindings = [{ name = "REALTIME_CHANNELS", class_name = "RealtimeChannelDO" }]
16
+ *
17
+ * [[migrations]]
18
+ * tag = "v1"
19
+ * new_classes = ["RealtimeChannelDO"]
20
+ * ```
21
+ */
22
+ declare class RealtimeChannelDO implements DurableObject {
23
+ private sessions;
24
+ private presence;
25
+ private channelName;
26
+ private state;
27
+ constructor(state: DurableObjectState, _env: unknown);
28
+ fetch(request: Request): Promise<Response>;
29
+ private handleWebSocketUpgrade;
30
+ webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void>;
31
+ webSocketClose(ws: WebSocket, code: number, reason: string, _wasClean: boolean): Promise<void>;
32
+ webSocketError(ws: WebSocket, _error: unknown): Promise<void>;
33
+ private handleMessage;
34
+ private handlePresenceJoin;
35
+ private handlePresenceUpdate;
36
+ private handlePresenceLeave;
37
+ private broadcastPresenceUpdate;
38
+ private handleBroadcast;
39
+ private handleGetPresence;
40
+ private handleGetInfo;
41
+ private handleSendToUser;
42
+ private getSessionByWebSocket;
43
+ private broadcastToAll;
44
+ }
45
+ /**
46
+ * Durable Objects Adapter
47
+ * Uses RealtimeChannelDO for channel management
48
+ */
49
+ declare class DurableObjectsAdapter implements RealtimeAdapter {
50
+ readonly type: "durable-objects";
51
+ private namespace;
52
+ private channelPrefix;
53
+ private localHandlers;
54
+ constructor(options: DurableObjectsAdapterOptions);
55
+ /**
56
+ * Get Durable Object stub for a channel
57
+ */
58
+ getChannelStub(channel: string): DurableObjectStub;
59
+ /**
60
+ * Get WebSocket URL for a channel
61
+ */
62
+ getWebSocketUrl(channel: string, sessionId: string, userId?: string, baseUrl?: string): string;
63
+ subscribe(channel: string, sessionId: string, handler: MessageHandler): Promise<void>;
64
+ unsubscribe(channel: string, sessionId: string): Promise<void>;
65
+ publish<T = unknown>(channel: string, message: RealtimeMessage<T>): Promise<void>;
66
+ sendToSession<T = unknown>(_sessionId: string, _message: RealtimeMessage<T>): Promise<boolean>;
67
+ getSubscribers(channel: string): Promise<string[]>;
68
+ setPresence<T = unknown>(_channel: string, _sessionId: string, _userId: string, _data: T): Promise<void>;
69
+ removePresence(_channel: string, _sessionId: string): Promise<void>;
70
+ getPresence<T = unknown>(channel: string): Promise<PresenceUser<T>[]>;
71
+ close(): Promise<void>;
72
+ /**
73
+ * Get channel info from Durable Object
74
+ */
75
+ getChannelInfo(channel: string): Promise<{
76
+ connections: number;
77
+ presence: number;
78
+ }>;
79
+ /**
80
+ * Send message to specific user in a channel
81
+ */
82
+ sendToUser<T = unknown>(channel: string, userId: string, event: string, data: T): Promise<boolean>;
83
+ }
84
+ /**
85
+ * Create Durable Objects adapter
86
+ */
87
+ declare function createDurableObjectsAdapter(options: DurableObjectsAdapterOptions): DurableObjectsAdapter;
88
+
89
+ export { DurableObjectsAdapter, RealtimeChannelDO, createDurableObjectsAdapter };
@@ -0,0 +1,414 @@
1
+ // src/types.ts
2
+ function createMessage(options) {
3
+ return {
4
+ id: crypto.randomUUID(),
5
+ event: options.event,
6
+ channel: options.channel,
7
+ data: options.data,
8
+ senderId: options.senderId,
9
+ timestamp: Date.now(),
10
+ metadata: options.metadata
11
+ };
12
+ }
13
+
14
+ // src/adapters/durable-objects.ts
15
+ var RealtimeChannelDO = class {
16
+ sessions = /* @__PURE__ */ new Map();
17
+ presence = /* @__PURE__ */ new Map();
18
+ channelName = "";
19
+ state;
20
+ constructor(state, _env) {
21
+ this.state = state;
22
+ this.state.getWebSockets().forEach((ws) => {
23
+ const meta = ws.deserializeAttachment();
24
+ if (meta) {
25
+ this.sessions.set(meta.sessionId, {
26
+ sessionId: meta.sessionId,
27
+ userId: meta.userId,
28
+ webSocket: ws,
29
+ state: "open",
30
+ createdAt: Date.now()
31
+ });
32
+ }
33
+ });
34
+ }
35
+ async fetch(request) {
36
+ const url = new URL(request.url);
37
+ const pathParts = url.pathname.split("/").filter(Boolean);
38
+ const lastPart = pathParts[pathParts.length - 1];
39
+ if (lastPart) {
40
+ this.channelName = lastPart;
41
+ }
42
+ if (request.headers.get("Upgrade") === "websocket") {
43
+ return this.handleWebSocketUpgrade(request);
44
+ }
45
+ switch (url.pathname.split("/").pop()) {
46
+ case "broadcast":
47
+ return this.handleBroadcast(request);
48
+ case "presence":
49
+ return this.handleGetPresence();
50
+ case "info":
51
+ return this.handleGetInfo();
52
+ case "send":
53
+ return this.handleSendToUser(request);
54
+ default:
55
+ return new Response("Not found", { status: 404 });
56
+ }
57
+ }
58
+ // ============================================================================
59
+ // WebSocket Handling
60
+ // ============================================================================
61
+ handleWebSocketUpgrade(request) {
62
+ const url = new URL(request.url);
63
+ const sessionId = url.searchParams.get("sessionId") || crypto.randomUUID();
64
+ const userId = url.searchParams.get("userId") || void 0;
65
+ const pair = new WebSocketPair();
66
+ const client = pair[0];
67
+ const server = pair[1];
68
+ server.serializeAttachment({ sessionId, userId });
69
+ this.state.acceptWebSocket(server);
70
+ const session = {
71
+ sessionId,
72
+ userId,
73
+ webSocket: server,
74
+ state: "open",
75
+ createdAt: Date.now()
76
+ };
77
+ this.sessions.set(sessionId, session);
78
+ server.send(
79
+ JSON.stringify(
80
+ createMessage({
81
+ event: "connection:open",
82
+ channel: this.channelName,
83
+ data: { sessionId, userId }
84
+ })
85
+ )
86
+ );
87
+ return new Response(null, { status: 101, webSocket: client });
88
+ }
89
+ async webSocketMessage(ws, message) {
90
+ const session = this.getSessionByWebSocket(ws);
91
+ if (!session) return;
92
+ try {
93
+ const data = typeof message === "string" ? message : new TextDecoder().decode(message);
94
+ const parsed = JSON.parse(data);
95
+ await this.handleMessage(session, parsed);
96
+ } catch {
97
+ }
98
+ }
99
+ async webSocketClose(ws, code, reason, _wasClean) {
100
+ const session = this.getSessionByWebSocket(ws);
101
+ if (!session) return;
102
+ this.presence.delete(session.sessionId);
103
+ await this.broadcastPresenceUpdate();
104
+ this.sessions.delete(session.sessionId);
105
+ await this.broadcastToAll({
106
+ event: "connection:close",
107
+ channel: this.channelName,
108
+ data: {
109
+ sessionId: session.sessionId,
110
+ userId: session.userId,
111
+ code,
112
+ reason
113
+ }
114
+ });
115
+ }
116
+ async webSocketError(ws, _error) {
117
+ const session = this.getSessionByWebSocket(ws);
118
+ if (session) {
119
+ session.state = "closed";
120
+ this.sessions.delete(session.sessionId);
121
+ this.presence.delete(session.sessionId);
122
+ }
123
+ }
124
+ // ============================================================================
125
+ // Message Handling
126
+ // ============================================================================
127
+ async handleMessage(session, message) {
128
+ switch (message.event) {
129
+ case "ping":
130
+ session.webSocket.send(
131
+ JSON.stringify(
132
+ createMessage({
133
+ event: "pong",
134
+ channel: this.channelName,
135
+ data: { timestamp: Date.now() }
136
+ })
137
+ )
138
+ );
139
+ break;
140
+ case "presence:join":
141
+ await this.handlePresenceJoin(session, message.data);
142
+ break;
143
+ case "presence:update":
144
+ await this.handlePresenceUpdate(session, message.data);
145
+ break;
146
+ case "presence:leave":
147
+ await this.handlePresenceLeave(session);
148
+ break;
149
+ case "broadcast":
150
+ await this.broadcastToAll(
151
+ {
152
+ event: message.event,
153
+ channel: this.channelName,
154
+ data: message.data,
155
+ senderId: session.sessionId
156
+ },
157
+ session.sessionId
158
+ );
159
+ break;
160
+ default:
161
+ await this.broadcastToAll(
162
+ {
163
+ event: message.event,
164
+ channel: this.channelName,
165
+ data: message.data,
166
+ senderId: session.sessionId
167
+ },
168
+ session.sessionId
169
+ );
170
+ }
171
+ }
172
+ // ============================================================================
173
+ // Presence Handling
174
+ // ============================================================================
175
+ async handlePresenceJoin(session, data) {
176
+ const now = Date.now();
177
+ const user = {
178
+ userId: session.userId || session.sessionId,
179
+ sessionId: session.sessionId,
180
+ data,
181
+ joinedAt: now,
182
+ lastSeenAt: now
183
+ };
184
+ this.presence.set(session.sessionId, user);
185
+ session.presence = data;
186
+ await this.broadcastPresenceUpdate();
187
+ }
188
+ async handlePresenceUpdate(session, data) {
189
+ const existing = this.presence.get(session.sessionId);
190
+ if (existing) {
191
+ existing.data = data;
192
+ existing.lastSeenAt = Date.now();
193
+ session.presence = data;
194
+ await this.broadcastPresenceUpdate();
195
+ }
196
+ }
197
+ async handlePresenceLeave(session) {
198
+ this.presence.delete(session.sessionId);
199
+ session.presence = void 0;
200
+ await this.broadcastPresenceUpdate();
201
+ }
202
+ async broadcastPresenceUpdate() {
203
+ const presenceList = Array.from(this.presence.values());
204
+ await this.broadcastToAll({
205
+ event: "presence:sync",
206
+ channel: this.channelName,
207
+ data: presenceList
208
+ });
209
+ }
210
+ // ============================================================================
211
+ // REST API Handlers
212
+ // ============================================================================
213
+ async handleBroadcast(request) {
214
+ try {
215
+ const body = await request.json();
216
+ await this.broadcastToAll({
217
+ event: body.event,
218
+ channel: this.channelName,
219
+ data: body.data
220
+ });
221
+ return new Response(JSON.stringify({ success: true }), {
222
+ headers: { "Content-Type": "application/json" }
223
+ });
224
+ } catch (err) {
225
+ return new Response(
226
+ JSON.stringify({ success: false, error: String(err) }),
227
+ { status: 400, headers: { "Content-Type": "application/json" } }
228
+ );
229
+ }
230
+ }
231
+ handleGetPresence() {
232
+ const presenceList = Array.from(this.presence.values());
233
+ return new Response(JSON.stringify(presenceList), {
234
+ headers: { "Content-Type": "application/json" }
235
+ });
236
+ }
237
+ handleGetInfo() {
238
+ return new Response(
239
+ JSON.stringify({
240
+ channel: this.channelName,
241
+ connections: this.sessions.size,
242
+ presence: this.presence.size
243
+ }),
244
+ { headers: { "Content-Type": "application/json" } }
245
+ );
246
+ }
247
+ async handleSendToUser(request) {
248
+ try {
249
+ const body = await request.json();
250
+ let sent = false;
251
+ for (const session of this.sessions.values()) {
252
+ if (session.userId === body.userId && session.state === "open") {
253
+ session.webSocket.send(
254
+ JSON.stringify(
255
+ createMessage({
256
+ event: body.event,
257
+ channel: this.channelName,
258
+ data: body.data
259
+ })
260
+ )
261
+ );
262
+ sent = true;
263
+ }
264
+ }
265
+ return new Response(JSON.stringify({ success: true, sent }), {
266
+ headers: { "Content-Type": "application/json" }
267
+ });
268
+ } catch (err) {
269
+ return new Response(
270
+ JSON.stringify({ success: false, error: String(err) }),
271
+ { status: 400, headers: { "Content-Type": "application/json" } }
272
+ );
273
+ }
274
+ }
275
+ // ============================================================================
276
+ // Helpers
277
+ // ============================================================================
278
+ getSessionByWebSocket(ws) {
279
+ for (const session of this.sessions.values()) {
280
+ if (session.webSocket === ws) {
281
+ return session;
282
+ }
283
+ }
284
+ return void 0;
285
+ }
286
+ async broadcastToAll(messageData, excludeSessionId) {
287
+ const message = createMessage(messageData);
288
+ const payload = JSON.stringify(message);
289
+ for (const session of this.sessions.values()) {
290
+ if (session.sessionId === excludeSessionId) continue;
291
+ if (session.state !== "open") continue;
292
+ try {
293
+ session.webSocket.send(payload);
294
+ } catch {
295
+ session.state = "closed";
296
+ }
297
+ }
298
+ }
299
+ };
300
+ var DurableObjectsAdapter = class {
301
+ type = "durable-objects";
302
+ namespace;
303
+ channelPrefix;
304
+ localHandlers = /* @__PURE__ */ new Map();
305
+ constructor(options) {
306
+ this.namespace = options.namespace;
307
+ this.channelPrefix = options.channelPrefix ?? "channel:";
308
+ }
309
+ /**
310
+ * Get Durable Object stub for a channel
311
+ */
312
+ getChannelStub(channel) {
313
+ const id = this.namespace.idFromName(`${this.channelPrefix}${channel}`);
314
+ return this.namespace.get(id);
315
+ }
316
+ /**
317
+ * Get WebSocket URL for a channel
318
+ */
319
+ getWebSocketUrl(channel, sessionId, userId, baseUrl) {
320
+ const base = baseUrl || "wss://your-worker.your-subdomain.workers.dev";
321
+ const params = new URLSearchParams({ sessionId });
322
+ if (userId) params.set("userId", userId);
323
+ return `${base}/realtime/${channel}?${params}`;
324
+ }
325
+ // ============================================================================
326
+ // RealtimeAdapter Implementation
327
+ // ============================================================================
328
+ async subscribe(channel, sessionId, handler) {
329
+ if (!this.localHandlers.has(channel)) {
330
+ this.localHandlers.set(channel, /* @__PURE__ */ new Map());
331
+ }
332
+ this.localHandlers.get(channel).set(sessionId, handler);
333
+ }
334
+ async unsubscribe(channel, sessionId) {
335
+ const handlers = this.localHandlers.get(channel);
336
+ if (handlers) {
337
+ handlers.delete(sessionId);
338
+ if (handlers.size === 0) {
339
+ this.localHandlers.delete(channel);
340
+ }
341
+ }
342
+ }
343
+ async publish(channel, message) {
344
+ const stub = this.getChannelStub(channel);
345
+ await stub.fetch(new Request("https://do/broadcast", {
346
+ method: "POST",
347
+ headers: { "Content-Type": "application/json" },
348
+ body: JSON.stringify({
349
+ event: message.event,
350
+ data: message.data
351
+ })
352
+ }));
353
+ const handlers = this.localHandlers.get(channel);
354
+ if (handlers) {
355
+ for (const handler of handlers.values()) {
356
+ try {
357
+ await handler(message);
358
+ } catch {
359
+ }
360
+ }
361
+ }
362
+ }
363
+ async sendToSession(_sessionId, _message) {
364
+ return false;
365
+ }
366
+ async getSubscribers(channel) {
367
+ const handlers = this.localHandlers.get(channel);
368
+ return handlers ? Array.from(handlers.keys()) : [];
369
+ }
370
+ async setPresence(_channel, _sessionId, _userId, _data) {
371
+ }
372
+ async removePresence(_channel, _sessionId) {
373
+ }
374
+ async getPresence(channel) {
375
+ const stub = this.getChannelStub(channel);
376
+ const response = await stub.fetch(new Request("https://do/presence"));
377
+ return response.json();
378
+ }
379
+ async close() {
380
+ this.localHandlers.clear();
381
+ }
382
+ /**
383
+ * Get channel info from Durable Object
384
+ */
385
+ async getChannelInfo(channel) {
386
+ const stub = this.getChannelStub(channel);
387
+ const response = await stub.fetch(new Request("https://do/info"));
388
+ return response.json();
389
+ }
390
+ /**
391
+ * Send message to specific user in a channel
392
+ */
393
+ async sendToUser(channel, userId, event, data) {
394
+ const stub = this.getChannelStub(channel);
395
+ const response = await stub.fetch(
396
+ new Request("https://do/send", {
397
+ method: "POST",
398
+ headers: { "Content-Type": "application/json" },
399
+ body: JSON.stringify({ userId, event, data })
400
+ })
401
+ );
402
+ const result = await response.json();
403
+ return result.sent;
404
+ }
405
+ };
406
+ function createDurableObjectsAdapter(options) {
407
+ return new DurableObjectsAdapter(options);
408
+ }
409
+ export {
410
+ DurableObjectsAdapter,
411
+ RealtimeChannelDO,
412
+ createDurableObjectsAdapter
413
+ };
414
+ //# sourceMappingURL=durable-objects.js.map