-
Notifications
You must be signed in to change notification settings - Fork 211
Agent SDK Prototype - React layer #268
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
356dfb7
effb228
ec87981
ed4a803
a926e16
809e322
7760aec
5e35853
3978348
b2355fc
2bf6419
abe2026
4c1c024
8fcd2ed
9425833
4443c04
994b209
1658dc9
76a3962
fb01009
b4ba41f
b192a46
6b9dfc5
58a5ad3
d38ddf6
91f4a08
728258b
0d312e3
22282c2
4d22785
b2602b8
6f0eeb5
d24be87
8f6301b
00674f4
34c3645
0d6288e
6fae521
82a5fe2
e5b1fc0
f4eeace
ad4c236
730dbed
80038a0
c3993be
fa6cac4
e47c9af
6f12617
1d716d7
a7ff04f
9d7c5b3
4f34a69
76c4887
eeab341
cdecd71
b9b0050
e53d4cb
9d2b72d
a062da9
df581f2
4a56259
33fa2bb
32532e5
c84aedc
5216add
69d854e
3982799
d6094c4
7ee05e4
c676977
b36eb97
57cf407
39500d8
d600c4b
a2c02a7
c1cc2cd
615d692
3a3609c
601fa29
aae69ac
c570ad7
b8aa239
d6dbe73
7bd7c36
46fd6d5
5c6ad68
d7b22cb
893441e
d535d08
d54bbed
395c83b
9aafd93
923afb3
b83b025
1b52a7d
6d634c8
3ddb872
1b352d8
5f0baed
790386b
d6d9516
cc2b17a
e67c286
9410259
2bcc1e0
44e879f
f1f70ee
0ba19ab
c51f21d
a1f408a
7bfa2ff
9a7b940
3b16d1d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,118 @@ | ||
import { decodeJwt } from 'jose'; | ||
|
||
import { ConnectionDetails } from "@/app/api/connection-details/route"; | ||
|
||
const ONE_MINUTE_IN_MILLISECONDS = 60 * 1000; | ||
|
||
/** | ||
* ConnectionDetails handles getting credentials for connecting to a new Room, caching | ||
* the last result and using it until it expires. */ | ||
export abstract class ConnectionCredentials { | ||
private cachedConnectionDetails: ConnectionDetails | null = null; | ||
|
||
protected isCachedConnectionDetailsExpired() { | ||
const token = this.cachedConnectionDetails?.participantToken; | ||
if (!token) { | ||
return true; | ||
} | ||
|
||
const jwtPayload = decodeJwt(token); | ||
if (!jwtPayload.exp) { | ||
return true; | ||
} | ||
const expiresAt = new Date(jwtPayload.exp - ONE_MINUTE_IN_MILLISECONDS); | ||
|
||
const now = new Date(); | ||
return expiresAt >= now; | ||
} | ||
|
||
async generate() { | ||
if (this.isCachedConnectionDetailsExpired()) { | ||
await this.refresh(); | ||
} | ||
|
||
return this.cachedConnectionDetails!; | ||
} | ||
|
||
async refresh() { | ||
this.cachedConnectionDetails = await this.fetch(); | ||
} | ||
|
||
protected abstract fetch(): Promise<ConnectionDetails>; | ||
}; | ||
|
||
export class ManualConnectionCredentials extends ConnectionCredentials { | ||
protected fetch: () => Promise<ConnectionDetails>; | ||
|
||
constructor(handler: () => Promise<ConnectionDetails>) { | ||
super(); | ||
this.fetch = handler; | ||
} | ||
} | ||
|
||
export class LiteralConnectionCredentials extends ConnectionCredentials { | ||
payload: ConnectionDetails; | ||
|
||
constructor(payload: ConnectionDetails) { | ||
super(); | ||
this.payload = payload; | ||
} | ||
|
||
async fetch() { | ||
if (this.isCachedConnectionDetailsExpired()) { | ||
// FIXME: figure out a better logging solution? | ||
console.warn('WARNING: The credentials within LiteralConnectionCredentials have expired, so any upcoming room connections will fail.'); | ||
} | ||
return this.payload; | ||
} | ||
|
||
async refresh() { /* cannot refresh a literal set of credentials! */ } | ||
} | ||
|
||
|
||
type SandboxConnectionCredentialsOptions = { | ||
sandboxId: string; | ||
baseUrl?: string; | ||
|
||
/** The name of the room to join. If omitted, a random new room name will be generated instead. */ | ||
roomName?: string; | ||
|
||
/** The identity of the participant the token should connect as connect as. If omitted, a random | ||
* identity will be used instead. */ | ||
participantName?: string; | ||
}; | ||
|
||
export class SandboxConnectionCredentials extends ConnectionCredentials { | ||
protected options: SandboxConnectionCredentialsOptions; | ||
|
||
constructor(options: SandboxConnectionCredentialsOptions) { | ||
super(); | ||
this.options = options; | ||
|
||
if (process.env.NODE_ENV === 'production') { | ||
// FIXME: figure out a better logging solution? | ||
console.warn('WARNING: SandboxConnectionCredentials is meant for development, and is not security hardened. In production, implement your own token generation solution.'); | ||
} | ||
} | ||
|
||
async fetch() { | ||
const baseUrl = this.options.baseUrl ?? "https://cloud-api.livekit.io"; | ||
const response = await fetch(`${baseUrl}/api/sandbox/connection-details`, { | ||
method: "POST", | ||
headers: { | ||
"X-Sandbox-ID": this.options.sandboxId, | ||
"Content-Type": "application/json", | ||
}, | ||
body: JSON.stringify({ | ||
roomName: this.options.roomName, | ||
participantName: this.options.participantName, | ||
}), | ||
}); | ||
|
||
if (!response.ok) { | ||
throw new Error(`Error generting token from sandbox token server: ${response.status} ${await response.text()}`); | ||
} | ||
|
||
return response.json(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,134 @@ | ||
import { ParticipantEvent, Room, RoomEvent } from 'livekit-client'; | ||
import type TypedEventEmitter from 'typed-emitter'; | ||
import { EventEmitter } from "events"; | ||
import { LocalParticipant, Track } from 'livekit-client'; | ||
import { createLocalTrack, LocalTrackInstance } from './LocalTrack'; | ||
import { ParticipantPermission } from 'livekit-server-sdk'; | ||
import { trackSourceToProtocol } from '../external-deps/components-js'; | ||
import { createScopedGetSet } from '../lib/scoped-get-set'; | ||
|
||
export enum LocalEvent { | ||
PermissionsChanged = 'permissionsChanged', | ||
}; | ||
|
||
export type LocalCallbacks = { | ||
[LocalEvent.PermissionsChanged]: (permissions: ParticipantPermission | null) => void; | ||
}; | ||
|
||
export type LocalInstance = { | ||
[Symbol.toStringTag]: "LocalInstance"; | ||
|
||
permissions: ParticipantPermission | null; | ||
publishPermissions: { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. exposing both this and There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Somewhere I had left a comment (I'm not sure where, I thought it was here 🤔 ) that maybe it makes sense to break this value up into individual "published" booleans on each underlying track type? Alternatively, maybe |
||
camera: boolean | null; | ||
microphone: boolean | null; | ||
screenShare: boolean | null; | ||
data: boolean; | ||
}; | ||
|
||
camera: LocalTrackInstance<Track.Source.Camera>; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. naming nit, |
||
microphone: LocalTrackInstance<Track.Source.Microphone>; | ||
screenShare: LocalTrackInstance<Track.Source.ScreenShare>; | ||
|
||
subtle: { | ||
emitter: TypedEventEmitter<LocalCallbacks>; | ||
initialize: () => void; | ||
teardown: () => void; | ||
|
||
localParticipant: LocalParticipant; | ||
}; | ||
}; | ||
|
||
export function createLocal( | ||
room: Room, | ||
get: () => LocalInstance, | ||
set: (fn: (old: LocalInstance) => LocalInstance) => void, | ||
): LocalInstance { | ||
const emitter = new EventEmitter() as TypedEventEmitter<LocalCallbacks>; | ||
|
||
const handleParticipantPermissionsChanged = () => { | ||
const permissions = room.localParticipant.permissions ?? null; | ||
|
||
const canPublishSource = (source: Track.Source) => { | ||
return ( | ||
permissions?.canPublish && | ||
(permissions.canPublishSources.length === 0 || | ||
permissions.canPublishSources.includes(trackSourceToProtocol(source))) | ||
); | ||
}; | ||
|
||
set((old) => ({ | ||
...old, | ||
permissions, | ||
publishPermissions: { // FIXME: figure out a better place to put this? Maybe in with tracks? | ||
camera: canPublishSource(Track.Source.Camera) ?? null, | ||
microphone: canPublishSource(Track.Source.Microphone) ?? null, | ||
screenShare: canPublishSource(Track.Source.ScreenShare) ?? null, | ||
data: permissions?.canPublishData ?? false, | ||
}, | ||
})); | ||
|
||
emitter.emit(LocalEvent.PermissionsChanged, permissions); | ||
}; | ||
|
||
const initialize = () => { | ||
get().camera.subtle.initialize(); | ||
get().microphone.subtle.initialize(); | ||
get().screenShare.subtle.initialize(); | ||
|
||
room.on(RoomEvent.ParticipantPermissionsChanged, handleParticipantPermissionsChanged); | ||
}; | ||
|
||
const teardown = () => { | ||
room.localParticipant.off(ParticipantEvent.ParticipantPermissionsChanged, handleParticipantPermissionsChanged); | ||
|
||
get().camera.subtle.teardown(); | ||
get().microphone.subtle.teardown(); | ||
get().screenShare.subtle.teardown(); | ||
}; | ||
|
||
const { get: trackGet, set: trackSet } = createScopedGetSet(get, set, 'camera', 'LocalTrack'); | ||
const camera = createLocalTrack({ | ||
room, | ||
trackSource: Track.Source.Camera, | ||
preventUserChoicesSave: false, | ||
}, trackGet, trackSet); | ||
|
||
const { get: microphoneTrackGet, set: microphoneTrackSet } = createScopedGetSet(get, set, 'microphone', 'LocalTrack'); | ||
const microphone = createLocalTrack({ | ||
room, | ||
trackSource: Track.Source.Microphone, | ||
preventUserChoicesSave: false, | ||
}, microphoneTrackGet, microphoneTrackSet); | ||
|
||
const { get: screenShareTrackGet, set: screenShareTrackSet } = createScopedGetSet(get, set, 'screenShare', 'LocalTrack'); | ||
const screenShare = createLocalTrack({ | ||
room, | ||
trackSource: Track.Source.ScreenShare, | ||
preventUserChoicesSave: false, | ||
}, screenShareTrackGet, screenShareTrackSet); | ||
|
||
return { | ||
[Symbol.toStringTag]: "LocalInstance", | ||
|
||
permissions: room.localParticipant.permissions ?? null, | ||
publishPermissions: { | ||
camera: null, | ||
microphone: null, | ||
screenShare: null, | ||
data: false, | ||
}, | ||
|
||
camera, | ||
microphone, | ||
screenShare, | ||
|
||
subtle: { | ||
emitter, | ||
initialize, | ||
teardown, | ||
|
||
localParticipant: room.localParticipant, | ||
}, | ||
}; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if
local
is only available in aconnected
state, then I believepermissions
can be set alwaysThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
At least in the underlying participant implementation, the type for
permissions
isParticipantPermission | undefined
- see here, so I just proxied that through.Are you saying that in this particular case, it will always be set for a
localParticipant
?If so, I can get rid of the
null
but it seems like that might also be a change worth upstreaming to the client sdk as well at the same time?If not, what would the "fallback" value be here if
permissions
wasn't set?