@maravilla-labs/platform 0.1.27 → 0.1.28

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,286 @@
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 proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
69
+ // Development mode (Vite on 5173)
70
+ if (window.location.port === '5173') {
71
+ return `ws://${window.location.hostname}:3001/_rt/ws`;
72
+ }
73
+ return `${proto}//${window.location.host}/_rt/ws`;
74
+ }
75
+ return 'ws://localhost:3001/_rt/ws';
76
+ }
77
+
78
+ private log(...args: any[]) {
79
+ if (this.debug) console.debug('[RealtimeClient]', ...args);
80
+ }
81
+
82
+ /** Connect to the realtime WebSocket server */
83
+ connect(): void {
84
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
85
+
86
+ const url = `${this.wsEndpoint}?cid=${encodeURIComponent(this.clientId)}`;
87
+ this.log('connecting', url);
88
+ this.closed = false;
89
+
90
+ try {
91
+ this.ws = new WebSocket(url);
92
+ } catch (e) {
93
+ this.log('WebSocket constructor failed', e);
94
+ this.scheduleReconnect();
95
+ return;
96
+ }
97
+
98
+ this.ws.onopen = () => {
99
+ this.attempt = 0;
100
+ this.log('connected');
101
+
102
+ // Re-subscribe to channels
103
+ for (const ch of this.subscribedChannels) {
104
+ this.sendRaw({ action: 'subscribe', channel: ch });
105
+ }
106
+
107
+ // Flush pending messages
108
+ for (const msg of this.pendingMessages) {
109
+ this.ws?.send(msg);
110
+ }
111
+ this.pendingMessages = [];
112
+ };
113
+
114
+ this.ws.onmessage = (ev) => {
115
+ try {
116
+ const event: RealtimeEvent = JSON.parse(ev.data);
117
+ this.log('received', event);
118
+ this.dispatch(event);
119
+ } catch (e) {
120
+ this.log('malformed message', ev.data, e);
121
+ }
122
+ };
123
+
124
+ this.ws.onerror = (ev) => {
125
+ this.log('error', ev);
126
+ };
127
+
128
+ this.ws.onclose = () => {
129
+ this.log('disconnected');
130
+ this.ws = null;
131
+ if (!this.closed && this.autoReconnect) {
132
+ this.scheduleReconnect();
133
+ }
134
+ };
135
+ }
136
+
137
+ private scheduleReconnect() {
138
+ const delay = Math.min(1000 * Math.pow(2, this.attempt++), this.maxBackoff);
139
+ this.log('reconnecting in', delay, 'ms');
140
+ setTimeout(() => this.connect(), delay);
141
+ }
142
+
143
+ private sendRaw(msg: Record<string, any>) {
144
+ const json = JSON.stringify(msg);
145
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
146
+ this.ws.send(json);
147
+ } else {
148
+ this.pendingMessages.push(json);
149
+ }
150
+ }
151
+
152
+ private dispatch(event: RealtimeEvent) {
153
+ // Notify global listeners
154
+ this.globalListeners.forEach(cb => cb(event));
155
+
156
+ // Notify channel-specific listeners
157
+ if (event.channel) {
158
+ const listeners = this.channelListeners.get(event.channel);
159
+ if (listeners) {
160
+ listeners.forEach(cb => cb(event));
161
+ }
162
+ }
163
+
164
+ // Handle presence events
165
+ if (event.event === 'presence:join' || event.event === 'presence:leave') {
166
+ const presenceSet = this.presenceListeners.get(event.channel);
167
+ if (presenceSet) {
168
+ const member: PresenceMember = {
169
+ userId: event.userId || '',
170
+ metadata: event.metadata,
171
+ lastSeen: event.ts,
172
+ };
173
+ if (event.event === 'presence:join') {
174
+ presenceSet.onJoin.forEach(cb => cb(member));
175
+ } else {
176
+ presenceSet.onLeave.forEach(cb => cb(member));
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ /** Subscribe to messages on a channel */
183
+ subscribe(channel: string, callback: EventCallback, options?: { token?: string }): Unsubscribe {
184
+ if (!this.channelListeners.has(channel)) {
185
+ this.channelListeners.set(channel, new Set());
186
+ }
187
+ this.channelListeners.get(channel)!.add(callback);
188
+
189
+ // Send subscribe message if not already subscribed
190
+ if (!this.subscribedChannels.has(channel)) {
191
+ this.subscribedChannels.add(channel);
192
+ const msg: Record<string, any> = { action: 'subscribe', channel };
193
+ if (options?.token) {
194
+ msg.token = options.token;
195
+ }
196
+ this.sendRaw(msg);
197
+ }
198
+
199
+ return () => {
200
+ const listeners = this.channelListeners.get(channel);
201
+ if (listeners) {
202
+ listeners.delete(callback);
203
+ if (listeners.size === 0) {
204
+ this.channelListeners.delete(channel);
205
+ this.subscribedChannels.delete(channel);
206
+ this.sendRaw({ action: 'unsubscribe', channel });
207
+ }
208
+ }
209
+ };
210
+ }
211
+
212
+ /** Listen to all events across all channels */
213
+ onAny(callback: EventCallback): Unsubscribe {
214
+ this.globalListeners.add(callback);
215
+ return () => { this.globalListeners.delete(callback); };
216
+ }
217
+
218
+ /** Publish a message to a channel */
219
+ publish(channel: string, data: any, options?: { userId?: string }): void {
220
+ this.sendRaw({
221
+ action: 'publish',
222
+ channel,
223
+ data,
224
+ userId: options?.userId,
225
+ });
226
+ }
227
+
228
+ /** Get a presence handle for a channel */
229
+ presence(channel: string) {
230
+ if (!this.presenceListeners.has(channel)) {
231
+ this.presenceListeners.set(channel, {
232
+ onJoin: new Set(),
233
+ onLeave: new Set(),
234
+ });
235
+ }
236
+ const listeners = this.presenceListeners.get(channel)!;
237
+
238
+ return {
239
+ /** Join the channel with presence */
240
+ join: (userId: string, metadata?: any): void => {
241
+ this.sendRaw({
242
+ action: 'presence:join',
243
+ channel,
244
+ userId,
245
+ metadata,
246
+ });
247
+ },
248
+
249
+ /** Leave the channel */
250
+ leave: (): void => {
251
+ this.sendRaw({ action: 'presence:leave', channel });
252
+ },
253
+
254
+ /** Listen for users joining */
255
+ onJoin: (callback: (member: PresenceMember) => void): Unsubscribe => {
256
+ listeners.onJoin.add(callback);
257
+ return () => { listeners.onJoin.delete(callback); };
258
+ },
259
+
260
+ /** Listen for users leaving */
261
+ onLeave: (callback: (member: PresenceMember) => void): Unsubscribe => {
262
+ listeners.onLeave.add(callback);
263
+ return () => { listeners.onLeave.delete(callback); };
264
+ },
265
+ };
266
+ }
267
+
268
+ /** Get current client ID */
269
+ getClientId(): string {
270
+ return this.clientId;
271
+ }
272
+
273
+ /** Check if connected */
274
+ isConnected(): boolean {
275
+ return this.ws?.readyState === WebSocket.OPEN;
276
+ }
277
+
278
+ /** Disconnect and stop reconnecting */
279
+ disconnect(): void {
280
+ this.closed = true;
281
+ this.ws?.close();
282
+ this.ws = null;
283
+ this.pendingMessages = [];
284
+ this.log('disconnected (manual)');
285
+ }
286
+ }
@@ -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
 
@@ -132,6 +135,12 @@ export class RenClient {
132
135
  'runtime.snapshot.ready',
133
136
  'runtime.worker.started',
134
137
  'runtime.worker.stopped',
138
+ // Realtime channel events
139
+ 'realtime.message',
140
+ // Presence events
141
+ 'presence.join',
142
+ 'presence.leave',
143
+ 'presence.update',
135
144
  // Meta events
136
145
  'ren.meta'
137
146
  ];
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
  }