@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/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
|