Files
byre/web/src/api.ts
T

211 lines
6.7 KiB
TypeScript
Raw Normal View History

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"];
2026-10-14 21:06:30 -07:00
export type TurnRef = Schemas["TurnRef"];
export type AgentProcess = Schemas["AgentProcess"];
export type Zombie = Schemas["Zombie"];
export type ProtocolErrors = Schemas["ProtocolErrors"];
2026-10-15 00:08:32 -07:00
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"];
2026-10-16 11:00:09 -07:00
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;
2026-10-14 20:07:26 -07:00
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 };
2026-10-14 20:07:26 -07:00
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();
}
};
2026-10-16 13:16:58 -07:00
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);
2026-10-16 13:16:58 -07:00
removeEventListener("load", start);
removeEventListener("online", connect);
document.removeEventListener("visibilitychange", onVisible);
source?.close();
};
}