@maravilla-labs/platform 0.1.27 → 0.1.29

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/src/media.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Media service for video/audio room management.
3
+ *
4
+ * In production (Deno runtime) calls go through `Deno.core.ops`.
5
+ * In development they are proxied as HTTP requests to the dev-server.
6
+ */
7
+
8
+ // ── Types ────────────────────────────────────────────────────────────
9
+
10
+ export interface MediaRoomInfo {
11
+ name: string;
12
+ numParticipants: number;
13
+ maxParticipants: number;
14
+ createdAt: number;
15
+ active: boolean;
16
+ }
17
+
18
+ export interface MediaRoomInfoSettings {
19
+ maxParticipants?: number;
20
+ emptyTimeoutSecs?: number;
21
+ }
22
+
23
+ export interface MediaParticipantInfo {
24
+ identity: string;
25
+ name: string;
26
+ canPublish?: boolean;
27
+ canSubscribe?: boolean;
28
+ canPublishData?: boolean;
29
+ }
30
+
31
+ export interface MediaTokenResult {
32
+ token: string;
33
+ url: string;
34
+ }
35
+
36
+ export interface MediaService {
37
+ createRoom(roomId: string, settings?: MediaRoomInfoSettings): Promise<MediaRoomInfo>;
38
+ deleteRoom(roomId: string): Promise<void>;
39
+ listRooms(): Promise<MediaRoomInfo[]>;
40
+ generateToken(roomId: string, participant: MediaParticipantInfo): Promise<MediaTokenResult>;
41
+ mediaUrl(): Promise<string | null>;
42
+ }
43
+
44
+ // ── Remote (dev-server) implementation ───────────────────────────────
45
+
46
+ export class RemoteMediaService implements MediaService {
47
+ constructor(
48
+ private baseUrl: string,
49
+ private headers: Record<string, string>,
50
+ ) {}
51
+
52
+ private async fetch(url: string, options: RequestInit = {}) {
53
+ const response = await fetch(url, {
54
+ ...options,
55
+ headers: { ...this.headers, ...options.headers },
56
+ });
57
+
58
+ if (!response.ok) {
59
+ const error = await response.text();
60
+ throw new Error(`Media API error: ${response.status} - ${error}`);
61
+ }
62
+
63
+ return response;
64
+ }
65
+
66
+ async createRoom(roomId: string, settings: MediaRoomInfoSettings = {}): Promise<MediaRoomInfo> {
67
+ const response = await this.fetch(`${this.baseUrl}/api/media/rooms`, {
68
+ method: 'POST',
69
+ body: JSON.stringify({ roomId, settings }),
70
+ });
71
+ return response.json() as Promise<MediaRoomInfo>;
72
+ }
73
+
74
+ async deleteRoom(roomId: string): Promise<void> {
75
+ await this.fetch(`${this.baseUrl}/api/media/rooms/${encodeURIComponent(roomId)}`, {
76
+ method: 'DELETE',
77
+ });
78
+ }
79
+
80
+ async listRooms(): Promise<MediaRoomInfo[]> {
81
+ const response = await this.fetch(`${this.baseUrl}/api/media/rooms`);
82
+ return response.json() as Promise<MediaRoomInfo[]>;
83
+ }
84
+
85
+ async generateToken(roomId: string, participant: MediaParticipantInfo): Promise<MediaTokenResult> {
86
+ const response = await this.fetch(
87
+ `${this.baseUrl}/api/media/rooms/${encodeURIComponent(roomId)}/token`,
88
+ {
89
+ method: 'POST',
90
+ body: JSON.stringify(participant),
91
+ },
92
+ );
93
+ return response.json() as Promise<MediaTokenResult>;
94
+ }
95
+
96
+ async mediaUrl(): Promise<string | null> {
97
+ const response = await this.fetch(`${this.baseUrl}/api/media/url`);
98
+ const data = (await response.json()) as { url: string | null };
99
+ return data.url;
100
+ }
101
+ }
@@ -0,0 +1,288 @@
1
+ // Maravilla Realtime Client
2
+ // Extends REN with channels, pub/sub, and presence support
3
+ // Uses WebSocket for bidirectional communication, SSE as fallback
4
+
5
+ import { getOrCreateClientId } from './ren.js';
6
+
7
+ export interface RealtimeEvent {
8
+ event: string;
9
+ channel: string;
10
+ data?: any;
11
+ from?: string;
12
+ userId?: string;
13
+ ts?: number;
14
+ metadata?: any;
15
+ }
16
+
17
+ export interface PresenceMember {
18
+ userId: string;
19
+ metadata?: any;
20
+ lastSeen?: number;
21
+ }
22
+
23
+ export interface RealtimeClientOptions {
24
+ /** WebSocket endpoint (default: auto-detect) */
25
+ wsEndpoint?: string;
26
+ /** Client ID (persistent across sessions) */
27
+ clientId?: string;
28
+ /** Auto-reconnect on disconnect (default: true) */
29
+ autoReconnect?: boolean;
30
+ /** Max reconnect backoff in ms (default: 15000) */
31
+ maxBackoffMs?: number;
32
+ /** Enable debug logging (default: false) */
33
+ debug?: boolean;
34
+ }
35
+
36
+ type EventCallback = (event: RealtimeEvent) => void;
37
+ type Unsubscribe = () => void;
38
+
39
+ export class RealtimeClient {
40
+ private wsEndpoint: string;
41
+ private clientId: string;
42
+ private ws: WebSocket | null = null;
43
+ private closed = false;
44
+ private attempt = 0;
45
+ private autoReconnect: boolean;
46
+ private maxBackoff: number;
47
+ private debug: boolean;
48
+
49
+ private channelListeners = new Map<string, Set<EventCallback>>();
50
+ private globalListeners = new Set<EventCallback>();
51
+ private presenceListeners = new Map<string, {
52
+ onJoin: Set<(member: PresenceMember) => void>;
53
+ onLeave: Set<(member: PresenceMember) => void>;
54
+ }>();
55
+ private subscribedChannels = new Set<string>();
56
+ private pendingMessages: string[] = [];
57
+
58
+ constructor(opts: RealtimeClientOptions = {}) {
59
+ this.wsEndpoint = opts.wsEndpoint || this.detectEndpoint();
60
+ this.clientId = opts.clientId || getOrCreateClientId();
61
+ this.autoReconnect = opts.autoReconnect !== false;
62
+ this.maxBackoff = opts.maxBackoffMs ?? 15000;
63
+ this.debug = !!opts.debug;
64
+ }
65
+
66
+ private detectEndpoint(): string {
67
+ if (typeof window !== 'undefined') {
68
+ const port = window.location.port;
69
+ const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
70
+ // Development mode: any non-standard port that isn't the dev server (3001)
71
+ // Vite uses 5173+ but can pick any available port
72
+ if (port && port !== '3001' && port !== '80' && port !== '443') {
73
+ return `ws://${window.location.hostname}:3001/_rt/ws`;
74
+ }
75
+ return `${proto}//${window.location.host}/_rt/ws`;
76
+ }
77
+ return 'ws://localhost:3001/_rt/ws';
78
+ }
79
+
80
+ private log(...args: any[]) {
81
+ if (this.debug) console.debug('[RealtimeClient]', ...args);
82
+ }
83
+
84
+ /** Connect to the realtime WebSocket server */
85
+ connect(): void {
86
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
87
+
88
+ const url = `${this.wsEndpoint}?cid=${encodeURIComponent(this.clientId)}`;
89
+ this.log('connecting', url);
90
+ this.closed = false;
91
+
92
+ try {
93
+ this.ws = new WebSocket(url);
94
+ } catch (e) {
95
+ this.log('WebSocket constructor failed', e);
96
+ this.scheduleReconnect();
97
+ return;
98
+ }
99
+
100
+ this.ws.onopen = () => {
101
+ this.attempt = 0;
102
+ this.log('connected');
103
+
104
+ // Re-subscribe to channels
105
+ for (const ch of this.subscribedChannels) {
106
+ this.sendRaw({ action: 'subscribe', channel: ch });
107
+ }
108
+
109
+ // Flush pending messages
110
+ for (const msg of this.pendingMessages) {
111
+ this.ws?.send(msg);
112
+ }
113
+ this.pendingMessages = [];
114
+ };
115
+
116
+ this.ws.onmessage = (ev) => {
117
+ try {
118
+ const event: RealtimeEvent = JSON.parse(ev.data);
119
+ this.log('received', event);
120
+ this.dispatch(event);
121
+ } catch (e) {
122
+ this.log('malformed message', ev.data, e);
123
+ }
124
+ };
125
+
126
+ this.ws.onerror = (ev) => {
127
+ this.log('error', ev);
128
+ };
129
+
130
+ this.ws.onclose = () => {
131
+ this.log('disconnected');
132
+ this.ws = null;
133
+ if (!this.closed && this.autoReconnect) {
134
+ this.scheduleReconnect();
135
+ }
136
+ };
137
+ }
138
+
139
+ private scheduleReconnect() {
140
+ const delay = Math.min(1000 * Math.pow(2, this.attempt++), this.maxBackoff);
141
+ this.log('reconnecting in', delay, 'ms');
142
+ setTimeout(() => this.connect(), delay);
143
+ }
144
+
145
+ private sendRaw(msg: Record<string, any>) {
146
+ const json = JSON.stringify(msg);
147
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
148
+ this.ws.send(json);
149
+ } else {
150
+ this.pendingMessages.push(json);
151
+ }
152
+ }
153
+
154
+ private dispatch(event: RealtimeEvent) {
155
+ // Notify global listeners
156
+ this.globalListeners.forEach(cb => cb(event));
157
+
158
+ // Notify channel-specific listeners
159
+ if (event.channel) {
160
+ const listeners = this.channelListeners.get(event.channel);
161
+ if (listeners) {
162
+ listeners.forEach(cb => cb(event));
163
+ }
164
+ }
165
+
166
+ // Handle presence events
167
+ if (event.event === 'presence:join' || event.event === 'presence:leave') {
168
+ const presenceSet = this.presenceListeners.get(event.channel);
169
+ if (presenceSet) {
170
+ const member: PresenceMember = {
171
+ userId: event.userId || '',
172
+ metadata: event.metadata,
173
+ lastSeen: event.ts,
174
+ };
175
+ if (event.event === 'presence:join') {
176
+ presenceSet.onJoin.forEach(cb => cb(member));
177
+ } else {
178
+ presenceSet.onLeave.forEach(cb => cb(member));
179
+ }
180
+ }
181
+ }
182
+ }
183
+
184
+ /** Subscribe to messages on a channel */
185
+ subscribe(channel: string, callback: EventCallback, options?: { token?: string }): Unsubscribe {
186
+ if (!this.channelListeners.has(channel)) {
187
+ this.channelListeners.set(channel, new Set());
188
+ }
189
+ this.channelListeners.get(channel)!.add(callback);
190
+
191
+ // Send subscribe message if not already subscribed
192
+ if (!this.subscribedChannels.has(channel)) {
193
+ this.subscribedChannels.add(channel);
194
+ const msg: Record<string, any> = { action: 'subscribe', channel };
195
+ if (options?.token) {
196
+ msg.token = options.token;
197
+ }
198
+ this.sendRaw(msg);
199
+ }
200
+
201
+ return () => {
202
+ const listeners = this.channelListeners.get(channel);
203
+ if (listeners) {
204
+ listeners.delete(callback);
205
+ if (listeners.size === 0) {
206
+ this.channelListeners.delete(channel);
207
+ this.subscribedChannels.delete(channel);
208
+ this.sendRaw({ action: 'unsubscribe', channel });
209
+ }
210
+ }
211
+ };
212
+ }
213
+
214
+ /** Listen to all events across all channels */
215
+ onAny(callback: EventCallback): Unsubscribe {
216
+ this.globalListeners.add(callback);
217
+ return () => { this.globalListeners.delete(callback); };
218
+ }
219
+
220
+ /** Publish a message to a channel */
221
+ publish(channel: string, data: any, options?: { userId?: string }): void {
222
+ this.sendRaw({
223
+ action: 'publish',
224
+ channel,
225
+ data,
226
+ userId: options?.userId,
227
+ });
228
+ }
229
+
230
+ /** Get a presence handle for a channel */
231
+ presence(channel: string) {
232
+ if (!this.presenceListeners.has(channel)) {
233
+ this.presenceListeners.set(channel, {
234
+ onJoin: new Set(),
235
+ onLeave: new Set(),
236
+ });
237
+ }
238
+ const listeners = this.presenceListeners.get(channel)!;
239
+
240
+ return {
241
+ /** Join the channel with presence */
242
+ join: (userId: string, metadata?: any): void => {
243
+ this.sendRaw({
244
+ action: 'presence:join',
245
+ channel,
246
+ userId,
247
+ metadata,
248
+ });
249
+ },
250
+
251
+ /** Leave the channel */
252
+ leave: (): void => {
253
+ this.sendRaw({ action: 'presence:leave', channel });
254
+ },
255
+
256
+ /** Listen for users joining */
257
+ onJoin: (callback: (member: PresenceMember) => void): Unsubscribe => {
258
+ listeners.onJoin.add(callback);
259
+ return () => { listeners.onJoin.delete(callback); };
260
+ },
261
+
262
+ /** Listen for users leaving */
263
+ onLeave: (callback: (member: PresenceMember) => void): Unsubscribe => {
264
+ listeners.onLeave.add(callback);
265
+ return () => { listeners.onLeave.delete(callback); };
266
+ },
267
+ };
268
+ }
269
+
270
+ /** Get current client ID */
271
+ getClientId(): string {
272
+ return this.clientId;
273
+ }
274
+
275
+ /** Check if connected */
276
+ isConnected(): boolean {
277
+ return this.ws?.readyState === WebSocket.OPEN;
278
+ }
279
+
280
+ /** Disconnect and stop reconnecting */
281
+ disconnect(): void {
282
+ this.closed = true;
283
+ this.ws?.close();
284
+ this.ws = null;
285
+ this.pendingMessages = [];
286
+ this.log('disconnected (manual)');
287
+ }
288
+ }
@@ -1,4 +1,5 @@
1
1
  import type { KvNamespace, KvListResult, Database, DbFindOptions, Storage } from './types.js';
2
+ import { RemoteMediaService } from './media.js';
2
3
 
3
4
  /**
4
5
  * Remote KV namespace implementation that communicates with a development server.
@@ -383,12 +384,14 @@ export function createRemoteClient(baseUrl: string, tenant: string) {
383
384
 
384
385
  const db = new RemoteDatabase(baseUrl, headers);
385
386
  const storage = new RemoteStorage(baseUrl, headers);
387
+ const media = new RemoteMediaService(baseUrl, headers);
386
388
 
387
389
  return {
388
390
  env: {
389
391
  KV: kvProxy,
390
392
  DB: db,
391
393
  STORAGE: storage,
392
- }
394
+ },
395
+ media,
393
396
  };
394
397
  }
package/src/ren.ts CHANGED
@@ -3,13 +3,16 @@
3
3
  // Reconnect w/ basic backoff; consumer filters self vs others using evt.src
4
4
 
5
5
  export interface RenEvent {
6
- t: string; // event type e.g. storage.object.created
7
- r: string; // resource domain e.g. storage, runtime
6
+ t: string; // event type e.g. storage.object.created, realtime.message, presence.join
7
+ r: string; // resource domain e.g. storage, runtime, realtime, presence
8
8
  k?: string; // key (object key, deployment key, etc.)
9
9
  v?: string; // version / etag
10
10
  ts?: number; // timestamp (ms)
11
11
  src?: string;// origin client id (if set on mutation request)
12
12
  ns?: string; // future namespace / tenant
13
+ ch?: string; // channel name (for realtime pub/sub)
14
+ data?: any; // arbitrary payload (for realtime messages)
15
+ uid?: string;// user identity (for presence)
13
16
  [extra: string]: any;
14
17
  }
15
18
 
@@ -60,12 +63,14 @@ export class RenClient {
60
63
  return '/api/maravilla/ren';
61
64
  }
62
65
 
63
- // Check if we're running on dev server (port 5173 is Vite dev server)
64
- if (typeof window !== 'undefined' && window.location.port === '5173') {
65
- // We're in development mode with Vite dev server
66
- const devServerUrl = `http://${window.location.hostname}:3001`;
67
- console.log('[REN detectEndpoint] Detected Vite dev server (port 5173), using dev server:', devServerUrl);
68
- return `${devServerUrl}/api/maravilla/ren`;
66
+ // Check if we're running on a dev server (Vite uses 5173+ but can pick any available port)
67
+ if (typeof window !== 'undefined') {
68
+ const port = window.location.port;
69
+ if (port && port !== '3001' && port !== '80' && port !== '443') {
70
+ const devServerUrl = `http://${window.location.hostname}:3001`;
71
+ console.log('[REN detectEndpoint] Detected dev mode (port ' + port + '), using dev server:', devServerUrl);
72
+ return `${devServerUrl}/api/maravilla/ren`;
73
+ }
69
74
  }
70
75
 
71
76
  // Check if we're in development with injected platform
@@ -132,6 +137,12 @@ export class RenClient {
132
137
  'runtime.snapshot.ready',
133
138
  'runtime.worker.started',
134
139
  'runtime.worker.stopped',
140
+ // Realtime channel events
141
+ 'realtime.message',
142
+ // Presence events
143
+ 'presence.join',
144
+ 'presence.leave',
145
+ 'presence.update',
135
146
  // Meta events
136
147
  'ren.meta'
137
148
  ];
package/src/types.ts CHANGED
@@ -542,4 +542,6 @@ export interface PlatformEnv {
542
542
  export interface Platform {
543
543
  /** Environment containing all available platform services */
544
544
  env: PlatformEnv;
545
+ /** Media service for video/audio room management (optional — available when media is configured) */
546
+ media?: import('./media.js').MediaService;
545
547
  }