@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/dist/index.d.ts +241 -1
- package/dist/index.js +544 -5
- package/dist/index.js.map +1 -1
- package/package.json +9 -2
- package/src/index.ts +3 -0
- package/src/media-room.ts +310 -0
- package/src/media.ts +101 -0
- package/src/realtime.ts +288 -0
- package/src/remote-client.ts +4 -1
- package/src/ren.ts +19 -8
- package/src/types.ts +2 -0
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
|
+
}
|
package/src/realtime.ts
ADDED
|
@@ -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
|
+
}
|
package/src/remote-client.ts
CHANGED
|
@@ -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 (
|
|
64
|
-
if (typeof window !== 'undefined'
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
}
|