// Provider logins run here, on the server; their prompts and events cross to the browser as a live state. import type { AuthEvent, AuthPrompt, AuthType, Models } from "@earendil-works/pi-ai"; import { BerryError } from "./berry.ts"; import { type Feed, published } from "./pubsub.ts"; type Prompt = Omit & { id: number }; export type LoginState = { provider: string; type: AuthType; status: "running" | "done" | "failed" | "cancelled"; events: AuthEvent[]; /** What the flow is waiting for the user to answer, if anything. */ prompt: Prompt | null; error: string | null; }; const SETTLED_LINGER_MS = 10 * 60_000; export function createLogins(models: Models) { const logins = new Map>; answers: Map>; controller: AbortController; }>(); let nextPrompt = 0; return { start(provider: string, type: AuthType): string { const methods = models.getProvider(provider)?.auth; if (!(type === "oauth" ? methods?.oauth : methods?.apiKey?.login)) { throw new BerryError(`${provider} has no ${type === "oauth" ? "oauth" : "api key"} login`); } const id = crypto.randomUUID(); const state = published({ provider, type, status: "running", events: [], prompt: null, error: null, }); const update = (change: Partial) => state.set({ ...state.value, ...change }); const answers = new Map>(); const controller = new AbortController(); logins.set(id, { state, answers, controller }); void models.login(provider, type, { signal: controller.signal, notify: (event) => update({ events: [...state.value.events, event] }), prompt: ({ signal, ...prompt }) => { const answer = Promise.withResolvers(); const promptId = nextPrompt++; answers.set(promptId, answer); update({ prompt: { ...prompt, id: promptId } as Prompt }); // the flow may withdraw its question, as when its callback server answers it first const withdraw = () => answer.reject(signal?.reason ?? new Error("withdrawn")); signal?.addEventListener("abort", withdraw, { once: true }); return answer.promise.finally(() => { signal?.removeEventListener("abort", withdraw); answers.delete(promptId); if (state.value.prompt?.id === promptId) update({ prompt: null }); }); }, }) .then(() => update({ status: "done", prompt: null })) .catch((error) => update({ status: controller.signal.aborted ? "cancelled" : "failed", prompt: null, error: controller.signal.aborted ? null : error instanceof Error ? error.message : String(error), }) ) .finally(() => Deno.unrefTimer(setTimeout(() => logins.delete(id), SETTLED_LINGER_MS))); return id; }, answer(id: string, prompt: number, value: string) { const answer = logins.get(id)?.answers.get(prompt); if (answer === undefined) throw new BerryError("that question is no longer asked"); answer.resolve(value); }, cancel(id: string) { logins.get(id)?.controller.abort(); }, feed(id: string): Feed | undefined { return logins.get(id)?.state.feed(); }, }; } export type Logins = ReturnType;