@volter/twin-livekit 0.1.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.
Files changed (99) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +106 -0
  3. package/dist/src/cli.d.ts +2 -0
  4. package/dist/src/cli.js +23 -0
  5. package/dist/src/egress-service-cli.d.ts +2 -0
  6. package/dist/src/egress-service-cli.js +101 -0
  7. package/dist/src/index.d.ts +14 -0
  8. package/dist/src/index.js +53 -0
  9. package/dist/src/livekit-budget.d.ts +57 -0
  10. package/dist/src/livekit-budget.js +131 -0
  11. package/dist/src/livekit-capabilities.d.ts +3 -0
  12. package/dist/src/livekit-capabilities.js +728 -0
  13. package/dist/src/livekit-conformance.d.ts +8 -0
  14. package/dist/src/livekit-conformance.js +13 -0
  15. package/dist/src/livekit-connector.d.ts +130 -0
  16. package/dist/src/livekit-connector.js +382 -0
  17. package/dist/src/livekit-data.d.ts +86 -0
  18. package/dist/src/livekit-data.js +246 -0
  19. package/dist/src/livekit-perform-harness.d.ts +5 -0
  20. package/dist/src/livekit-perform-harness.js +17 -0
  21. package/dist/src/livekit-server.d.ts +23 -0
  22. package/dist/src/livekit-server.js +45 -0
  23. package/dist/src/livekit-service-cli.d.ts +2 -0
  24. package/dist/src/livekit-service-cli.js +88 -0
  25. package/dist/src/livekit-token.d.ts +68 -0
  26. package/dist/src/livekit-token.js +76 -0
  27. package/dist/src/livekit-twin.d.ts +4 -0
  28. package/dist/src/livekit-twin.js +1161 -0
  29. package/dist/src/livekit-types.d.ts +203 -0
  30. package/dist/src/livekit-types.js +1 -0
  31. package/dist/src/livekit-webhook.d.ts +26 -0
  32. package/dist/src/livekit-webhook.js +79 -0
  33. package/dist/src/redis-service-cli.d.ts +2 -0
  34. package/dist/src/redis-service-cli.js +35 -0
  35. package/dist/test-fixtures/livekit-openapi-operations.SOURCE.md +88 -0
  36. package/dist/test-fixtures/livekit-openapi-operations.json +442 -0
  37. package/dist/test-fixtures/protobufs/cloud_replay.proto +82 -0
  38. package/dist/test-fixtures/protobufs/livekit_agent.proto +185 -0
  39. package/dist/test-fixtures/protobufs/livekit_agent_dispatch.proto +103 -0
  40. package/dist/test-fixtures/protobufs/livekit_agent_simulation.proto +422 -0
  41. package/dist/test-fixtures/protobufs/livekit_agent_worker.proto +29 -0
  42. package/dist/test-fixtures/protobufs/livekit_agentdb.proto +226 -0
  43. package/dist/test-fixtures/protobufs/livekit_analytics.proto +312 -0
  44. package/dist/test-fixtures/protobufs/livekit_cloud_agent.proto +376 -0
  45. package/dist/test-fixtures/protobufs/livekit_connector.proto +41 -0
  46. package/dist/test-fixtures/protobufs/livekit_connector_twilio.proto +65 -0
  47. package/dist/test-fixtures/protobufs/livekit_connector_whatsapp.proto +171 -0
  48. package/dist/test-fixtures/protobufs/livekit_egress.proto +641 -0
  49. package/dist/test-fixtures/protobufs/livekit_ingress.proto +222 -0
  50. package/dist/test-fixtures/protobufs/livekit_internal.proto +228 -0
  51. package/dist/test-fixtures/protobufs/livekit_metrics.proto +103 -0
  52. package/dist/test-fixtures/protobufs/livekit_models.proto +992 -0
  53. package/dist/test-fixtures/protobufs/livekit_phone_number.proto +151 -0
  54. package/dist/test-fixtures/protobufs/livekit_room.proto +312 -0
  55. package/dist/test-fixtures/protobufs/livekit_rtc.proto +673 -0
  56. package/dist/test-fixtures/protobufs/livekit_sip.proto +1000 -0
  57. package/dist/test-fixtures/protobufs/livekit_token_source.proto +49 -0
  58. package/dist/test-fixtures/protobufs/livekit_webhook.proto +62 -0
  59. package/package.json +58 -0
  60. package/src/cli.ts +22 -0
  61. package/src/egress-service-cli.ts +111 -0
  62. package/src/index.ts +103 -0
  63. package/src/livekit-budget.ts +158 -0
  64. package/src/livekit-capabilities.ts +789 -0
  65. package/src/livekit-conformance.ts +15 -0
  66. package/src/livekit-connector.ts +406 -0
  67. package/src/livekit-data.ts +278 -0
  68. package/src/livekit-perform-harness.ts +17 -0
  69. package/src/livekit-server.ts +67 -0
  70. package/src/livekit-service-cli.ts +93 -0
  71. package/src/livekit-token.ts +133 -0
  72. package/src/livekit-twin.ts +1162 -0
  73. package/src/livekit-types.ts +211 -0
  74. package/src/livekit-webhook.ts +91 -0
  75. package/src/redis-service-cli.ts +39 -0
  76. package/test-fixtures/livekit-openapi-operations.SOURCE.md +88 -0
  77. package/test-fixtures/livekit-openapi-operations.json +442 -0
  78. package/test-fixtures/protobufs/cloud_replay.proto +82 -0
  79. package/test-fixtures/protobufs/livekit_agent.proto +185 -0
  80. package/test-fixtures/protobufs/livekit_agent_dispatch.proto +103 -0
  81. package/test-fixtures/protobufs/livekit_agent_simulation.proto +422 -0
  82. package/test-fixtures/protobufs/livekit_agent_worker.proto +29 -0
  83. package/test-fixtures/protobufs/livekit_agentdb.proto +226 -0
  84. package/test-fixtures/protobufs/livekit_analytics.proto +312 -0
  85. package/test-fixtures/protobufs/livekit_cloud_agent.proto +376 -0
  86. package/test-fixtures/protobufs/livekit_connector.proto +41 -0
  87. package/test-fixtures/protobufs/livekit_connector_twilio.proto +65 -0
  88. package/test-fixtures/protobufs/livekit_connector_whatsapp.proto +171 -0
  89. package/test-fixtures/protobufs/livekit_egress.proto +641 -0
  90. package/test-fixtures/protobufs/livekit_ingress.proto +222 -0
  91. package/test-fixtures/protobufs/livekit_internal.proto +228 -0
  92. package/test-fixtures/protobufs/livekit_metrics.proto +103 -0
  93. package/test-fixtures/protobufs/livekit_models.proto +992 -0
  94. package/test-fixtures/protobufs/livekit_phone_number.proto +151 -0
  95. package/test-fixtures/protobufs/livekit_room.proto +312 -0
  96. package/test-fixtures/protobufs/livekit_rtc.proto +673 -0
  97. package/test-fixtures/protobufs/livekit_sip.proto +1000 -0
  98. package/test-fixtures/protobufs/livekit_token_source.proto +49 -0
  99. package/test-fixtures/protobufs/livekit_webhook.proto +62 -0
@@ -0,0 +1,278 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { projectResources } from '@volter/world-core';
3
+ import type {
4
+ LiveKitAgentDispatch,
5
+ LiveKitEgress,
6
+ LiveKitIngress,
7
+ LiveKitParticipant,
8
+ LiveKitRoom,
9
+ LiveKitSIPDispatchRule,
10
+ LiveKitSIPInboundTrunk,
11
+ LiveKitSIPOutboundTrunk,
12
+ LiveKitSIPParticipant,
13
+ } from './livekit-types.ts';
14
+
15
+ export const SERVICE = 'livekit';
16
+ export const FIXED_NOW = '1970-01-01T00:00:00.000Z';
17
+
18
+ export const TRACK_SOURCE = {
19
+ UNKNOWN: 0,
20
+ CAMERA: 1,
21
+ MICROPHONE: 2,
22
+ SCREEN_SHARE: 3,
23
+ SCREEN_SHARE_AUDIO: 4,
24
+ } as const;
25
+
26
+ export const TRACK_TYPE = { AUDIO: 0, VIDEO: 1, DATA: 2 } as const;
27
+ export const PARTICIPANT_STATE = { JOINING: 0, JOINED: 1, ACTIVE: 2, DISCONNECTED: 3 } as const;
28
+ export const EGRESS_STATUS = {
29
+ STARTING: 0,
30
+ ACTIVE: 1,
31
+ ENDING: 2,
32
+ COMPLETE: 3,
33
+ FAILED: 4,
34
+ ABORTED: 5,
35
+ LIMIT_REACHED: 6,
36
+ } as const;
37
+
38
+ export const INGRESS_INPUT = { RTMP_INPUT: 0, WHIP_INPUT: 1, URL_INPUT: 2 } as const;
39
+ export const INGRESS_STATUS = {
40
+ ENDPOINT_INACTIVE: 0,
41
+ ENDPOINT_BUFFERING: 1,
42
+ ENDPOINT_PUBLISHING: 2,
43
+ ENDPOINT_ERROR: 3,
44
+ ENDPOINT_COMPLETE: 4,
45
+ } as const;
46
+
47
+ /** active egress statuses, used by both ListEgress(active) and room activeRecording */
48
+ export function isActiveEgressStatus(status: number): boolean {
49
+ return status === EGRESS_STATUS.STARTING || status === EGRESS_STATUS.ACTIVE || status === EGRESS_STATUS.ENDING;
50
+ }
51
+
52
+ export function ingressInput(input: unknown): number {
53
+ if (typeof input === 'number') return input;
54
+ const value = String(input ?? '').toUpperCase();
55
+ if (value === 'WHIP_INPUT' || value === 'WHIP') return INGRESS_INPUT.WHIP_INPUT;
56
+ if (value === 'URL_INPUT' || value === 'URL') return INGRESS_INPUT.URL_INPUT;
57
+ return INGRESS_INPUT.RTMP_INPUT;
58
+ }
59
+
60
+ export function stableHash(input: unknown): string {
61
+ return createHash('sha256').update(JSON.stringify(input)).digest('hex').slice(0, 16);
62
+ }
63
+
64
+ export function seconds(iso: string): string {
65
+ return String(Math.floor(new Date(iso).getTime() / 1000));
66
+ }
67
+
68
+ export function millis(iso: string): string {
69
+ return String(new Date(iso).getTime());
70
+ }
71
+
72
+ export function roomId(name: string): string {
73
+ return `room_${stableHash(name)}`;
74
+ }
75
+
76
+ export function roomSid(name: string): string {
77
+ return `RM_${stableHash(name)}`;
78
+ }
79
+
80
+ export function participantId(roomName: string, identity: string): string {
81
+ return `participant_${stableHash({ roomName, identity })}`;
82
+ }
83
+
84
+ export function participantSid(roomName: string, identity: string): string {
85
+ return `PA_${stableHash({ roomName, identity })}`;
86
+ }
87
+
88
+ export function egressId(roomName: string, request: unknown, occurredAt: string, sequence: number): string {
89
+ return `EG_${stableHash({ roomName, request, occurredAt, sequence })}`;
90
+ }
91
+
92
+ export function egressResourceId(id: string): string {
93
+ return `egress_${id}`;
94
+ }
95
+
96
+ export function ingressId(roomName: string, participantIdentity: string, request: unknown, sequence: number): string {
97
+ return `IN_${stableHash({ roomName, participantIdentity, request, sequence })}`;
98
+ }
99
+
100
+ export function ingressResourceId(id: string): string {
101
+ return `ingress_${id}`;
102
+ }
103
+
104
+ export function ingressStreamKey(id: string): string {
105
+ return stableHash({ streamKey: id });
106
+ }
107
+
108
+ export function trackSource(input: unknown): number {
109
+ if (typeof input === 'number') return input;
110
+ const value = String(input ?? '').toUpperCase();
111
+ if (value === 'SCREEN_SHARE') return TRACK_SOURCE.SCREEN_SHARE;
112
+ if (value === 'SCREEN_SHARE_AUDIO') return TRACK_SOURCE.SCREEN_SHARE_AUDIO;
113
+ if (value === 'MICROPHONE') return TRACK_SOURCE.MICROPHONE;
114
+ if (value === 'CAMERA') return TRACK_SOURCE.CAMERA;
115
+ return TRACK_SOURCE.UNKNOWN;
116
+ }
117
+
118
+ export function defaultRoom(name: string, occurredAt = FIXED_NOW, input: Record<string, unknown> = {}): LiveKitRoom {
119
+ return {
120
+ id: roomId(name),
121
+ sid: roomSid(name),
122
+ name,
123
+ emptyTimeout: Number(input.emptyTimeout ?? 300),
124
+ departureTimeout: Number(input.departureTimeout ?? 20),
125
+ maxParticipants: Number(input.maxParticipants ?? 0),
126
+ metadata: String(input.metadata ?? ''),
127
+ creationTime: seconds(occurredAt),
128
+ creationTimeMs: millis(occurredAt),
129
+ numParticipants: 0,
130
+ activeRecording: false,
131
+ generation: Number(input.generation ?? 0),
132
+ deleted: false,
133
+ };
134
+ }
135
+
136
+ export function defaultParticipant(roomName: string, identity: string, occurredAt = FIXED_NOW, input: Record<string, any> = {}): LiveKitParticipant {
137
+ const tracks = Array.isArray(input.tracks) ? input.tracks : [];
138
+ return {
139
+ id: participantId(roomName, identity),
140
+ sid: String(input.sid ?? participantSid(roomName, identity)),
141
+ roomName,
142
+ identity,
143
+ state: Number(input.state ?? PARTICIPANT_STATE.ACTIVE),
144
+ tracks: tracks.map((track, index) => ({
145
+ sid: String(track.sid ?? `TR_${stableHash({ roomName, identity, index })}`),
146
+ name: String(track.name ?? `track-${index + 1}`),
147
+ type: Number(track.type ?? (trackSource(track.source) === TRACK_SOURCE.MICROPHONE || trackSource(track.source) === TRACK_SOURCE.SCREEN_SHARE_AUDIO ? TRACK_TYPE.AUDIO : TRACK_TYPE.VIDEO)),
148
+ muted: Boolean(track.muted ?? false),
149
+ width: Number(track.width ?? 1280),
150
+ height: Number(track.height ?? 720),
151
+ source: trackSource(track.source),
152
+ mimeType: String(track.mimeType ?? 'video/vp8'),
153
+ })),
154
+ metadata: String(input.metadata ?? ''),
155
+ joinedAt: seconds(occurredAt),
156
+ joinedAtMs: millis(occurredAt),
157
+ name: String(input.name ?? identity),
158
+ version: Number(input.version ?? 1),
159
+ isPublisher: Boolean(input.isPublisher ?? true),
160
+ kind: Number(input.kind ?? 0),
161
+ attributes: input.attributes && typeof input.attributes === 'object' ? input.attributes : {},
162
+ permission: input.permission && typeof input.permission === 'object'
163
+ ? {
164
+ canSubscribe: Boolean(input.permission.canSubscribe ?? true),
165
+ canPublish: Boolean(input.permission.canPublish ?? true),
166
+ canPublishData: Boolean(input.permission.canPublishData ?? true),
167
+ canUpdateMetadata: Boolean(input.permission.canUpdateMetadata ?? false),
168
+ hidden: Boolean(input.permission.hidden ?? false),
169
+ canSubscribeMetrics: Boolean(input.permission.canSubscribeMetrics ?? false),
170
+ }
171
+ : { canSubscribe: true, canPublish: true, canPublishData: true, canUpdateMetadata: false, hidden: false, canSubscribeMetrics: false },
172
+ subscribedTracks: Array.isArray(input.subscribedTracks) ? input.subscribedTracks.map(String) : [],
173
+ };
174
+ }
175
+
176
+ export function rowList<T>(type: string, root?: string): T[] {
177
+ return projectResources(SERVICE, root)
178
+ .filter((r) => r.type === type)
179
+ .map((r) => {
180
+ const { type: _type, updatedAt: _updatedAt, ...rest } = r;
181
+ return rest as T;
182
+ });
183
+ }
184
+
185
+ export const rooms = (root?: string): LiveKitRoom[] => rowList<LiveKitRoom>('room', root);
186
+ export const participants = (root?: string): LiveKitParticipant[] => rowList<LiveKitParticipant>('participant', root);
187
+ export const egresses = (root?: string): LiveKitEgress[] => rowList<LiveKitEgress>('egress', root);
188
+ export const ingresses = (root?: string): LiveKitIngress[] => rowList<LiveKitIngress>('ingress', root);
189
+
190
+ export function defaultIngress(id: string, inputType: number, occurredAt = FIXED_NOW, input: Record<string, any> = {}): LiveKitIngress {
191
+ // WHIP defaults to bypass (no transcoding); RTMP/URL require transcoding.
192
+ const enableTranscoding = input.enableTranscoding !== undefined
193
+ ? Boolean(input.enableTranscoding)
194
+ : inputType !== INGRESS_INPUT.WHIP_INPUT;
195
+ return {
196
+ id: ingressResourceId(id),
197
+ ingressId: id,
198
+ name: String(input.name ?? ''),
199
+ streamKey: inputType === INGRESS_INPUT.URL_INPUT ? '' : ingressStreamKey(id),
200
+ url: ingressUrl(inputType, id, input),
201
+ inputType,
202
+ bypassTranscoding: !enableTranscoding,
203
+ enableTranscoding,
204
+ roomName: String(input.roomName ?? ''),
205
+ participantIdentity: String(input.participantIdentity ?? ''),
206
+ participantName: String(input.participantName ?? ''),
207
+ participantMetadata: String(input.participantMetadata ?? ''),
208
+ reusable: inputType === INGRESS_INPUT.RTMP_INPUT,
209
+ enabled: input.enabled === undefined ? true : Boolean(input.enabled),
210
+ audio: input.audio && typeof input.audio === 'object' ? input.audio : undefined,
211
+ video: input.video && typeof input.video === 'object' ? input.video : undefined,
212
+ state: {
213
+ status: INGRESS_STATUS.ENDPOINT_INACTIVE,
214
+ error: '',
215
+ roomId: '',
216
+ startedAt: '0',
217
+ endedAt: '0',
218
+ updatedAt: seconds(occurredAt),
219
+ resourceId: '',
220
+ },
221
+ deleted: false,
222
+ };
223
+ }
224
+
225
+ function ingressUrl(inputType: number, id: string, input: Record<string, any>): string {
226
+ if (inputType === INGRESS_INPUT.URL_INPUT) return String(input.url ?? '');
227
+ if (inputType === INGRESS_INPUT.WHIP_INPUT) return 'https://twin.whip.livekit.cloud/w';
228
+ return 'rtmp://twin.rtmp.livekit.cloud/x';
229
+ }
230
+
231
+ // --- SIP + Agent dispatch -------------------------------------------------
232
+
233
+ /** SIP transport enum JSON names (mirrors @livekit/protocol SIPTransport). */
234
+ export const SIP_TRANSPORT = {
235
+ SIP_TRANSPORT_AUTO: 'SIP_TRANSPORT_AUTO',
236
+ SIP_TRANSPORT_UDP: 'SIP_TRANSPORT_UDP',
237
+ SIP_TRANSPORT_TCP: 'SIP_TRANSPORT_TCP',
238
+ SIP_TRANSPORT_TLS: 'SIP_TRANSPORT_TLS',
239
+ } as const;
240
+
241
+ export function sipTransport(input: unknown): string {
242
+ if (typeof input === 'number') {
243
+ return ['SIP_TRANSPORT_AUTO', 'SIP_TRANSPORT_UDP', 'SIP_TRANSPORT_TCP', 'SIP_TRANSPORT_TLS'][input] ?? SIP_TRANSPORT.SIP_TRANSPORT_AUTO;
244
+ }
245
+ const value = String(input ?? '').toUpperCase();
246
+ return (SIP_TRANSPORT as Record<string, string>)[value] ?? SIP_TRANSPORT.SIP_TRANSPORT_AUTO;
247
+ }
248
+
249
+ export function sipInboundTrunkId(name: string, numbers: unknown, sequence: number): string {
250
+ return `ST_${stableHash({ kind: 'inbound', name, numbers, sequence })}`;
251
+ }
252
+ export function sipOutboundTrunkId(name: string, address: string, sequence: number): string {
253
+ return `ST_${stableHash({ kind: 'outbound', name, address, sequence })}`;
254
+ }
255
+ export function sipDispatchRuleId(request: unknown, sequence: number): string {
256
+ return `SDR_${stableHash({ request, sequence })}`;
257
+ }
258
+ export function sipCallId(roomName: string, sipCallTo: string, sequence: number): string {
259
+ return `SCL_${stableHash({ roomName, sipCallTo, sequence })}`;
260
+ }
261
+ export function sipParticipantSid(roomName: string, identity: string): string {
262
+ return `PA_${stableHash({ sip: true, roomName, identity })}`;
263
+ }
264
+ export function agentDispatchId(room: string, agentName: string, sequence: number): string {
265
+ return `AD_${stableHash({ room, agentName, sequence })}`;
266
+ }
267
+
268
+ export const sipInboundTrunks = (root?: string): LiveKitSIPInboundTrunk[] => rowList<LiveKitSIPInboundTrunk>('sip_inbound_trunk', root);
269
+ export const sipOutboundTrunks = (root?: string): LiveKitSIPOutboundTrunk[] => rowList<LiveKitSIPOutboundTrunk>('sip_outbound_trunk', root);
270
+ export const sipDispatchRules = (root?: string): LiveKitSIPDispatchRule[] => rowList<LiveKitSIPDispatchRule>('sip_dispatch_rule', root);
271
+ export const sipParticipants = (root?: string): LiveKitSIPParticipant[] => rowList<LiveKitSIPParticipant>('sip_participant', root);
272
+ export const agentDispatches = (root?: string): LiveKitAgentDispatch[] => rowList<LiveKitAgentDispatch>('agent_dispatch', root);
273
+
274
+ export function withCounts(room: LiveKitRoom, root?: string): LiveKitRoom {
275
+ const numParticipants = participants(root).filter((p) => p.roomName === room.name && p.state !== PARTICIPANT_STATE.DISCONNECTED).length;
276
+ const activeRecording = egresses(root).some((e) => e.roomName === room.name && isActiveEgressStatus(e.status));
277
+ return { ...room, numParticipants, activeRecording };
278
+ }
@@ -0,0 +1,17 @@
1
+ // A MINIATURE OF THE HEAD, for this pack's own claims and suites (protocol 2). Not on the serve path.
2
+ import { confirmAction, deployableEntries, worldNow } from '@volter/world-core';
3
+ import { pushLiveKitAction, type LiveKitExecute } from './livekit-connector.ts';
4
+
5
+ export async function performPending(execute: LiveKitExecute, opts: { root?: string; occurredAt?: string } = {}): Promise<number> {
6
+ let pushed = 0;
7
+ for (const entry of deployableEntries('livekit', opts.root)) {
8
+ if (!(await pushLiveKitAction(entry, execute))) continue;
9
+ confirmAction({
10
+ service: 'livekit', actionId: entry.id, subject: entry.subject, fields: entry.fields ?? {},
11
+ occurredAt: opts.occurredAt ?? worldNow(), receipt: { status: 'deployed' },
12
+ ...(opts.root !== undefined ? { root: opts.root } : {}),
13
+ });
14
+ pushed += 1;
15
+ }
16
+ return pushed;
17
+ }
@@ -0,0 +1,67 @@
1
+ // LiveKit twin HTTP server.
2
+ //
3
+ // FETCH-FIRST (runtime contract R12b): the serve path is the plain fetch below, built from the
4
+ // kernel's ONE adaptation (`createTwinFetchFromHandler`) — the access-token bearer is threaded
5
+ // per request through `extras`, and the pinned api keys / egress ceiling ride the static
6
+ // `handlerOptions`. The server is one line of Bun.serve around that same closure.
7
+ import { serveHttp } from '@volter/world-core';
8
+ import { handleLiveKitTwinRequest } from './livekit-twin.ts';
9
+ import { egresses, ingresses, participants, rooms } from './livekit-data.ts';
10
+ import { createTwinFetchFromHandler, statefulTwinManifest } from '@volter/world-core';
11
+
12
+ export type LiveKitServerOptions = {
13
+ root?: string;
14
+ port?: number;
15
+ readOnly?: boolean;
16
+ /** apiKey -> apiSecret. When set, the server enforces access-token JWT auth. */
17
+ apiKeys?: Record<string, string>;
18
+ /** Max simultaneously-active egress jobs before quota_capacity (429). */
19
+ maxActiveEgress?: number;
20
+ };
21
+
22
+ /** Options every LiveKit-twin HTTP surface needs, independent of who owns the socket. */
23
+ export interface LiveKitTwinFetchOptions {
24
+ root?: string;
25
+ readOnly?: boolean;
26
+ /** apiKey -> apiSecret. When set, the fetch enforces access-token JWT auth. */
27
+ apiKeys?: Record<string, string>;
28
+ /** Max simultaneously-active egress jobs before quota_capacity (429). */
29
+ maxActiveEgress?: number;
30
+ }
31
+
32
+ export function createLiveKitTwinFetch(options: LiveKitTwinFetchOptions = {}): (request: Request) => Promise<Response> {
33
+ return createTwinFetchFromHandler(handleLiveKitTwinRequest, {
34
+ root: options.root,
35
+ readOnly: options.readOnly,
36
+ manifest: statefulTwinManifest({ vendor: 'livekit', twinOf: 'the LiveKit control plane', stores: 'rooms, participants and egress state' }),
37
+ // THE STORE DOOR (R5c): LiveKit's server API is Twirp — EVERY operation, listings included, is
38
+ // a POST to `/twirp/livekit.<Service>/<Method>`. The twin has no GET at all, so its state was
39
+ // unreadable by anything that reads a twin the ordinary way (a mirror, an operator, the
40
+ // resource-level determinism row). These four are that read: the pack's own named deterministic
41
+ // projections over stored state, served by the same adapter the Twirp paths come out of.
42
+ stores: {
43
+ rooms: () => rooms(options.root),
44
+ participants: () => participants(options.root),
45
+ egress: () => egresses(options.root),
46
+ ingress: () => ingresses(options.root),
47
+ },
48
+ handlerOptions: {
49
+ ...(options.apiKeys ? { apiKeys: options.apiKeys } : {}),
50
+ ...(options.maxActiveEgress !== undefined ? { maxActiveEgress: options.maxActiveEgress } : {}),
51
+ },
52
+ extras: (request) => {
53
+ const auth = request.headers.get('authorization') ?? '';
54
+ const authToken = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : auth.trim();
55
+ return authToken ? { authToken } : {};
56
+ },
57
+ });
58
+ }
59
+
60
+ export async function createLiveKitTwinServer(options: LiveKitServerOptions = {}): Promise<{ port: number; stop: () => void }> {
61
+ const server = await serveHttp({
62
+ port: options.port ?? 0,
63
+ idleTimeout: 60,
64
+ fetch: createLiveKitTwinFetch(options),
65
+ });
66
+ return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
67
+ }
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ // @ts-nocheck
3
+ import { spawn } from 'node:child_process';
4
+ import dgram from 'node:dgram';
5
+ import net from 'node:net';
6
+ import { networkInterfaces } from 'node:os';
7
+
8
+ const PORT = process.env.PORT;
9
+ const REDIS_HOST = process.env.REDIS_HOST;
10
+ if (!PORT) throw new Error('PORT is required');
11
+
12
+ function hostReachableIp(): string {
13
+ if (process.env.LIVEKIT_NODE_IP) return process.env.LIVEKIT_NODE_IP;
14
+ for (const entries of Object.values(networkInterfaces())) {
15
+ for (const entry of entries ?? []) {
16
+ if (entry.family === 'IPv4' && !entry.internal) return entry.address;
17
+ }
18
+ }
19
+ return '127.0.0.1';
20
+ }
21
+
22
+ async function freeUdpPort(): Promise<number> {
23
+ return await new Promise((resolve, reject) => {
24
+ const socket = dgram.createSocket('udp4');
25
+ socket.once('error', reject);
26
+ socket.bind(0, '127.0.0.1', () => {
27
+ const address = socket.address();
28
+ const port = typeof address === 'object' ? address.port : 0;
29
+ socket.close(() => resolve(port));
30
+ });
31
+ });
32
+ }
33
+
34
+ async function freeTcpPort(): Promise<number> {
35
+ return await new Promise((resolve, reject) => {
36
+ const server = net.createServer();
37
+ server.once('error', reject);
38
+ server.listen(0, '127.0.0.1', () => {
39
+ const address = server.address();
40
+ const port = typeof address === 'object' && address ? address.port : 0;
41
+ server.close(() => resolve(port));
42
+ });
43
+ });
44
+ }
45
+
46
+ // Redis answers PING before LiveKit is allowed to dial it. The store is a container whose host
47
+ // port is published by the VM's forwarder, which ACCEPTS a connection before the container can
48
+ // answer on it; livekit-server dials once with a short deadline and exits on "i/o timeout". The
49
+ // World's readiness is port-based, so the consumer is where the wait belongs.
50
+ async function redisAnswers(hostPort: string): Promise<boolean> {
51
+ const [host, port] = hostPort.split(':');
52
+ return await new Promise((resolve) => {
53
+ const socket = net.connect({ host, port: Number(port) });
54
+ const done = (value: boolean) => { socket.destroy(); resolve(value); };
55
+ socket.setTimeout(2000, () => done(false));
56
+ socket.once('error', () => done(false));
57
+ socket.once('connect', () => socket.write('PING\r\n'));
58
+ socket.once('data', (chunk) => done(chunk.toString().startsWith('+PONG')));
59
+ });
60
+ }
61
+
62
+ // A single node needs no store: without REDIS_HOST, livekit-server keeps rooms in memory. A
63
+ // multi-node plane (or one with egress) names its store, and then waits for it as above.
64
+ if (REDIS_HOST) {
65
+ const redisDeadline = Date.now() + 90_000;
66
+ while (!(await redisAnswers(REDIS_HOST))) {
67
+ if (Date.now() > redisDeadline) throw new Error(`redis at ${REDIS_HOST} did not answer PING within 90s`);
68
+ await new Promise((resolve) => setTimeout(resolve, 250));
69
+ }
70
+ }
71
+
72
+ const udpPort = await freeUdpPort();
73
+ const tcpPort = await freeTcpPort();
74
+ const nodeIp = hostReachableIp();
75
+ const child = spawn('livekit-server', [
76
+ '--dev',
77
+ '--bind',
78
+ '0.0.0.0',
79
+ '--node-ip',
80
+ nodeIp,
81
+ '--port',
82
+ PORT,
83
+ '--udp-port',
84
+ String(udpPort),
85
+ '--rtc.tcp_port',
86
+ String(tcpPort),
87
+ ...(REDIS_HOST ? ['--redis-host', REDIS_HOST] : []),
88
+ ], { stdio: 'inherit' });
89
+
90
+ process.on('SIGTERM', () => child.kill('SIGTERM'));
91
+ process.on('SIGINT', () => child.kill('SIGINT'));
92
+
93
+ child.on('exit', (code) => process.exit(code ?? 0));
@@ -0,0 +1,133 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+
3
+ // LiveKit access tokens are HS256 JWTs whose payload is the grant claim set
4
+ // (name?, video?, sip?, inference?, metadata?, attributes?, ...) plus the
5
+ // standard JWT registered claims (iss=apiKey, sub=identity, exp, nbf).
6
+ // livekit-server-sdk's AccessToken.toJwt() / TokenVerifier.verify() use exactly
7
+ // this scheme; replicating it here gives real, byte-faithful grant parity that
8
+ // the vendor SDK can cross-verify (see livekit-twin.test.ts).
9
+
10
+ export type LiveKitVideoGrant = {
11
+ roomCreate?: boolean;
12
+ roomJoin?: boolean;
13
+ roomList?: boolean;
14
+ roomRecord?: boolean;
15
+ roomAdmin?: boolean;
16
+ room?: string;
17
+ ingressAdmin?: boolean;
18
+ canPublish?: boolean;
19
+ canPublishSources?: string[];
20
+ canSubscribe?: boolean;
21
+ canPublishData?: boolean;
22
+ canUpdateOwnMetadata?: boolean;
23
+ hidden?: boolean;
24
+ recorder?: boolean;
25
+ agent?: boolean;
26
+ canSubscribeMetrics?: boolean;
27
+ destinationRoom?: string;
28
+ };
29
+
30
+ export type LiveKitSIPGrant = { admin?: boolean; call?: boolean };
31
+
32
+ export type LiveKitClaimGrants = {
33
+ iss?: string;
34
+ sub?: string;
35
+ exp?: number;
36
+ nbf?: number;
37
+ name?: string;
38
+ video?: LiveKitVideoGrant;
39
+ sip?: LiveKitSIPGrant;
40
+ metadata?: string;
41
+ attributes?: Record<string, string>;
42
+ kind?: string;
43
+ sha256?: string;
44
+ [key: string]: unknown;
45
+ };
46
+
47
+ export type MintTokenInput = {
48
+ apiKey: string;
49
+ apiSecret: string;
50
+ identity?: string;
51
+ name?: string;
52
+ metadata?: string;
53
+ attributes?: Record<string, string>;
54
+ ttlSeconds?: number;
55
+ /** issued/now reference, for deterministic exp/nbf */
56
+ nowSeconds?: number;
57
+ video?: LiveKitVideoGrant;
58
+ sip?: LiveKitSIPGrant;
59
+ kind?: string;
60
+ };
61
+
62
+ function base64url(input: Buffer | string): string {
63
+ return Buffer.from(input).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
64
+ }
65
+
66
+ function base64urlDecode(input: string): Buffer {
67
+ const pad = input.length % 4 === 0 ? '' : '='.repeat(4 - (input.length % 4));
68
+ return Buffer.from(input.replace(/-/g, '+').replace(/_/g, '/') + pad, 'base64');
69
+ }
70
+
71
+ function sign(signingInput: string, secret: string): string {
72
+ return base64url(createHmac('sha256', secret).update(signingInput).digest());
73
+ }
74
+
75
+ /** Mint a LiveKit access token (HS256 JWT) with grant parity to AccessToken.toJwt(). */
76
+ export function mintAccessToken(input: MintTokenInput): string {
77
+ if (!input.apiKey || !input.apiSecret) throw new Error('apiKey and apiSecret are required');
78
+ if (input.apiSecret.length < 32) throw new Error('apiSecret must be at least 32 bytes');
79
+ const now = input.nowSeconds ?? 0;
80
+ const ttl = input.ttlSeconds ?? 6 * 60 * 60;
81
+ const payload: LiveKitClaimGrants = {
82
+ iss: input.apiKey,
83
+ nbf: now,
84
+ exp: now + ttl,
85
+ };
86
+ if (input.identity !== undefined) payload.sub = input.identity;
87
+ if (input.name !== undefined) payload.name = input.name;
88
+ if (input.metadata !== undefined) payload.metadata = input.metadata;
89
+ if (input.attributes !== undefined) payload.attributes = input.attributes;
90
+ if (input.kind !== undefined) payload.kind = input.kind;
91
+ if (input.video !== undefined) payload.video = input.video;
92
+ if (input.sip !== undefined) payload.sip = input.sip;
93
+ const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
94
+ const body = base64url(JSON.stringify(payload));
95
+ const signingInput = `${header}.${body}`;
96
+ return `${signingInput}.${sign(signingInput, input.apiSecret)}`;
97
+ }
98
+
99
+ export type VerifyResult =
100
+ | { ok: true; grants: LiveKitClaimGrants }
101
+ | { ok: false; reason: 'malformed' | 'bad_signature' | 'expired' | 'not_yet_valid' | 'unknown_key' };
102
+
103
+ /**
104
+ * Verify a LiveKit access token against the apiSecret resolved from the token's
105
+ * issuer (apiKey). Failable: a forged/tampered/expired token returns ok:false.
106
+ */
107
+ export function verifyAccessToken(
108
+ token: string,
109
+ resolveSecret: (apiKey: string) => string | undefined,
110
+ opts: { nowSeconds?: number; clockToleranceSeconds?: number } = {},
111
+ ): VerifyResult {
112
+ const parts = token.split('.');
113
+ if (parts.length !== 3) return { ok: false, reason: 'malformed' };
114
+ const [header, body, signature] = parts as [string, string, string];
115
+ let payload: LiveKitClaimGrants;
116
+ try {
117
+ payload = JSON.parse(base64urlDecode(body).toString('utf8'));
118
+ } catch {
119
+ return { ok: false, reason: 'malformed' };
120
+ }
121
+ const apiKey = String(payload.iss ?? '');
122
+ const secret = resolveSecret(apiKey);
123
+ if (!secret) return { ok: false, reason: 'unknown_key' };
124
+ const expected = sign(`${header}.${body}`, secret);
125
+ const a = Buffer.from(signature);
126
+ const b = Buffer.from(expected);
127
+ if (a.length !== b.length || !timingSafeEqual(a, b)) return { ok: false, reason: 'bad_signature' };
128
+ const now = opts.nowSeconds ?? 0;
129
+ const tol = opts.clockToleranceSeconds ?? 0;
130
+ if (typeof payload.exp === 'number' && now > payload.exp + tol) return { ok: false, reason: 'expired' };
131
+ if (typeof payload.nbf === 'number' && now + tol < payload.nbf) return { ok: false, reason: 'not_yet_valid' };
132
+ return { ok: true, grants: payload };
133
+ }