← octto
import type { ServerWebSocket } from "bun";
import type * as v from "valibot";
import { DEFAULT_ANSWER_TIMEOUT_MS } from "@/constants";
import { generateQuestionId, generateSessionId } from "@/utils";
import { openBrowser } from "./browser";
import { createServer } from "./server";
import {
type Answer,
type BaseConfig,
type EndSessionOutput,
type GetAnswerInput,
type GetAnswerOutput,
type GetNextAnswerInput,
type GetNextAnswerOutput,
type ListQuestionsOutput,
type PushQuestionOutput,
type Question,
type QuestionType,
type Session,
STATUSES,
type StartSessionInput,
type StartSessionOutput,
WS_MESSAGES,
type WsClientMessageSchema,
type WsServerMessage,
} from "./types";
import { createWaiters } from "./waiter";
export interface SessionStoreOptions {
readonly skipBrowser?: boolean;
readonly port?: number;
}
export interface SessionStore {
startSession: (input: StartSessionInput) => Promise<StartSessionOutput>;
endSession: (sessionId: string) => Promise<EndSessionOutput>;
pushQuestion: (sessionId: string, type: QuestionType, config: BaseConfig) => PushQuestionOutput;
getAnswer: (input: GetAnswerInput) => Promise<GetAnswerOutput>;
getNextAnswer: (input: GetNextAnswerInput) => Promise<GetNextAnswerOutput>;
cancelQuestion: (questionId: string) => { ok: boolean };
listQuestions: (sessionId?: string) => ListQuestionsOutput;
handleWsConnect: (sessionId: string, ws: ServerWebSocket<unknown>) => void;
handleWsDisconnect: (sessionId: string) => void;
handleWsMessage: (sessionId: string, message: v.InferOutput<typeof WsClientMessageSchema>) => void;
getSession: (sessionId: string) => Session | undefined;
cleanup: () => Promise<void>;
}
interface StoreState {
readonly sessions: Map<string, Session>;
readonly questionToSession: Map<string, string>;
readonly responseWaiters: ReturnType<typeof createWaiters<string, Answer | { cancelled: true }>>;
readonly sessionWaiters: ReturnType<typeof createWaiters<string, { questionId: string; response: Answer }>>;
readonly options: SessionStoreOptions;
}
function addQuestionsToSession(
session: Session,
questions: StartSessionInput["questions"],
questionToSession: Map<string, string>,
): string[] {
return (questions ?? []).map((q) => {
const questionId = generateQuestionId();
const question: Question = {
id: questionId,
sessionId: session.id,
type: q.type,
config: q.config,
status: STATUSES.PENDING,
createdAt: new Date(),
};
session.questions.set(questionId, question);
questionToSession.set(questionId, session.id);
return questionId;
});
}
async function startSession(
state: StoreState,
store: SessionStore,
input: StartSessionInput,
): Promise<StartSessionOutput> {
const sessionId = generateSessionId();
const { server, port } = await createServer(sessionId, store, state.options.port);
const url = `http://localhost:${port}`;
const session: Session = {
id: sessionId,
title: input.title,
port,
url,
createdAt: new Date(),
questions: new Map(),
wsConnected: false,
server,
};
state.sessions.set(sessionId, session);
const questionIds = addQuestionsToSession(session, input.questions, state.questionToSession);
if (!state.options.skipBrowser) {
await openBrowser(url).catch((error: unknown) => {
state.sessions.delete(sessionId);
for (const qId of questionIds) state.questionToSession.delete(qId);
void server.stop();
throw error;
});
}
return {
session_id: sessionId,
url,
question_ids: questionIds.length > 0 ? questionIds : undefined,
};
}
async function endSession(state: StoreState, sessionId: string): Promise<EndSessionOutput> {
const session = state.sessions.get(sessionId);
if (!session) {
return { ok: false };
}
if (session.wsClient) {
const msg: WsServerMessage = { type: WS_MESSAGES.END };
session.wsClient.send(JSON.stringify(msg));
}
if (session.server) {
// Without closeActiveConnections the promise never settles while the UI socket is attached.
await session.server.stop(true);
}
for (const questionId of session.questions.keys()) {
state.questionToSession.delete(questionId);
state.responseWaiters.clear(questionId);
}
state.sessions.delete(sessionId);
return { ok: true };
}
function pushQuestion(
state: StoreState,
sessionId: string,
type: QuestionType,
config: BaseConfig,
): PushQuestionOutput {
const session = state.sessions.get(sessionId);
if (!session) {
throw new Error(`Session not found: ${sessionId}`);
}
const questionId = generateQuestionId();
const question: Question = {
id: questionId,
sessionId,
type,
config,
status: STATUSES.PENDING,
createdAt: new Date(),
};
session.questions.set(questionId, question);
state.questionToSession.set(questionId, sessionId);
if (session.wsConnected && session.wsClient) {
const msg: WsServerMessage = {
type: WS_MESSAGES.QUESTION,
id: questionId,
questionType: type,
config,
};
session.wsClient.send(JSON.stringify(msg));
} else if (!state.options.skipBrowser) {
void openBrowser(session.url).catch(console.error);
}
return { question_id: questionId };
}
function resolveExistingAnswer(state: StoreState, questionId: string): GetAnswerOutput | undefined {
const sessionId = state.questionToSession.get(questionId);
if (!sessionId) {
return { completed: false, status: STATUSES.CANCELLED, reason: STATUSES.CANCELLED };
}
const session = state.sessions.get(sessionId);
if (!session) {
return { completed: false, status: STATUSES.CANCELLED, reason: STATUSES.CANCELLED };
}
const question = session.questions.get(questionId);
if (!question) {
return { completed: false, status: STATUSES.CANCELLED, reason: STATUSES.CANCELLED };
}
if (question.status === STATUSES.ANSWERED) {
return { completed: true, status: STATUSES.ANSWERED, response: question.response };
}
if (question.status === STATUSES.CANCELLED || question.status === STATUSES.TIMEOUT) {
return { completed: false, status: question.status, reason: question.status };
}
return undefined;
}
function waitForAnswer(state: StoreState, input: GetAnswerInput, question: Question): Promise<GetAnswerOutput> {
const timeout = input.timeout ?? DEFAULT_ANSWER_TIMEOUT_MS;
return new Promise<GetAnswerOutput>((resolve) => {
const cleanup = state.responseWaiters.register(input.question_id, (response) => {
clearTimeout(timeoutId);
if (response && typeof response === "object" && "cancelled" in response) {
resolve({ completed: false, status: STATUSES.CANCELLED, reason: STATUSES.CANCELLED });
} else {
resolve({ completed: true, status: STATUSES.ANSWERED, response });
}
});
const timeoutId = setTimeout(() => {
cleanup();
question.status = STATUSES.TIMEOUT;
resolve({ completed: false, status: STATUSES.TIMEOUT, reason: STATUSES.TIMEOUT });
}, timeout);
});
}
async function getAnswer(state: StoreState, input: GetAnswerInput): Promise<GetAnswerOutput> {
const existing = resolveExistingAnswer(state, input.question_id);
if (existing) return existing;
if (!input.block) {
return { completed: false, status: STATUSES.PENDING, reason: STATUSES.PENDING };
}
const sessionId = state.questionToSession.get(input.question_id);
const session = sessionId ? state.sessions.get(sessionId) : undefined;
const question = session?.questions.get(input.question_id);
if (!sessionId || !session || !question) {
return { completed: false, status: STATUSES.CANCELLED, reason: STATUSES.CANCELLED };
}
return waitForAnswer(state, input, question);
}
function findUnretrievedAnswer(session: Session): GetNextAnswerOutput | undefined {
for (const question of session.questions.values()) {
if (question.status === STATUSES.ANSWERED && !question.retrieved) {
question.retrieved = true;
return {
completed: true,
question_id: question.id,
question_type: question.type,
status: STATUSES.ANSWERED,
response: question.response,
};
}
}
return undefined;
}
function waitForNextAnswer(
state: StoreState,
session: Session,
input: GetNextAnswerInput,
): Promise<GetNextAnswerOutput> {
const timeout = input.timeout ?? DEFAULT_ANSWER_TIMEOUT_MS;
return new Promise<GetNextAnswerOutput>((resolve) => {
const cleanup = state.sessionWaiters.register(input.session_id, ({ questionId, response }) => {
clearTimeout(timeoutId);
const question = session.questions.get(questionId);
if (question) question.retrieved = true;
resolve({
completed: true,
question_id: questionId,
question_type: question?.type,
status: STATUSES.ANSWERED,
response,
});
});
const timeoutId = setTimeout(() => {
cleanup();
resolve({ completed: false, status: STATUSES.TIMEOUT, reason: STATUSES.TIMEOUT });
}, timeout);
});
}
async function getNextAnswer(state: StoreState, input: GetNextAnswerInput): Promise<GetNextAnswerOutput> {
const session = state.sessions.get(input.session_id);
if (!session) {
return { completed: false, status: STATUSES.NONE_PENDING, reason: STATUSES.NONE_PENDING };
}
const unretrieved = findUnretrievedAnswer(session);
if (unretrieved) return unretrieved;
const hasPending = Array.from(session.questions.values()).some((q) => q.status === STATUSES.PENDING);
if (!hasPending) {
return { completed: false, status: STATUSES.NONE_PENDING, reason: STATUSES.NONE_PENDING };
}
if (!input.block) {
return { completed: false, status: STATUSES.PENDING };
}
return waitForNextAnswer(state, session, input);
}
function cancelQuestion(state: StoreState, questionId: string): { ok: boolean } {
const sessionId = state.questionToSession.get(questionId);
if (!sessionId) return { ok: false };
const session = state.sessions.get(sessionId);
if (!session) return { ok: false };
const question = session.questions.get(questionId);
if (!question || question.status !== STATUSES.PENDING) return { ok: false };
question.status = STATUSES.CANCELLED;
if (session.wsClient) {
const msg: WsServerMessage = { type: WS_MESSAGES.CANCEL, id: questionId };
session.wsClient.send(JSON.stringify(msg));
}
state.responseWaiters.notifyAll(questionId, { cancelled: true });
return { ok: true };
}
function listQuestions(state: StoreState, sessionId?: string): ListQuestionsOutput {
const questions: ListQuestionsOutput["questions"] = [];
const sessionsToCheck = sessionId
? [state.sessions.get(sessionId)].filter(Boolean)
: Array.from(state.sessions.values());
for (const session of sessionsToCheck) {
if (!session) continue;
for (const question of session.questions.values()) {
questions.push({
id: question.id,
type: question.type,
status: question.status,
createdAt: question.createdAt.toISOString(),
answeredAt: question.answeredAt?.toISOString(),
});
}
}
questions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return { questions };
}
function handleWsConnect(state: StoreState, sessionId: string, ws: ServerWebSocket<unknown>): void {
const session = state.sessions.get(sessionId);
if (!session) return;
session.wsConnected = true;
session.wsClient = ws;
for (const question of session.questions.values()) {
if (question.status === STATUSES.PENDING) {
const msg: WsServerMessage = {
type: WS_MESSAGES.QUESTION,
id: question.id,
questionType: question.type,
config: question.config,
};
ws.send(JSON.stringify(msg));
}
}
}
function handleWsDisconnect(state: StoreState, sessionId: string): void {
const session = state.sessions.get(sessionId);
if (!session) return;
session.wsConnected = false;
session.wsClient = undefined;
}
function handleWsMessage(
state: StoreState,
sessionId: string,
message: v.InferOutput<typeof WsClientMessageSchema>,
): void {
if (message.type === WS_MESSAGES.CONNECTED) return;
if (message.type !== WS_MESSAGES.RESPONSE) return;
const session = state.sessions.get(sessionId);
if (!session) return;
const question = session.questions.get(message.id);
if (!question || question.status !== STATUSES.PENDING) return;
question.status = STATUSES.ANSWERED;
question.answeredAt = new Date();
question.response = message.answer;
state.responseWaiters.notifyAll(message.id, message.answer);
state.sessionWaiters.notifyFirst(sessionId, {
questionId: message.id,
response: message.answer,
});
}
export function createSessionStore(options: SessionStoreOptions = {}): SessionStore {
const state: StoreState = {
sessions: new Map(),
questionToSession: new Map(),
responseWaiters: createWaiters(),
sessionWaiters: createWaiters(),
options,
};
const store: SessionStore = {
startSession: (input) => startSession(state, store, input),
endSession: (sessionId) => endSession(state, sessionId),
pushQuestion: (sessionId, type, config) => pushQuestion(state, sessionId, type, config),
getAnswer: (input) => getAnswer(state, input),
getNextAnswer: (input) => getNextAnswer(state, input),
cancelQuestion: (questionId) => cancelQuestion(state, questionId),
listQuestions: (sessionId) => listQuestions(state, sessionId),
handleWsConnect: (sessionId, ws) => handleWsConnect(state, sessionId, ws),
handleWsDisconnect: (sessionId) => handleWsDisconnect(state, sessionId),
handleWsMessage: (sessionId, message) => handleWsMessage(state, sessionId, message),
getSession: (sessionId) => state.sessions.get(sessionId),
cleanup: async () => {
for (const sessionId of state.sessions.keys()) {
await store.endSession(sessionId);
}
},
};
return store;
}