create-spatius-app 0.1.2 → 0.1.3-beta.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.
- package/README.md +18 -6
- package/dist/cli.js +647 -144
- package/dist/templates.js +529 -66
- package/package.json +4 -4
- package/schemas/result-v3.schema.json +6 -2
- package/schemas/result-v4.schema.json +170 -0
- package/templates/cloudflare-livekit/AGENTS.md +2 -0
- package/templates/cloudflare-livekit/agent/README.md +13 -0
- package/templates/cloudflare-livekit/agent/env.example +3 -0
- package/templates/cloudflare-livekit/agent/pyproject.toml +1 -1
- package/templates/cloudflare-livekit/agent/src/agent.py +7 -4
- package/templates/cloudflare-livekit/agent/tests/test_agent_startup.py +49 -0
- package/templates/cloudflare-livekit/agent/uv.lock +14 -14
- package/templates/cloudflare-livekit/gitignore +1 -0
- package/templates/cloudflare-livekit/package-lock.json +247 -158
- package/templates/cloudflare-livekit/package.json +9 -8
- package/templates/cloudflare-livekit/pnpm-lock.yaml +200 -177
- package/templates/cloudflare-livekit/tests/dev-events.test.mjs +23 -0
- package/templates/cloudflare-livekit/tests/dev-fixtures.mjs +14 -5
- package/templates/cloudflare-livekit/tests/dev-supervision.test.mjs +17 -14
- package/templates/cloudflare-livekit/tsconfig.web.json +1 -0
- package/templates/cloudflare-livekit/tsconfig.worker.json +1 -0
- package/templates/cloudflare-livekit/web/api.ts +12 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/agent-chat-transcript.tsx +6 -90
- package/templates/cloudflare-livekit/web/components/transcript-view.tsx +90 -0
- package/templates/cloudflare-livekit/web/session-attempt.ts +23 -2
- package/templates/cloudflare-livekit/worker/capability.test.ts +31 -0
- package/templates/cloudflare-livekit/worker/capability.ts +72 -0
- package/templates/cloudflare-livekit/worker/index.test.ts +2 -2
- package/templates/cloudflare-livekit/worker/index.ts +15 -3
- package/templates/cloudflare-livekit/worker/session.ts +34 -0
- package/templates/layers/agora/AGENTS.md +13 -0
- package/templates/layers/agora/README.md +25 -0
- package/templates/layers/agora/dev.vars.example +9 -0
- package/templates/layers/agora/package-lock.json +6988 -0
- package/templates/layers/agora/package.json +60 -0
- package/templates/layers/agora/pnpm-lock.yaml +4648 -0
- package/templates/layers/agora/scripts/check-worker.mjs +98 -0
- package/templates/layers/agora/tsconfig.worker.json +23 -0
- package/templates/layers/agora/vitest.config.ts +4 -0
- package/templates/layers/agora/web/App.tsx +174 -0
- package/templates/layers/agora/web/agora-session.test.ts +70 -0
- package/templates/layers/agora/web/agora-session.ts +267 -0
- package/templates/layers/agora/worker/agora.test.ts +62 -0
- package/templates/layers/agora/worker/agora.ts +156 -0
- package/templates/layers/agora/worker/index.test.ts +66 -0
- package/templates/layers/agora/worker/index.ts +54 -0
- package/templates/layers/agora/wrangler.jsonc +24 -0
- package/templates/layers/catalog/DEPLOYMENT.md +3 -0
- package/templates/layers/catalog/spatius.config.json +1 -0
- package/dist/cli.js.map +0 -1
- package/dist/templates.js.map +0 -1
- package/templates/cloudflare-livekit/worker-configuration.d.ts +0 -17184
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import { readEvents } from './dev-fixtures.mjs';
|
|
7
|
+
|
|
8
|
+
test('event polling waits for a complete trailing record', async (t) => {
|
|
9
|
+
const directory = await mkdtemp(join(tmpdir(), 'spatius-events-'));
|
|
10
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
11
|
+
const path = join(directory, 'events.jsonl');
|
|
12
|
+
await writeFile(path, '{"type":"ready"}\n{"type":"reg');
|
|
13
|
+
assert.deepEqual(await readEvents(path), [{ type: 'ready' }]);
|
|
14
|
+
await appendFile(path, 'istered"}\n');
|
|
15
|
+
assert.deepEqual(await readEvents(path), [
|
|
16
|
+
{ type: 'ready' },
|
|
17
|
+
{ type: 'registered' },
|
|
18
|
+
]);
|
|
19
|
+
await writeFile(path, '{"type":');
|
|
20
|
+
assert.deepEqual(await readEvents(path), []);
|
|
21
|
+
await appendFile(path, 'broken}\n');
|
|
22
|
+
await assert.rejects(readEvents(path), SyntaxError);
|
|
23
|
+
});
|
|
@@ -54,7 +54,7 @@ if (role === 'lk' && args[0] === '--version') {
|
|
|
54
54
|
} else if (role === 'lk' && args.includes('--help')) {
|
|
55
55
|
if (process.env.DEV_TEST_PROBE === 'help-hang') { hold(); ready(); }
|
|
56
56
|
else { console.log(process.env.DEV_TEST_HELP ?? 'NAME: lk agent dev\\nUSAGE: lk agent dev [entrypoint]'); process.exit(Number(process.env.DEV_TEST_HELP_EXIT ?? 0)); }
|
|
57
|
-
} else if (role === 'grandchild') {
|
|
57
|
+
} else if (role === 'grandchild' || role === 'api') {
|
|
58
58
|
hold(); ready();
|
|
59
59
|
} else {
|
|
60
60
|
if (role !== 'web' && process.env.SPATIUS_DEV_READY_FILE && process.env.DEV_TEST_RUNTIME_HOLD === '1') {
|
|
@@ -129,6 +129,7 @@ export async function fixture(
|
|
|
129
129
|
await writeFile(path, body, { mode: 0o755 });
|
|
130
130
|
return path;
|
|
131
131
|
};
|
|
132
|
+
await executable('tsx', bin, 'api');
|
|
132
133
|
if (lk) await executable('lk');
|
|
133
134
|
if (uv) await executable('uv');
|
|
134
135
|
if (python) {
|
|
@@ -206,8 +207,11 @@ export async function fixture(
|
|
|
206
207
|
}
|
|
207
208
|
|
|
208
209
|
export async function readEvents(path) {
|
|
209
|
-
|
|
210
|
-
|
|
210
|
+
// A concurrent append can be visible before its terminating newline.
|
|
211
|
+
// Parse only complete records; the next poll will read the finished tail.
|
|
212
|
+
const contents = await readFile(path, 'utf8');
|
|
213
|
+
return contents
|
|
214
|
+
.slice(0, contents.lastIndexOf('\n') + 1)
|
|
211
215
|
.split('\n')
|
|
212
216
|
.filter(Boolean)
|
|
213
217
|
.map((line) => JSON.parse(line));
|
|
@@ -215,12 +219,17 @@ export async function readEvents(path) {
|
|
|
215
219
|
|
|
216
220
|
export async function waitForEvents(path, predicate) {
|
|
217
221
|
const deadline = Date.now() + 8000;
|
|
222
|
+
let events = [];
|
|
218
223
|
while (Date.now() < deadline) {
|
|
219
|
-
|
|
224
|
+
events = await readEvents(path);
|
|
220
225
|
if (predicate(events)) return events;
|
|
221
226
|
await delay(25);
|
|
222
227
|
}
|
|
223
|
-
|
|
228
|
+
// Include progress without logging the fixture's environment or credentials.
|
|
229
|
+
const progress = events.map(({ role, type }) => `${role}:${type}`).join(', ');
|
|
230
|
+
assert.fail(
|
|
231
|
+
`Timed out waiting for fake processes to become ready. Events: ${progress || 'none'}`,
|
|
232
|
+
);
|
|
224
233
|
}
|
|
225
234
|
|
|
226
235
|
export async function assertStopped(pids) {
|
|
@@ -95,20 +95,24 @@ async function supervise(f, extraEnv) {
|
|
|
95
95
|
return { child, done };
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
for (const [name, side, code] of [
|
|
98
|
+
for (const [name, side, code, probe] of [
|
|
99
99
|
['web exits zero', 'web', '0'],
|
|
100
100
|
['web fails', 'web', '7'],
|
|
101
101
|
['agent exits zero', 'agent', '0'],
|
|
102
102
|
['agent fails', 'agent', '29'],
|
|
103
|
+
['web exits zero after a timed-out CLI probe', 'web', '0', 'version-hang'],
|
|
103
104
|
]) {
|
|
104
105
|
test(
|
|
105
106
|
`root dev supervisor: ${name} stops its sibling and all grandchildren`,
|
|
106
107
|
{ timeout: 15000 },
|
|
107
108
|
async (t) => {
|
|
108
|
-
|
|
109
|
+
// Windows has only a preflight python.exe stub. Use the runnable uv shim
|
|
110
|
+
// so a slow CLI probe can fall back without exiting before registration.
|
|
111
|
+
const f = await fixture(t, { uv: windows || Boolean(probe) });
|
|
109
112
|
const run = await supervise(f, {
|
|
110
113
|
DEV_TEST_RUNTIME_HOLD: '1',
|
|
111
114
|
DEV_TEST_GRANDCHILD: '1',
|
|
115
|
+
...(probe ? { DEV_TEST_PROBE: probe } : {}),
|
|
112
116
|
...(side === 'web'
|
|
113
117
|
? { DEV_TEST_WEB_EXIT: code }
|
|
114
118
|
: { DEV_TEST_EXIT_WHEN_RELEASED: '1', DEV_TEST_RUNTIME_EXIT: code }),
|
|
@@ -130,15 +134,14 @@ for (const [name, side, code] of [
|
|
|
130
134
|
await assertStopped(events.map(({ pid }) => pid));
|
|
131
135
|
// A bounded CLI probe may select Python fallback on a busy machine.
|
|
132
136
|
// Supervision must launch exactly one runtime through either supported path.
|
|
133
|
-
|
|
134
|
-
(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
args.includes('src/agent.py'),
|
|
139
|
-
).length,
|
|
140
|
-
1,
|
|
137
|
+
const runtimes = (await readEvents(f.events)).filter(
|
|
138
|
+
({ role, args, type }) =>
|
|
139
|
+
['lk', 'uv', 'python'].includes(role) &&
|
|
140
|
+
type === 'invoked' &&
|
|
141
|
+
args.includes('src/agent.py'),
|
|
141
142
|
);
|
|
143
|
+
assert.equal(runtimes.length, 1);
|
|
144
|
+
if (probe) assert.equal(runtimes[0].role, 'uv');
|
|
142
145
|
},
|
|
143
146
|
);
|
|
144
147
|
}
|
|
@@ -152,7 +155,7 @@ test(
|
|
|
152
155
|
'Node cannot send console Ctrl+C events on Windows; taskkill tree cleanup is tested separately.',
|
|
153
156
|
},
|
|
154
157
|
async (t) => {
|
|
155
|
-
const f = await fixture(t);
|
|
158
|
+
const f = await fixture(t, { uv: windows });
|
|
156
159
|
const run = await supervise(f, {
|
|
157
160
|
DEV_TEST_RUNTIME_HOLD: '1',
|
|
158
161
|
DEV_TEST_GRANDCHILD: '1',
|
|
@@ -252,7 +255,7 @@ test(
|
|
|
252
255
|
'agent failure before registration never starts web',
|
|
253
256
|
{ timeout: 15000 },
|
|
254
257
|
async (t) => {
|
|
255
|
-
const f = await fixture(t);
|
|
258
|
+
const f = await fixture(t, { uv: windows });
|
|
256
259
|
const run = await supervise(f, { DEV_TEST_RUNTIME_EXIT: '29' });
|
|
257
260
|
const result = await run.done;
|
|
258
261
|
assert.notEqual(result.code, 0);
|
|
@@ -265,7 +268,7 @@ test(
|
|
|
265
268
|
'registration timeout stops the agent without starting web',
|
|
266
269
|
{ timeout: 15000 },
|
|
267
270
|
async (t) => {
|
|
268
|
-
const f = await fixture(t);
|
|
271
|
+
const f = await fixture(t, { uv: windows });
|
|
269
272
|
// Accelerate only the copied fixture's deadline; exercise the real web gate.
|
|
270
273
|
const path = join(f.root, 'scripts', 'wait-for-agent.mjs');
|
|
271
274
|
await writeFile(
|
|
@@ -290,7 +293,7 @@ test(
|
|
|
290
293
|
'Ctrl+C while waiting for registration stops the agent without starting web',
|
|
291
294
|
{ timeout: 15000, skip: windows },
|
|
292
295
|
async (t) => {
|
|
293
|
-
const f = await fixture(t);
|
|
296
|
+
const f = await fixture(t, { uv: windows });
|
|
294
297
|
const run = await supervise(f, {
|
|
295
298
|
DEV_TEST_RUNTIME_HOLD: '1',
|
|
296
299
|
DEV_TEST_REGISTER: join(f.root, 'never-register'),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export interface VoiceSession {
|
|
2
|
+
session_capability?: string;
|
|
2
3
|
participant_token: string;
|
|
3
4
|
room_name: string;
|
|
4
5
|
server_url: string;
|
|
@@ -67,3 +68,14 @@ export async function requestVoiceSession(
|
|
|
67
68
|
}
|
|
68
69
|
return payload;
|
|
69
70
|
}
|
|
71
|
+
|
|
72
|
+
export async function stopVoiceSession(capability?: string): Promise<void> {
|
|
73
|
+
if (!capability) return;
|
|
74
|
+
const response = await fetch('/api/session/stop', {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'content-type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({ capability }),
|
|
78
|
+
keepalive: true,
|
|
79
|
+
});
|
|
80
|
+
if (!response.ok) throw new Error('Session cleanup failed');
|
|
81
|
+
}
|
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
// Copyright LiveKit. Apache-2.0. Adapted from Agents UI; see ./PROVENANCE.md.
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
isUser: boolean;
|
|
9
|
-
source?: 'user' | 'agent' | 'avatar';
|
|
10
|
-
senderIdentity?: string;
|
|
11
|
-
};
|
|
2
|
+
import type { ReceivedMessage } from '@livekit/components-react';
|
|
3
|
+
import type { TranscriptMessage } from '../transcript-view.js';
|
|
4
|
+
export {
|
|
5
|
+
AgentChatTranscript,
|
|
6
|
+
type TranscriptMessage,
|
|
7
|
+
} from '../transcript-view.js';
|
|
12
8
|
export type ConversationParticipants = {
|
|
13
9
|
agentIdentity?: string;
|
|
14
10
|
avatarIdentity?: string;
|
|
@@ -63,83 +59,3 @@ export function snapshotMessages(
|
|
|
63
59
|
];
|
|
64
60
|
});
|
|
65
61
|
}
|
|
66
|
-
export function AgentChatTranscript({
|
|
67
|
-
messages = [],
|
|
68
|
-
agentState,
|
|
69
|
-
agentName = 'Agent',
|
|
70
|
-
}: {
|
|
71
|
-
messages?: readonly TranscriptMessage[];
|
|
72
|
-
agentState?: AgentState;
|
|
73
|
-
agentName?: string;
|
|
74
|
-
}) {
|
|
75
|
-
const viewport = useRef<HTMLDivElement>(null);
|
|
76
|
-
const atBottom = useRef(true);
|
|
77
|
-
const [showLatest, setShowLatest] = useState(false);
|
|
78
|
-
const scrollToLatest = () => {
|
|
79
|
-
const element = viewport.current;
|
|
80
|
-
if (element) element.scrollTop = element.scrollHeight;
|
|
81
|
-
atBottom.current = true;
|
|
82
|
-
setShowLatest(false);
|
|
83
|
-
};
|
|
84
|
-
useLayoutEffect(() => {
|
|
85
|
-
if (atBottom.current) scrollToLatest();
|
|
86
|
-
else setShowLatest(true);
|
|
87
|
-
}, [messages, agentState]);
|
|
88
|
-
return (
|
|
89
|
-
<div className="transcript-container">
|
|
90
|
-
<div
|
|
91
|
-
ref={viewport}
|
|
92
|
-
className="transcript-scroll"
|
|
93
|
-
role="log"
|
|
94
|
-
aria-label="Conversation transcript"
|
|
95
|
-
aria-live="polite"
|
|
96
|
-
onScroll={(event) => {
|
|
97
|
-
const element = event.currentTarget;
|
|
98
|
-
atBottom.current =
|
|
99
|
-
element.scrollHeight - element.scrollTop - element.clientHeight <
|
|
100
|
-
48;
|
|
101
|
-
if (atBottom.current) setShowLatest(false);
|
|
102
|
-
}}
|
|
103
|
-
>
|
|
104
|
-
{messages.length === 0 && (
|
|
105
|
-
<p className="transcript-empty">
|
|
106
|
-
Your conversation will appear here as you speak or type.
|
|
107
|
-
</p>
|
|
108
|
-
)}
|
|
109
|
-
{messages.map(({ id, timestamp, isUser, message, source }) => (
|
|
110
|
-
<article
|
|
111
|
-
key={id}
|
|
112
|
-
className="transcript-message"
|
|
113
|
-
data-speaker={isUser ? 'user' : 'agent'}
|
|
114
|
-
data-source={source}
|
|
115
|
-
>
|
|
116
|
-
<div className="message-meta">
|
|
117
|
-
<span>{isUser ? 'You' : agentName}</span>
|
|
118
|
-
<time dateTime={new Date(timestamp).toISOString()}>
|
|
119
|
-
{new Date(timestamp).toLocaleTimeString([], {
|
|
120
|
-
hour: '2-digit',
|
|
121
|
-
minute: '2-digit',
|
|
122
|
-
})}
|
|
123
|
-
</time>
|
|
124
|
-
</div>
|
|
125
|
-
<p>{message}</p>
|
|
126
|
-
</article>
|
|
127
|
-
))}
|
|
128
|
-
{agentState === 'thinking' && (
|
|
129
|
-
<p className="thinking-indicator" role="status">
|
|
130
|
-
Thinking<span aria-hidden="true">…</span>
|
|
131
|
-
</p>
|
|
132
|
-
)}
|
|
133
|
-
</div>
|
|
134
|
-
{showLatest && (
|
|
135
|
-
<button
|
|
136
|
-
className="jump-to-latest"
|
|
137
|
-
type="button"
|
|
138
|
-
onClick={scrollToLatest}
|
|
139
|
-
>
|
|
140
|
-
Latest messages ↓
|
|
141
|
-
</button>
|
|
142
|
-
)}
|
|
143
|
-
</div>
|
|
144
|
-
);
|
|
145
|
-
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Copyright LiveKit. Apache-2.0. Adapted from Agents UI; see agents-ui/PROVENANCE.md.
|
|
2
|
+
import { useLayoutEffect, useRef, useState } from 'react';
|
|
3
|
+
export interface TranscriptMessage {
|
|
4
|
+
id: string;
|
|
5
|
+
timestamp: number;
|
|
6
|
+
message: string;
|
|
7
|
+
isUser: boolean;
|
|
8
|
+
source?: 'user' | 'agent' | 'avatar';
|
|
9
|
+
senderIdentity?: string;
|
|
10
|
+
}
|
|
11
|
+
export function AgentChatTranscript({
|
|
12
|
+
messages = [],
|
|
13
|
+
agentState,
|
|
14
|
+
agentName = 'Agent',
|
|
15
|
+
}: {
|
|
16
|
+
messages?: readonly TranscriptMessage[];
|
|
17
|
+
agentState?: string;
|
|
18
|
+
agentName?: string;
|
|
19
|
+
}) {
|
|
20
|
+
const viewport = useRef<HTMLDivElement>(null);
|
|
21
|
+
const atBottom = useRef(true);
|
|
22
|
+
const [showLatest, setShowLatest] = useState(false);
|
|
23
|
+
const scrollToLatest = () => {
|
|
24
|
+
const element = viewport.current;
|
|
25
|
+
if (element) element.scrollTop = element.scrollHeight;
|
|
26
|
+
atBottom.current = true;
|
|
27
|
+
setShowLatest(false);
|
|
28
|
+
};
|
|
29
|
+
useLayoutEffect(() => {
|
|
30
|
+
if (atBottom.current) scrollToLatest();
|
|
31
|
+
else setShowLatest(true);
|
|
32
|
+
}, [messages, agentState]);
|
|
33
|
+
return (
|
|
34
|
+
<div className="transcript-container">
|
|
35
|
+
<div
|
|
36
|
+
ref={viewport}
|
|
37
|
+
className="transcript-scroll"
|
|
38
|
+
role="log"
|
|
39
|
+
aria-label="Conversation transcript"
|
|
40
|
+
aria-live="polite"
|
|
41
|
+
onScroll={(event) => {
|
|
42
|
+
const element = event.currentTarget;
|
|
43
|
+
atBottom.current =
|
|
44
|
+
element.scrollHeight - element.scrollTop - element.clientHeight <
|
|
45
|
+
48;
|
|
46
|
+
if (atBottom.current) setShowLatest(false);
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
{messages.length === 0 && (
|
|
50
|
+
<p className="transcript-empty">
|
|
51
|
+
Your conversation will appear here as you speak or type.
|
|
52
|
+
</p>
|
|
53
|
+
)}
|
|
54
|
+
{messages.map(({ id, timestamp, isUser, message, source }) => (
|
|
55
|
+
<article
|
|
56
|
+
key={id}
|
|
57
|
+
className="transcript-message"
|
|
58
|
+
data-speaker={isUser ? 'user' : 'agent'}
|
|
59
|
+
data-source={source}
|
|
60
|
+
>
|
|
61
|
+
<div className="message-meta">
|
|
62
|
+
<span>{isUser ? 'You' : agentName}</span>
|
|
63
|
+
<time dateTime={new Date(timestamp).toISOString()}>
|
|
64
|
+
{new Date(timestamp).toLocaleTimeString([], {
|
|
65
|
+
hour: '2-digit',
|
|
66
|
+
minute: '2-digit',
|
|
67
|
+
})}
|
|
68
|
+
</time>
|
|
69
|
+
</div>
|
|
70
|
+
<p>{message}</p>
|
|
71
|
+
</article>
|
|
72
|
+
))}
|
|
73
|
+
{agentState === 'thinking' && (
|
|
74
|
+
<p className="thinking-indicator" role="status">
|
|
75
|
+
Thinking<span aria-hidden="true">…</span>
|
|
76
|
+
</p>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
{showLatest && (
|
|
80
|
+
<button
|
|
81
|
+
className="jump-to-latest"
|
|
82
|
+
type="button"
|
|
83
|
+
onClick={scrollToLatest}
|
|
84
|
+
>
|
|
85
|
+
Latest messages ↓
|
|
86
|
+
</button>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { Room, TokenSource, Track } from 'livekit-client';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
requestVoiceSession,
|
|
4
|
+
stopVoiceSession,
|
|
5
|
+
type VoiceSession,
|
|
6
|
+
} from './api.js';
|
|
3
7
|
import type { AvatarSessionController } from './avatar-session.js';
|
|
4
8
|
|
|
5
9
|
export type PreparedSession = {
|
|
@@ -30,6 +34,16 @@ export class SessionAttempt {
|
|
|
30
34
|
stage: 'bootstrap' | 'avatar' = 'bootstrap';
|
|
31
35
|
readonly room: Room;
|
|
32
36
|
readonly abort = new AbortController();
|
|
37
|
+
private capability?: string;
|
|
38
|
+
private readonly unload = () => {
|
|
39
|
+
if (this.capability)
|
|
40
|
+
navigator.sendBeacon?.(
|
|
41
|
+
'/api/session/stop',
|
|
42
|
+
new Blob([JSON.stringify({ capability: this.capability })], {
|
|
43
|
+
type: 'application/json',
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
};
|
|
33
47
|
private avatar?: AvatarSessionController;
|
|
34
48
|
private disposal?: Promise<void>;
|
|
35
49
|
constructor(private readonly deps: AttemptDependencies = dependencies) {
|
|
@@ -41,7 +55,12 @@ export class SessionAttempt {
|
|
|
41
55
|
): Promise<PreparedSession> {
|
|
42
56
|
const { signal } = this.abort;
|
|
43
57
|
const credentials = await this.deps.request(signal);
|
|
44
|
-
|
|
58
|
+
this.capability = credentials.session_capability;
|
|
59
|
+
if (signal.aborted) {
|
|
60
|
+
await stopVoiceSession(this.capability).catch(() => undefined);
|
|
61
|
+
signal.throwIfAborted();
|
|
62
|
+
}
|
|
63
|
+
globalThis.window?.addEventListener('pagehide', this.unload);
|
|
45
64
|
// Warm LiveKit's region selection and connection while the avatar loads.
|
|
46
65
|
// This does not join the room or acquire media; attach must still finish
|
|
47
66
|
// before useSession starts the connection. A failed warmup is non-fatal.
|
|
@@ -100,7 +119,9 @@ export class SessionAttempt {
|
|
|
100
119
|
dispose(): Promise<void> {
|
|
101
120
|
this.abort.abort();
|
|
102
121
|
this.disposal ??= (async () => {
|
|
122
|
+
globalThis.window?.removeEventListener('pagehide', this.unload);
|
|
103
123
|
await Promise.allSettled([
|
|
124
|
+
stopVoiceSession(this.capability),
|
|
104
125
|
this.room.disconnect(true),
|
|
105
126
|
this.avatar?.dispose() ?? Promise.resolve(),
|
|
106
127
|
]);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { signCapability, verifyCapability } from './capability.js';
|
|
3
|
+
describe('session capabilities', () => {
|
|
4
|
+
it('binds purpose and expiry and rejects tampering', async () => {
|
|
5
|
+
const secret = 'test-only-signing-key';
|
|
6
|
+
const token = await signCapability(
|
|
7
|
+
secret,
|
|
8
|
+
{ purpose: 'session', room: 'one' },
|
|
9
|
+
10,
|
|
10
|
+
);
|
|
11
|
+
expect(await verifyCapability(secret, token, 'session')).toMatchObject({
|
|
12
|
+
room: 'one',
|
|
13
|
+
});
|
|
14
|
+
await expect(verifyCapability(secret, token, 'memory')).rejects.toThrow();
|
|
15
|
+
await expect(
|
|
16
|
+
verifyCapability(secret, token + 'x', 'session'),
|
|
17
|
+
).rejects.toThrow();
|
|
18
|
+
await expect(
|
|
19
|
+
verifyCapability('different-key', token, 'session'),
|
|
20
|
+
).rejects.toThrow();
|
|
21
|
+
vi.useFakeTimers();
|
|
22
|
+
try {
|
|
23
|
+
vi.setSystemTime(Date.now() + 20000);
|
|
24
|
+
await expect(verifyCapability(secret, token, 'session')).rejects.toThrow(
|
|
25
|
+
'Expired',
|
|
26
|
+
);
|
|
27
|
+
} finally {
|
|
28
|
+
vi.useRealTimers();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const encoder = new TextEncoder();
|
|
2
|
+
function encode(value: Uint8Array): string {
|
|
3
|
+
return btoa(String.fromCharCode(...value))
|
|
4
|
+
.replaceAll('+', '-')
|
|
5
|
+
.replaceAll('/', '_')
|
|
6
|
+
.replaceAll('=', '');
|
|
7
|
+
}
|
|
8
|
+
function decode(value: string): Uint8Array<ArrayBuffer> {
|
|
9
|
+
return Uint8Array.from(
|
|
10
|
+
atob(value.replaceAll('-', '+').replaceAll('_', '/')),
|
|
11
|
+
(character) => character.charCodeAt(0),
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
async function key(secret: string) {
|
|
15
|
+
if (!secret) throw new Error('Session signing secret is missing');
|
|
16
|
+
return crypto.subtle.importKey(
|
|
17
|
+
'raw',
|
|
18
|
+
encoder.encode(secret),
|
|
19
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
20
|
+
false,
|
|
21
|
+
['sign', 'verify'],
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
export async function signCapability(
|
|
25
|
+
secret: string,
|
|
26
|
+
claims: Record<string, unknown>,
|
|
27
|
+
seconds = 3600,
|
|
28
|
+
): Promise<string> {
|
|
29
|
+
const payload = encode(
|
|
30
|
+
encoder.encode(
|
|
31
|
+
JSON.stringify({
|
|
32
|
+
...claims,
|
|
33
|
+
exp: Math.floor(Date.now() / 1000) + seconds,
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
const signature = await crypto.subtle.sign(
|
|
38
|
+
'HMAC',
|
|
39
|
+
await key(secret),
|
|
40
|
+
encoder.encode(payload),
|
|
41
|
+
);
|
|
42
|
+
return `${payload}.${encode(new Uint8Array(signature))}`;
|
|
43
|
+
}
|
|
44
|
+
export async function verifyCapability(
|
|
45
|
+
secret: string,
|
|
46
|
+
token: string,
|
|
47
|
+
purpose: string,
|
|
48
|
+
): Promise<Record<string, unknown>> {
|
|
49
|
+
if (typeof token !== 'string' || token.length > 8192)
|
|
50
|
+
throw new Error('Invalid session authorization');
|
|
51
|
+
const parts = token.split('.');
|
|
52
|
+
if (
|
|
53
|
+
parts.length !== 2 ||
|
|
54
|
+
!(await crypto.subtle.verify(
|
|
55
|
+
'HMAC',
|
|
56
|
+
await key(secret),
|
|
57
|
+
decode(parts[1]),
|
|
58
|
+
encoder.encode(parts[0]),
|
|
59
|
+
))
|
|
60
|
+
)
|
|
61
|
+
throw new Error('Invalid session authorization');
|
|
62
|
+
const value = JSON.parse(
|
|
63
|
+
new TextDecoder().decode(decode(parts[0])),
|
|
64
|
+
) as Record<string, unknown>;
|
|
65
|
+
if (
|
|
66
|
+
value.purpose !== purpose ||
|
|
67
|
+
typeof value.exp !== 'number' ||
|
|
68
|
+
value.exp <= Date.now() / 1000
|
|
69
|
+
)
|
|
70
|
+
throw new Error('Expired session authorization');
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
@@ -30,7 +30,7 @@ describe('createSession', () => {
|
|
|
30
30
|
) as {
|
|
31
31
|
roomConfig: { agents: Array<{ metadata: string }> };
|
|
32
32
|
};
|
|
33
|
-
expect(JSON.parse(payload.roomConfig.agents[0].metadata)).
|
|
33
|
+
expect(JSON.parse(payload.roomConfig.agents[0].metadata)).toMatchObject({
|
|
34
34
|
version: 1,
|
|
35
35
|
avatar: { id: TEST_ENV.SPATIUS_AVATAR_ID },
|
|
36
36
|
voice: { id: voiceId },
|
|
@@ -145,7 +145,7 @@ describe('Worker routes', () => {
|
|
|
145
145
|
expect(response.status).toBe(503);
|
|
146
146
|
await expect(response.json()).resolves.toEqual({
|
|
147
147
|
error:
|
|
148
|
-
'The voice session service is not configured. Check the
|
|
148
|
+
'The voice session service is not configured. Check the server environment.',
|
|
149
149
|
});
|
|
150
150
|
consoleError.mockRestore();
|
|
151
151
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConfigurationError, createSession } from './session.js';
|
|
1
|
+
import { ConfigurationError, createSession, stopSession } from './session.js';
|
|
2
2
|
|
|
3
3
|
const JSON_HEADERS = {
|
|
4
4
|
'cache-control': 'no-store',
|
|
@@ -26,6 +26,18 @@ export async function handleRequest(
|
|
|
26
26
|
return json({ ok: true });
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
if (url.pathname === '/api/session/stop') {
|
|
30
|
+
if (request.method !== 'POST')
|
|
31
|
+
return json({ error: 'Method not allowed.' }, 405);
|
|
32
|
+
try {
|
|
33
|
+
const body: { capability?: string } = await request.json();
|
|
34
|
+
await stopSession(env, body.capability ?? '');
|
|
35
|
+
return json({ ok: true });
|
|
36
|
+
} catch {
|
|
37
|
+
return json({ error: 'Could not stop this session.' }, 403);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
if (url.pathname === '/api/session') {
|
|
30
42
|
if (request.method !== 'POST') {
|
|
31
43
|
return json({ error: 'Method not allowed.' }, 405);
|
|
@@ -42,13 +54,13 @@ export async function handleRequest(
|
|
|
42
54
|
return json(
|
|
43
55
|
{
|
|
44
56
|
error:
|
|
45
|
-
'The voice session service is not configured. Check the
|
|
57
|
+
'The voice session service is not configured. Check the server environment.',
|
|
46
58
|
},
|
|
47
59
|
503,
|
|
48
60
|
);
|
|
49
61
|
}
|
|
50
62
|
|
|
51
|
-
console.error('Could not create a voice session
|
|
63
|
+
console.error('Could not create a voice session.');
|
|
52
64
|
return json({ error: 'Could not create a voice session.' }, 500);
|
|
53
65
|
}
|
|
54
66
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AccessToken,
|
|
3
|
+
RoomServiceClient,
|
|
3
4
|
RoomAgentDispatch,
|
|
4
5
|
RoomConfiguration,
|
|
5
6
|
} from 'livekit-server-sdk';
|
|
6
7
|
|
|
8
|
+
import { signCapability, verifyCapability } from './capability.js';
|
|
7
9
|
import { serializeAgentDispatchMetadata } from './dispatch-metadata.js';
|
|
8
10
|
|
|
9
11
|
const TOKEN_TTL = '10m';
|
|
@@ -17,6 +19,7 @@ const REQUIRED_ENVIRONMENT_KEYS = [
|
|
|
17
19
|
] as const;
|
|
18
20
|
|
|
19
21
|
export interface SessionResponse {
|
|
22
|
+
session_capability?: string;
|
|
20
23
|
participant_token: string;
|
|
21
24
|
room_name: string;
|
|
22
25
|
server_url: string;
|
|
@@ -91,6 +94,10 @@ export async function createSession(
|
|
|
91
94
|
});
|
|
92
95
|
|
|
93
96
|
return {
|
|
97
|
+
session_capability: await signCapability(env.LIVEKIT_API_SECRET, {
|
|
98
|
+
purpose: 'session',
|
|
99
|
+
room: roomName,
|
|
100
|
+
}),
|
|
94
101
|
participant_token: await accessToken.toJwt(),
|
|
95
102
|
room_name: roomName,
|
|
96
103
|
server_url: env.LIVEKIT_URL,
|
|
@@ -101,3 +108,30 @@ export async function createSession(
|
|
|
101
108
|
: {}),
|
|
102
109
|
};
|
|
103
110
|
}
|
|
111
|
+
|
|
112
|
+
export async function stopSession(
|
|
113
|
+
env: CloudflareBindings,
|
|
114
|
+
token: string,
|
|
115
|
+
): Promise<void> {
|
|
116
|
+
const claims = await verifyCapability(
|
|
117
|
+
env.LIVEKIT_API_SECRET,
|
|
118
|
+
token,
|
|
119
|
+
'session',
|
|
120
|
+
);
|
|
121
|
+
if (typeof claims.room !== 'string') throw new Error('Invalid room');
|
|
122
|
+
const rooms = new RoomServiceClient(
|
|
123
|
+
env.LIVEKIT_URL.replace(/^wss:/, 'https:'),
|
|
124
|
+
env.LIVEKIT_API_KEY,
|
|
125
|
+
env.LIVEKIT_API_SECRET,
|
|
126
|
+
);
|
|
127
|
+
try {
|
|
128
|
+
await rooms.deleteRoom(claims.room);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (!(
|
|
131
|
+
error instanceof Error &&
|
|
132
|
+
'code' in error &&
|
|
133
|
+
error.code === 'not_found'
|
|
134
|
+
))
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|