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.
Files changed (53) hide show
  1. package/README.md +18 -6
  2. package/dist/cli.js +647 -144
  3. package/dist/templates.js +529 -66
  4. package/package.json +4 -4
  5. package/schemas/result-v3.schema.json +6 -2
  6. package/schemas/result-v4.schema.json +170 -0
  7. package/templates/cloudflare-livekit/AGENTS.md +2 -0
  8. package/templates/cloudflare-livekit/agent/README.md +13 -0
  9. package/templates/cloudflare-livekit/agent/env.example +3 -0
  10. package/templates/cloudflare-livekit/agent/pyproject.toml +1 -1
  11. package/templates/cloudflare-livekit/agent/src/agent.py +7 -4
  12. package/templates/cloudflare-livekit/agent/tests/test_agent_startup.py +49 -0
  13. package/templates/cloudflare-livekit/agent/uv.lock +14 -14
  14. package/templates/cloudflare-livekit/gitignore +1 -0
  15. package/templates/cloudflare-livekit/package-lock.json +247 -158
  16. package/templates/cloudflare-livekit/package.json +9 -8
  17. package/templates/cloudflare-livekit/pnpm-lock.yaml +200 -177
  18. package/templates/cloudflare-livekit/tests/dev-events.test.mjs +23 -0
  19. package/templates/cloudflare-livekit/tests/dev-fixtures.mjs +14 -5
  20. package/templates/cloudflare-livekit/tests/dev-supervision.test.mjs +17 -14
  21. package/templates/cloudflare-livekit/tsconfig.web.json +1 -0
  22. package/templates/cloudflare-livekit/tsconfig.worker.json +1 -0
  23. package/templates/cloudflare-livekit/web/api.ts +12 -0
  24. package/templates/cloudflare-livekit/web/components/agents-ui/agent-chat-transcript.tsx +6 -90
  25. package/templates/cloudflare-livekit/web/components/transcript-view.tsx +90 -0
  26. package/templates/cloudflare-livekit/web/session-attempt.ts +23 -2
  27. package/templates/cloudflare-livekit/worker/capability.test.ts +31 -0
  28. package/templates/cloudflare-livekit/worker/capability.ts +72 -0
  29. package/templates/cloudflare-livekit/worker/index.test.ts +2 -2
  30. package/templates/cloudflare-livekit/worker/index.ts +15 -3
  31. package/templates/cloudflare-livekit/worker/session.ts +34 -0
  32. package/templates/layers/agora/AGENTS.md +13 -0
  33. package/templates/layers/agora/README.md +25 -0
  34. package/templates/layers/agora/dev.vars.example +9 -0
  35. package/templates/layers/agora/package-lock.json +6988 -0
  36. package/templates/layers/agora/package.json +60 -0
  37. package/templates/layers/agora/pnpm-lock.yaml +4648 -0
  38. package/templates/layers/agora/scripts/check-worker.mjs +98 -0
  39. package/templates/layers/agora/tsconfig.worker.json +23 -0
  40. package/templates/layers/agora/vitest.config.ts +4 -0
  41. package/templates/layers/agora/web/App.tsx +174 -0
  42. package/templates/layers/agora/web/agora-session.test.ts +70 -0
  43. package/templates/layers/agora/web/agora-session.ts +267 -0
  44. package/templates/layers/agora/worker/agora.test.ts +62 -0
  45. package/templates/layers/agora/worker/agora.ts +156 -0
  46. package/templates/layers/agora/worker/index.test.ts +66 -0
  47. package/templates/layers/agora/worker/index.ts +54 -0
  48. package/templates/layers/agora/wrangler.jsonc +24 -0
  49. package/templates/layers/catalog/DEPLOYMENT.md +3 -0
  50. package/templates/layers/catalog/spatius.config.json +1 -0
  51. package/dist/cli.js.map +0 -1
  52. package/dist/templates.js.map +0 -1
  53. package/templates/cloudflare-livekit/worker-configuration.d.ts +0 -17184
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ createAgoraSession,
4
+ stopAgoraSession,
5
+ type AgoraEnvironment,
6
+ } from './agora.js';
7
+ const env: AgoraEnvironment = {
8
+ AGORA_APP_ID: 'a'.repeat(32),
9
+ AGORA_APP_CERTIFICATE: 'b'.repeat(32),
10
+ AGORA_PIPELINE_ID: 'test-pipeline',
11
+ SPATIUS_API_KEY: 'private-key',
12
+ SPATIUS_APP_ID: 'test-app',
13
+ SPATIUS_AVATAR_ID: 'test-avatar',
14
+ SPATIUS_REGION: 'cn-beijing',
15
+ AGORA_AVATAR_SAMPLE_RATE: '24000',
16
+ };
17
+ describe('hosted Agora sessions', () => {
18
+ it('keeps secrets server-side and authorizes idempotent cleanup', async () => {
19
+ const fetcher = vi
20
+ .fn<typeof fetch>()
21
+ .mockResolvedValueOnce(Response.json({ agent_id: 'hosted-agent' }))
22
+ .mockResolvedValueOnce(new Response(null, { status: 404 }));
23
+ const session = await createAgoraSession(env, fetcher);
24
+ expect(session.uid).not.toBe(session.agentUid);
25
+ expect(JSON.stringify(session)).not.toContain(env.AGORA_APP_CERTIFICATE);
26
+ expect(JSON.stringify(session)).not.toContain(env.SPATIUS_API_KEY);
27
+ const body = JSON.parse(fetcher.mock.calls[0][1]!.body as string) as {
28
+ pipeline_id: string;
29
+ properties: {
30
+ agent_rtc_uid: string;
31
+ remote_rtc_uids: string[];
32
+ avatar: { params: { agora_uid: string; sample_rate: number } };
33
+ };
34
+ };
35
+ expect(body.pipeline_id).toBe('test-pipeline');
36
+ expect(body.properties.remote_rtc_uids).toEqual([String(session.uid)]);
37
+ expect(body.properties.agent_rtc_uid).toBe(String(session.agentUid));
38
+ expect(body.properties.avatar.params.agora_uid).not.toBe(
39
+ String(session.uid),
40
+ );
41
+ expect(body.properties.avatar.params.sample_rate).toBe(24000);
42
+ await stopAgoraSession(env, session.capability, fetcher);
43
+ expect(fetcher.mock.calls[1][0]).toContain('/agents/hosted-agent/leave');
44
+ await expect(
45
+ stopAgoraSession(env, session.capability + 'bad', fetcher),
46
+ ).rejects.toThrow();
47
+ expect(fetcher).toHaveBeenCalledTimes(2);
48
+ });
49
+ it('validates configuration before starting a billable agent and redacts upstream errors', async () => {
50
+ const fetcher = vi.fn<typeof fetch>();
51
+ await expect(
52
+ createAgoraSession({ ...env, AGORA_AVATAR_SAMPLE_RATE: '123' }, fetcher),
53
+ ).rejects.toThrow('sample rate');
54
+ expect(fetcher).not.toHaveBeenCalled();
55
+ fetcher.mockResolvedValue(
56
+ Response.json({ error: env.SPATIUS_API_KEY }, { status: 400 }),
57
+ );
58
+ await expect(createAgoraSession(env, fetcher)).rejects.toThrow(
59
+ 'Agora request failed (400)',
60
+ );
61
+ });
62
+ });
@@ -0,0 +1,156 @@
1
+ import { randomInt, randomUUID } from 'node:crypto';
2
+ import agoraToken from 'agora-token';
3
+ const { RtcTokenBuilder, RtcRole } = agoraToken;
4
+ import { signCapability, verifyCapability } from './capability.js';
5
+ export type AgoraEnvironment = CloudflareBindings;
6
+ export class ConfigurationError extends Error {}
7
+ function configuration(env: AgoraEnvironment) {
8
+ for (const key of [
9
+ 'AGORA_APP_ID',
10
+ 'AGORA_APP_CERTIFICATE',
11
+ 'AGORA_PIPELINE_ID',
12
+ 'SPATIUS_APP_ID',
13
+ 'SPATIUS_API_KEY',
14
+ 'SPATIUS_AVATAR_ID',
15
+ ] as const)
16
+ if (!env[key] || env[key].startsWith('your-'))
17
+ throw new ConfigurationError(`Missing ${key}`);
18
+ if (
19
+ !/^[a-f\d]{32}$/i.test(env.AGORA_APP_ID) ||
20
+ !/^[a-f\d]{32}$/i.test(env.AGORA_APP_CERTIFICATE)
21
+ )
22
+ throw new ConfigurationError(
23
+ 'Agora App ID and certificate must be 32 hexadecimal characters.',
24
+ );
25
+ const rate = Number(env.AGORA_AVATAR_SAMPLE_RATE ?? 24000);
26
+ if (![8000, 16000, 22050, 24000, 32000, 44100, 48000].includes(rate))
27
+ throw new ConfigurationError('Unsupported avatar sample rate.');
28
+ return rate;
29
+ }
30
+ function rtcToken(
31
+ env: AgoraEnvironment,
32
+ channel: string,
33
+ uid: number,
34
+ seconds = 1800,
35
+ ) {
36
+ return RtcTokenBuilder.buildTokenWithUid(
37
+ env.AGORA_APP_ID,
38
+ env.AGORA_APP_CERTIFICATE,
39
+ channel,
40
+ uid,
41
+ RtcRole.PUBLISHER,
42
+ seconds,
43
+ seconds,
44
+ );
45
+ }
46
+ async function upstream(
47
+ env: AgoraEnvironment,
48
+ path: string,
49
+ body?: object,
50
+ fetcher: typeof fetch = fetch,
51
+ ) {
52
+ const response = await fetcher(
53
+ `https://api.agora.io/api/conversational-ai-agent/v2/projects/${env.AGORA_APP_ID}/${path}`,
54
+ {
55
+ method: 'POST',
56
+ headers: {
57
+ authorization: `agora token="${rtcToken(env, '', 1, 300)}"`,
58
+ 'content-type': 'application/json',
59
+ },
60
+ body: JSON.stringify(body ?? {}),
61
+ signal: AbortSignal.timeout(15000),
62
+ },
63
+ );
64
+ if (path.endsWith('/leave') && response.status === 404) return {};
65
+ if (!response.ok)
66
+ throw new Error(
67
+ `Agora request failed (${response.status}). Check the published pipeline and project configuration.`,
68
+ );
69
+ return (await response.json().catch(() => ({}))) as Record<string, unknown>;
70
+ }
71
+ export async function createAgoraSession(
72
+ env: AgoraEnvironment,
73
+ fetcher: typeof fetch = fetch,
74
+ ) {
75
+ const sampleRate = configuration(env);
76
+ const channel = `spatius-${randomUUID().replaceAll('-', '')}`;
77
+ const uid = randomInt(100000, 599999),
78
+ agentUid = randomInt(600000, 799999),
79
+ avatarUid = randomInt(800000, 999999);
80
+ const response = await upstream(
81
+ env,
82
+ 'join',
83
+ {
84
+ name: channel,
85
+ pipeline_id: env.AGORA_PIPELINE_ID,
86
+ properties: {
87
+ channel,
88
+ agent_rtc_uid: String(agentUid),
89
+ token: rtcToken(env, channel, agentUid),
90
+ remote_rtc_uids: [String(uid)],
91
+ idle_timeout: 60,
92
+ advanced_features: { enable_rtm: true },
93
+ parameters: { data_channel: 'rtm', enable_error_message: true },
94
+ avatar: {
95
+ enable: true,
96
+ vendor: 'spatius',
97
+ params: {
98
+ spatius_api_key: env.SPATIUS_API_KEY,
99
+ spatius_app_id: env.SPATIUS_APP_ID,
100
+ spatius_avatar_id: env.SPATIUS_AVATAR_ID,
101
+ agora_uid: String(avatarUid),
102
+ agora_token: rtcToken(env, channel, avatarUid),
103
+ region: env.SPATIUS_REGION ?? 'cn-beijing',
104
+ sample_rate: sampleRate,
105
+ session_expire_minutes: 30,
106
+ },
107
+ },
108
+ },
109
+ },
110
+ fetcher,
111
+ );
112
+ if (typeof response.agent_id !== 'string' || !response.agent_id)
113
+ throw new Error('Agora returned an invalid session.');
114
+ const capability = await signCapability(
115
+ env.AGORA_APP_CERTIFICATE,
116
+ { purpose: 'agora-session', agent: response.agent_id },
117
+ 3600,
118
+ );
119
+ return {
120
+ capability,
121
+ appId: env.AGORA_APP_ID,
122
+ channel,
123
+ uid,
124
+ agentUid,
125
+ token: RtcTokenBuilder.buildTokenWithRtm(
126
+ env.AGORA_APP_ID,
127
+ env.AGORA_APP_CERTIFICATE,
128
+ channel,
129
+ String(uid),
130
+ RtcRole.PUBLISHER,
131
+ 1800,
132
+ 1800,
133
+ ),
134
+ spatiusAppId: env.SPATIUS_APP_ID,
135
+ avatarId: env.SPATIUS_AVATAR_ID,
136
+ region: env.SPATIUS_REGION ?? 'cn-beijing',
137
+ };
138
+ }
139
+ export async function stopAgoraSession(
140
+ env: AgoraEnvironment,
141
+ capability: string,
142
+ fetcher: typeof fetch = fetch,
143
+ ) {
144
+ const claims = await verifyCapability(
145
+ env.AGORA_APP_CERTIFICATE,
146
+ capability,
147
+ 'agora-session',
148
+ );
149
+ if (typeof claims.agent !== 'string') throw new Error('Invalid session');
150
+ await upstream(
151
+ env,
152
+ `agents/${encodeURIComponent(claims.agent)}/leave`,
153
+ {},
154
+ fetcher,
155
+ );
156
+ }
@@ -0,0 +1,66 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import worker from './index.js';
3
+
4
+ const env: CloudflareBindings = {
5
+ AGORA_APP_ID: 'a'.repeat(32),
6
+ AGORA_APP_CERTIFICATE: 'b'.repeat(32),
7
+ AGORA_PIPELINE_ID: 'test-pipeline',
8
+ SPATIUS_API_KEY: 'private-key',
9
+ SPATIUS_APP_ID: 'test-app',
10
+ SPATIUS_AVATAR_ID: 'test-avatar',
11
+ SPATIUS_REGION: 'cn-beijing',
12
+ AGORA_AVATAR_SAMPLE_RATE: '24000',
13
+ };
14
+ const request = (path: string, method = 'GET', body?: object) =>
15
+ new Request(`https://app.example${path}`, {
16
+ method,
17
+ ...(body ? { body: JSON.stringify(body) } : {}),
18
+ });
19
+ afterEach(() => vi.unstubAllGlobals());
20
+ describe('Cloudflare Agora API', () => {
21
+ it('serves health and rejects unknown routes and unsupported methods', async () => {
22
+ expect(
23
+ await (await worker.fetch(request('/api/health'), env)).json(),
24
+ ).toEqual({ ok: true });
25
+ expect((await worker.fetch(request('/api/missing'), env)).status).toBe(404);
26
+ expect((await worker.fetch(request('/api/session'), env)).status).toBe(405);
27
+ expect(
28
+ (await worker.fetch(request('/api/health', 'POST'), env)).status,
29
+ ).toBe(405);
30
+ });
31
+ it('uses Worker bindings to create and stop hosted sessions without leaking secrets', async () => {
32
+ const fetcher = vi
33
+ .fn<typeof fetch>()
34
+ .mockResolvedValueOnce(Response.json({ agent_id: 'hosted-agent' }))
35
+ .mockResolvedValueOnce(new Response(null, { status: 404 }));
36
+ vi.stubGlobal('fetch', fetcher);
37
+ const response = await worker.fetch(request('/api/session', 'POST'), env);
38
+ expect(response.status).toBe(201);
39
+ expect(response.headers.get('cache-control')).toBe('no-store');
40
+ const session = await response.json<{ capability: string }>();
41
+ expect(JSON.stringify(session)).not.toContain(env.AGORA_APP_CERTIFICATE);
42
+ expect(JSON.stringify(session)).not.toContain(env.SPATIUS_API_KEY);
43
+ expect(
44
+ (await worker.fetch(request('/api/session/stop', 'POST', session), env))
45
+ .status,
46
+ ).toBe(200);
47
+ expect(fetcher).toHaveBeenCalledTimes(2);
48
+ });
49
+ it('fails missing configuration and invalid stop authorization before contacting Agora', async () => {
50
+ const fetcher = vi.fn<typeof fetch>();
51
+ vi.stubGlobal('fetch', fetcher);
52
+ expect(
53
+ (
54
+ await worker.fetch(request('/api/session', 'POST'), {
55
+ ...env,
56
+ AGORA_APP_CERTIFICATE: '',
57
+ })
58
+ ).status,
59
+ ).toBe(503);
60
+ expect(
61
+ (await worker.fetch(request('/api/session/stop', 'POST', {}), env))
62
+ .status,
63
+ ).toBe(403);
64
+ expect(fetcher).not.toHaveBeenCalled();
65
+ });
66
+ });
@@ -0,0 +1,54 @@
1
+ import {
2
+ ConfigurationError,
3
+ createAgoraSession,
4
+ stopAgoraSession,
5
+ type AgoraEnvironment,
6
+ } from './agora.js';
7
+ export async function handleRequest(
8
+ request: Request,
9
+ env: AgoraEnvironment,
10
+ ): Promise<Response> {
11
+ const path = new URL(request.url).pathname;
12
+ const json = (value: unknown, status = 200) =>
13
+ Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
14
+ if (path === '/api/health')
15
+ return request.method === 'GET'
16
+ ? json({ ok: true })
17
+ : json({ error: 'Method not allowed' }, 405);
18
+ if (!['/api/session', '/api/session/stop'].includes(path))
19
+ return json({ error: 'Not found' }, 404);
20
+ if (request.method !== 'POST')
21
+ return json({ error: 'Method not allowed' }, 405);
22
+ try {
23
+ if (path === '/api/session')
24
+ return json(await createAgoraSession(env), 201);
25
+ const body: unknown = await request.json();
26
+ if (
27
+ !body ||
28
+ typeof body !== 'object' ||
29
+ !('capability' in body) ||
30
+ typeof body.capability !== 'string'
31
+ )
32
+ return json({ error: 'Missing session authorization' }, 403);
33
+ await stopAgoraSession(env, body.capability);
34
+ return json({ ok: true });
35
+ } catch (error) {
36
+ return json(
37
+ {
38
+ error:
39
+ error instanceof ConfigurationError
40
+ ? 'Configure the Agora pipeline and Spatius credentials before starting.'
41
+ : 'The session request failed. Please try again.',
42
+ },
43
+ error instanceof ConfigurationError
44
+ ? 503
45
+ : path.endsWith('/stop')
46
+ ? 403
47
+ : 502,
48
+ );
49
+ }
50
+ }
51
+
52
+ export default {
53
+ fetch: handleRequest,
54
+ } satisfies ExportedHandler<CloudflareBindings>;
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "./node_modules/wrangler/config-schema.json",
3
+ "name": "spatius-agora-app",
4
+ "main": "./worker/index.ts",
5
+ "compatibility_date": "2026-09-11",
6
+ "compatibility_flags": ["nodejs_compat"],
7
+ "vars": {
8
+ "AGORA_APP_ID": "your-agora-app-id",
9
+ "AGORA_PIPELINE_ID": "your-published-pipeline-id",
10
+ "SPATIUS_APP_ID": "your-spatius-app-id",
11
+ "SPATIUS_AVATAR_ID": "your-spatius-avatar-id",
12
+ "SPATIUS_REGION": "cn-beijing",
13
+ "AGORA_AVATAR_SAMPLE_RATE": "24000",
14
+ },
15
+ "secrets": {
16
+ "required": ["AGORA_APP_CERTIFICATE", "SPATIUS_API_KEY"],
17
+ },
18
+ "assets": {
19
+ "not_found_handling": "single-page-application",
20
+ "run_worker_first": ["/api/*"],
21
+ },
22
+ "observability": { "enabled": true, "head_sampling_rate": 1 },
23
+ "upload_source_maps": true,
24
+ }
@@ -0,0 +1,3 @@
1
+ # Deployment
2
+
3
+ **SPATIUS_DEPLOYMENT**
@@ -0,0 +1 @@
1
+ { "version": 2, "stack": "cloudflare-livekit" }