Skip to content

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.

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 match ATTEMPT_ID_PATTERN (/^[A-Za-z0-9_-]{1,128}$/); others get 404.
  • The action comes from the URL. Only the final path segment is read: check or submit. Anything else is 404; methods other than POST are 405.
  • Bodies. Content-Type: application/json only, at most 256 KiB (413 above), validated against the protocol schema (400 otherwise). Every response is Cache-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.

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).

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

createGradingHandler, createMemoryAttemptStore, ATTEMPT_ID_PATTERN, DEADLINE_GRACE_MS, and the types GradingHandler, GradingHandlerOptions, GradingContext, AttemptStore, AttemptRecord, NewAttempt, Submission, MemoryAttemptStore. Full signatures: API reference.