@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 +176 -0
- package/dist/adapters/durable-objects.d.ts +89 -0
- package/dist/adapters/durable-objects.js +414 -0
- package/dist/adapters/durable-objects.js.map +1 -0
- package/dist/adapters/index.d.ts +3 -0
- package/dist/adapters/index.js +740 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/sse.d.ts +69 -0
- package/dist/adapters/sse.js +330 -0
- package/dist/adapters/sse.js.map +1 -0
- package/dist/hono.d.ts +76 -0
- package/dist/hono.js +551 -0
- package/dist/hono.js.map +1 -0
- package/dist/index.d.ts +120 -0
- package/dist/index.js +1084 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +317 -0
- package/dist/types.js +75 -0
- package/dist/types.js.map +1 -0
- package/package.json +75 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1084 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var RealtimeError = class extends Error {
|
|
3
|
+
constructor(message, code, cause) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.cause = cause;
|
|
7
|
+
this.name = "RealtimeError";
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
var RealtimeErrorCodes = {
|
|
11
|
+
CONNECTION_FAILED: "CONNECTION_FAILED",
|
|
12
|
+
CHANNEL_NOT_FOUND: "CHANNEL_NOT_FOUND",
|
|
13
|
+
UNAUTHORIZED: "UNAUTHORIZED",
|
|
14
|
+
MESSAGE_TOO_LARGE: "MESSAGE_TOO_LARGE",
|
|
15
|
+
RATE_LIMITED: "RATE_LIMITED",
|
|
16
|
+
ADAPTER_ERROR: "ADAPTER_ERROR",
|
|
17
|
+
INVALID_MESSAGE: "INVALID_MESSAGE"
|
|
18
|
+
};
|
|
19
|
+
function createMessage(options) {
|
|
20
|
+
return {
|
|
21
|
+
id: crypto.randomUUID(),
|
|
22
|
+
event: options.event,
|
|
23
|
+
channel: options.channel,
|
|
24
|
+
data: options.data,
|
|
25
|
+
senderId: options.senderId,
|
|
26
|
+
timestamp: Date.now(),
|
|
27
|
+
metadata: options.metadata
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function parseSSEEvent(eventString) {
|
|
31
|
+
try {
|
|
32
|
+
const lines = eventString.split("\n");
|
|
33
|
+
let event = "message";
|
|
34
|
+
let data = "";
|
|
35
|
+
let id = "";
|
|
36
|
+
for (const line of lines) {
|
|
37
|
+
if (line.startsWith("event:")) {
|
|
38
|
+
event = line.slice(6).trim();
|
|
39
|
+
} else if (line.startsWith("data:")) {
|
|
40
|
+
data = line.slice(5).trim();
|
|
41
|
+
} else if (line.startsWith("id:")) {
|
|
42
|
+
id = line.slice(3).trim();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!data) return null;
|
|
46
|
+
const parsed = JSON.parse(data);
|
|
47
|
+
return {
|
|
48
|
+
id: id || parsed.id || crypto.randomUUID(),
|
|
49
|
+
event,
|
|
50
|
+
channel: parsed.channel || "",
|
|
51
|
+
data: parsed.data ?? parsed,
|
|
52
|
+
senderId: parsed.senderId,
|
|
53
|
+
timestamp: parsed.timestamp || Date.now(),
|
|
54
|
+
metadata: parsed.metadata
|
|
55
|
+
};
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function formatSSEEvent(message) {
|
|
61
|
+
const lines = [];
|
|
62
|
+
lines.push(`id:${message.id}`);
|
|
63
|
+
lines.push(`event:${message.event}`);
|
|
64
|
+
lines.push(`data:${JSON.stringify(message)}`);
|
|
65
|
+
lines.push("");
|
|
66
|
+
return lines.join("\n") + "\n";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/adapters/sse.ts
|
|
70
|
+
var DEFAULT_OPTIONS = {
|
|
71
|
+
pingInterval: 3e4,
|
|
72
|
+
connectionTimeout: 0,
|
|
73
|
+
maxConnectionsPerChannel: 1e3,
|
|
74
|
+
retryDelay: 3e3
|
|
75
|
+
};
|
|
76
|
+
var SSEAdapter = class {
|
|
77
|
+
type = "sse";
|
|
78
|
+
options;
|
|
79
|
+
connections = /* @__PURE__ */ new Map();
|
|
80
|
+
channelSubscribers = /* @__PURE__ */ new Map();
|
|
81
|
+
presence = /* @__PURE__ */ new Map();
|
|
82
|
+
pingIntervalId = null;
|
|
83
|
+
constructor(options = {}) {
|
|
84
|
+
this.options = {
|
|
85
|
+
pingInterval: options.pingInterval ?? DEFAULT_OPTIONS.pingInterval,
|
|
86
|
+
connectionTimeout: options.connectionTimeout ?? DEFAULT_OPTIONS.connectionTimeout,
|
|
87
|
+
maxConnectionsPerChannel: options.maxConnectionsPerChannel ?? DEFAULT_OPTIONS.maxConnectionsPerChannel,
|
|
88
|
+
retryDelay: options.retryDelay ?? DEFAULT_OPTIONS.retryDelay
|
|
89
|
+
};
|
|
90
|
+
if (this.options.pingInterval > 0) {
|
|
91
|
+
this.startPingInterval();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Create SSE response for a new connection
|
|
96
|
+
*/
|
|
97
|
+
createConnection(sessionId, userId) {
|
|
98
|
+
const { readable, writable } = new TransformStream();
|
|
99
|
+
const writer = writable.getWriter();
|
|
100
|
+
const connection = {
|
|
101
|
+
sessionId,
|
|
102
|
+
userId,
|
|
103
|
+
channels: /* @__PURE__ */ new Set(),
|
|
104
|
+
writer,
|
|
105
|
+
state: "open",
|
|
106
|
+
createdAt: Date.now(),
|
|
107
|
+
lastPingAt: Date.now()
|
|
108
|
+
};
|
|
109
|
+
this.connections.set(sessionId, connection);
|
|
110
|
+
const encoder = new TextEncoder();
|
|
111
|
+
writer.write(encoder.encode(`retry:${this.options.retryDelay}
|
|
112
|
+
|
|
113
|
+
`));
|
|
114
|
+
const response = new Response(readable, {
|
|
115
|
+
headers: {
|
|
116
|
+
"Content-Type": "text/event-stream",
|
|
117
|
+
"Cache-Control": "no-cache",
|
|
118
|
+
"Connection": "keep-alive",
|
|
119
|
+
"X-Accel-Buffering": "no"
|
|
120
|
+
// Disable nginx buffering
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
return { response, connection };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Close a connection
|
|
127
|
+
*/
|
|
128
|
+
async closeConnection(sessionId) {
|
|
129
|
+
const connection = this.connections.get(sessionId);
|
|
130
|
+
if (!connection) return;
|
|
131
|
+
connection.state = "closed";
|
|
132
|
+
for (const channel of connection.channels) {
|
|
133
|
+
await this.unsubscribe(channel, sessionId);
|
|
134
|
+
await this.removePresence(channel, sessionId);
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
await connection.writer.close();
|
|
138
|
+
} catch {
|
|
139
|
+
}
|
|
140
|
+
this.connections.delete(sessionId);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Get a connection by session ID
|
|
144
|
+
*/
|
|
145
|
+
getConnection(sessionId) {
|
|
146
|
+
return this.connections.get(sessionId);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Get all active connections
|
|
150
|
+
*/
|
|
151
|
+
getConnections() {
|
|
152
|
+
return this.connections;
|
|
153
|
+
}
|
|
154
|
+
// ============================================================================
|
|
155
|
+
// RealtimeAdapter Implementation
|
|
156
|
+
// ============================================================================
|
|
157
|
+
async subscribe(channel, sessionId, handler) {
|
|
158
|
+
const connection = this.connections.get(sessionId);
|
|
159
|
+
if (!connection) {
|
|
160
|
+
throw new RealtimeError(
|
|
161
|
+
"Connection not found",
|
|
162
|
+
RealtimeErrorCodes.CONNECTION_FAILED
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const subscribers = this.channelSubscribers.get(channel);
|
|
166
|
+
if (subscribers && subscribers.size >= this.options.maxConnectionsPerChannel) {
|
|
167
|
+
throw new RealtimeError(
|
|
168
|
+
"Channel subscriber limit reached",
|
|
169
|
+
RealtimeErrorCodes.RATE_LIMITED
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
if (!this.channelSubscribers.has(channel)) {
|
|
173
|
+
this.channelSubscribers.set(channel, /* @__PURE__ */ new Map());
|
|
174
|
+
}
|
|
175
|
+
this.channelSubscribers.get(channel).set(sessionId, handler);
|
|
176
|
+
connection.channels.add(channel);
|
|
177
|
+
await this.sendToConnection(connection, {
|
|
178
|
+
id: crypto.randomUUID(),
|
|
179
|
+
event: "channel:subscribe",
|
|
180
|
+
channel,
|
|
181
|
+
data: { channel },
|
|
182
|
+
timestamp: Date.now()
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
async unsubscribe(channel, sessionId) {
|
|
186
|
+
const subscribers = this.channelSubscribers.get(channel);
|
|
187
|
+
if (subscribers) {
|
|
188
|
+
subscribers.delete(sessionId);
|
|
189
|
+
if (subscribers.size === 0) {
|
|
190
|
+
this.channelSubscribers.delete(channel);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const connection = this.connections.get(sessionId);
|
|
194
|
+
if (connection) {
|
|
195
|
+
connection.channels.delete(channel);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async publish(channel, message) {
|
|
199
|
+
const subscribers = this.channelSubscribers.get(channel);
|
|
200
|
+
if (!subscribers) return;
|
|
201
|
+
const promises = [];
|
|
202
|
+
for (const [sessionId, handler] of subscribers) {
|
|
203
|
+
const connection = this.connections.get(sessionId);
|
|
204
|
+
if (connection && connection.state === "open") {
|
|
205
|
+
promises.push(this.sendToConnection(connection, message));
|
|
206
|
+
try {
|
|
207
|
+
const result = handler(message);
|
|
208
|
+
if (result instanceof Promise) {
|
|
209
|
+
promises.push(result.then(() => {
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
await Promise.allSettled(promises);
|
|
217
|
+
}
|
|
218
|
+
async sendToSession(sessionId, message) {
|
|
219
|
+
const connection = this.connections.get(sessionId);
|
|
220
|
+
if (!connection || connection.state !== "open") {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
await this.sendToConnection(connection, message);
|
|
225
|
+
return true;
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async getSubscribers(channel) {
|
|
231
|
+
const subscribers = this.channelSubscribers.get(channel);
|
|
232
|
+
return subscribers ? Array.from(subscribers.keys()) : [];
|
|
233
|
+
}
|
|
234
|
+
async setPresence(channel, sessionId, userId, data) {
|
|
235
|
+
if (!this.presence.has(channel)) {
|
|
236
|
+
this.presence.set(channel, /* @__PURE__ */ new Map());
|
|
237
|
+
}
|
|
238
|
+
const now = Date.now();
|
|
239
|
+
const existing = this.presence.get(channel).get(sessionId);
|
|
240
|
+
const user = {
|
|
241
|
+
userId,
|
|
242
|
+
sessionId,
|
|
243
|
+
data,
|
|
244
|
+
joinedAt: existing?.joinedAt ?? now,
|
|
245
|
+
lastSeenAt: now
|
|
246
|
+
};
|
|
247
|
+
this.presence.get(channel).set(sessionId, user);
|
|
248
|
+
const eventType = existing ? "presence:update" : "presence:join";
|
|
249
|
+
await this.publish(channel, {
|
|
250
|
+
id: crypto.randomUUID(),
|
|
251
|
+
event: eventType,
|
|
252
|
+
channel,
|
|
253
|
+
data: {
|
|
254
|
+
type: existing ? "update" : "join",
|
|
255
|
+
user,
|
|
256
|
+
presence: await this.getPresence(channel)
|
|
257
|
+
},
|
|
258
|
+
timestamp: now
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
async removePresence(channel, sessionId) {
|
|
262
|
+
const channelPresence = this.presence.get(channel);
|
|
263
|
+
if (!channelPresence) return;
|
|
264
|
+
const user = channelPresence.get(sessionId);
|
|
265
|
+
if (!user) return;
|
|
266
|
+
channelPresence.delete(sessionId);
|
|
267
|
+
if (channelPresence.size === 0) {
|
|
268
|
+
this.presence.delete(channel);
|
|
269
|
+
}
|
|
270
|
+
await this.publish(channel, {
|
|
271
|
+
id: crypto.randomUUID(),
|
|
272
|
+
event: "presence:leave",
|
|
273
|
+
channel,
|
|
274
|
+
data: {
|
|
275
|
+
type: "leave",
|
|
276
|
+
user,
|
|
277
|
+
presence: await this.getPresence(channel)
|
|
278
|
+
},
|
|
279
|
+
timestamp: Date.now()
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
async getPresence(channel) {
|
|
283
|
+
const channelPresence = this.presence.get(channel);
|
|
284
|
+
if (!channelPresence) return [];
|
|
285
|
+
return Array.from(channelPresence.values());
|
|
286
|
+
}
|
|
287
|
+
async close() {
|
|
288
|
+
if (this.pingIntervalId) {
|
|
289
|
+
clearInterval(this.pingIntervalId);
|
|
290
|
+
this.pingIntervalId = null;
|
|
291
|
+
}
|
|
292
|
+
const closePromises = Array.from(this.connections.keys()).map(
|
|
293
|
+
(sessionId) => this.closeConnection(sessionId)
|
|
294
|
+
);
|
|
295
|
+
await Promise.allSettled(closePromises);
|
|
296
|
+
this.connections.clear();
|
|
297
|
+
this.channelSubscribers.clear();
|
|
298
|
+
this.presence.clear();
|
|
299
|
+
}
|
|
300
|
+
// ============================================================================
|
|
301
|
+
// Private Methods
|
|
302
|
+
// ============================================================================
|
|
303
|
+
async sendToConnection(connection, message) {
|
|
304
|
+
if (connection.state !== "open") return;
|
|
305
|
+
const encoder = new TextEncoder();
|
|
306
|
+
const sseEvent = formatSSEEvent(message);
|
|
307
|
+
try {
|
|
308
|
+
await connection.writer.write(encoder.encode(sseEvent));
|
|
309
|
+
} catch (err) {
|
|
310
|
+
connection.state = "closed";
|
|
311
|
+
await this.closeConnection(connection.sessionId);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
startPingInterval() {
|
|
315
|
+
this.pingIntervalId = setInterval(() => {
|
|
316
|
+
this.sendPingToAll();
|
|
317
|
+
}, this.options.pingInterval);
|
|
318
|
+
}
|
|
319
|
+
async sendPingToAll() {
|
|
320
|
+
const now = Date.now();
|
|
321
|
+
const encoder = new TextEncoder();
|
|
322
|
+
const pingEvent = `event:ping
|
|
323
|
+
data:${now}
|
|
324
|
+
|
|
325
|
+
`;
|
|
326
|
+
for (const [sessionId, connection] of this.connections) {
|
|
327
|
+
if (connection.state !== "open") continue;
|
|
328
|
+
if (this.options.connectionTimeout > 0 && now - connection.lastPingAt > this.options.connectionTimeout) {
|
|
329
|
+
await this.closeConnection(sessionId);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
await connection.writer.write(encoder.encode(pingEvent));
|
|
334
|
+
connection.lastPingAt = now;
|
|
335
|
+
} catch {
|
|
336
|
+
await this.closeConnection(sessionId);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Update last ping time (call when receiving client ping)
|
|
342
|
+
*/
|
|
343
|
+
updateLastPing(sessionId) {
|
|
344
|
+
const connection = this.connections.get(sessionId);
|
|
345
|
+
if (connection) {
|
|
346
|
+
connection.lastPingAt = Date.now();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Get statistics
|
|
351
|
+
*/
|
|
352
|
+
getStats() {
|
|
353
|
+
const connectionsByChannel = {};
|
|
354
|
+
for (const [channel, subscribers] of this.channelSubscribers) {
|
|
355
|
+
connectionsByChannel[channel] = subscribers.size;
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
totalConnections: this.connections.size,
|
|
359
|
+
totalChannels: this.channelSubscribers.size,
|
|
360
|
+
connectionsByChannel
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
function createSSEAdapter(options) {
|
|
365
|
+
return new SSEAdapter(options);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// src/adapters/durable-objects.ts
|
|
369
|
+
var RealtimeChannelDO = class {
|
|
370
|
+
sessions = /* @__PURE__ */ new Map();
|
|
371
|
+
presence = /* @__PURE__ */ new Map();
|
|
372
|
+
channelName = "";
|
|
373
|
+
state;
|
|
374
|
+
constructor(state, _env) {
|
|
375
|
+
this.state = state;
|
|
376
|
+
this.state.getWebSockets().forEach((ws) => {
|
|
377
|
+
const meta = ws.deserializeAttachment();
|
|
378
|
+
if (meta) {
|
|
379
|
+
this.sessions.set(meta.sessionId, {
|
|
380
|
+
sessionId: meta.sessionId,
|
|
381
|
+
userId: meta.userId,
|
|
382
|
+
webSocket: ws,
|
|
383
|
+
state: "open",
|
|
384
|
+
createdAt: Date.now()
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
async fetch(request) {
|
|
390
|
+
const url = new URL(request.url);
|
|
391
|
+
const pathParts = url.pathname.split("/").filter(Boolean);
|
|
392
|
+
const lastPart = pathParts[pathParts.length - 1];
|
|
393
|
+
if (lastPart) {
|
|
394
|
+
this.channelName = lastPart;
|
|
395
|
+
}
|
|
396
|
+
if (request.headers.get("Upgrade") === "websocket") {
|
|
397
|
+
return this.handleWebSocketUpgrade(request);
|
|
398
|
+
}
|
|
399
|
+
switch (url.pathname.split("/").pop()) {
|
|
400
|
+
case "broadcast":
|
|
401
|
+
return this.handleBroadcast(request);
|
|
402
|
+
case "presence":
|
|
403
|
+
return this.handleGetPresence();
|
|
404
|
+
case "info":
|
|
405
|
+
return this.handleGetInfo();
|
|
406
|
+
case "send":
|
|
407
|
+
return this.handleSendToUser(request);
|
|
408
|
+
default:
|
|
409
|
+
return new Response("Not found", { status: 404 });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// ============================================================================
|
|
413
|
+
// WebSocket Handling
|
|
414
|
+
// ============================================================================
|
|
415
|
+
handleWebSocketUpgrade(request) {
|
|
416
|
+
const url = new URL(request.url);
|
|
417
|
+
const sessionId = url.searchParams.get("sessionId") || crypto.randomUUID();
|
|
418
|
+
const userId = url.searchParams.get("userId") || void 0;
|
|
419
|
+
const pair = new WebSocketPair();
|
|
420
|
+
const client = pair[0];
|
|
421
|
+
const server = pair[1];
|
|
422
|
+
server.serializeAttachment({ sessionId, userId });
|
|
423
|
+
this.state.acceptWebSocket(server);
|
|
424
|
+
const session = {
|
|
425
|
+
sessionId,
|
|
426
|
+
userId,
|
|
427
|
+
webSocket: server,
|
|
428
|
+
state: "open",
|
|
429
|
+
createdAt: Date.now()
|
|
430
|
+
};
|
|
431
|
+
this.sessions.set(sessionId, session);
|
|
432
|
+
server.send(
|
|
433
|
+
JSON.stringify(
|
|
434
|
+
createMessage({
|
|
435
|
+
event: "connection:open",
|
|
436
|
+
channel: this.channelName,
|
|
437
|
+
data: { sessionId, userId }
|
|
438
|
+
})
|
|
439
|
+
)
|
|
440
|
+
);
|
|
441
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
442
|
+
}
|
|
443
|
+
async webSocketMessage(ws, message) {
|
|
444
|
+
const session = this.getSessionByWebSocket(ws);
|
|
445
|
+
if (!session) return;
|
|
446
|
+
try {
|
|
447
|
+
const data = typeof message === "string" ? message : new TextDecoder().decode(message);
|
|
448
|
+
const parsed = JSON.parse(data);
|
|
449
|
+
await this.handleMessage(session, parsed);
|
|
450
|
+
} catch {
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
async webSocketClose(ws, code, reason, _wasClean) {
|
|
454
|
+
const session = this.getSessionByWebSocket(ws);
|
|
455
|
+
if (!session) return;
|
|
456
|
+
this.presence.delete(session.sessionId);
|
|
457
|
+
await this.broadcastPresenceUpdate();
|
|
458
|
+
this.sessions.delete(session.sessionId);
|
|
459
|
+
await this.broadcastToAll({
|
|
460
|
+
event: "connection:close",
|
|
461
|
+
channel: this.channelName,
|
|
462
|
+
data: {
|
|
463
|
+
sessionId: session.sessionId,
|
|
464
|
+
userId: session.userId,
|
|
465
|
+
code,
|
|
466
|
+
reason
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
async webSocketError(ws, _error) {
|
|
471
|
+
const session = this.getSessionByWebSocket(ws);
|
|
472
|
+
if (session) {
|
|
473
|
+
session.state = "closed";
|
|
474
|
+
this.sessions.delete(session.sessionId);
|
|
475
|
+
this.presence.delete(session.sessionId);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// ============================================================================
|
|
479
|
+
// Message Handling
|
|
480
|
+
// ============================================================================
|
|
481
|
+
async handleMessage(session, message) {
|
|
482
|
+
switch (message.event) {
|
|
483
|
+
case "ping":
|
|
484
|
+
session.webSocket.send(
|
|
485
|
+
JSON.stringify(
|
|
486
|
+
createMessage({
|
|
487
|
+
event: "pong",
|
|
488
|
+
channel: this.channelName,
|
|
489
|
+
data: { timestamp: Date.now() }
|
|
490
|
+
})
|
|
491
|
+
)
|
|
492
|
+
);
|
|
493
|
+
break;
|
|
494
|
+
case "presence:join":
|
|
495
|
+
await this.handlePresenceJoin(session, message.data);
|
|
496
|
+
break;
|
|
497
|
+
case "presence:update":
|
|
498
|
+
await this.handlePresenceUpdate(session, message.data);
|
|
499
|
+
break;
|
|
500
|
+
case "presence:leave":
|
|
501
|
+
await this.handlePresenceLeave(session);
|
|
502
|
+
break;
|
|
503
|
+
case "broadcast":
|
|
504
|
+
await this.broadcastToAll(
|
|
505
|
+
{
|
|
506
|
+
event: message.event,
|
|
507
|
+
channel: this.channelName,
|
|
508
|
+
data: message.data,
|
|
509
|
+
senderId: session.sessionId
|
|
510
|
+
},
|
|
511
|
+
session.sessionId
|
|
512
|
+
);
|
|
513
|
+
break;
|
|
514
|
+
default:
|
|
515
|
+
await this.broadcastToAll(
|
|
516
|
+
{
|
|
517
|
+
event: message.event,
|
|
518
|
+
channel: this.channelName,
|
|
519
|
+
data: message.data,
|
|
520
|
+
senderId: session.sessionId
|
|
521
|
+
},
|
|
522
|
+
session.sessionId
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
// ============================================================================
|
|
527
|
+
// Presence Handling
|
|
528
|
+
// ============================================================================
|
|
529
|
+
async handlePresenceJoin(session, data) {
|
|
530
|
+
const now = Date.now();
|
|
531
|
+
const user = {
|
|
532
|
+
userId: session.userId || session.sessionId,
|
|
533
|
+
sessionId: session.sessionId,
|
|
534
|
+
data,
|
|
535
|
+
joinedAt: now,
|
|
536
|
+
lastSeenAt: now
|
|
537
|
+
};
|
|
538
|
+
this.presence.set(session.sessionId, user);
|
|
539
|
+
session.presence = data;
|
|
540
|
+
await this.broadcastPresenceUpdate();
|
|
541
|
+
}
|
|
542
|
+
async handlePresenceUpdate(session, data) {
|
|
543
|
+
const existing = this.presence.get(session.sessionId);
|
|
544
|
+
if (existing) {
|
|
545
|
+
existing.data = data;
|
|
546
|
+
existing.lastSeenAt = Date.now();
|
|
547
|
+
session.presence = data;
|
|
548
|
+
await this.broadcastPresenceUpdate();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
async handlePresenceLeave(session) {
|
|
552
|
+
this.presence.delete(session.sessionId);
|
|
553
|
+
session.presence = void 0;
|
|
554
|
+
await this.broadcastPresenceUpdate();
|
|
555
|
+
}
|
|
556
|
+
async broadcastPresenceUpdate() {
|
|
557
|
+
const presenceList = Array.from(this.presence.values());
|
|
558
|
+
await this.broadcastToAll({
|
|
559
|
+
event: "presence:sync",
|
|
560
|
+
channel: this.channelName,
|
|
561
|
+
data: presenceList
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
// ============================================================================
|
|
565
|
+
// REST API Handlers
|
|
566
|
+
// ============================================================================
|
|
567
|
+
async handleBroadcast(request) {
|
|
568
|
+
try {
|
|
569
|
+
const body = await request.json();
|
|
570
|
+
await this.broadcastToAll({
|
|
571
|
+
event: body.event,
|
|
572
|
+
channel: this.channelName,
|
|
573
|
+
data: body.data
|
|
574
|
+
});
|
|
575
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
576
|
+
headers: { "Content-Type": "application/json" }
|
|
577
|
+
});
|
|
578
|
+
} catch (err) {
|
|
579
|
+
return new Response(
|
|
580
|
+
JSON.stringify({ success: false, error: String(err) }),
|
|
581
|
+
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
handleGetPresence() {
|
|
586
|
+
const presenceList = Array.from(this.presence.values());
|
|
587
|
+
return new Response(JSON.stringify(presenceList), {
|
|
588
|
+
headers: { "Content-Type": "application/json" }
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
handleGetInfo() {
|
|
592
|
+
return new Response(
|
|
593
|
+
JSON.stringify({
|
|
594
|
+
channel: this.channelName,
|
|
595
|
+
connections: this.sessions.size,
|
|
596
|
+
presence: this.presence.size
|
|
597
|
+
}),
|
|
598
|
+
{ headers: { "Content-Type": "application/json" } }
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
async handleSendToUser(request) {
|
|
602
|
+
try {
|
|
603
|
+
const body = await request.json();
|
|
604
|
+
let sent = false;
|
|
605
|
+
for (const session of this.sessions.values()) {
|
|
606
|
+
if (session.userId === body.userId && session.state === "open") {
|
|
607
|
+
session.webSocket.send(
|
|
608
|
+
JSON.stringify(
|
|
609
|
+
createMessage({
|
|
610
|
+
event: body.event,
|
|
611
|
+
channel: this.channelName,
|
|
612
|
+
data: body.data
|
|
613
|
+
})
|
|
614
|
+
)
|
|
615
|
+
);
|
|
616
|
+
sent = true;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return new Response(JSON.stringify({ success: true, sent }), {
|
|
620
|
+
headers: { "Content-Type": "application/json" }
|
|
621
|
+
});
|
|
622
|
+
} catch (err) {
|
|
623
|
+
return new Response(
|
|
624
|
+
JSON.stringify({ success: false, error: String(err) }),
|
|
625
|
+
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
// ============================================================================
|
|
630
|
+
// Helpers
|
|
631
|
+
// ============================================================================
|
|
632
|
+
getSessionByWebSocket(ws) {
|
|
633
|
+
for (const session of this.sessions.values()) {
|
|
634
|
+
if (session.webSocket === ws) {
|
|
635
|
+
return session;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return void 0;
|
|
639
|
+
}
|
|
640
|
+
async broadcastToAll(messageData, excludeSessionId) {
|
|
641
|
+
const message = createMessage(messageData);
|
|
642
|
+
const payload = JSON.stringify(message);
|
|
643
|
+
for (const session of this.sessions.values()) {
|
|
644
|
+
if (session.sessionId === excludeSessionId) continue;
|
|
645
|
+
if (session.state !== "open") continue;
|
|
646
|
+
try {
|
|
647
|
+
session.webSocket.send(payload);
|
|
648
|
+
} catch {
|
|
649
|
+
session.state = "closed";
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
var DurableObjectsAdapter = class {
|
|
655
|
+
type = "durable-objects";
|
|
656
|
+
namespace;
|
|
657
|
+
channelPrefix;
|
|
658
|
+
localHandlers = /* @__PURE__ */ new Map();
|
|
659
|
+
constructor(options) {
|
|
660
|
+
this.namespace = options.namespace;
|
|
661
|
+
this.channelPrefix = options.channelPrefix ?? "channel:";
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Get Durable Object stub for a channel
|
|
665
|
+
*/
|
|
666
|
+
getChannelStub(channel) {
|
|
667
|
+
const id = this.namespace.idFromName(`${this.channelPrefix}${channel}`);
|
|
668
|
+
return this.namespace.get(id);
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Get WebSocket URL for a channel
|
|
672
|
+
*/
|
|
673
|
+
getWebSocketUrl(channel, sessionId, userId, baseUrl) {
|
|
674
|
+
const base = baseUrl || "wss://your-worker.your-subdomain.workers.dev";
|
|
675
|
+
const params = new URLSearchParams({ sessionId });
|
|
676
|
+
if (userId) params.set("userId", userId);
|
|
677
|
+
return `${base}/realtime/${channel}?${params}`;
|
|
678
|
+
}
|
|
679
|
+
// ============================================================================
|
|
680
|
+
// RealtimeAdapter Implementation
|
|
681
|
+
// ============================================================================
|
|
682
|
+
async subscribe(channel, sessionId, handler) {
|
|
683
|
+
if (!this.localHandlers.has(channel)) {
|
|
684
|
+
this.localHandlers.set(channel, /* @__PURE__ */ new Map());
|
|
685
|
+
}
|
|
686
|
+
this.localHandlers.get(channel).set(sessionId, handler);
|
|
687
|
+
}
|
|
688
|
+
async unsubscribe(channel, sessionId) {
|
|
689
|
+
const handlers = this.localHandlers.get(channel);
|
|
690
|
+
if (handlers) {
|
|
691
|
+
handlers.delete(sessionId);
|
|
692
|
+
if (handlers.size === 0) {
|
|
693
|
+
this.localHandlers.delete(channel);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
async publish(channel, message) {
|
|
698
|
+
const stub = this.getChannelStub(channel);
|
|
699
|
+
await stub.fetch(new Request("https://do/broadcast", {
|
|
700
|
+
method: "POST",
|
|
701
|
+
headers: { "Content-Type": "application/json" },
|
|
702
|
+
body: JSON.stringify({
|
|
703
|
+
event: message.event,
|
|
704
|
+
data: message.data
|
|
705
|
+
})
|
|
706
|
+
}));
|
|
707
|
+
const handlers = this.localHandlers.get(channel);
|
|
708
|
+
if (handlers) {
|
|
709
|
+
for (const handler of handlers.values()) {
|
|
710
|
+
try {
|
|
711
|
+
await handler(message);
|
|
712
|
+
} catch {
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
async sendToSession(_sessionId, _message) {
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
async getSubscribers(channel) {
|
|
721
|
+
const handlers = this.localHandlers.get(channel);
|
|
722
|
+
return handlers ? Array.from(handlers.keys()) : [];
|
|
723
|
+
}
|
|
724
|
+
async setPresence(_channel, _sessionId, _userId, _data) {
|
|
725
|
+
}
|
|
726
|
+
async removePresence(_channel, _sessionId) {
|
|
727
|
+
}
|
|
728
|
+
async getPresence(channel) {
|
|
729
|
+
const stub = this.getChannelStub(channel);
|
|
730
|
+
const response = await stub.fetch(new Request("https://do/presence"));
|
|
731
|
+
return response.json();
|
|
732
|
+
}
|
|
733
|
+
async close() {
|
|
734
|
+
this.localHandlers.clear();
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Get channel info from Durable Object
|
|
738
|
+
*/
|
|
739
|
+
async getChannelInfo(channel) {
|
|
740
|
+
const stub = this.getChannelStub(channel);
|
|
741
|
+
const response = await stub.fetch(new Request("https://do/info"));
|
|
742
|
+
return response.json();
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Send message to specific user in a channel
|
|
746
|
+
*/
|
|
747
|
+
async sendToUser(channel, userId, event, data) {
|
|
748
|
+
const stub = this.getChannelStub(channel);
|
|
749
|
+
const response = await stub.fetch(
|
|
750
|
+
new Request("https://do/send", {
|
|
751
|
+
method: "POST",
|
|
752
|
+
headers: { "Content-Type": "application/json" },
|
|
753
|
+
body: JSON.stringify({ userId, event, data })
|
|
754
|
+
})
|
|
755
|
+
);
|
|
756
|
+
const result = await response.json();
|
|
757
|
+
return result.sent;
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
function createDurableObjectsAdapter(options) {
|
|
761
|
+
return new DurableObjectsAdapter(options);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// src/hono.ts
|
|
765
|
+
import { Hono } from "hono";
|
|
766
|
+
function sseMiddleware(options) {
|
|
767
|
+
const adapter = new SSEAdapter(options);
|
|
768
|
+
return async (c, next) => {
|
|
769
|
+
const sessionId = c.req.query("sessionId") || crypto.randomUUID();
|
|
770
|
+
const vars = c.var;
|
|
771
|
+
const userId = typeof vars["userId"] === "string" ? vars["userId"] : void 0;
|
|
772
|
+
c.set("realtime", {
|
|
773
|
+
adapter,
|
|
774
|
+
sessionId,
|
|
775
|
+
userId
|
|
776
|
+
});
|
|
777
|
+
await next();
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
function createSSEHandler(adapter, options = {}) {
|
|
781
|
+
return async (c) => {
|
|
782
|
+
const sessionId = options.getSessionId?.(c) || c.req.query("sessionId") || crypto.randomUUID();
|
|
783
|
+
const vars = c.var;
|
|
784
|
+
const userId = options.getUserId?.(c) || (typeof vars["userId"] === "string" ? vars["userId"] : void 0);
|
|
785
|
+
const channels = options.getChannels?.(c) || c.req.query("channels")?.split(",") || [];
|
|
786
|
+
const { response } = adapter.createConnection(sessionId, userId);
|
|
787
|
+
for (const channel of channels) {
|
|
788
|
+
await adapter.subscribe(channel, sessionId, () => {
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
if (options.onConnect) {
|
|
792
|
+
await options.onConnect(c, sessionId);
|
|
793
|
+
}
|
|
794
|
+
c.req.raw.signal.addEventListener("abort", async () => {
|
|
795
|
+
await adapter.closeConnection(sessionId);
|
|
796
|
+
if (options.onDisconnect) {
|
|
797
|
+
await options.onDisconnect(c, sessionId);
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
return response;
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
function createSSERoutes(adapter, options = {}) {
|
|
804
|
+
const app = new Hono();
|
|
805
|
+
app.get("/subscribe", createSSEHandler(adapter, options));
|
|
806
|
+
app.post("/subscribe/:channel", async (c) => {
|
|
807
|
+
const channel = c.req.param("channel");
|
|
808
|
+
const sessionId = c.req.query("sessionId");
|
|
809
|
+
if (!sessionId) {
|
|
810
|
+
return c.json({ error: "sessionId required" }, 400);
|
|
811
|
+
}
|
|
812
|
+
const connection = adapter.getConnection(sessionId);
|
|
813
|
+
if (!connection) {
|
|
814
|
+
return c.json({ error: "Connection not found" }, 404);
|
|
815
|
+
}
|
|
816
|
+
await adapter.subscribe(channel, sessionId, () => {
|
|
817
|
+
});
|
|
818
|
+
return c.json({ success: true, channel });
|
|
819
|
+
});
|
|
820
|
+
app.post("/unsubscribe/:channel", async (c) => {
|
|
821
|
+
const channel = c.req.param("channel");
|
|
822
|
+
const sessionId = c.req.query("sessionId");
|
|
823
|
+
if (!sessionId) {
|
|
824
|
+
return c.json({ error: "sessionId required" }, 400);
|
|
825
|
+
}
|
|
826
|
+
await adapter.unsubscribe(channel, sessionId);
|
|
827
|
+
return c.json({ success: true, channel });
|
|
828
|
+
});
|
|
829
|
+
app.post("/broadcast/:channel", async (c) => {
|
|
830
|
+
const channel = c.req.param("channel");
|
|
831
|
+
const body = await c.req.json();
|
|
832
|
+
const message = createMessage({
|
|
833
|
+
event: body.event,
|
|
834
|
+
channel,
|
|
835
|
+
data: body.data
|
|
836
|
+
});
|
|
837
|
+
await adapter.publish(channel, message);
|
|
838
|
+
return c.json({ success: true });
|
|
839
|
+
});
|
|
840
|
+
app.get("/presence/:channel", async (c) => {
|
|
841
|
+
const channel = c.req.param("channel");
|
|
842
|
+
const presence = await adapter.getPresence(channel);
|
|
843
|
+
return c.json(presence);
|
|
844
|
+
});
|
|
845
|
+
app.post("/presence/:channel", async (c) => {
|
|
846
|
+
const channel = c.req.param("channel");
|
|
847
|
+
const sessionId = c.req.query("sessionId");
|
|
848
|
+
const userId = c.req.query("userId") || sessionId;
|
|
849
|
+
const data = await c.req.json();
|
|
850
|
+
if (!sessionId) {
|
|
851
|
+
return c.json({ error: "sessionId required" }, 400);
|
|
852
|
+
}
|
|
853
|
+
await adapter.setPresence(channel, sessionId, userId, data);
|
|
854
|
+
return c.json({ success: true });
|
|
855
|
+
});
|
|
856
|
+
app.delete("/presence/:channel", async (c) => {
|
|
857
|
+
const channel = c.req.param("channel");
|
|
858
|
+
const sessionId = c.req.query("sessionId");
|
|
859
|
+
if (!sessionId) {
|
|
860
|
+
return c.json({ error: "sessionId required" }, 400);
|
|
861
|
+
}
|
|
862
|
+
await adapter.removePresence(channel, sessionId);
|
|
863
|
+
return c.json({ success: true });
|
|
864
|
+
});
|
|
865
|
+
app.get("/stats", (c) => {
|
|
866
|
+
return c.json(adapter.getStats());
|
|
867
|
+
});
|
|
868
|
+
return app;
|
|
869
|
+
}
|
|
870
|
+
function createDORoutes(options) {
|
|
871
|
+
const app = new Hono();
|
|
872
|
+
const prefix = options.channelPrefix ?? "channel:";
|
|
873
|
+
app.get("/ws/:channel", async (c) => {
|
|
874
|
+
const channel = c.req.param("channel");
|
|
875
|
+
if (options.authorize) {
|
|
876
|
+
const authorized = await options.authorize(c, channel);
|
|
877
|
+
if (!authorized) {
|
|
878
|
+
return c.json({ error: "Unauthorized" }, 403);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
const env = c.env;
|
|
882
|
+
const namespace = env[options.namespaceBinding];
|
|
883
|
+
if (!namespace) {
|
|
884
|
+
return c.json(
|
|
885
|
+
{ error: `Namespace ${options.namespaceBinding} not found` },
|
|
886
|
+
500
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
const id = namespace.idFromName(`${prefix}${channel}`);
|
|
890
|
+
const stub = namespace.get(id);
|
|
891
|
+
const url = new URL(c.req.url);
|
|
892
|
+
url.pathname = `/ws/${channel}`;
|
|
893
|
+
return stub.fetch(new Request(url.toString(), c.req.raw));
|
|
894
|
+
});
|
|
895
|
+
app.post("/broadcast/:channel", async (c) => {
|
|
896
|
+
const channel = c.req.param("channel");
|
|
897
|
+
const env = c.env;
|
|
898
|
+
const namespace = env[options.namespaceBinding];
|
|
899
|
+
if (!namespace) {
|
|
900
|
+
return c.json(
|
|
901
|
+
{ error: `Namespace ${options.namespaceBinding} not found` },
|
|
902
|
+
500
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
const id = namespace.idFromName(`${prefix}${channel}`);
|
|
906
|
+
const stub = namespace.get(id);
|
|
907
|
+
const body = await c.req.json();
|
|
908
|
+
const response = await stub.fetch(
|
|
909
|
+
new Request("https://do/broadcast", {
|
|
910
|
+
method: "POST",
|
|
911
|
+
headers: { "Content-Type": "application/json" },
|
|
912
|
+
body: JSON.stringify(body)
|
|
913
|
+
})
|
|
914
|
+
);
|
|
915
|
+
return new Response(response.body, {
|
|
916
|
+
status: response.status,
|
|
917
|
+
headers: response.headers
|
|
918
|
+
});
|
|
919
|
+
});
|
|
920
|
+
app.get("/presence/:channel", async (c) => {
|
|
921
|
+
const channel = c.req.param("channel");
|
|
922
|
+
const env = c.env;
|
|
923
|
+
const namespace = env[options.namespaceBinding];
|
|
924
|
+
if (!namespace) {
|
|
925
|
+
return c.json(
|
|
926
|
+
{ error: `Namespace ${options.namespaceBinding} not found` },
|
|
927
|
+
500
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
const id = namespace.idFromName(`${prefix}${channel}`);
|
|
931
|
+
const stub = namespace.get(id);
|
|
932
|
+
const response = await stub.fetch(new Request("https://do/presence"));
|
|
933
|
+
return new Response(response.body, {
|
|
934
|
+
status: response.status,
|
|
935
|
+
headers: response.headers
|
|
936
|
+
});
|
|
937
|
+
});
|
|
938
|
+
app.get("/info/:channel", async (c) => {
|
|
939
|
+
const channel = c.req.param("channel");
|
|
940
|
+
const env = c.env;
|
|
941
|
+
const namespace = env[options.namespaceBinding];
|
|
942
|
+
if (!namespace) {
|
|
943
|
+
return c.json(
|
|
944
|
+
{ error: `Namespace ${options.namespaceBinding} not found` },
|
|
945
|
+
500
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
const id = namespace.idFromName(`${prefix}${channel}`);
|
|
949
|
+
const stub = namespace.get(id);
|
|
950
|
+
const response = await stub.fetch(new Request("https://do/info"));
|
|
951
|
+
return new Response(response.body, {
|
|
952
|
+
status: response.status,
|
|
953
|
+
headers: response.headers
|
|
954
|
+
});
|
|
955
|
+
});
|
|
956
|
+
return app;
|
|
957
|
+
}
|
|
958
|
+
async function broadcast(adapter, channel, event, data) {
|
|
959
|
+
const message = createMessage({ event, channel, data });
|
|
960
|
+
await adapter.publish(channel, message);
|
|
961
|
+
}
|
|
962
|
+
async function sendToUser(adapter, channel, userId, event, data) {
|
|
963
|
+
const presence = await adapter.getPresence(channel);
|
|
964
|
+
const userSessions = presence.filter((p) => p.userId === userId);
|
|
965
|
+
const message = createMessage({ event, channel, data });
|
|
966
|
+
for (const session of userSessions) {
|
|
967
|
+
await adapter.sendToSession(session.sessionId, message);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// src/core/channel.ts
|
|
972
|
+
var ChannelImpl = class {
|
|
973
|
+
constructor(name, adapter, sessionId) {
|
|
974
|
+
this.name = name;
|
|
975
|
+
this.adapter = adapter;
|
|
976
|
+
this.sessionId = sessionId;
|
|
977
|
+
}
|
|
978
|
+
presenceHandlers = /* @__PURE__ */ new Set();
|
|
979
|
+
async broadcast(event, data) {
|
|
980
|
+
const message = createMessage({
|
|
981
|
+
event,
|
|
982
|
+
channel: this.name,
|
|
983
|
+
data
|
|
984
|
+
});
|
|
985
|
+
await this.adapter.publish(this.name, message);
|
|
986
|
+
}
|
|
987
|
+
async send(userId, event, data) {
|
|
988
|
+
const message = createMessage({
|
|
989
|
+
event,
|
|
990
|
+
channel: this.name,
|
|
991
|
+
data,
|
|
992
|
+
metadata: { targetUserId: userId }
|
|
993
|
+
});
|
|
994
|
+
const presence = await this.adapter.getPresence(this.name);
|
|
995
|
+
for (const user of presence) {
|
|
996
|
+
if (user.userId === userId) {
|
|
997
|
+
await this.adapter.sendToSession(user.sessionId, message);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
async getPresence() {
|
|
1002
|
+
return this.adapter.getPresence(this.name);
|
|
1003
|
+
}
|
|
1004
|
+
subscribe(handler) {
|
|
1005
|
+
this.adapter.subscribe(this.name, this.sessionId, handler);
|
|
1006
|
+
return () => {
|
|
1007
|
+
this.adapter.unsubscribe(this.name, this.sessionId);
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
onPresence(handler) {
|
|
1011
|
+
this.presenceHandlers.add(handler);
|
|
1012
|
+
return () => {
|
|
1013
|
+
this.presenceHandlers.delete(handler);
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Emit presence event to handlers
|
|
1018
|
+
*/
|
|
1019
|
+
emitPresence(event) {
|
|
1020
|
+
for (const handler of this.presenceHandlers) {
|
|
1021
|
+
try {
|
|
1022
|
+
handler(event);
|
|
1023
|
+
} catch {
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
async getInfo() {
|
|
1028
|
+
const subscribers = await this.adapter.getSubscribers(this.name);
|
|
1029
|
+
const presence = await this.adapter.getPresence(this.name);
|
|
1030
|
+
return {
|
|
1031
|
+
name: this.name,
|
|
1032
|
+
subscriberCount: subscribers.length,
|
|
1033
|
+
presence
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
function createChannel(name, adapter, sessionId) {
|
|
1038
|
+
return new ChannelImpl(name, adapter, sessionId);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// src/index.ts
|
|
1042
|
+
function createRealtimeAdapter(config) {
|
|
1043
|
+
switch (config.adapter) {
|
|
1044
|
+
case "sse":
|
|
1045
|
+
return new SSEAdapter(config.sse);
|
|
1046
|
+
case "durable-objects":
|
|
1047
|
+
if (!config.durableObjects) {
|
|
1048
|
+
throw new Error("durableObjects config required for durable-objects adapter");
|
|
1049
|
+
}
|
|
1050
|
+
return new DurableObjectsAdapter(config.durableObjects);
|
|
1051
|
+
case "memory":
|
|
1052
|
+
return new SSEAdapter({ pingInterval: 0 });
|
|
1053
|
+
default:
|
|
1054
|
+
throw new Error(`Unknown adapter type: ${config.adapter}`);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
var index_default = {
|
|
1058
|
+
createRealtimeAdapter,
|
|
1059
|
+
createSSEAdapter,
|
|
1060
|
+
createDurableObjectsAdapter
|
|
1061
|
+
};
|
|
1062
|
+
export {
|
|
1063
|
+
ChannelImpl,
|
|
1064
|
+
DurableObjectsAdapter,
|
|
1065
|
+
RealtimeChannelDO,
|
|
1066
|
+
RealtimeError,
|
|
1067
|
+
RealtimeErrorCodes,
|
|
1068
|
+
SSEAdapter,
|
|
1069
|
+
broadcast,
|
|
1070
|
+
createChannel,
|
|
1071
|
+
createDORoutes,
|
|
1072
|
+
createDurableObjectsAdapter,
|
|
1073
|
+
createMessage,
|
|
1074
|
+
createRealtimeAdapter,
|
|
1075
|
+
createSSEAdapter,
|
|
1076
|
+
createSSEHandler,
|
|
1077
|
+
createSSERoutes,
|
|
1078
|
+
index_default as default,
|
|
1079
|
+
formatSSEEvent,
|
|
1080
|
+
parseSSEEvent,
|
|
1081
|
+
sendToUser,
|
|
1082
|
+
sseMiddleware
|
|
1083
|
+
};
|
|
1084
|
+
//# sourceMappingURL=index.js.map
|