Skip to content
File

Blob: worker/src/client/radio/api.ts

typescript93 lines
1import type { z } from 'zod';
2import { channelsSchema } from '@/shared/contracts/channels.ts';
3import {
4 audioResponseSchema,
5 controllerSchema,
6 joinedSchema,
7 okSchema,
8 statusSchema,
9} from '@/shared/contracts/signaling.ts';
10import type { Answer, ViewerCredentials } from '@/shared/contracts/signaling.ts';
11 
12export class ApiError extends Error {
13 readonly status: number;
14 constructor(status: number, message: string) {
15 super(message);
16 this.status = status;
17 }
18}
19 
20/** Each operation chooses its response schema; callers cannot assert a return type. */
21export function createRadioApi(fetcher: typeof fetch = (...args) => fetch(...args)) {
22 async function request<T>(
23 path: string,
24 schema: z.ZodType<T>,
25 input?: unknown,
26 member?: ViewerCredentials,
27 ): Promise<T> {
28 const response = await fetcher(`/api${path}`, {
29 method: input === undefined ? 'GET' : 'POST',
30 credentials: 'same-origin',
31 headers: {
32 'Content-Type': 'application/json',
33 ...(member ? { 'X-Viewer-Token': member.viewerToken } : {}),
34 },
35 ...(input === undefined ? {} : { body: JSON.stringify(input) }),
36 signal: AbortSignal.timeout(25000),
37 });
38 let value: unknown;
39 try {
40 value = await response.json();
41 } catch {
42 throw new ApiError(response.status, 'Unreadable radio response. Try again.');
43 }
44 if (!response.ok) {
45 const message =
46 value && typeof value === 'object' && 'error' in value && typeof value.error === 'string'
47 ? value.error
48 : 'Radio request failed. Try again.';
49 throw new ApiError(response.status, message);
50 }
51 const parsed = schema.safeParse(value);
52 if (!parsed.success) throw new ApiError(502, 'Unexpected radio response. Try again.');
53 return parsed.data;
54 }
55 
56 return {
57 getStatus: () => request('/status', statusSchema),
58 login: (password: string) => request('/login', okSchema, { password }),
59 joinViewer: () => request('/viewers', joinedSchema, {}),
60 answerViewer: (member: ViewerCredentials, answer: Answer) =>
61 request(
62 `/viewers/${member.id}/answer`,
63 channelsSchema,
64 { sessionDescription: answer },
65 member,
66 ),
67 subscribeAudio: (member: ViewerCredentials) =>
68 request(`/viewers/${member.id}/audio`, audioResponseSchema, {}, member),
69 renegotiateViewer: (member: ViewerCredentials, answer: Answer) =>
70 request(
71 `/viewers/${member.id}/renegotiate`,
72 okSchema,
73 { sessionDescription: answer },
74 member,
75 ),
76 heartbeatViewer: (member: ViewerCredentials) =>
77 request(`/viewers/${member.id}/heartbeat`, controllerSchema, {}, member),
78 claimControl: (member: ViewerCredentials) =>
79 request(`/viewers/${member.id}/claim`, controllerSchema, {}, member),
80 releaseControl: (member: ViewerCredentials) =>
81 request(`/viewers/${member.id}/release`, controllerSchema, {}, member),
82 leaveViewer: (member: ViewerCredentials) =>
83 request(`/viewers/${member.id}/leave`, okSchema, {}, member),
84 };
85}
86 
87export type RadioApi = ReturnType<typeof createRadioApi>;
88export const radioApi = createRadioApi();
89 
90export function errorText(error: unknown): string {
91 return error instanceof Error ? error.message : 'Connection failed. Try again.';
92}