@strevokit/meeting-sdk 0.0.0-stage → 0.8.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/CHANGELOG.md +96 -0
- package/LICENSE +215 -0
- package/README.md +173 -2
- package/dist/index.d.ts +249 -0
- package/dist/index.js +3935 -0
- package/dist/index.js.map +7 -0
- package/docs/api-reference.md +361 -0
- package/docs/backend-api.md +370 -0
- package/docs/examples/backend-integration.md +103 -0
- package/docs/examples/react.tsx +144 -0
- package/docs/examples/vanilla.html +101 -0
- package/package.json +62 -4
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
# Backend API for meeting clients
|
|
2
|
+
|
|
3
|
+
Your backend prepares each meeting with the Strevokit HTTP API, then mints a short-lived **join token** for the
|
|
4
|
+
browser. The meeting SDK only ever receives that token — never your API credential.
|
|
5
|
+
|
|
6
|
+
Base URL in the examples: `https://api.example.com` (replace with your Strevokit API host).
|
|
7
|
+
|
|
8
|
+
## Auth
|
|
9
|
+
|
|
10
|
+
Every route below requires:
|
|
11
|
+
|
|
12
|
+
```http
|
|
13
|
+
Authorization: Bearer <STREVOKIT_API_CREDENTIAL>
|
|
14
|
+
Content-Type: application/json
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Keep `STREVOKIT_API_CREDENTIAL` on the server only. Never put it in browser code, `VITE_*` / `NEXT_PUBLIC_*`
|
|
18
|
+
variables, or mobile apps.
|
|
19
|
+
|
|
20
|
+
Meeting and participant identifiers in paths and JSON are opaque `public_id` values (26-character Crockford ULIDs).
|
|
21
|
+
Responses never include numeric internal database ids.
|
|
22
|
+
|
|
23
|
+
## Route index
|
|
24
|
+
|
|
25
|
+
| Method | Path | Purpose |
|
|
26
|
+
|--------|------|---------|
|
|
27
|
+
| `POST` | `/v1/embed-origins` | Allow-list a browser origin that may join |
|
|
28
|
+
| `GET` | `/v1/embed-origins` | List allow-listed origins |
|
|
29
|
+
| `DELETE` | `/v1/embed-origins/{public_id}` | Remove an allow-listed origin |
|
|
30
|
+
| `POST` | `/v1/meetings` | Create a meeting |
|
|
31
|
+
| `GET` | `/v1/meetings/{public_id}` | Fetch a meeting |
|
|
32
|
+
| `POST` | `/v1/meetings/{public_id}/participants` | Authorize (register) a participant |
|
|
33
|
+
| `POST` | `/v1/meetings/{public_id}/join-token` | Mint a join token for one participant |
|
|
34
|
+
| `POST` | `/v1/meetings/{public_id}/end` | End the meeting for everyone |
|
|
35
|
+
|
|
36
|
+
## Integration sequence
|
|
37
|
+
|
|
38
|
+
1. **Allow-list your app origin** (`POST /v1/embed-origins`) once per environment (for example `https://app.example.com`).
|
|
39
|
+
Joins from other origins fail with `origin_not_allowed` at the realtime handshake.
|
|
40
|
+
2. **Create a meeting** (`POST /v1/meetings`) when your product starts a session. Store `data.public_id`.
|
|
41
|
+
3. **Authorize each person** (`POST /v1/meetings/{public_id}/participants`) as `host` or `participant`. Store each
|
|
42
|
+
`data.public_id` (the participant public id).
|
|
43
|
+
4. **Mint a join token** (`POST /v1/meetings/{public_id}/join-token`) when that user opens the meeting page. Return
|
|
44
|
+
only `data.token` to the browser.
|
|
45
|
+
5. **Join in the browser** with `createMeetingEngine({ token, realtimeUrl })` (see the [README](../README.md)).
|
|
46
|
+
|
|
47
|
+
Optional later: `GET /v1/meetings/{public_id}` to read status; `POST /v1/meetings/{public_id}/end` to end from your
|
|
48
|
+
backend.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## POST /v1/embed-origins
|
|
53
|
+
|
|
54
|
+
Register one browser origin that may open meetings.
|
|
55
|
+
|
|
56
|
+
```http
|
|
57
|
+
POST /v1/embed-origins
|
|
58
|
+
Authorization: Bearer <STREVOKIT_API_CREDENTIAL>
|
|
59
|
+
Content-Type: application/json
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{ "origin": "https://app.example.com" }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`origin` must be a full web origin (`http` or `https`, host, optional non-default port). No path, query, fragment, or
|
|
67
|
+
wildcards.
|
|
68
|
+
|
|
69
|
+
**Success:** `201` when newly inserted, `200` when the same origin already exists.
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"data": {
|
|
74
|
+
"public_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
75
|
+
"origin": "https://app.example.com",
|
|
76
|
+
"created_at": "2026-09-24T12:00:00.000000Z"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Also available: `GET /v1/embed-origins` (list) and `DELETE /v1/embed-origins/{public_id}` (`204` on success). An empty
|
|
82
|
+
allow-list means joins fail closed until you add at least one origin.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## POST /v1/meetings
|
|
87
|
+
|
|
88
|
+
Create a meeting.
|
|
89
|
+
|
|
90
|
+
```http
|
|
91
|
+
POST /v1/meetings
|
|
92
|
+
Authorization: Bearer <STREVOKIT_API_CREDENTIAL>
|
|
93
|
+
Content-Type: application/json
|
|
94
|
+
Idempotency-Key: <optional opaque string>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"type": "one_to_one",
|
|
100
|
+
"max_participants": 2,
|
|
101
|
+
"recording_requested": false,
|
|
102
|
+
"external_reference": "session_9876",
|
|
103
|
+
"empty_timeout_seconds": 300
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
| Field | Required | Notes |
|
|
108
|
+
|-------|----------|--------|
|
|
109
|
+
| `type` | yes | `one_to_one` or `one_to_many` |
|
|
110
|
+
| `max_participants` | yes | `2` for `one_to_one`; `3`–`50` for `one_to_many` |
|
|
111
|
+
| `recording_requested` | no | Default `false`. Alias `recording` is accepted |
|
|
112
|
+
| `scheduled_start` | no | ISO-8601 with timezone; stored as `scheduled_at` |
|
|
113
|
+
| `metadata` | no | Flat JSON object; keep it small |
|
|
114
|
+
| `external_reference` | no | Your own id, 1–255 characters when present; echoed unchanged |
|
|
115
|
+
| `empty_timeout_seconds` | no | `60`–`86400`; default `300`. Idle empty meetings end after this many seconds |
|
|
116
|
+
|
|
117
|
+
Do not send `tenant_id`, numeric `id`, or a client-chosen media mode — the platform selects delivery.
|
|
118
|
+
|
|
119
|
+
Optional `Idempotency-Key` (≤255 characters): same key + same body replays the first response; same key + different
|
|
120
|
+
body returns `409` `idempotency_conflict`.
|
|
121
|
+
|
|
122
|
+
**Success:** `201 Created` (idempotent replay may return the original status).
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"data": {
|
|
127
|
+
"public_id": "01JB2Q8ABCDEF01234567890AB",
|
|
128
|
+
"external_reference": "session_9876",
|
|
129
|
+
"type": "one_to_one",
|
|
130
|
+
"max_participants": 2,
|
|
131
|
+
"recording_requested": false,
|
|
132
|
+
"recording_allowed": false,
|
|
133
|
+
"status": "created",
|
|
134
|
+
"scheduled_at": null,
|
|
135
|
+
"started_at": null,
|
|
136
|
+
"ended_at": null,
|
|
137
|
+
"end_reason": null,
|
|
138
|
+
"empty_timeout_seconds": 300,
|
|
139
|
+
"metadata": null,
|
|
140
|
+
"created_at": "2026-09-22T12:00:00+00:00",
|
|
141
|
+
"updated_at": "2026-09-22T12:00:00+00:00"
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The response may also include platform-selected fields (for example how media is delivered). Store `public_id` for
|
|
147
|
+
later calls.
|
|
148
|
+
|
|
149
|
+
| Case | HTTP | `error.code` |
|
|
150
|
+
|------|------|--------------|
|
|
151
|
+
| Missing or invalid credential | 401 | `unauthorized` |
|
|
152
|
+
| Bad or missing fields | 422 | `validation_failed` |
|
|
153
|
+
| Idempotency key reused with a different body | 409 | `idempotency_conflict` |
|
|
154
|
+
| Rate limited | 429 | `rate_limited` (+ `Retry-After`) |
|
|
155
|
+
| Suspended tenant | 403 | `forbidden` |
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## GET /v1/meetings/{public_id}
|
|
160
|
+
|
|
161
|
+
Fetch the meeting resource for your tenant (same shape as create). Missing or other-tenant ids return `404`
|
|
162
|
+
`meeting_not_found`.
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## POST /v1/meetings/{public_id}/participants
|
|
167
|
+
|
|
168
|
+
Authorize (register) a participant on a meeting. Call once per person who may join.
|
|
169
|
+
|
|
170
|
+
```http
|
|
171
|
+
POST /v1/meetings/{public_id}/participants
|
|
172
|
+
Authorization: Bearer <STREVOKIT_API_CREDENTIAL>
|
|
173
|
+
Content-Type: application/json
|
|
174
|
+
Idempotency-Key: <optional opaque string>
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
```json
|
|
178
|
+
{
|
|
179
|
+
"external_user_id": "user_42",
|
|
180
|
+
"display_name": "Alex",
|
|
181
|
+
"role": "host"
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
| Field | Required | Notes |
|
|
186
|
+
|-------|----------|--------|
|
|
187
|
+
| `external_user_id` | yes | Your user id within this meeting (1–255 chars; not whitespace-only) |
|
|
188
|
+
| `display_name` | yes | Shown name (1–255 chars; not whitespace-only) |
|
|
189
|
+
| `role` | yes | `host` or `participant` |
|
|
190
|
+
|
|
191
|
+
**Upsert:** the same `external_user_id` on the same meeting returns the same participant `public_id`, updates name and
|
|
192
|
+
role, and does not consume an extra capacity slot. A **new** identity is rejected with `422`
|
|
193
|
+
`participant_limit_exceeded` when the meeting already has `max_participants` distinct people.
|
|
194
|
+
|
|
195
|
+
**Display names:** Pass the name users should see in the roster as `display_name` when you authorize. The join-token
|
|
196
|
+
route and the browser (including this SDK) do not accept a name. If no usable name is available at join, the UI shows
|
|
197
|
+
**Guest**. To rename someone, re-authorize with the same `external_user_id` and a new `display_name`, then mint a new
|
|
198
|
+
join token and have them join again.
|
|
199
|
+
|
|
200
|
+
Optional `Idempotency-Key` behaves like create (same key + different body → `409` `idempotency_conflict`).
|
|
201
|
+
|
|
202
|
+
**Success:** `201` on first insert, `200` on upsert of an existing identity (idempotent replay returns the stored
|
|
203
|
+
status and body).
|
|
204
|
+
|
|
205
|
+
```json
|
|
206
|
+
{
|
|
207
|
+
"data": {
|
|
208
|
+
"public_id": "01JQ4ABCDEF01234567890ABCD",
|
|
209
|
+
"external_user_id": "user_42",
|
|
210
|
+
"display_name": "Alex",
|
|
211
|
+
"role": "host",
|
|
212
|
+
"status": "invited"
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
| Case | HTTP | `error.code` |
|
|
218
|
+
|------|------|--------------|
|
|
219
|
+
| Missing or invalid credential | 401 | `unauthorized` |
|
|
220
|
+
| Bad or missing fields | 422 | `validation_failed` |
|
|
221
|
+
| Meeting missing or other tenant | 404 | `meeting_not_found` |
|
|
222
|
+
| Meeting already `ended` | 409 | `meeting_ended` |
|
|
223
|
+
| Meeting `cancelled` / `failed` | 403 | `forbidden` |
|
|
224
|
+
| New identity over capacity | 422 | `participant_limit_exceeded` |
|
|
225
|
+
| Idempotency conflict | 409 | `idempotency_conflict` |
|
|
226
|
+
| Rate limited | 429 | `rate_limited` (+ `Retry-After`) |
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## POST /v1/meetings/{public_id}/join-token
|
|
231
|
+
|
|
232
|
+
Mint a short-lived join token for one authorized participant. Mint a **new** token for every join (including rejoin
|
|
233
|
+
after leave). Do **not** send `Idempotency-Key` — each successful call issues a new token.
|
|
234
|
+
|
|
235
|
+
```http
|
|
236
|
+
POST /v1/meetings/{public_id}/join-token
|
|
237
|
+
Authorization: Bearer <STREVOKIT_API_CREDENTIAL>
|
|
238
|
+
Content-Type: application/json
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
```json
|
|
242
|
+
{ "participant_public_id": "01JQ4ABCDEF01234567890ABCD" }
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
| Field | Required | Notes |
|
|
246
|
+
|-------|----------|--------|
|
|
247
|
+
| `participant_public_id` | yes | From authorize; must belong to this meeting |
|
|
248
|
+
|
|
249
|
+
Do not send `role`, `display_name`, `tenant_id`, or numeric `id`. Role and display name come from the authorized
|
|
250
|
+
participant row.
|
|
251
|
+
|
|
252
|
+
**Success:** `201 Created`
|
|
253
|
+
|
|
254
|
+
```json
|
|
255
|
+
{
|
|
256
|
+
"data": {
|
|
257
|
+
"token": "<compact JWT>",
|
|
258
|
+
"expires_at": "2026-09-24T15:05:00Z",
|
|
259
|
+
"expires_in": 300
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Default TTL is 300 seconds. Treat `token` like a secret: do not log it, put it in URLs, or cache it (`Cache-Control:
|
|
265
|
+
no-store` on your app response). Return only the token string to the browser.
|
|
266
|
+
|
|
267
|
+
| Case | HTTP | `error.code` |
|
|
268
|
+
|------|------|--------------|
|
|
269
|
+
| Missing or invalid credential | 401 | `unauthorized` |
|
|
270
|
+
| Bad or missing `participant_public_id` | 422 | `validation_failed` |
|
|
271
|
+
| Meeting missing or other tenant | 404 | `meeting_not_found` |
|
|
272
|
+
| Participant missing or not on this meeting | 404 | `participant_not_found` |
|
|
273
|
+
| Meeting already `ended` | 409 | `meeting_ended` |
|
|
274
|
+
| Meeting `cancelled` / `failed` | 403 | `forbidden` |
|
|
275
|
+
| Join-token rate limited | 429 | `rate_limited` (+ `Retry-After`) |
|
|
276
|
+
|
|
277
|
+
Realtime handshake failures (after you hand the token to the SDK) include `invalid_join_token`, `join_token_expired`,
|
|
278
|
+
and `origin_not_allowed`.
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## POST /v1/meetings/{public_id}/end
|
|
283
|
+
|
|
284
|
+
End the meeting for everyone from your backend. Optional `Idempotency-Key`. Success returns the updated meeting
|
|
285
|
+
resource with `status` `ended` and `end_reason` `tenant_api`.
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## Errors
|
|
290
|
+
|
|
291
|
+
Error bodies use a stable `error.code` string (and a message). On `429`, honor `Retry-After`. Prefer mapping known
|
|
292
|
+
codes in your backend rather than parsing message text.
|
|
293
|
+
|
|
294
|
+
## End-to-end example (Node.js)
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
const STREVOKIT_API = "https://api.example.com";
|
|
298
|
+
const API_CREDENTIAL = process.env.STREVOKIT_API_CREDENTIAL!;
|
|
299
|
+
|
|
300
|
+
async function strevokit(path: string, init: RequestInit = {}) {
|
|
301
|
+
const response = await fetch(`${STREVOKIT_API}${path}`, {
|
|
302
|
+
...init,
|
|
303
|
+
headers: {
|
|
304
|
+
Authorization: `Bearer ${API_CREDENTIAL}`,
|
|
305
|
+
"Content-Type": "application/json",
|
|
306
|
+
...(init.headers ?? {}),
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
if (!response.ok) throw new Error(`Strevokit ${path} → HTTP ${response.status}`);
|
|
310
|
+
if (response.status === 204) return null;
|
|
311
|
+
return response.json();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Once per environment:
|
|
315
|
+
await strevokit("/v1/embed-origins", {
|
|
316
|
+
method: "POST",
|
|
317
|
+
body: JSON.stringify({ origin: "https://app.example.com" }),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// When your product starts a session:
|
|
321
|
+
const { data: meeting } = await strevokit("/v1/meetings", {
|
|
322
|
+
method: "POST",
|
|
323
|
+
headers: { "Idempotency-Key": crypto.randomUUID() },
|
|
324
|
+
body: JSON.stringify({
|
|
325
|
+
type: "one_to_one",
|
|
326
|
+
max_participants: 2,
|
|
327
|
+
external_reference: "booking_123",
|
|
328
|
+
}),
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const { data: host } = await strevokit(`/v1/meetings/${meeting.public_id}/participants`, {
|
|
332
|
+
method: "POST",
|
|
333
|
+
body: JSON.stringify({
|
|
334
|
+
external_user_id: "user_host",
|
|
335
|
+
display_name: "Alex",
|
|
336
|
+
role: "host",
|
|
337
|
+
}),
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const { data: guest } = await strevokit(`/v1/meetings/${meeting.public_id}/participants`, {
|
|
341
|
+
method: "POST",
|
|
342
|
+
body: JSON.stringify({
|
|
343
|
+
external_user_id: "user_guest",
|
|
344
|
+
display_name: "Sam",
|
|
345
|
+
role: "participant",
|
|
346
|
+
}),
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// When that signed-in user opens the meeting page:
|
|
350
|
+
const { data: issued } = await strevokit(`/v1/meetings/${meeting.public_id}/join-token`, {
|
|
351
|
+
method: "POST",
|
|
352
|
+
body: JSON.stringify({ participant_public_id: host.public_id }),
|
|
353
|
+
});
|
|
354
|
+
// Return issued.token to the browser only.
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
## Hand the token to the SDK
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
import { createMeetingEngine } from "@strevokit/meeting-sdk";
|
|
361
|
+
|
|
362
|
+
const engine = createMeetingEngine({
|
|
363
|
+
token, // from your backend
|
|
364
|
+
realtimeUrl: "wss://realtime.example.com",
|
|
365
|
+
});
|
|
366
|
+
await engine.join();
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
A longer Express-style proxy that creates, authorizes, and mints is in
|
|
370
|
+
[examples/backend-integration.md](./examples/backend-integration.md).
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Backend integration (create, authorize, join token)
|
|
2
|
+
|
|
3
|
+
The meeting SDK needs a join token for each participant. Tokens are short-lived and single-use. Only your backend can
|
|
4
|
+
mint them, because minting needs your Strevokit API credential.
|
|
5
|
+
|
|
6
|
+
Before minting, your backend usually **creates** the meeting and **authorizes** participants. The full HTTP reference
|
|
7
|
+
(methods, paths, bodies, errors) is in [Backend API](../backend-api.md).
|
|
8
|
+
|
|
9
|
+
**Keep the API credential on your server.** Never ship it in browser code, environment variables exposed to the
|
|
10
|
+
browser (such as `VITE_*` or `NEXT_PUBLIC_*`), or mobile apps. The browser only ever receives the join token.
|
|
11
|
+
|
|
12
|
+
## Flow
|
|
13
|
+
|
|
14
|
+
1. (Once) Allow-list the origin that hosts your meeting page (`POST /v1/embed-origins`).
|
|
15
|
+
2. When your product starts a session, create a meeting (`POST /v1/meetings`) and authorize each person
|
|
16
|
+
(`POST /v1/meetings/{public_id}/participants`).
|
|
17
|
+
3. The signed-in user opens a meeting page in your app.
|
|
18
|
+
4. The page asks your backend for a join token.
|
|
19
|
+
5. Your backend checks that this user may join, then calls `POST /v1/meetings/{public_id}/join-token`.
|
|
20
|
+
6. Your backend returns only the token to the page, which passes it to `createMeetingEngine({ token, realtimeUrl })`.
|
|
21
|
+
|
|
22
|
+
## Example (Node.js with Express)
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import express from "express";
|
|
26
|
+
|
|
27
|
+
const app = express();
|
|
28
|
+
app.use(express.json());
|
|
29
|
+
|
|
30
|
+
const STREVOKIT_API = "https://api.example.com"; // your Strevokit API base URL
|
|
31
|
+
const API_CREDENTIAL = process.env.STREVOKIT_API_CREDENTIAL; // server-side secret, never sent to browsers
|
|
32
|
+
|
|
33
|
+
const strevokitHeaders = {
|
|
34
|
+
Authorization: `Bearer ${API_CREDENTIAL}`,
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Once per environment (run from a setup script or admin path):
|
|
39
|
+
// await fetch(`${STREVOKIT_API}/v1/embed-origins`, {
|
|
40
|
+
// method: "POST",
|
|
41
|
+
// headers: strevokitHeaders,
|
|
42
|
+
// body: JSON.stringify({ origin: "https://app.example.com" }),
|
|
43
|
+
// });
|
|
44
|
+
|
|
45
|
+
app.post("/api/meetings", requireSignedInUser, async (req, res) => {
|
|
46
|
+
const response = await fetch(`${STREVOKIT_API}/v1/meetings`, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { ...strevokitHeaders, "Idempotency-Key": crypto.randomUUID() },
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
type: "one_to_one",
|
|
51
|
+
max_participants: 2,
|
|
52
|
+
external_reference: req.body.bookingId,
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
if (!response.ok) return res.sendStatus(502);
|
|
56
|
+
const { data: meeting } = await response.json();
|
|
57
|
+
|
|
58
|
+
const hostResponse = await fetch(`${STREVOKIT_API}/v1/meetings/${meeting.public_id}/participants`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: strevokitHeaders,
|
|
61
|
+
body: JSON.stringify({
|
|
62
|
+
external_user_id: String(req.user.id),
|
|
63
|
+
display_name: req.user.displayName,
|
|
64
|
+
role: "host",
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
if (!hostResponse.ok) return res.sendStatus(502);
|
|
68
|
+
const { data: host } = await hostResponse.json();
|
|
69
|
+
|
|
70
|
+
// Persist meeting.public_id and host.public_id in your own database, keyed by your booking / room.
|
|
71
|
+
res.status(201).json({ meetingPublicId: meeting.public_id, participantPublicId: host.public_id });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
app.post("/api/meetings/:meetingId/join-token", requireSignedInUser, async (req, res) => {
|
|
75
|
+
// Your own authorization: may this user join this meeting, and as which Strevokit participant?
|
|
76
|
+
const participantPublicId = await participantFor(req.user, req.params.meetingId);
|
|
77
|
+
if (!participantPublicId) return res.sendStatus(403);
|
|
78
|
+
|
|
79
|
+
const response = await fetch(
|
|
80
|
+
`${STREVOKIT_API}/v1/meetings/${encodeURIComponent(req.params.meetingId)}/join-token`,
|
|
81
|
+
{
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: strevokitHeaders,
|
|
84
|
+
body: JSON.stringify({ participant_public_id: participantPublicId }),
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
if (!response.ok) return res.sendStatus(502);
|
|
88
|
+
|
|
89
|
+
const { data } = await response.json(); // { token, expires_at, expires_in }
|
|
90
|
+
res.set("Cache-Control", "no-store").json({ token: data.token });
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`requireSignedInUser` and `participantFor` stand for your own session and authorization code.
|
|
95
|
+
|
|
96
|
+
## Notes
|
|
97
|
+
|
|
98
|
+
- Create and authorize before the first join-token call; join-token needs an existing participant `public_id`.
|
|
99
|
+
- Pass a real `display_name` on authorize; join-token cannot set or change it. If the UI shows **Guest**, authorize
|
|
100
|
+
used a missing or placeholder name, or that step was skipped.
|
|
101
|
+
- Mint a new token for every join, including rejoining after `leave()` / `dispose()`.
|
|
102
|
+
- Send the join-token response with `Cache-Control: no-store` so the token isn't cached.
|
|
103
|
+
- Don't log the token or put it in a URL. The SDK keeps it in memory only.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// A minimal React integration: one hook that owns the engine, plus a few buttons. Not a UI kit.
|
|
2
|
+
import { useEffect, useReducer, useRef, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
createMeetingEngine,
|
|
5
|
+
MeetingError,
|
|
6
|
+
type ConnectionState,
|
|
7
|
+
type MeetingEngine,
|
|
8
|
+
} from "@strevokit/meeting-sdk";
|
|
9
|
+
|
|
10
|
+
export function useMeetingEngine(token: string, realtimeUrl: string) {
|
|
11
|
+
const [engine, setEngine] = useState<MeetingEngine | null>(null);
|
|
12
|
+
const [state, setState] = useState<ConnectionState>("idle");
|
|
13
|
+
const [error, setError] = useState<MeetingError | null>(null);
|
|
14
|
+
const [, refresh] = useReducer((n: number) => n + 1, 0);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
setError(null);
|
|
18
|
+
setState("idle");
|
|
19
|
+
let next: MeetingEngine;
|
|
20
|
+
try {
|
|
21
|
+
next = createMeetingEngine({ token, realtimeUrl });
|
|
22
|
+
} catch (reason) {
|
|
23
|
+
if (!(reason instanceof MeetingError)) throw reason;
|
|
24
|
+
setError(reason);
|
|
25
|
+
setEngine(null);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const unsubscribe = [
|
|
29
|
+
next.on("state_changed", (event) => setState(event.state)),
|
|
30
|
+
next.on("joined", refresh),
|
|
31
|
+
next.on("participant_joined", refresh),
|
|
32
|
+
next.on("participant_left", refresh),
|
|
33
|
+
next.on("participant_media_changed", refresh),
|
|
34
|
+
next.on("local_media_changed", refresh),
|
|
35
|
+
next.on("error", (event) => setError(event.error)),
|
|
36
|
+
next.on("closed", (event) => setError(event.error ?? null)),
|
|
37
|
+
];
|
|
38
|
+
setEngine(next);
|
|
39
|
+
next.join().catch((reason: unknown) => {
|
|
40
|
+
if (reason instanceof MeetingError && reason.code !== "join_cancelled") setError(reason);
|
|
41
|
+
});
|
|
42
|
+
return () => {
|
|
43
|
+
for (const off of unsubscribe) off();
|
|
44
|
+
next.dispose();
|
|
45
|
+
};
|
|
46
|
+
}, [token, realtimeUrl]);
|
|
47
|
+
|
|
48
|
+
return { engine, state, error };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function SelfPreview({ stream }: { stream: MediaStream | null }) {
|
|
52
|
+
const ref = useRef<HTMLVideoElement>(null);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (ref.current) ref.current.srcObject = stream;
|
|
55
|
+
}, [stream]);
|
|
56
|
+
return <video ref={ref} autoPlay muted playsInline width={320} />;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function RemoteParticipant({ engine, participantId }: { engine: MeetingEngine; participantId: string }) {
|
|
60
|
+
const video = useRef<HTMLVideoElement>(null);
|
|
61
|
+
const audio = useRef<HTMLAudioElement>(null);
|
|
62
|
+
const [status, setStatus] = useState(() => engine.mediaConnectionStatus(participantId));
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const attach = () => {
|
|
66
|
+
const media = engine.remoteMedia(participantId);
|
|
67
|
+
if (video.current) video.current.srcObject = media.camera;
|
|
68
|
+
if (audio.current) audio.current.srcObject = media.microphone;
|
|
69
|
+
};
|
|
70
|
+
attach();
|
|
71
|
+
const unsubscribe = [
|
|
72
|
+
engine.on("remote_media_changed", (event) => {
|
|
73
|
+
if (event.participantId === participantId) attach();
|
|
74
|
+
}),
|
|
75
|
+
engine.on("media_connection_changed", (event) => {
|
|
76
|
+
if (event.participantId === participantId) setStatus(event.status);
|
|
77
|
+
}),
|
|
78
|
+
];
|
|
79
|
+
return () => {
|
|
80
|
+
for (const off of unsubscribe) off();
|
|
81
|
+
};
|
|
82
|
+
}, [engine, participantId]);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<figure>
|
|
86
|
+
<video ref={video} autoPlay muted playsInline width={320} />
|
|
87
|
+
<audio ref={audio} autoPlay />
|
|
88
|
+
<figcaption>{status === "interrupted" ? "Connection lost — reconnecting…" : status}</figcaption>
|
|
89
|
+
</figure>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function Meeting({ token, realtimeUrl }: { token: string; realtimeUrl: string }) {
|
|
94
|
+
const { engine, state, error } = useMeetingEngine(token, realtimeUrl);
|
|
95
|
+
if (!engine) return error ? <p role="alert">{error.message}</p> : null;
|
|
96
|
+
|
|
97
|
+
const media = engine.localMediaState;
|
|
98
|
+
const run = (action: () => Promise<void>) => {
|
|
99
|
+
action().catch((reason: unknown) => {
|
|
100
|
+
if (reason instanceof MeetingError) alert(reason.message);
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<section>
|
|
106
|
+
<p>{error ? error.message : state}</p>
|
|
107
|
+
<SelfPreview stream={engine.localMedia.camera} />
|
|
108
|
+
<div role="group" aria-label="Media controls">
|
|
109
|
+
<button
|
|
110
|
+
aria-pressed={media.camera}
|
|
111
|
+
disabled={state !== "joined"}
|
|
112
|
+
onClick={() => run(() => (media.camera ? engine.disableCamera() : engine.enableCamera()))}
|
|
113
|
+
>
|
|
114
|
+
Camera
|
|
115
|
+
</button>
|
|
116
|
+
<button
|
|
117
|
+
aria-pressed={media.microphone}
|
|
118
|
+
disabled={state !== "joined"}
|
|
119
|
+
onClick={() => run(() => (media.microphone ? engine.mute() : engine.unmute()))}
|
|
120
|
+
>
|
|
121
|
+
Microphone
|
|
122
|
+
</button>
|
|
123
|
+
<button
|
|
124
|
+
aria-pressed={media.screen}
|
|
125
|
+
disabled={state !== "joined"}
|
|
126
|
+
onClick={() => run(() => (media.screen ? engine.stopScreenShare() : engine.startScreenShare()))}
|
|
127
|
+
>
|
|
128
|
+
Share screen
|
|
129
|
+
</button>
|
|
130
|
+
<button disabled={state !== "joined"} onClick={() => void engine.leave()}>
|
|
131
|
+
Leave
|
|
132
|
+
</button>
|
|
133
|
+
</div>
|
|
134
|
+
<ul>
|
|
135
|
+
{engine.participants.map((participant) => (
|
|
136
|
+
<li key={participant.id}>
|
|
137
|
+
{participant.displayName ?? participant.id} ({participant.role})
|
|
138
|
+
<RemoteParticipant engine={engine} participantId={participant.id} />
|
|
139
|
+
</li>
|
|
140
|
+
))}
|
|
141
|
+
</ul>
|
|
142
|
+
</section>
|
|
143
|
+
);
|
|
144
|
+
}
|