Server
@astrolabe/server implements the grading protocol on the standard Fetch API, so it runs
unchanged on Node 22+, Deno, Bun, Cloudflare Workers and Hono. Its rules mirror the engine's localChecker: a player
behaves the same against a server as with local grading. Platform snippets:
HTML + backend.
createGradingHandler
Section titled “createGradingHandler”import { createGradingHandler, createMemoryAttemptStore } from '@astrolabe/server';
const grading = createGradingHandler({ engine, attempts: createMemoryAttemptStore() });const response = await grading(request, { attemptId: '123' });| Option | Type | Meaning |
|---|---|---|
engine |
Engine |
grades with engine.grade and engine.solution |
attempts |
AttemptStore |
where attempts live (see below) |
now |
() => number |
clock in milliseconds, for deadlines and submittedAt; default Date.now |
onError |
(error, request) => void |
called with every internal failure, for logging; the response stays a generic 500 |
The handler is a GradingHandler: (request: Request, context: { attemptId: string }) => Promise<Response>.
- The attempt comes from the host. The handler takes the attempt id only from
context, never from the URL, the query or the body. Ids must matchATTEMPT_ID_PATTERN(/^[A-Za-z0-9_-]{1,128}$/); others get404. - The action comes from the URL. Only the final path segment is read:
checkorsubmit. Anything else is404; methods other thanPOSTare405. - Bodies.
Content-Type: application/jsononly, at most 256 KiB (413above), validated against the protocol schema (400otherwise). Every response isCache-Control: no-store.
| Request | Result |
|---|---|
check under a test or review policy |
403 check_not_allowed |
check after maxChecks |
403 checks_exhausted |
check after the deadline |
409 attempt_closed |
check with an unknown page |
404 not_found |
check |
200 CheckResponse: the result and checksLeft |
submit |
200 SubmitResponse: what the policy reveals (nothing, score, answers) and passed against passMark |
submit up to 30 s after the deadline (DEADLINE_GRACE_MS) |
still accepted: the player hands in when its countdown ends |
a second submit, or one later than that |
409 attempt_closed |
| an unknown attempt | 404 not_found |
| a store that fails or returns an invalid record, a grading error | 500 internal_error, onError called |
Error bodies are application/problem+json (RFC 9457) with a code; see
Errors.
AttemptStore
Section titled “AttemptStore”The handler needs three operations, so any database can back it.
interface AttemptStore { /** null for an unknown attempt. */ get(id: string): Promise<AttemptRecord | null>; /** Counts one check; resolves to the record after counting, or null for an unknown attempt. */ recordCheck(id: string): Promise<AttemptRecord | null>; /** Closes the attempt with its submission; resolves false when it was already submitted (or is unknown). */ recordSubmit(id: string, submission: Submission): Promise<boolean>;}
interface AttemptRecord { readonly id: string; readonly key: AnswerKey; // never leaves the server readonly policy: AttemptPolicy; readonly checksUsed: number; readonly submitted: boolean; readonly createdAt: string; // ISO 8601}
interface Submission { readonly answers: Answers; // within their kind's length limit: open answers (2000 code points) included readonly response: SubmitResponse; // what the student got; re-grade the answers to release more later}A submission keeps the open answers (slots of kind open, listed in the exercise's Metadata.open) for the
teacher; an answer longer than its kind allows (500 code points, 2000 for open slots) counts as unanswered and is
not stored (the player does not count it as sent either). Open answers are the student's own text: escape them
where a teacher reads them, and guard a CSV export against formulas
(Security).
Make both writes atomic per attempt (for example UPDATE ... RETURNING, or an update conditional on
submitted = false), so parallel requests can neither exceed maxChecks nor submit twice. The handler validates
every record it reads (isAnswerKey, isAttemptPolicy, the counters, the id).
createMemoryAttemptStore
Section titled “createMemoryAttemptStore”An AttemptStore in a Map, for examples, tests and single-process servers. Attempts are lost on restart.
| Member | Meaning |
|---|---|
create({ key, policy }) |
starts an attempt with a random id; throws a TypeError for an invalid key or policy |
submission(id) |
the stored Submission of an attempt, or null |
get, recordCheck, recordSubmit |
the AttemptStore methods |
Exports
Section titled “Exports”createGradingHandler, createMemoryAttemptStore, ATTEMPT_ID_PATTERN, DEADLINE_GRACE_MS, and the types
GradingHandler, GradingHandlerOptions, GradingContext, AttemptStore, AttemptRecord, NewAttempt,
Submission, MemoryAttemptStore. Full signatures: API reference.