211 lines
6.7 KiB
TypeScript
211 lines
6.7 KiB
TypeScript
import type { components, paths } from "./api.gen.js";
|
|
|
|
export type Schemas = components["schemas"];
|
|
export type User = Schemas["User"];
|
|
export type Project = Schemas["Project"];
|
|
export type Task = Schemas["Task"];
|
|
export type AgentSession = Schemas["AgentSession"];
|
|
export type InboxItem = Schemas["InboxItem"];
|
|
export type InboxResponse = Schemas["InboxResponse"];
|
|
export type InboxResponseCreate = Schemas["InboxResponseCreate"];
|
|
export type Attachment = Schemas["Attachment"];
|
|
export type ByreEvent = Schemas["Event"];
|
|
export type Status = Schemas["Status"];
|
|
export type NotificationPrefs = Schemas["NotificationPrefs"];
|
|
export type TurnRef = Schemas["TurnRef"];
|
|
export type AgentProcess = Schemas["AgentProcess"];
|
|
export type Zombie = Schemas["Zombie"];
|
|
export type ProtocolErrors = Schemas["ProtocolErrors"];
|
|
export type Snapshot = Schemas["Snapshot"];
|
|
export type Participant = Schemas["Participant"];
|
|
export type Focus = Schemas["Focus"];
|
|
export type TaskTab = Schemas["TaskTab"];
|
|
export type LeadNote = Schemas["LeadNote"];
|
|
export type WorkflowSummary = Schemas["WorkflowSummary"];
|
|
|
|
type Method = "get" | "post" | "put" | "delete";
|
|
|
|
type PathsWith<M extends Method> = {
|
|
[P in keyof paths]: paths[P][M] extends { responses: unknown } ? P : never;
|
|
}[keyof paths];
|
|
|
|
type Op<P extends keyof paths, M extends Method> = NonNullable<paths[P][M]>;
|
|
type Params<O> = O extends { parameters: infer X } ? X : never;
|
|
type PathParams<O> = Params<O> extends { path: infer X } ? X : never;
|
|
type QueryParams<O> = Params<O> extends { query?: infer X } ? X : never;
|
|
type Body<O> = O extends { requestBody: { content: { "application/json": infer B } } }
|
|
? B
|
|
: O extends { requestBody: { content: Record<`image/${string}`, unknown> } }
|
|
? Blob
|
|
: never;
|
|
type Success<O> = O extends { responses: infer R } ? R[Extract<keyof R, 200 | 201 | 202>] : never;
|
|
type Result<O> = Success<O> extends { content: { "application/json": infer J } } ? J : never;
|
|
|
|
type Options<O> = {
|
|
path?: PathParams<O>;
|
|
query?: QueryParams<O>;
|
|
body?: Body<O>;
|
|
keepalive?: boolean;
|
|
};
|
|
|
|
export class ApiError extends Error {
|
|
constructor(readonly status: number, message: string) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
function target(path: string, opts: { path?: unknown; query?: unknown }): string {
|
|
const pathParams = (opts.path ?? {}) as Record<string, unknown>;
|
|
let url: string = path.replace(/\{(\w+)\}/g, (_, key: string) => {
|
|
const value = pathParams[key];
|
|
if (value === undefined) {
|
|
throw new Error(`missing path parameter ${key} for ${path}`);
|
|
}
|
|
return encodeURIComponent(String(value));
|
|
});
|
|
|
|
const query = new URLSearchParams();
|
|
for (const [key, value] of Object.entries((opts.query ?? {}) as Record<string, unknown>)) {
|
|
if (value !== undefined) {
|
|
query.set(key, String(value));
|
|
}
|
|
}
|
|
if (query.size > 0) {
|
|
url += `?${query}`;
|
|
}
|
|
return url;
|
|
}
|
|
|
|
export async function request<M extends Method, P extends PathsWith<M>>(
|
|
method: M,
|
|
path: P,
|
|
opts: Options<Op<P, M>> = {},
|
|
): Promise<Result<Op<P, M>>> {
|
|
const url = target(path, opts);
|
|
const init: RequestInit = { method: method.toUpperCase(), keepalive: opts.keepalive ?? false };
|
|
if (opts.body instanceof Blob) {
|
|
init.headers = { "Content-Type": opts.body.type || "application/octet-stream" };
|
|
init.body = opts.body;
|
|
} else if (opts.body !== undefined) {
|
|
init.headers = { "Content-Type": "application/json" };
|
|
init.body = JSON.stringify(opts.body);
|
|
}
|
|
|
|
const res = await fetch(url, init);
|
|
if (res.status === 204) {
|
|
return undefined as Result<Op<P, M>>;
|
|
}
|
|
if (!res.headers.get("Content-Type")?.startsWith("application/json")) {
|
|
const text = (await res.text()).trim();
|
|
throw new ApiError(res.status, `${res.status} ${res.statusText}${text ? `: ${text}` : ""}`);
|
|
}
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
throw new ApiError(res.status, (data as Schemas["Error"]).message);
|
|
}
|
|
return data as Result<Op<P, M>>;
|
|
}
|
|
|
|
export const get = <P extends PathsWith<"get">>(path: P, opts?: Options<Op<P, "get">>) => request("get", path, opts);
|
|
export const post = <P extends PathsWith<"post">>(path: P, opts?: Options<Op<P, "post">>) => request("post", path, opts);
|
|
export const put = <P extends PathsWith<"put">>(path: P, opts?: Options<Op<P, "put">>) => request("put", path, opts);
|
|
export const del = <P extends PathsWith<"delete">>(path: P, opts?: Options<Op<P, "delete">>) => request("delete", path, opts);
|
|
|
|
export function socket<P extends PathsWith<"get">>(path: P, opts: Options<Op<P, "get">>): WebSocket {
|
|
const ws = new WebSocket(`${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}${target(path, opts)}`);
|
|
ws.binaryType = "arraybuffer";
|
|
return ws;
|
|
}
|
|
|
|
const staleAfterMs = 40_000;
|
|
const retryAfterMs = 2_000;
|
|
|
|
export function subscribe(
|
|
after: number,
|
|
filter: { task_id?: string; lead?: boolean },
|
|
onEvent: (ev: ByreEvent) => void,
|
|
onState: (connected: boolean) => void,
|
|
onReady: () => void,
|
|
): () => void {
|
|
let last = after;
|
|
let heard = Date.now();
|
|
let source: EventSource | undefined;
|
|
let retry = 0;
|
|
|
|
const connect = () => {
|
|
clearTimeout(retry);
|
|
source?.close();
|
|
heard = Date.now();
|
|
const query = new URLSearchParams({ after: String(last) });
|
|
if (filter.task_id) {
|
|
query.set("task_id", filter.task_id);
|
|
}
|
|
if (filter.lead) {
|
|
query.set("lead", "true");
|
|
}
|
|
const s = new EventSource(`/api/events?${query}`);
|
|
source = s;
|
|
s.onopen = () => {
|
|
heard = Date.now();
|
|
onState(true);
|
|
};
|
|
s.onerror = () => {
|
|
onState(false);
|
|
if (s.readyState === EventSource.CLOSED) {
|
|
retry = setTimeout(connect, retryAfterMs);
|
|
}
|
|
};
|
|
s.addEventListener("ping", () => {
|
|
heard = Date.now();
|
|
});
|
|
s.addEventListener("ready", () => {
|
|
heard = Date.now();
|
|
onReady();
|
|
});
|
|
s.onmessage = (msg: MessageEvent<string>) => {
|
|
heard = Date.now();
|
|
const ev = JSON.parse(msg.data) as ByreEvent;
|
|
if (ev.seq > last) {
|
|
last = ev.seq;
|
|
}
|
|
onEvent(ev);
|
|
};
|
|
};
|
|
|
|
const check = () => {
|
|
if (Date.now() - heard < staleAfterMs) {
|
|
return;
|
|
}
|
|
onState(false);
|
|
connect();
|
|
};
|
|
|
|
const onVisible = () => {
|
|
if (document.visibilityState === "visible") {
|
|
check();
|
|
}
|
|
};
|
|
|
|
let interval = 0;
|
|
const start = () => {
|
|
interval = setInterval(check, 5_000);
|
|
addEventListener("online", connect);
|
|
document.addEventListener("visibilitychange", onVisible);
|
|
connect();
|
|
};
|
|
|
|
if (document.readyState === "complete") {
|
|
start();
|
|
} else {
|
|
addEventListener("load", start, { once: true });
|
|
}
|
|
return () => {
|
|
clearInterval(interval);
|
|
clearTimeout(retry);
|
|
removeEventListener("load", start);
|
|
removeEventListener("online", connect);
|
|
document.removeEventListener("visibilitychange", onVisible);
|
|
source?.close();
|
|
};
|
|
}
|