122 lines
3.7 KiB
TypeScript
122 lines
3.7 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>();
|
|
private queue: Promise<void> = Promise.resolve();
|
|
|
|
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 controller = new AbortController();
|
|
this.controllers.set(request.actionId, controller);
|
|
const pending = this.queue.then(async () => {
|
|
const result = await this.executeOnce(request, controller);
|
|
storeActionResult(request.sessionId, result);
|
|
await this.submit(request.sessionId, request.actionId, result);
|
|
});
|
|
this.queue = pending.catch(() => undefined);
|
|
return pending;
|
|
}
|
|
|
|
cancelAll(): void {
|
|
for (const controller of this.controllers.values()) controller.abort();
|
|
this.controllers.clear();
|
|
this.queue = Promise.resolve();
|
|
}
|
|
|
|
private async executeOnce(
|
|
request: FrontendActionRequest,
|
|
controller: AbortController
|
|
): Promise<FrontendActionResult> {
|
|
const remaining = request.expiresAt - Date.now();
|
|
const timer =
|
|
remaining > 0
|
|
? setTimeout(
|
|
() => controller.abort(new DOMException("Action expired", "TimeoutError")),
|
|
remaining
|
|
)
|
|
: undefined;
|
|
try {
|
|
controller.signal.throwIfAborted();
|
|
if (remaining <= 0)
|
|
return this.failure(
|
|
request,
|
|
"expired",
|
|
"ACTION_EXPIRED",
|
|
"frontend action expired before execution"
|
|
);
|
|
const output = await this.execute(request, controller.signal);
|
|
controller.signal.throwIfAborted();
|
|
return {
|
|
version: "frontend-action-result@2",
|
|
actionId: request.actionId,
|
|
status: "succeeded",
|
|
output,
|
|
completedAt: Date.now()
|
|
};
|
|
} catch (error) {
|
|
const expired =
|
|
controller.signal.reason instanceof DOMException &&
|
|
controller.signal.reason.name === "TimeoutError";
|
|
const message = error instanceof Error ? error.message : "frontend action failed";
|
|
const code = expired
|
|
? "ACTION_EXPIRED"
|
|
: controller.signal.aborted
|
|
? "ACTION_CANCELLED"
|
|
: ERROR_CODE_PATTERN.test(message)
|
|
? message
|
|
: "ACTION_FAILED";
|
|
return this.failure(
|
|
request,
|
|
expired ? "expired" : controller.signal.aborted ? "cancelled" : "failed",
|
|
code,
|
|
message
|
|
);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
if (this.controllers.get(request.actionId) === controller)
|
|
this.controllers.delete(request.actionId);
|
|
}
|
|
}
|
|
|
|
private failure(
|
|
request: FrontendActionRequest,
|
|
status: "failed" | "cancelled" | "expired",
|
|
code: string,
|
|
message: string
|
|
): FrontendActionResult {
|
|
return {
|
|
version: "frontend-action-result@2",
|
|
actionId: request.actionId,
|
|
status,
|
|
error: { code, message },
|
|
completedAt: Date.now()
|
|
};
|
|
}
|
|
}
|