Files
openmaic/OpenMAIC/packages/@openmaic/storage/test/http-conformance-server.ts
2026-08-16 14:58:47 +08:00

483 lines
15 KiB
TypeScript

import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { IDBFactory } from 'fake-indexeddb';
import {
isChatMessageSkeleton,
isQuizAttemptSkeleton,
needsRuntimeMigration,
RUNTIME_DSL_VERSION,
runtimeDslVersionOf,
validateRuntimeRecord,
validateRuntimeSession,
} from '@openmaic/dsl';
import type {
RuntimeRecordInit,
RuntimeSession,
RuntimeSessionStatus,
ValidationResult,
} from '@openmaic/dsl';
import { BrowserRuntimeStore } from '../src/runtime/browser.js';
import { assertJsonValue } from '../src/runtime/json-value.js';
import type {
RuntimeAppendOptions,
RuntimeSessionInit,
RuntimeStore,
RuntimeTailOptions,
} from '../src/runtime/types.js';
import { RuntimeAppendConflictError } from '../src/runtime/types.js';
export interface HttpConformanceServer {
baseUrl: string;
fetch: typeof globalThis.fetch;
close(): Promise<void>;
}
export interface HttpConformanceServerOptions {
/** Bind a loopback TCP port. Tests can disable this in network-restricted sandboxes. */
listen?: boolean;
}
interface ErrorBody {
error: {
code: string;
message: string;
details?: unknown;
};
}
class ConformanceHttpError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
}
function sendNoContent(res: ServerResponse): void {
res.writeHead(204);
res.end();
}
async function readJson<T>(req: IncomingMessage): Promise<T> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
if (chunks.length === 0) {
throw new ConformanceHttpError(400, 'VALIDATION_FAILED', 'request body must be a JSON object');
}
let body: unknown;
try {
body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new ConformanceHttpError(400, 'VALIDATION_FAILED', message);
}
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
throw new ConformanceHttpError(400, 'VALIDATION_FAILED', 'request body must be a JSON object');
}
return body as T;
}
function errorResponse(error: unknown): { status: number; body: ErrorBody } {
const message = error instanceof Error ? error.message : String(error);
if (error instanceof RuntimeAppendConflictError) {
return {
status: 409,
body: {
error: {
code: 'RUNTIME_APPEND_CONFLICT',
message,
details: {
sessionId: error.sessionId,
expectedLastSeq: error.expectedLastSeq,
actualLastSeq: error.actualLastSeq,
},
},
},
};
}
if (error instanceof SyntaxError) {
return { status: 400, body: { error: { code: 'VALIDATION_FAILED', message } } };
}
if (error instanceof ConformanceHttpError) {
return { status: error.status, body: { error: { code: error.code, message } } };
}
return { status: 500, body: { error: { code: 'INTERNAL_ERROR', message } } };
}
function validationError(result: ValidationResult, label: string): void {
if (result.valid) return;
const detail = result.errors.map((error) => `${error.path || '/'}: ${error.message}`).join('; ');
throw new ConformanceHttpError(400, 'VALIDATION_FAILED', `${label}: ${detail}`);
}
function assertAddressableSegment(value: string): void {
if (value === '.' || value === '..') {
throw new ConformanceHttpError(
400,
'VALIDATION_FAILED',
`@openmaic/storage: URL path segment must not be ${JSON.stringify(value)}`,
);
}
}
function assertJsonRequestValue(value: unknown, label: string): void {
try {
assertJsonValue(value, label);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new ConformanceHttpError(400, 'VALIDATION_FAILED', message);
}
}
function missingSessionError(sessionId: string): ConformanceHttpError {
return new ConformanceHttpError(
404,
'SESSION_NOT_FOUND',
`@openmaic/storage: no session ${JSON.stringify(sessionId)}`,
);
}
function assertNotFutureSession(session: RuntimeSession): void {
const version = runtimeDslVersionOf(session);
if (!needsRuntimeMigration(session) && version !== RUNTIME_DSL_VERSION) {
throw new ConformanceHttpError(
409,
'FUTURE_VERSION',
`@openmaic/storage: session ${JSON.stringify(session.id)} was written at runtime DSL ` +
`version ${JSON.stringify(version)}, newer than this client's ${RUNTIME_DSL_VERSION}`,
);
}
}
function validatePayloadForKind(session: RuntimeSession, payload: unknown): void {
if (session.kind === 'chat' && !isChatMessageSkeleton(payload)) {
throw new ConformanceHttpError(
400,
'VALIDATION_FAILED',
'@openmaic/storage: invalid runtime record: /payload: chat payload must match ' +
'ChatMessageSkeleton (role + content)',
);
}
if (session.kind === 'quizAttempt' && !isQuizAttemptSkeleton(payload)) {
throw new ConformanceHttpError(
400,
'VALIDATION_FAILED',
'@openmaic/storage: invalid runtime record: /payload: quizAttempt payload must match ' +
'QuizAttemptSkeleton (phase + answers)',
);
}
}
async function requireSession(store: RuntimeStore, sessionId: string): Promise<RuntimeSession> {
const session = await store.getSession(sessionId);
if (session === undefined) throw missingSessionError(sessionId);
return session;
}
function pathParts(req: IncomingMessage): { parts: string[]; url: URL } {
const url = new URL(req.url ?? '/', 'http://conformance.invalid');
const parts = url.pathname.split('/');
if (parts[0] === '') parts.shift();
return {
parts: parts.map((part) => decodeURIComponent(part)),
url,
};
}
async function route(
req: IncomingMessage,
res: ServerResponse,
store: RuntimeStore,
): Promise<void> {
const { parts, url } = pathParts(req);
const method = req.method ?? 'GET';
if (parts[0] !== 'runtime') {
sendJson(res, 404, { error: { code: 'ROUTE_NOT_FOUND', message: 'route not found' } });
return;
}
if (method === 'POST' && parts.length === 2 && parts[1] === 'sessions') {
const init = await readJson<RuntimeSessionInit & { runtimeDslVersion?: unknown }>(req);
assertAddressableSegment(init.id);
assertAddressableSegment(init.stageId);
assertAddressableSegment(init.learnerKey);
validationError(
validateRuntimeSession({ ...init, runtimeDslVersion: RUNTIME_DSL_VERSION }),
`@openmaic/storage: invalid runtime session ${JSON.stringify(init.id)}`,
);
if (await store.getSession(init.id)) {
throw new ConformanceHttpError(
409,
'SESSION_ALREADY_EXISTS',
`@openmaic/storage: runtime session ${JSON.stringify(init.id)} already exists`,
);
}
let created: RuntimeSession;
try {
created = await store.createSession(init);
} catch (error) {
if (await store.getSession(init.id)) {
throw new ConformanceHttpError(
409,
'SESSION_ALREADY_EXISTS',
`@openmaic/storage: runtime session ${JSON.stringify(init.id)} already exists`,
);
}
throw error;
}
sendJson(res, 201, created);
return;
}
if (parts[1] === 'sessions' && parts.length >= 3) {
const sessionId = parts[2]!;
if (method === 'GET' && parts.length === 3) {
const session = await store.getSession(sessionId);
if (session === undefined) {
sendJson(res, 404, {
error: {
code: 'SESSION_NOT_FOUND',
message: `@openmaic/storage: no session ${JSON.stringify(sessionId)}`,
},
});
} else {
sendJson(res, 200, session);
}
return;
}
if (method === 'PATCH' && parts.length === 4 && parts[3] === 'status') {
const body = await readJson<
{ status: RuntimeSessionStatus; updatedAt: string } & RuntimeTailOptions
>(req);
const session = await requireSession(store, sessionId);
assertNotFutureSession(session);
validationError(
validateRuntimeSession({ ...session, status: body.status, updatedAt: body.updatedAt }),
`@openmaic/storage: invalid runtime session ${JSON.stringify(sessionId)}`,
);
await store.setSessionStatus(sessionId, body.status, body.updatedAt, {
...(body.expectedLastSeq === undefined ? {} : { expectedLastSeq: body.expectedLastSeq }),
});
sendNoContent(res);
return;
}
if (method === 'DELETE' && parts.length === 3) {
await store.deleteSession(sessionId);
sendNoContent(res);
return;
}
if (method === 'POST' && parts.length === 4 && parts[3] === 'records') {
const body = await readJson<RuntimeRecordInit & RuntimeAppendOptions & { seq?: unknown }>(
req,
);
const { expectedLastSeq, sessionTransition, ...init } = body;
if (init.sessionId !== sessionId) {
throw new ConformanceHttpError(
400,
'VALIDATION_FAILED',
'invalid runtime record: body sessionId does not match the request path',
);
}
validationError(
validateRuntimeRecord({ ...init, seq: 0 }),
`@openmaic/storage: invalid runtime record ${JSON.stringify(init.id)}`,
);
const session = await requireSession(store, sessionId);
assertNotFutureSession(session);
validatePayloadForKind(session, init.payload);
if (session.status !== 'active') {
throw new ConformanceHttpError(
400,
'VALIDATION_FAILED',
`@openmaic/storage: cannot append to session ${JSON.stringify(sessionId)} with ` +
`status '${session.status}' — records may only be appended to an active session`,
);
}
sendJson(
res,
201,
await store.appendRecord(init, {
...(expectedLastSeq === undefined ? {} : { expectedLastSeq }),
...(sessionTransition === undefined ? {} : { sessionTransition }),
}),
);
return;
}
if (method === 'GET' && parts.length === 4 && parts[3] === 'records') {
const sceneId = url.searchParams.get('sceneId');
sendJson(
res,
200,
await store.listRecords(sessionId, sceneId === null ? undefined : { sceneId }),
);
return;
}
}
if (
parts[1] === 'stages' &&
parts.length === 6 &&
parts[3] === 'learners' &&
parts[5] === 'sessions' &&
method === 'GET'
) {
sendJson(res, 200, await store.listSessions(parts[2]!, parts[4]!));
return;
}
if (parts[1] === 'learners' && parts[2] === 'merge' && parts.length === 3 && method === 'POST') {
const body = await readJson<{ fromLearnerKey?: unknown; toLearnerKey?: unknown }>(req);
if (
typeof body.fromLearnerKey !== 'string' ||
body.fromLearnerKey === '' ||
typeof body.toLearnerKey !== 'string' ||
body.toLearnerKey === ''
) {
throw new ConformanceHttpError(
400,
'VALIDATION_FAILED',
'@openmaic/storage: learner keys must be non-empty strings',
);
}
assertAddressableSegment(body.toLearnerKey);
assertJsonRequestValue(body.fromLearnerKey, 'runtime learner merge fromLearnerKey');
assertJsonRequestValue(body.toLearnerKey, 'runtime learner merge toLearnerKey');
assertAddressableSegment(body.toLearnerKey);
sendJson(res, 200, {
moved: await store.mergeLearner(body.fromLearnerKey, body.toLearnerKey),
});
return;
}
if (
parts[1] === 'stages' &&
parts.length === 5 &&
parts[3] === 'learners' &&
method === 'DELETE'
) {
await store.deleteLearnerRuntime(parts[2]!, parts[4]!);
sendNoContent(res);
return;
}
if (parts[1] === 'stages' && parts.length === 3 && method === 'DELETE') {
await store.deleteStageRuntime(parts[2]!);
sendNoContent(res);
return;
}
if (parts.length === 1 && method === 'DELETE') {
await store.deleteAllRuntime();
sendNoContent(res);
return;
}
sendJson(res, 404, { error: { code: 'ROUTE_NOT_FOUND', message: 'route not found' } });
}
/**
* Start a test-only HTTP adapter. Each `x-runtime-store-id` value selects a
* fresh BrowserRuntimeStore so factories used by the shared contract remain
* isolated without duplicating any persistence logic in this server.
*/
export async function startHttpConformanceServer(
options: HttpConformanceServerOptions = {},
): Promise<HttpConformanceServer> {
const stores = new Map<string, RuntimeStore>();
const storeFor = (req: IncomingMessage): RuntimeStore => {
const id = req.headers['x-runtime-store-id'];
const namespace = typeof id === 'string' && id !== '' ? id : 'default';
let store = stores.get(namespace);
if (!store) {
store = new BrowserRuntimeStore({
indexedDB: new IDBFactory(),
dbName: `http-runtime-${namespace}`,
});
stores.set(namespace, store);
}
return store;
};
const server = createServer((req, res) => {
void route(req, res, storeFor(req)).catch((error: unknown) => {
const mapped = errorResponse(error);
sendJson(res, mapped.status, mapped.body);
});
});
let baseUrl = 'http://runtime-conformance.invalid';
if (options.listen !== false) {
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
if (address === null || typeof address === 'string') {
throw new Error('HTTP conformance server did not bind a TCP port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
}
const injectedFetch: typeof globalThis.fetch = async (input, init) => {
const request = new Request(input, init);
const url = new URL(request.url);
const requestBody = await request.text();
const fakeRequest = {
method: request.method,
url: `${url.pathname}${url.search}`,
headers: Object.fromEntries(request.headers.entries()),
async *[Symbol.asyncIterator]() {
if (requestBody !== '') yield Buffer.from(requestBody);
},
} as unknown as IncomingMessage;
let status = 200;
let responseHeaders: Record<string, string> = {};
let responseBody: string | undefined;
const fakeResponse = {
writeHead(nextStatus: number, headers?: Record<string, string>) {
status = nextStatus;
responseHeaders = headers ?? {};
return this;
},
end(chunk?: string) {
responseBody = chunk;
return this;
},
} as unknown as ServerResponse;
try {
await route(fakeRequest, fakeResponse, storeFor(fakeRequest));
} catch (error) {
const mapped = errorResponse(error);
status = mapped.status;
responseHeaders = { 'content-type': 'application/json' };
responseBody = JSON.stringify(mapped.body);
}
return new Response(status === 204 ? null : responseBody, {
status,
headers: responseHeaders,
});
};
return {
baseUrl,
fetch: injectedFetch,
close: () =>
server.listening
? new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
})
: Promise.resolve(),
};
}