@soulcraft/sdk 2.0.0 → 2.0.2

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.
Files changed (93) hide show
  1. package/dist/client/index.d.ts +5 -38
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +5 -47
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/client/namespace-proxy.d.ts +3 -4
  6. package/dist/client/namespace-proxy.d.ts.map +1 -1
  7. package/dist/client/namespace-proxy.js +3 -4
  8. package/dist/client/namespace-proxy.js.map +1 -1
  9. package/dist/index.d.ts +6 -6
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +4 -4
  12. package/dist/index.js.map +1 -1
  13. package/dist/modules/hall/browser.d.ts +83 -27
  14. package/dist/modules/hall/browser.d.ts.map +1 -1
  15. package/dist/modules/hall/browser.js +238 -49
  16. package/dist/modules/hall/browser.js.map +1 -1
  17. package/dist/modules/hall/media.d.ts +164 -0
  18. package/dist/modules/hall/media.d.ts.map +1 -0
  19. package/dist/modules/hall/media.js +182 -0
  20. package/dist/modules/hall/media.js.map +1 -0
  21. package/dist/modules/hall/server.d.ts +83 -6
  22. package/dist/modules/hall/server.d.ts.map +1 -1
  23. package/dist/modules/hall/server.js +206 -9
  24. package/dist/modules/hall/server.js.map +1 -1
  25. package/dist/modules/hall/types.d.ts +548 -25
  26. package/dist/modules/hall/types.d.ts.map +1 -1
  27. package/dist/modules/hall/types.js +12 -7
  28. package/dist/modules/hall/types.js.map +1 -1
  29. package/dist/server/hall-handlers.d.ts +40 -12
  30. package/dist/server/hall-handlers.d.ts.map +1 -1
  31. package/dist/server/hall-handlers.js +40 -12
  32. package/dist/server/hall-handlers.js.map +1 -1
  33. package/dist/server/handlers/chat/engine.d.ts.map +1 -1
  34. package/dist/server/handlers/chat/engine.js +5 -1
  35. package/dist/server/handlers/chat/engine.js.map +1 -1
  36. package/dist/server/handlers/chat/types.d.ts +17 -2
  37. package/dist/server/handlers/chat/types.d.ts.map +1 -1
  38. package/dist/server/hono-router.d.ts +2 -9
  39. package/dist/server/hono-router.d.ts.map +1 -1
  40. package/dist/server/hono-router.js +2 -46
  41. package/dist/server/hono-router.js.map +1 -1
  42. package/dist/server/index.d.ts +4 -19
  43. package/dist/server/index.d.ts.map +1 -1
  44. package/dist/server/index.js +10 -29
  45. package/dist/server/index.js.map +1 -1
  46. package/dist/types.d.ts +2 -41
  47. package/dist/types.d.ts.map +1 -1
  48. package/docs/ADR-005-hall-integration.md +449 -0
  49. package/package.json +1 -1
  50. package/dist/client/create-client-sdk.d.ts +0 -113
  51. package/dist/client/create-client-sdk.d.ts.map +0 -1
  52. package/dist/client/create-client-sdk.js +0 -169
  53. package/dist/client/create-client-sdk.js.map +0 -1
  54. package/dist/modules/app-context/index.d.ts +0 -214
  55. package/dist/modules/app-context/index.d.ts.map +0 -1
  56. package/dist/modules/app-context/index.js +0 -569
  57. package/dist/modules/app-context/index.js.map +0 -1
  58. package/dist/modules/billing/firestore-provider.d.ts +0 -60
  59. package/dist/modules/billing/firestore-provider.d.ts.map +0 -1
  60. package/dist/modules/billing/firestore-provider.js +0 -315
  61. package/dist/modules/billing/firestore-provider.js.map +0 -1
  62. package/dist/modules/brainy/proxy.d.ts +0 -48
  63. package/dist/modules/brainy/proxy.d.ts.map +0 -1
  64. package/dist/modules/brainy/proxy.js +0 -95
  65. package/dist/modules/brainy/proxy.js.map +0 -1
  66. package/dist/server/create-sdk.d.ts +0 -74
  67. package/dist/server/create-sdk.d.ts.map +0 -1
  68. package/dist/server/create-sdk.js +0 -104
  69. package/dist/server/create-sdk.js.map +0 -1
  70. package/dist/server/from-license.d.ts +0 -252
  71. package/dist/server/from-license.d.ts.map +0 -1
  72. package/dist/server/from-license.js +0 -349
  73. package/dist/server/from-license.js.map +0 -1
  74. package/dist/server/handlers.d.ts +0 -312
  75. package/dist/server/handlers.d.ts.map +0 -1
  76. package/dist/server/handlers.js +0 -376
  77. package/dist/server/handlers.js.map +0 -1
  78. package/dist/server/postmessage-handler.d.ts +0 -152
  79. package/dist/server/postmessage-handler.d.ts.map +0 -1
  80. package/dist/server/postmessage-handler.js +0 -138
  81. package/dist/server/postmessage-handler.js.map +0 -1
  82. package/dist/transports/http.d.ts +0 -86
  83. package/dist/transports/http.d.ts.map +0 -1
  84. package/dist/transports/http.js +0 -137
  85. package/dist/transports/http.js.map +0 -1
  86. package/dist/transports/postmessage.d.ts +0 -159
  87. package/dist/transports/postmessage.d.ts.map +0 -1
  88. package/dist/transports/postmessage.js +0 -207
  89. package/dist/transports/postmessage.js.map +0 -1
  90. package/dist/transports/workshop.d.ts +0 -173
  91. package/dist/transports/workshop.d.ts.map +0 -1
  92. package/dist/transports/workshop.js +0 -307
  93. package/dist/transports/workshop.js.map +0 -1
@@ -0,0 +1,182 @@
1
+ /**
2
+ * @module modules/hall/media
3
+ * @description HTTP client for the Hall media pipeline — upload, transcode, retrieve, and delete media.
4
+ *
5
+ * Hall provides a media pipeline for processing audio, video, and image files. This module
6
+ * wraps the HTTP endpoints (not WebSocket) with a typed client. Async notifications for
7
+ * transcoding completion arrive via the product WebSocket (`mediaReady` / `mediaError` events
8
+ * on the `HallRoom` handle).
9
+ *
10
+ * **Auth model:** All media HTTP requests use `Authorization: Hall <productName>:<secret>`.
11
+ * This client is server-only — never expose the product secret to browsers. Browser clients
12
+ * access processed media via the public `GET /media/{mediaId}` endpoint (no auth required
13
+ * for read access).
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * import { createHallMediaClient } from '@soulcraft/sdk/server'
18
+ *
19
+ * const media = createHallMediaClient({
20
+ * baseUrl: 'https://hall.soulcraft.com',
21
+ * productName: 'workshop',
22
+ * secret: process.env.HALL_WORKSHOP_SECRET!,
23
+ * })
24
+ *
25
+ * // Upload a file with transcoding
26
+ * const result = await media.upload(file, { transcode: 'video/mp4' })
27
+ * console.log(result.mediaId) // listen for 'mediaReady' event on the room
28
+ *
29
+ * // Get media info
30
+ * const info = await media.getInfo(result.mediaId)
31
+ *
32
+ * // Delete media
33
+ * await media.delete(result.mediaId)
34
+ * ```
35
+ */
36
+ // ─── HallMediaClient ─────────────────────────────────────────────────────────
37
+ /**
38
+ * Server-side HTTP client for the Hall media pipeline.
39
+ *
40
+ * Handles upload, info retrieval, thumbnail access, and deletion.
41
+ * All requests are authenticated with the product shared secret.
42
+ *
43
+ * Async transcoding notifications arrive via the product WebSocket, not this client.
44
+ * Listen for `mediaReady` / `mediaError` events on the `HallRoom` handle.
45
+ */
46
+ export class HallMediaClient {
47
+ #baseUrl;
48
+ #productName;
49
+ #secret;
50
+ /**
51
+ * @param options - Hall server URL, product name, and shared secret.
52
+ */
53
+ constructor(options) {
54
+ this.#baseUrl = options.baseUrl.replace(/\/$/, '');
55
+ this.#productName = options.productName;
56
+ this.#secret = options.secret;
57
+ }
58
+ /**
59
+ * Upload a media file to Hall for processing.
60
+ *
61
+ * @param file - The file to upload. Can be a `Blob`, `File`, or `Buffer`.
62
+ * @param options - Optional transcode target.
63
+ * @returns The assigned media ID. Listen for `mediaReady` on the room for completion.
64
+ *
65
+ * @example
66
+ * ```typescript
67
+ * const file = new Blob([audioBuffer], { type: 'audio/wav' })
68
+ * const { mediaId } = await media.upload(file, { transcode: 'audio/mp3' })
69
+ * ```
70
+ */
71
+ async upload(file, options = {}) {
72
+ const form = new FormData();
73
+ form.append('file', file);
74
+ if (options.transcode) {
75
+ form.append('transcode', options.transcode);
76
+ }
77
+ const response = await fetch(`${this.#baseUrl}/media/upload`, {
78
+ method: 'POST',
79
+ headers: { Authorization: this.#authHeader() },
80
+ body: form,
81
+ signal: AbortSignal.timeout(120_000),
82
+ });
83
+ if (!response.ok) {
84
+ throw new Error(`Hall media upload failed (${response.status}): ${await response.text()}`);
85
+ }
86
+ return response.json();
87
+ }
88
+ /**
89
+ * Get metadata for a processed media file.
90
+ *
91
+ * @param mediaId - The media identifier from the upload result.
92
+ * @returns Media metadata including MIME type, size, duration, dimensions, and status.
93
+ */
94
+ async getInfo(mediaId) {
95
+ const response = await fetch(`${this.#baseUrl}/media/${encodeURIComponent(mediaId)}/info`, {
96
+ headers: { Authorization: this.#authHeader() },
97
+ signal: AbortSignal.timeout(10_000),
98
+ });
99
+ if (!response.ok) {
100
+ throw new Error(`Hall media info failed (${response.status}): ${await response.text()}`);
101
+ }
102
+ return response.json();
103
+ }
104
+ /**
105
+ * Get the URL for streaming a media file.
106
+ * Supports HTTP range requests (206 Partial Content).
107
+ *
108
+ * @param mediaId - The media identifier.
109
+ * @returns The full URL to the media file.
110
+ */
111
+ getStreamUrl(mediaId) {
112
+ return `${this.#baseUrl}/media/${encodeURIComponent(mediaId)}`;
113
+ }
114
+ /**
115
+ * Get the URL for an auto-generated thumbnail (WebP).
116
+ *
117
+ * @param mediaId - The media identifier.
118
+ * @returns The full URL to the thumbnail image.
119
+ */
120
+ getThumbnailUrl(mediaId) {
121
+ return `${this.#baseUrl}/media/${encodeURIComponent(mediaId)}/thumbnail`;
122
+ }
123
+ /**
124
+ * Delete a media file. Only the uploading product can delete its own media.
125
+ *
126
+ * @param mediaId - The media identifier to delete.
127
+ */
128
+ async delete(mediaId) {
129
+ const response = await fetch(`${this.#baseUrl}/media/${encodeURIComponent(mediaId)}`, {
130
+ method: 'DELETE',
131
+ headers: { Authorization: this.#authHeader() },
132
+ signal: AbortSignal.timeout(10_000),
133
+ });
134
+ if (!response.ok) {
135
+ throw new Error(`Hall media delete failed (${response.status}): ${await response.text()}`);
136
+ }
137
+ }
138
+ /**
139
+ * Build the WHEP endpoint URL for receive-only WebRTC viewing.
140
+ * Browser viewers POST an SDP offer to this URL with a session token.
141
+ *
142
+ * @param roomId - The room to view.
143
+ * @returns The WHEP endpoint URL.
144
+ */
145
+ getWhepUrl(roomId) {
146
+ return `${this.#baseUrl}/whep/${encodeURIComponent(roomId)}`;
147
+ }
148
+ /**
149
+ * Build the LL-HLS master playlist URL for scalable HLS viewing.
150
+ *
151
+ * @param roomId - The room to view.
152
+ * @returns The HLS playlist URL.
153
+ */
154
+ getHlsUrl(roomId) {
155
+ return `${this.#baseUrl}/hls/${encodeURIComponent(roomId)}/playlist.m3u8`;
156
+ }
157
+ #authHeader() {
158
+ return `Hall ${this.#productName}:${this.#secret}`;
159
+ }
160
+ }
161
+ // ─── Factory ─────────────────────────────────────────────────────────────────
162
+ /**
163
+ * Create a server-side Hall media client for upload, transcode, and retrieval.
164
+ *
165
+ * @param options - Hall server URL, product name, and shared secret.
166
+ * @returns A `HallMediaClient` instance ready for use.
167
+ *
168
+ * @example
169
+ * ```typescript
170
+ * import { createHallMediaClient } from '@soulcraft/sdk/server'
171
+ *
172
+ * const media = createHallMediaClient({
173
+ * baseUrl: 'https://hall.soulcraft.com',
174
+ * productName: 'workshop',
175
+ * secret: process.env.HALL_WORKSHOP_SECRET!,
176
+ * })
177
+ * ```
178
+ */
179
+ export function createHallMediaClient(options) {
180
+ return new HallMediaClient(options);
181
+ }
182
+ //# sourceMappingURL=media.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"media.js","sourceRoot":"","sources":["../../../src/modules/hall/media.ts"],"names":[],"mappings":"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"}
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * `HallClient` maintains a single authenticated WebSocket connection from the product
6
6
  * backend to `hall.soulcraft.com`. All room management (createRoom, closeRoom, session
7
- * tokens, recording) goes through this connection.
7
+ * tokens, recording), pub/sub (topics, presence), broadcast control (promote/demote),
8
+ * and media pipeline notifications go through this connection.
8
9
  *
9
10
  * This module is **server-only** — it uses WebSocket, reconnect timers, and the
10
11
  * product shared secret, none of which should ever reach a browser bundle.
@@ -27,7 +28,7 @@
27
28
  * const { token } = await hall.createSessionToken('cohort-123', 'user-456')
28
29
  * ```
29
30
  */
30
- import type { HallConnectionOptions, HallModule, HallRoom, HallRoomEvents, RoomOptions } from './types.js';
31
+ import type { HallConnectionOptions, HallModule, HallPubsubEvents, HallRoom, HallRoomEvents, HallPeerRole, RoomOptions, ScreenShareMode } from './types.js';
31
32
  type Listener<T> = (event: T) => void;
32
33
  /**
33
34
  * Concrete implementation of the {@link HallRoom} interface.
@@ -49,8 +50,9 @@ export declare class HallRoomImpl implements HallRoom {
49
50
  * Product-backend WebSocket client for the Hall real-time communication server.
50
51
  *
51
52
  * Create one instance per product process. Maintains a single authenticated control-plane
52
- * WebSocket to `hall.soulcraft.com`. All room management and event routing goes through
53
- * this single connection — do not create multiple instances per product.
53
+ * WebSocket to `hall.soulcraft.com`. All room management, pub/sub, broadcast control,
54
+ * and event routing goes through this single connection — do not create multiple instances
55
+ * per product.
54
56
  *
55
57
  * Auto-reconnects on unexpected disconnect (configurable via `reconnectDelayMs`).
56
58
  * In-flight promises are rejected on disconnect; register `onReconnect` to re-create rooms.
@@ -93,7 +95,7 @@ export declare class HallClient implements HallModule {
93
95
  * Create a new room on the Hall server.
94
96
  *
95
97
  * @param roomId - Unique room identifier (e.g. Brainy session entity ID).
96
- * @param options - Room configuration: peer limit, transcription, recording, concept list.
98
+ * @param options - Room configuration: peer limit, transcription, recording, broadcast, concept list.
97
99
  * @returns The HallRoom handle — register event listeners on it immediately.
98
100
  * @throws {Error} If a room with the same ID already exists or the server returns an error.
99
101
  */
@@ -124,9 +126,10 @@ export declare class HallClient implements HallModule {
124
126
  * @param roomId - The room the browser peer will join.
125
127
  * @param peerId - The peer's unique identifier (e.g. authenticated user ID).
126
128
  * @param ttlSecs - Token lifetime in seconds (default: 300).
129
+ * @param role - Optional role hint: `"participant"`, `"viewer"`, or undefined (auto).
127
130
  * @returns `{ token, expiresAt }` — send `token` and `this.url` to the browser.
128
131
  */
129
- createSessionToken(roomId: string, peerId: string, ttlSecs?: number): Promise<{
132
+ createSessionToken(roomId: string, peerId: string, ttlSecs?: number, role?: HallPeerRole): Promise<{
130
133
  token: string;
131
134
  expiresAt: number;
132
135
  }>;
@@ -144,6 +147,80 @@ export declare class HallClient implements HallModule {
144
147
  * @param roomId - The room to stop recording.
145
148
  */
146
149
  stopRecording(roomId: string): Promise<void>;
150
+ /**
151
+ * Subscribe to a pub/sub topic. Topics are product-scoped.
152
+ *
153
+ * @param topic - Topic name to subscribe to.
154
+ * @param metadata - Optional presence metadata visible to other subscribers.
155
+ */
156
+ subscribeTopic(topic: string, metadata?: Record<string, unknown>): void;
157
+ /**
158
+ * Unsubscribe from a pub/sub topic.
159
+ *
160
+ * @param topic - Topic name to unsubscribe from.
161
+ */
162
+ unsubscribeTopic(topic: string): void;
163
+ /**
164
+ * Broadcast a message to all subscribers of a pub/sub topic.
165
+ *
166
+ * @param topic - Topic to broadcast to.
167
+ * @param payload - Arbitrary JSON payload delivered to all subscribers.
168
+ */
169
+ broadcastTopic(topic: string, payload: unknown): void;
170
+ /**
171
+ * Request a snapshot of all current subscribers on a topic.
172
+ *
173
+ * @param topic - Topic to query presence for.
174
+ */
175
+ getPresence(topic: string): void;
176
+ /**
177
+ * Register a listener for pub/sub events on the product connection.
178
+ *
179
+ * @param event - The pub/sub event name.
180
+ * @param listener - Callback invoked with the typed event payload.
181
+ */
182
+ onPubsub<K extends keyof HallPubsubEvents>(event: K, listener: (data: HallPubsubEvents[K]) => void): void;
183
+ /**
184
+ * Issue a browser pub/sub token.
185
+ *
186
+ * @param peerId - Peer ID the token is issued for.
187
+ * @param allowedTopics - Optional topic whitelist.
188
+ * @param ttlSecs - Token lifetime in seconds (default: 300).
189
+ * @returns `{ token, expiresAt }`.
190
+ */
191
+ createPubsubToken(peerId: string, allowedTopics?: string[], ttlSecs?: number): Promise<{
192
+ token: string;
193
+ expiresAt: number;
194
+ }>;
195
+ /**
196
+ * Promote a viewer to a full bidirectional participant.
197
+ *
198
+ * @param roomId - Room containing the peer.
199
+ * @param peerId - Peer ID to promote.
200
+ */
201
+ promotePeer(roomId: string, peerId: string): void;
202
+ /**
203
+ * Demote a participant to a receive-only viewer.
204
+ *
205
+ * @param roomId - Room containing the peer.
206
+ * @param peerId - Peer ID to demote.
207
+ */
208
+ demotePeer(roomId: string, peerId: string): void;
209
+ /**
210
+ * Set the screen share simulcast strategy for a peer.
211
+ *
212
+ * @param roomId - Room containing the screensharing peer.
213
+ * @param peerId - Peer sharing their screen.
214
+ * @param mode - `"static"` (resolution) or `"motion"` (framerate).
215
+ */
216
+ setScreenShareMode(roomId: string, peerId: string, mode: ScreenShareMode): void;
217
+ /**
218
+ * Request historical chat messages for a room.
219
+ *
220
+ * @param roomId - Room to get chat history for.
221
+ * @param lastN - Maximum number of recent messages to return.
222
+ */
223
+ getChatHistory(roomId: string, lastN?: number): void;
147
224
  }
148
225
  /**
149
226
  * Create a server-mode Hall module that connects to the Hall standalone server.
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../src/modules/hall/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAGH,OAAO,KAAK,EACV,qBAAqB,EACrB,UAAU,EACV,QAAQ,EACR,cAAc,EAEd,WAAW,EAQZ,MAAM,YAAY,CAAA;AAInB,KAAK,QAAQ,CAAC,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAA;AAGrC;;;;;GAKG;AACH,qBAAa,YAAa,YAAW,QAAQ;;IAC3C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;gBAad,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM;IAK7C,EAAE,CAAC,CAAC,SAAS,MAAM,cAAc,EAAE,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI;IAKzF,GAAG,CAAC,CAAC,SAAS,MAAM,cAAc,EAAE,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI;IAK1F,iFAAiF;IACjF,SAAS,CAAC,CAAC,SAAS,MAAM,cAAc,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,cAAc,CAAC,CAAC,CAAC,GAAG,IAAI;CAStF;AAWD;;;;;;;;;GASG;AACH,qBAAa,UAAW,YAAW,UAAU;;IAC3C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IAwBpB;;OAEG;gBACS,OAAO,EAAE,qBAAqB;IAS1C;;;;;;OAMG;IACH,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAOxB;;;OAGG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAS5B;;;;;OAKG;IACH,YAAY,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI;IAItD;;;;;OAKG;IACH,WAAW,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI;IAMvC;;;;;;;OAOG;IACH,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,YAAY,CAAC;IAQ5E;;;;;OAKG;IACH,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQxC;;;;OAIG;IACH,SAAS,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IAQ9B;;;;OAIG;IACH,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS;IAIjD;;;;;;;;OAQG;IACH,kBAAkB,CAChB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,MAAM,GACf,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;IAShD;;;;;OAKG;IACH,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAU7C;;;;;OAKG;IACH,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CA6N7C;AAID;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,qBAAqB,GAAG,UAAU,CAE3E"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../src/modules/hall/server.ts"],"names":[],"mappings":"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"}
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * `HallClient` maintains a single authenticated WebSocket connection from the product
6
6
  * backend to `hall.soulcraft.com`. All room management (createRoom, closeRoom, session
7
- * tokens, recording) goes through this connection.
7
+ * tokens, recording), pub/sub (topics, presence), broadcast control (promote/demote),
8
+ * and media pipeline notifications go through this connection.
8
9
  *
9
10
  * This module is **server-only** — it uses WebSocket, reconnect timers, and the
10
11
  * product shared secret, none of which should ever reach a browser bundle.
@@ -45,6 +46,13 @@ export class HallRoomImpl {
45
46
  peerJoined: new Set(),
46
47
  peerLeft: new Set(),
47
48
  recordingManifest: new Set(),
49
+ peerPromoted: new Set(),
50
+ peerDemoted: new Set(),
51
+ viewerCount: new Set(),
52
+ screenShareThumbnail: new Set(),
53
+ chatHistory: new Set(),
54
+ mediaReady: new Set(),
55
+ mediaError: new Set(),
48
56
  closed: new Set(),
49
57
  };
50
58
  constructor(roomId, sessionId) {
@@ -78,8 +86,9 @@ export class HallRoomImpl {
78
86
  * Product-backend WebSocket client for the Hall real-time communication server.
79
87
  *
80
88
  * Create one instance per product process. Maintains a single authenticated control-plane
81
- * WebSocket to `hall.soulcraft.com`. All room management and event routing goes through
82
- * this single connection — do not create multiple instances per product.
89
+ * WebSocket to `hall.soulcraft.com`. All room management, pub/sub, broadcast control,
90
+ * and event routing goes through this single connection — do not create multiple instances
91
+ * per product.
83
92
  *
84
93
  * Auto-reconnects on unexpected disconnect (configurable via `reconnectDelayMs`).
85
94
  * In-flight promises are rejected on disconnect; register `onReconnect` to re-create rooms.
@@ -98,8 +107,16 @@ export class HallClient {
98
107
  #pendingCloseRoom = new Map();
99
108
  #pendingSessionToken = new Map();
100
109
  #pendingRoomList = [];
110
+ #pendingPubsubToken = [];
101
111
  #disconnectListeners = new Set();
102
112
  #reconnectListeners = new Set();
113
+ #pubsubListeners = {
114
+ topicSubscribed: new Set(),
115
+ topicUnsubscribed: new Set(),
116
+ topicMessage: new Set(),
117
+ presenceUpdate: new Set(),
118
+ presenceSnapshot: new Set(),
119
+ };
103
120
  /**
104
121
  * @param options - Connection options: url, productName, secret, and optional reconnectDelayMs.
105
122
  */
@@ -158,7 +175,7 @@ export class HallClient {
158
175
  * Create a new room on the Hall server.
159
176
  *
160
177
  * @param roomId - Unique room identifier (e.g. Brainy session entity ID).
161
- * @param options - Room configuration: peer limit, transcription, recording, concept list.
178
+ * @param options - Room configuration: peer limit, transcription, recording, broadcast, concept list.
162
179
  * @returns The HallRoom handle — register event listeners on it immediately.
163
180
  * @throws {Error} If a room with the same ID already exists or the server returns an error.
164
181
  */
@@ -202,6 +219,7 @@ export class HallClient {
202
219
  getRoom(roomId) {
203
220
  return this.#rooms.get(roomId);
204
221
  }
222
+ // ── Session tokens ─────────────────────────────────────────────────────────
205
223
  /**
206
224
  * Mint a short-lived HMAC session token for a browser peer.
207
225
  * Pass `{ token, hallUrl }` to the browser — **never** the product secret.
@@ -209,16 +227,18 @@ export class HallClient {
209
227
  * @param roomId - The room the browser peer will join.
210
228
  * @param peerId - The peer's unique identifier (e.g. authenticated user ID).
211
229
  * @param ttlSecs - Token lifetime in seconds (default: 300).
230
+ * @param role - Optional role hint: `"participant"`, `"viewer"`, or undefined (auto).
212
231
  * @returns `{ token, expiresAt }` — send `token` and `this.url` to the browser.
213
232
  */
214
- createSessionToken(roomId, peerId, ttlSecs) {
233
+ createSessionToken(roomId, peerId, ttlSecs, role) {
215
234
  this.#requireConnected();
216
235
  const key = `${roomId}:${peerId}`;
217
236
  return new Promise((resolve, reject) => {
218
237
  this.#pendingSessionToken.set(key, { resolve, reject });
219
- this.#send({ t: 'createSessionToken', d: { roomId, peerId, ttlSecs } });
238
+ this.#send({ t: 'createSessionToken', d: { roomId, peerId, ttlSecs, role } });
220
239
  });
221
240
  }
241
+ // ── Recording ──────────────────────────────────────────────────────────────
222
242
  /**
223
243
  * Start per-track MKV recording for an active room.
224
244
  * The room emits `recordingManifest` when `stopRecording` is called.
@@ -228,8 +248,6 @@ export class HallClient {
228
248
  startRecording(roomId) {
229
249
  this.#requireConnected();
230
250
  return new Promise((resolve, _reject) => {
231
- // Hall sends `recordingStarted` as an informational ack — errors arrive as
232
- // t:'error' messages and are routed to #rejectFirstPending.
233
251
  this.#send({ t: 'startRecording', d: { roomId } });
234
252
  resolve();
235
253
  });
@@ -247,6 +265,114 @@ export class HallClient {
247
265
  resolve();
248
266
  });
249
267
  }
268
+ // ── Pub/Sub ────────────────────────────────────────────────────────────────
269
+ /**
270
+ * Subscribe to a pub/sub topic. Topics are product-scoped.
271
+ *
272
+ * @param topic - Topic name to subscribe to.
273
+ * @param metadata - Optional presence metadata visible to other subscribers.
274
+ */
275
+ subscribeTopic(topic, metadata) {
276
+ this.#requireConnected();
277
+ this.#send({ t: 'subscribeTopic', d: { topic, metadata } });
278
+ }
279
+ /**
280
+ * Unsubscribe from a pub/sub topic.
281
+ *
282
+ * @param topic - Topic name to unsubscribe from.
283
+ */
284
+ unsubscribeTopic(topic) {
285
+ this.#requireConnected();
286
+ this.#send({ t: 'unsubscribeTopic', d: { topic } });
287
+ }
288
+ /**
289
+ * Broadcast a message to all subscribers of a pub/sub topic.
290
+ *
291
+ * @param topic - Topic to broadcast to.
292
+ * @param payload - Arbitrary JSON payload delivered to all subscribers.
293
+ */
294
+ broadcastTopic(topic, payload) {
295
+ this.#requireConnected();
296
+ this.#send({ t: 'broadcastTopic', d: { topic, payload } });
297
+ }
298
+ /**
299
+ * Request a snapshot of all current subscribers on a topic.
300
+ *
301
+ * @param topic - Topic to query presence for.
302
+ */
303
+ getPresence(topic) {
304
+ this.#requireConnected();
305
+ this.#send({ t: 'getPresence', d: { topic } });
306
+ }
307
+ /**
308
+ * Register a listener for pub/sub events on the product connection.
309
+ *
310
+ * @param event - The pub/sub event name.
311
+ * @param listener - Callback invoked with the typed event payload.
312
+ */
313
+ onPubsub(event, listener) {
314
+ ;
315
+ this.#pubsubListeners[event].add(listener);
316
+ }
317
+ /**
318
+ * Issue a browser pub/sub token.
319
+ *
320
+ * @param peerId - Peer ID the token is issued for.
321
+ * @param allowedTopics - Optional topic whitelist.
322
+ * @param ttlSecs - Token lifetime in seconds (default: 300).
323
+ * @returns `{ token, expiresAt }`.
324
+ */
325
+ createPubsubToken(peerId, allowedTopics, ttlSecs) {
326
+ this.#requireConnected();
327
+ return new Promise((resolve, reject) => {
328
+ this.#pendingPubsubToken.push({ resolve, reject });
329
+ this.#send({ t: 'createPubsubToken', d: { peerId, allowedTopics, ttlSecs } });
330
+ });
331
+ }
332
+ // ── Broadcast control ──────────────────────────────────────────────────────
333
+ /**
334
+ * Promote a viewer to a full bidirectional participant.
335
+ *
336
+ * @param roomId - Room containing the peer.
337
+ * @param peerId - Peer ID to promote.
338
+ */
339
+ promotePeer(roomId, peerId) {
340
+ this.#requireConnected();
341
+ this.#send({ t: 'promotePeer', d: { roomId, peerId } });
342
+ }
343
+ /**
344
+ * Demote a participant to a receive-only viewer.
345
+ *
346
+ * @param roomId - Room containing the peer.
347
+ * @param peerId - Peer ID to demote.
348
+ */
349
+ demotePeer(roomId, peerId) {
350
+ this.#requireConnected();
351
+ this.#send({ t: 'demotePeer', d: { roomId, peerId } });
352
+ }
353
+ // ── Screen sharing ─────────────────────────────────────────────────────────
354
+ /**
355
+ * Set the screen share simulcast strategy for a peer.
356
+ *
357
+ * @param roomId - Room containing the screensharing peer.
358
+ * @param peerId - Peer sharing their screen.
359
+ * @param mode - `"static"` (resolution) or `"motion"` (framerate).
360
+ */
361
+ setScreenShareMode(roomId, peerId, mode) {
362
+ this.#requireConnected();
363
+ this.#send({ t: 'setScreenShareMode', d: { roomId, peerId, mode } });
364
+ }
365
+ // ── Chat history ───────────────────────────────────────────────────────────
366
+ /**
367
+ * Request historical chat messages for a room.
368
+ *
369
+ * @param roomId - Room to get chat history for.
370
+ * @param lastN - Maximum number of recent messages to return.
371
+ */
372
+ getChatHistory(roomId, lastN) {
373
+ this.#requireConnected();
374
+ this.#send({ t: 'getChatHistory', d: { roomId, lastN } });
375
+ }
250
376
  // ── Private: socket management ─────────────────────────────────────────────
251
377
  #openSocket() {
252
378
  const ws = new WebSocket(`${this.url}/ws/product`);
@@ -313,6 +439,17 @@ export class HallClient {
313
439
  throw new Error('Hall: not authenticated — await hall.connect() first');
314
440
  }
315
441
  }
442
+ // ── Private: pub/sub event dispatch ────────────────────────────────────────
443
+ #dispatchPubsub(event, payload) {
444
+ for (const listener of this.#pubsubListeners[event]) {
445
+ try {
446
+ listener(payload);
447
+ }
448
+ catch (e) {
449
+ console.error(`[Hall] uncaught error in pubsub '${event}' listener:`, e);
450
+ }
451
+ }
452
+ }
316
453
  // ── Private: message routing ───────────────────────────────────────────────
317
454
  #handleMessage(msg) {
318
455
  switch (msg.t) {
@@ -367,6 +504,7 @@ export class HallClient {
367
504
  console.warn(`[Hall] server error ${msg.d.code}: ${msg.d.message}`);
368
505
  this.#rejectFirstPending(new Error(msg.d.message));
369
506
  break;
507
+ // ── Room events ──────────────────────────────────────────────────────
370
508
  case 'transcript':
371
509
  this.#dispatchRoomEvent(msg.d.roomId, 'transcript', msg.d);
372
510
  break;
@@ -388,6 +526,54 @@ export class HallClient {
388
526
  case 'recordingManifest':
389
527
  this.#dispatchRoomEvent(msg.d.roomId, 'recordingManifest', msg.d.manifest);
390
528
  break;
529
+ case 'peerPromoted':
530
+ this.#dispatchRoomEvent(msg.d.roomId, 'peerPromoted', msg.d);
531
+ break;
532
+ case 'peerDemoted':
533
+ this.#dispatchRoomEvent(msg.d.roomId, 'peerDemoted', msg.d);
534
+ break;
535
+ case 'viewerCount':
536
+ this.#dispatchRoomEvent(msg.d.roomId, 'viewerCount', msg.d);
537
+ break;
538
+ case 'screenShareThumbnail':
539
+ this.#dispatchRoomEvent(msg.d.roomId, 'screenShareThumbnail', msg.d);
540
+ break;
541
+ case 'chatHistory':
542
+ this.#dispatchRoomEvent(msg.d.roomId, 'chatHistory', msg.d);
543
+ break;
544
+ case 'mediaReady':
545
+ // Media events are room-scoped — dispatch to all rooms (media is product-level).
546
+ // Products typically only have one active room per media upload, so this is fine.
547
+ for (const room of this.#rooms.values()) {
548
+ room._dispatch('mediaReady', msg.d);
549
+ }
550
+ break;
551
+ case 'mediaError':
552
+ for (const room of this.#rooms.values()) {
553
+ room._dispatch('mediaError', msg.d);
554
+ }
555
+ break;
556
+ // ── Pub/Sub events ───────────────────────────────────────────────────
557
+ case 'topicSubscribed':
558
+ this.#dispatchPubsub('topicSubscribed', msg.d);
559
+ break;
560
+ case 'topicUnsubscribed':
561
+ this.#dispatchPubsub('topicUnsubscribed', msg.d);
562
+ break;
563
+ case 'topicMessage':
564
+ this.#dispatchPubsub('topicMessage', msg.d);
565
+ break;
566
+ case 'presenceUpdate':
567
+ this.#dispatchPubsub('presenceUpdate', msg.d);
568
+ break;
569
+ case 'presenceSnapshot':
570
+ this.#dispatchPubsub('presenceSnapshot', msg.d);
571
+ break;
572
+ case 'pubsubToken': {
573
+ const pending = this.#pendingPubsubToken.shift();
574
+ pending?.resolve({ token: msg.d.token, expiresAt: msg.d.expiresAt });
575
+ break;
576
+ }
391
577
  case 'recordingStarted':
392
578
  case 'dataSent':
393
579
  // Informational acks — no pending promise to resolve.
@@ -412,6 +598,8 @@ export class HallClient {
412
598
  this.#pendingSessionToken.clear();
413
599
  this.#pendingRoomList.forEach((p) => p.reject(err));
414
600
  this.#pendingRoomList.length = 0;
601
+ this.#pendingPubsubToken.forEach((p) => p.reject(err));
602
+ this.#pendingPubsubToken.length = 0;
415
603
  }
416
604
  #rejectFirstPending(err) {
417
605
  for (const [key, p] of this.#pendingCreateRoom) {
@@ -429,7 +617,16 @@ export class HallClient {
429
617
  p.reject(err);
430
618
  return;
431
619
  }
432
- this.#pendingRoomList.shift()?.reject(err);
620
+ const listPending = this.#pendingRoomList.shift();
621
+ if (listPending) {
622
+ listPending.reject(err);
623
+ return;
624
+ }
625
+ const pubsubPending = this.#pendingPubsubToken.shift();
626
+ if (pubsubPending) {
627
+ pubsubPending.reject(err);
628
+ return;
629
+ }
433
630
  }
434
631
  }
435
632
  // ─── Factory ──────────────────────────────────────────────────────────────────