56 lines
2.5 KiB
TypeScript
56 lines
2.5 KiB
TypeScript
import {
|
|
parseFrontendActionRequest,
|
|
readActionResults,
|
|
storeActionResult,
|
|
type FrontendActionRequest,
|
|
type FrontendActionResult
|
|
} from ".";
|
|
|
|
type ExecuteAction = (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
|
|
type SubmitResult = (sessionId: string, actionId: string, result: FrontendActionResult) => Promise<void>;
|
|
const ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]+$/;
|
|
|
|
export class FrontendActionExecutor {
|
|
private readonly controllers = new Map<string, AbortController>();
|
|
|
|
constructor(private readonly execute: ExecuteAction, private readonly submit: SubmitResult) {}
|
|
|
|
async handle(value: unknown, activeSessionId: string | null): Promise<void> {
|
|
const request = parseFrontendActionRequest(value);
|
|
if (!request || request.sessionId !== activeSessionId) return;
|
|
const saved = readActionResults(request.sessionId)[request.actionId];
|
|
if (saved) {
|
|
await this.submit(request.sessionId, request.actionId, saved);
|
|
return;
|
|
}
|
|
if (this.controllers.has(request.actionId)) return;
|
|
const result = await this.executeOnce(request);
|
|
storeActionResult(request.sessionId, result);
|
|
await this.submit(request.sessionId, request.actionId, result);
|
|
}
|
|
|
|
cancelAll(): void {
|
|
for (const controller of this.controllers.values()) controller.abort();
|
|
this.controllers.clear();
|
|
}
|
|
|
|
private async executeOnce(request: FrontendActionRequest): Promise<FrontendActionResult> {
|
|
if (Date.now() >= request.expiresAt) return this.failure(request, "expired", "ACTION_EXPIRED", "frontend action expired before execution");
|
|
const controller = new AbortController();
|
|
this.controllers.set(request.actionId, controller);
|
|
try {
|
|
return { version: "frontend-action-result@1", actionId: request.actionId, status: "succeeded", output: await this.execute(request, controller.signal), completedAt: Date.now() };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "frontend action failed";
|
|
const code = controller.signal.aborted ? "ACTION_CANCELLED" : ERROR_CODE_PATTERN.test(message) ? message : "ACTION_FAILED";
|
|
return this.failure(request, controller.signal.aborted ? "cancelled" : "failed", code, message);
|
|
} finally {
|
|
this.controllers.delete(request.actionId);
|
|
}
|
|
}
|
|
|
|
private failure(request: FrontendActionRequest, status: "failed" | "cancelled" | "expired", code: string, message: string): FrontendActionResult {
|
|
return { version: "frontend-action-result@1", actionId: request.actionId, status, error: { code, message }, completedAt: Date.now() };
|
|
}
|
|
}
|