@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/dist/hono.d.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { Context, MiddlewareHandler, Hono } from 'hono';
2
+ import { SSEAdapter } from './adapters/sse.js';
3
+ import { RealtimeAdapter, SSEAdapterOptions } from './types.js';
4
+
5
+ /**
6
+ * @parsrun/realtime - Hono Integration
7
+ * Middleware and routes for Hono framework
8
+ */
9
+
10
+ /**
11
+ * Hono realtime context variables
12
+ */
13
+ interface RealtimeVariables {
14
+ realtime: {
15
+ adapter: RealtimeAdapter;
16
+ sessionId: string;
17
+ userId: string | undefined;
18
+ };
19
+ }
20
+ /**
21
+ * SSE route options
22
+ */
23
+ interface SSERouteOptions {
24
+ /** Get session ID from context (default: query param or random) */
25
+ getSessionId?: (c: Context) => string;
26
+ /** Get user ID from context (default: from auth) */
27
+ getUserId?: (c: Context) => string | undefined;
28
+ /** Channels the user can subscribe to */
29
+ getChannels?: (c: Context) => string[];
30
+ /** Called when connection opens */
31
+ onConnect?: (c: Context, sessionId: string) => void | Promise<void>;
32
+ /** Called when connection closes */
33
+ onDisconnect?: (c: Context, sessionId: string) => void | Promise<void>;
34
+ }
35
+ /**
36
+ * Durable Objects route options
37
+ */
38
+ interface DORouteOptions {
39
+ /** Durable Object namespace binding name */
40
+ namespaceBinding: string;
41
+ /** Channel prefix */
42
+ channelPrefix?: string;
43
+ /** Authorize connection */
44
+ authorize?: (c: Context, channel: string) => boolean | Promise<boolean>;
45
+ }
46
+ /**
47
+ * Create SSE realtime middleware
48
+ * Adds SSE adapter to context
49
+ */
50
+ declare function sseMiddleware(options?: SSEAdapterOptions): MiddlewareHandler<{
51
+ Variables: RealtimeVariables;
52
+ }>;
53
+ /**
54
+ * Create SSE subscription endpoint
55
+ * Client connects to this endpoint to receive events
56
+ */
57
+ declare function createSSEHandler(adapter: SSEAdapter, options?: SSERouteOptions): (c: Context) => Response | Promise<Response>;
58
+ /**
59
+ * Create complete SSE routes for Hono
60
+ */
61
+ declare function createSSERoutes(adapter: SSEAdapter, options?: SSERouteOptions): Hono;
62
+ /**
63
+ * Create Durable Objects realtime routes for Hono
64
+ * Proxies requests to the appropriate Durable Object
65
+ */
66
+ declare function createDORoutes(options: DORouteOptions): Hono;
67
+ /**
68
+ * Broadcast helper for use in route handlers
69
+ */
70
+ declare function broadcast(adapter: RealtimeAdapter, channel: string, event: string, data: unknown): Promise<void>;
71
+ /**
72
+ * Send to user helper
73
+ */
74
+ declare function sendToUser(adapter: RealtimeAdapter, channel: string, userId: string, event: string, data: unknown): Promise<void>;
75
+
76
+ export { type DORouteOptions, type RealtimeVariables, type SSERouteOptions, broadcast, createDORoutes, createSSEHandler, createSSERoutes, sendToUser, sseMiddleware };
package/dist/hono.js ADDED
@@ -0,0 +1,551 @@
1
+ // src/hono.ts
2
+ import { Hono } from "hono";
3
+
4
+ // src/types.ts
5
+ var RealtimeError = class extends Error {
6
+ constructor(message, code, cause) {
7
+ super(message);
8
+ this.code = code;
9
+ this.cause = cause;
10
+ this.name = "RealtimeError";
11
+ }
12
+ };
13
+ var RealtimeErrorCodes = {
14
+ CONNECTION_FAILED: "CONNECTION_FAILED",
15
+ CHANNEL_NOT_FOUND: "CHANNEL_NOT_FOUND",
16
+ UNAUTHORIZED: "UNAUTHORIZED",
17
+ MESSAGE_TOO_LARGE: "MESSAGE_TOO_LARGE",
18
+ RATE_LIMITED: "RATE_LIMITED",
19
+ ADAPTER_ERROR: "ADAPTER_ERROR",
20
+ INVALID_MESSAGE: "INVALID_MESSAGE"
21
+ };
22
+ function createMessage(options) {
23
+ return {
24
+ id: crypto.randomUUID(),
25
+ event: options.event,
26
+ channel: options.channel,
27
+ data: options.data,
28
+ senderId: options.senderId,
29
+ timestamp: Date.now(),
30
+ metadata: options.metadata
31
+ };
32
+ }
33
+ function formatSSEEvent(message) {
34
+ const lines = [];
35
+ lines.push(`id:${message.id}`);
36
+ lines.push(`event:${message.event}`);
37
+ lines.push(`data:${JSON.stringify(message)}`);
38
+ lines.push("");
39
+ return lines.join("\n") + "\n";
40
+ }
41
+
42
+ // src/adapters/sse.ts
43
+ var DEFAULT_OPTIONS = {
44
+ pingInterval: 3e4,
45
+ connectionTimeout: 0,
46
+ maxConnectionsPerChannel: 1e3,
47
+ retryDelay: 3e3
48
+ };
49
+ var SSEAdapter = class {
50
+ type = "sse";
51
+ options;
52
+ connections = /* @__PURE__ */ new Map();
53
+ channelSubscribers = /* @__PURE__ */ new Map();
54
+ presence = /* @__PURE__ */ new Map();
55
+ pingIntervalId = null;
56
+ constructor(options = {}) {
57
+ this.options = {
58
+ pingInterval: options.pingInterval ?? DEFAULT_OPTIONS.pingInterval,
59
+ connectionTimeout: options.connectionTimeout ?? DEFAULT_OPTIONS.connectionTimeout,
60
+ maxConnectionsPerChannel: options.maxConnectionsPerChannel ?? DEFAULT_OPTIONS.maxConnectionsPerChannel,
61
+ retryDelay: options.retryDelay ?? DEFAULT_OPTIONS.retryDelay
62
+ };
63
+ if (this.options.pingInterval > 0) {
64
+ this.startPingInterval();
65
+ }
66
+ }
67
+ /**
68
+ * Create SSE response for a new connection
69
+ */
70
+ createConnection(sessionId, userId) {
71
+ const { readable, writable } = new TransformStream();
72
+ const writer = writable.getWriter();
73
+ const connection = {
74
+ sessionId,
75
+ userId,
76
+ channels: /* @__PURE__ */ new Set(),
77
+ writer,
78
+ state: "open",
79
+ createdAt: Date.now(),
80
+ lastPingAt: Date.now()
81
+ };
82
+ this.connections.set(sessionId, connection);
83
+ const encoder = new TextEncoder();
84
+ writer.write(encoder.encode(`retry:${this.options.retryDelay}
85
+
86
+ `));
87
+ const response = new Response(readable, {
88
+ headers: {
89
+ "Content-Type": "text/event-stream",
90
+ "Cache-Control": "no-cache",
91
+ "Connection": "keep-alive",
92
+ "X-Accel-Buffering": "no"
93
+ // Disable nginx buffering
94
+ }
95
+ });
96
+ return { response, connection };
97
+ }
98
+ /**
99
+ * Close a connection
100
+ */
101
+ async closeConnection(sessionId) {
102
+ const connection = this.connections.get(sessionId);
103
+ if (!connection) return;
104
+ connection.state = "closed";
105
+ for (const channel of connection.channels) {
106
+ await this.unsubscribe(channel, sessionId);
107
+ await this.removePresence(channel, sessionId);
108
+ }
109
+ try {
110
+ await connection.writer.close();
111
+ } catch {
112
+ }
113
+ this.connections.delete(sessionId);
114
+ }
115
+ /**
116
+ * Get a connection by session ID
117
+ */
118
+ getConnection(sessionId) {
119
+ return this.connections.get(sessionId);
120
+ }
121
+ /**
122
+ * Get all active connections
123
+ */
124
+ getConnections() {
125
+ return this.connections;
126
+ }
127
+ // ============================================================================
128
+ // RealtimeAdapter Implementation
129
+ // ============================================================================
130
+ async subscribe(channel, sessionId, handler) {
131
+ const connection = this.connections.get(sessionId);
132
+ if (!connection) {
133
+ throw new RealtimeError(
134
+ "Connection not found",
135
+ RealtimeErrorCodes.CONNECTION_FAILED
136
+ );
137
+ }
138
+ const subscribers = this.channelSubscribers.get(channel);
139
+ if (subscribers && subscribers.size >= this.options.maxConnectionsPerChannel) {
140
+ throw new RealtimeError(
141
+ "Channel subscriber limit reached",
142
+ RealtimeErrorCodes.RATE_LIMITED
143
+ );
144
+ }
145
+ if (!this.channelSubscribers.has(channel)) {
146
+ this.channelSubscribers.set(channel, /* @__PURE__ */ new Map());
147
+ }
148
+ this.channelSubscribers.get(channel).set(sessionId, handler);
149
+ connection.channels.add(channel);
150
+ await this.sendToConnection(connection, {
151
+ id: crypto.randomUUID(),
152
+ event: "channel:subscribe",
153
+ channel,
154
+ data: { channel },
155
+ timestamp: Date.now()
156
+ });
157
+ }
158
+ async unsubscribe(channel, sessionId) {
159
+ const subscribers = this.channelSubscribers.get(channel);
160
+ if (subscribers) {
161
+ subscribers.delete(sessionId);
162
+ if (subscribers.size === 0) {
163
+ this.channelSubscribers.delete(channel);
164
+ }
165
+ }
166
+ const connection = this.connections.get(sessionId);
167
+ if (connection) {
168
+ connection.channels.delete(channel);
169
+ }
170
+ }
171
+ async publish(channel, message) {
172
+ const subscribers = this.channelSubscribers.get(channel);
173
+ if (!subscribers) return;
174
+ const promises = [];
175
+ for (const [sessionId, handler] of subscribers) {
176
+ const connection = this.connections.get(sessionId);
177
+ if (connection && connection.state === "open") {
178
+ promises.push(this.sendToConnection(connection, message));
179
+ try {
180
+ const result = handler(message);
181
+ if (result instanceof Promise) {
182
+ promises.push(result.then(() => {
183
+ }));
184
+ }
185
+ } catch {
186
+ }
187
+ }
188
+ }
189
+ await Promise.allSettled(promises);
190
+ }
191
+ async sendToSession(sessionId, message) {
192
+ const connection = this.connections.get(sessionId);
193
+ if (!connection || connection.state !== "open") {
194
+ return false;
195
+ }
196
+ try {
197
+ await this.sendToConnection(connection, message);
198
+ return true;
199
+ } catch {
200
+ return false;
201
+ }
202
+ }
203
+ async getSubscribers(channel) {
204
+ const subscribers = this.channelSubscribers.get(channel);
205
+ return subscribers ? Array.from(subscribers.keys()) : [];
206
+ }
207
+ async setPresence(channel, sessionId, userId, data) {
208
+ if (!this.presence.has(channel)) {
209
+ this.presence.set(channel, /* @__PURE__ */ new Map());
210
+ }
211
+ const now = Date.now();
212
+ const existing = this.presence.get(channel).get(sessionId);
213
+ const user = {
214
+ userId,
215
+ sessionId,
216
+ data,
217
+ joinedAt: existing?.joinedAt ?? now,
218
+ lastSeenAt: now
219
+ };
220
+ this.presence.get(channel).set(sessionId, user);
221
+ const eventType = existing ? "presence:update" : "presence:join";
222
+ await this.publish(channel, {
223
+ id: crypto.randomUUID(),
224
+ event: eventType,
225
+ channel,
226
+ data: {
227
+ type: existing ? "update" : "join",
228
+ user,
229
+ presence: await this.getPresence(channel)
230
+ },
231
+ timestamp: now
232
+ });
233
+ }
234
+ async removePresence(channel, sessionId) {
235
+ const channelPresence = this.presence.get(channel);
236
+ if (!channelPresence) return;
237
+ const user = channelPresence.get(sessionId);
238
+ if (!user) return;
239
+ channelPresence.delete(sessionId);
240
+ if (channelPresence.size === 0) {
241
+ this.presence.delete(channel);
242
+ }
243
+ await this.publish(channel, {
244
+ id: crypto.randomUUID(),
245
+ event: "presence:leave",
246
+ channel,
247
+ data: {
248
+ type: "leave",
249
+ user,
250
+ presence: await this.getPresence(channel)
251
+ },
252
+ timestamp: Date.now()
253
+ });
254
+ }
255
+ async getPresence(channel) {
256
+ const channelPresence = this.presence.get(channel);
257
+ if (!channelPresence) return [];
258
+ return Array.from(channelPresence.values());
259
+ }
260
+ async close() {
261
+ if (this.pingIntervalId) {
262
+ clearInterval(this.pingIntervalId);
263
+ this.pingIntervalId = null;
264
+ }
265
+ const closePromises = Array.from(this.connections.keys()).map(
266
+ (sessionId) => this.closeConnection(sessionId)
267
+ );
268
+ await Promise.allSettled(closePromises);
269
+ this.connections.clear();
270
+ this.channelSubscribers.clear();
271
+ this.presence.clear();
272
+ }
273
+ // ============================================================================
274
+ // Private Methods
275
+ // ============================================================================
276
+ async sendToConnection(connection, message) {
277
+ if (connection.state !== "open") return;
278
+ const encoder = new TextEncoder();
279
+ const sseEvent = formatSSEEvent(message);
280
+ try {
281
+ await connection.writer.write(encoder.encode(sseEvent));
282
+ } catch (err) {
283
+ connection.state = "closed";
284
+ await this.closeConnection(connection.sessionId);
285
+ }
286
+ }
287
+ startPingInterval() {
288
+ this.pingIntervalId = setInterval(() => {
289
+ this.sendPingToAll();
290
+ }, this.options.pingInterval);
291
+ }
292
+ async sendPingToAll() {
293
+ const now = Date.now();
294
+ const encoder = new TextEncoder();
295
+ const pingEvent = `event:ping
296
+ data:${now}
297
+
298
+ `;
299
+ for (const [sessionId, connection] of this.connections) {
300
+ if (connection.state !== "open") continue;
301
+ if (this.options.connectionTimeout > 0 && now - connection.lastPingAt > this.options.connectionTimeout) {
302
+ await this.closeConnection(sessionId);
303
+ continue;
304
+ }
305
+ try {
306
+ await connection.writer.write(encoder.encode(pingEvent));
307
+ connection.lastPingAt = now;
308
+ } catch {
309
+ await this.closeConnection(sessionId);
310
+ }
311
+ }
312
+ }
313
+ /**
314
+ * Update last ping time (call when receiving client ping)
315
+ */
316
+ updateLastPing(sessionId) {
317
+ const connection = this.connections.get(sessionId);
318
+ if (connection) {
319
+ connection.lastPingAt = Date.now();
320
+ }
321
+ }
322
+ /**
323
+ * Get statistics
324
+ */
325
+ getStats() {
326
+ const connectionsByChannel = {};
327
+ for (const [channel, subscribers] of this.channelSubscribers) {
328
+ connectionsByChannel[channel] = subscribers.size;
329
+ }
330
+ return {
331
+ totalConnections: this.connections.size,
332
+ totalChannels: this.channelSubscribers.size,
333
+ connectionsByChannel
334
+ };
335
+ }
336
+ };
337
+
338
+ // src/hono.ts
339
+ function sseMiddleware(options) {
340
+ const adapter = new SSEAdapter(options);
341
+ return async (c, next) => {
342
+ const sessionId = c.req.query("sessionId") || crypto.randomUUID();
343
+ const vars = c.var;
344
+ const userId = typeof vars["userId"] === "string" ? vars["userId"] : void 0;
345
+ c.set("realtime", {
346
+ adapter,
347
+ sessionId,
348
+ userId
349
+ });
350
+ await next();
351
+ };
352
+ }
353
+ function createSSEHandler(adapter, options = {}) {
354
+ return async (c) => {
355
+ const sessionId = options.getSessionId?.(c) || c.req.query("sessionId") || crypto.randomUUID();
356
+ const vars = c.var;
357
+ const userId = options.getUserId?.(c) || (typeof vars["userId"] === "string" ? vars["userId"] : void 0);
358
+ const channels = options.getChannels?.(c) || c.req.query("channels")?.split(",") || [];
359
+ const { response } = adapter.createConnection(sessionId, userId);
360
+ for (const channel of channels) {
361
+ await adapter.subscribe(channel, sessionId, () => {
362
+ });
363
+ }
364
+ if (options.onConnect) {
365
+ await options.onConnect(c, sessionId);
366
+ }
367
+ c.req.raw.signal.addEventListener("abort", async () => {
368
+ await adapter.closeConnection(sessionId);
369
+ if (options.onDisconnect) {
370
+ await options.onDisconnect(c, sessionId);
371
+ }
372
+ });
373
+ return response;
374
+ };
375
+ }
376
+ function createSSERoutes(adapter, options = {}) {
377
+ const app = new Hono();
378
+ app.get("/subscribe", createSSEHandler(adapter, options));
379
+ app.post("/subscribe/:channel", async (c) => {
380
+ const channel = c.req.param("channel");
381
+ const sessionId = c.req.query("sessionId");
382
+ if (!sessionId) {
383
+ return c.json({ error: "sessionId required" }, 400);
384
+ }
385
+ const connection = adapter.getConnection(sessionId);
386
+ if (!connection) {
387
+ return c.json({ error: "Connection not found" }, 404);
388
+ }
389
+ await adapter.subscribe(channel, sessionId, () => {
390
+ });
391
+ return c.json({ success: true, channel });
392
+ });
393
+ app.post("/unsubscribe/:channel", async (c) => {
394
+ const channel = c.req.param("channel");
395
+ const sessionId = c.req.query("sessionId");
396
+ if (!sessionId) {
397
+ return c.json({ error: "sessionId required" }, 400);
398
+ }
399
+ await adapter.unsubscribe(channel, sessionId);
400
+ return c.json({ success: true, channel });
401
+ });
402
+ app.post("/broadcast/:channel", async (c) => {
403
+ const channel = c.req.param("channel");
404
+ const body = await c.req.json();
405
+ const message = createMessage({
406
+ event: body.event,
407
+ channel,
408
+ data: body.data
409
+ });
410
+ await adapter.publish(channel, message);
411
+ return c.json({ success: true });
412
+ });
413
+ app.get("/presence/:channel", async (c) => {
414
+ const channel = c.req.param("channel");
415
+ const presence = await adapter.getPresence(channel);
416
+ return c.json(presence);
417
+ });
418
+ app.post("/presence/:channel", async (c) => {
419
+ const channel = c.req.param("channel");
420
+ const sessionId = c.req.query("sessionId");
421
+ const userId = c.req.query("userId") || sessionId;
422
+ const data = await c.req.json();
423
+ if (!sessionId) {
424
+ return c.json({ error: "sessionId required" }, 400);
425
+ }
426
+ await adapter.setPresence(channel, sessionId, userId, data);
427
+ return c.json({ success: true });
428
+ });
429
+ app.delete("/presence/:channel", async (c) => {
430
+ const channel = c.req.param("channel");
431
+ const sessionId = c.req.query("sessionId");
432
+ if (!sessionId) {
433
+ return c.json({ error: "sessionId required" }, 400);
434
+ }
435
+ await adapter.removePresence(channel, sessionId);
436
+ return c.json({ success: true });
437
+ });
438
+ app.get("/stats", (c) => {
439
+ return c.json(adapter.getStats());
440
+ });
441
+ return app;
442
+ }
443
+ function createDORoutes(options) {
444
+ const app = new Hono();
445
+ const prefix = options.channelPrefix ?? "channel:";
446
+ app.get("/ws/:channel", async (c) => {
447
+ const channel = c.req.param("channel");
448
+ if (options.authorize) {
449
+ const authorized = await options.authorize(c, channel);
450
+ if (!authorized) {
451
+ return c.json({ error: "Unauthorized" }, 403);
452
+ }
453
+ }
454
+ const env = c.env;
455
+ const namespace = env[options.namespaceBinding];
456
+ if (!namespace) {
457
+ return c.json(
458
+ { error: `Namespace ${options.namespaceBinding} not found` },
459
+ 500
460
+ );
461
+ }
462
+ const id = namespace.idFromName(`${prefix}${channel}`);
463
+ const stub = namespace.get(id);
464
+ const url = new URL(c.req.url);
465
+ url.pathname = `/ws/${channel}`;
466
+ return stub.fetch(new Request(url.toString(), c.req.raw));
467
+ });
468
+ app.post("/broadcast/:channel", async (c) => {
469
+ const channel = c.req.param("channel");
470
+ const env = c.env;
471
+ const namespace = env[options.namespaceBinding];
472
+ if (!namespace) {
473
+ return c.json(
474
+ { error: `Namespace ${options.namespaceBinding} not found` },
475
+ 500
476
+ );
477
+ }
478
+ const id = namespace.idFromName(`${prefix}${channel}`);
479
+ const stub = namespace.get(id);
480
+ const body = await c.req.json();
481
+ const response = await stub.fetch(
482
+ new Request("https://do/broadcast", {
483
+ method: "POST",
484
+ headers: { "Content-Type": "application/json" },
485
+ body: JSON.stringify(body)
486
+ })
487
+ );
488
+ return new Response(response.body, {
489
+ status: response.status,
490
+ headers: response.headers
491
+ });
492
+ });
493
+ app.get("/presence/:channel", async (c) => {
494
+ const channel = c.req.param("channel");
495
+ const env = c.env;
496
+ const namespace = env[options.namespaceBinding];
497
+ if (!namespace) {
498
+ return c.json(
499
+ { error: `Namespace ${options.namespaceBinding} not found` },
500
+ 500
501
+ );
502
+ }
503
+ const id = namespace.idFromName(`${prefix}${channel}`);
504
+ const stub = namespace.get(id);
505
+ const response = await stub.fetch(new Request("https://do/presence"));
506
+ return new Response(response.body, {
507
+ status: response.status,
508
+ headers: response.headers
509
+ });
510
+ });
511
+ app.get("/info/:channel", async (c) => {
512
+ const channel = c.req.param("channel");
513
+ const env = c.env;
514
+ const namespace = env[options.namespaceBinding];
515
+ if (!namespace) {
516
+ return c.json(
517
+ { error: `Namespace ${options.namespaceBinding} not found` },
518
+ 500
519
+ );
520
+ }
521
+ const id = namespace.idFromName(`${prefix}${channel}`);
522
+ const stub = namespace.get(id);
523
+ const response = await stub.fetch(new Request("https://do/info"));
524
+ return new Response(response.body, {
525
+ status: response.status,
526
+ headers: response.headers
527
+ });
528
+ });
529
+ return app;
530
+ }
531
+ async function broadcast(adapter, channel, event, data) {
532
+ const message = createMessage({ event, channel, data });
533
+ await adapter.publish(channel, message);
534
+ }
535
+ async function sendToUser(adapter, channel, userId, event, data) {
536
+ const presence = await adapter.getPresence(channel);
537
+ const userSessions = presence.filter((p) => p.userId === userId);
538
+ const message = createMessage({ event, channel, data });
539
+ for (const session of userSessions) {
540
+ await adapter.sendToSession(session.sessionId, message);
541
+ }
542
+ }
543
+ export {
544
+ broadcast,
545
+ createDORoutes,
546
+ createSSEHandler,
547
+ createSSERoutes,
548
+ sendToUser,
549
+ sseMiddleware
550
+ };
551
+ //# sourceMappingURL=hono.js.map