Files
makelore/electron/api/routes/runtime-data.ts

392 lines
14 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from 'node:http';
import type { HostApiContext } from '../context';
import { PREVIEW_DATA_MAX_REQUEST_BYTES, PREVIEW_DATA_MAX_RESPONSE_BYTES, PREVIEW_DATA_ROUTE_ROOT, type PreviewDataSessionManager } from '../../services/preview-data-session';
import { sendNoContent } from '../route-utils';
import type {
DataServiceErrorContext,
DataServiceHostResult,
} from '../../../shared/data-service';
const MAX_CURSOR_LENGTH = 1_024;
const COLLECTION_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/;
const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/;
const CORS_METHODS = 'GET, PUT, DELETE, OPTIONS';
const CORS_HEADERS = 'Authorization, Content-Type, If-Match';
class PreviewDataRouteError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly retryable = false,
) {
super(message);
this.name = 'PreviewDataRouteError';
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function boundedString(value: unknown, maximum: number): string | null {
if (typeof value !== 'string') return null;
return value && value.length <= maximum ? value : null;
}
function routeFailure<T>(
status: number,
code: string,
message: string,
retryable = false,
extra: { retry_after_seconds?: number; context?: DataServiceErrorContext } = {},
): DataServiceHostResult<T> {
return {
success: false,
status,
code,
error: message,
retryable,
...(extra.retry_after_seconds === undefined ? {} : { retry_after_seconds: extra.retry_after_seconds }),
...(extra.context === undefined ? {} : { context: extra.context }),
data: null,
};
}
function unavailable<T>(): DataServiceHostResult<T> {
return routeFailure(503, 'runtime_unavailable', 'Preview data runtime is unavailable', true);
}
function invalidResponse<T>(): DataServiceHostResult<T> {
return routeFailure(502, 'upstream_invalid_response', 'Data Service returned an invalid response');
}
function isPreviewDataPath(pathname: string): boolean {
return pathname === PREVIEW_DATA_ROUTE_ROOT || pathname.startsWith(`${PREVIEW_DATA_ROUTE_ROOT}/`);
}
function managerFor(ctx: HostApiContext): PreviewDataSessionManager | undefined {
return ctx.previewDataSession ?? ctx.codingProducts?.previewDataSession;
}
function applyCors(res: ServerResponse, manager: PreviewDataSessionManager | undefined, req: IncomingMessage): void {
const origin = manager?.corsOrigin(req);
if (origin) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}
res.setHeader('Access-Control-Allow-Methods', CORS_METHODS);
res.setHeader('Access-Control-Allow-Headers', CORS_HEADERS);
}
function serializeBounded(value: unknown): string | null {
let encoded: string;
try {
encoded = JSON.stringify(value);
} catch {
return null;
}
if (typeof encoded !== 'string') return null;
return Buffer.byteLength(encoded, 'utf8') <= PREVIEW_DATA_MAX_RESPONSE_BYTES
? encoded
: null;
}
function sendBoundedJson(res: ServerResponse, status: number, payload: unknown): boolean {
const encoded = serializeBounded(payload);
if (encoded === null) return false;
res.statusCode = status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(encoded);
return true;
}
function sendError(res: ServerResponse, result: DataServiceHostResult<unknown>): void {
const detail = {
code: result.code ?? 'runtime_unavailable',
message: result.error ?? 'Preview data runtime is unavailable',
retryable: result.retryable,
...(result.context && Object.keys(result.context).length > 0 ? { context: result.context } : {}),
};
if (result.retry_after_seconds !== undefined) {
res.setHeader('Retry-After', String(result.retry_after_seconds));
}
if (!sendBoundedJson(res, result.status >= 400 && result.status <= 599 ? result.status : 503, { detail })) {
sendBoundedJson(res, 503, {
detail: {
code: 'runtime_unavailable',
message: 'Preview data runtime is unavailable',
retryable: true,
},
});
}
}
function sendResult<T>(res: ServerResponse, result: DataServiceHostResult<T>): void {
res.setHeader('Cache-Control', 'private, no-store');
if (!result.success) {
sendError(res, result as DataServiceHostResult<unknown>);
return;
}
if (result.status === 204) {
sendNoContent(res);
return;
}
if (result.data === null) {
sendError(res, invalidResponse());
return;
}
if (isRecord(result.data) && Number.isSafeInteger(result.data.revision) && result.data.revision > 0) {
res.setHeader('ETag', `"${result.data.revision}"`);
}
if (!sendBoundedJson(res, result.status, result.data)) {
sendError(res, invalidResponse());
}
}
async function readBoundedJson(req: IncomingMessage): Promise<Record<string, unknown>> {
const declared = req.headers['content-length'];
const declaredValue = Array.isArray(declared) ? declared[0] : declared;
if (declaredValue && /^\d+$/.test(declaredValue) && Number(declaredValue) > PREVIEW_DATA_MAX_REQUEST_BYTES) {
throw new PreviewDataRouteError(413, 'request_too_large', 'Preview data request is too large');
}
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.byteLength;
if (size > PREVIEW_DATA_MAX_REQUEST_BYTES) {
throw new PreviewDataRouteError(413, 'request_too_large', 'Preview data request is too large');
}
chunks.push(buffer);
}
try {
const value = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
if (!isRecord(value)) throw new Error('object required');
return value;
} catch {
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
}
}
function requireExactKeys(value: Record<string, unknown>, keys: readonly string[]): void {
const expected = new Set(keys);
if (Object.keys(value).some((key) => !expected.has(key))
|| keys.some((key) => !Object.prototype.hasOwnProperty.call(value, key))) {
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
}
}
function decodeSegment(value: string, maximum: number, pattern: RegExp): string {
let decoded: string;
try {
decoded = decodeURIComponent(value);
} catch {
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
}
if (decoded.length > maximum || !pattern.test(decoded)) {
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
}
return decoded;
}
function parseCollection(value: string): string {
return decodeSegment(value, 48, COLLECTION_PATTERN);
}
function parseDocumentId(value: string): string {
const documentId = decodeSegment(value, 128, DOCUMENT_ID_PATTERN);
if (documentId === '.' || documentId === '..') {
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
}
return documentId;
}
function requireQueryKeys(url: URL, allowed: readonly string[]): void {
const accepted = new Set(allowed);
if ([...url.searchParams.keys()].some((key) => !accepted.has(key))) {
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
}
}
function parseLimit(url: URL): number | undefined {
const values = url.searchParams.getAll('limit');
if (values.length === 0) return undefined;
if (values.length !== 1 || !/^[1-9]\d*$/.test(values[0])) {
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
}
const limit = Number(values[0]);
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
}
return limit;
}
function parseCursor(url: URL): string | undefined {
const values = url.searchParams.getAll('cursor');
if (values.length === 0) return undefined;
const cursor = values.length === 1 ? boundedString(values[0], MAX_CURSOR_LENGTH) : null;
if (!cursor) throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
return cursor;
}
function parseIfMatch(req: IncomingMessage): number | undefined {
const value = req.headers['if-match'];
if (value === undefined) return undefined;
if (Array.isArray(value) || !/^"[1-9]\d*"$/.test(value)) {
throw new PreviewDataRouteError(422, 'invalid_revision', 'Data Service document revision is invalid');
}
const revision = Number(value.slice(1, -1));
if (!Number.isSafeInteger(revision) || revision < 1) {
throw new PreviewDataRouteError(422, 'invalid_revision', 'Data Service document revision is invalid');
}
return revision;
}
function parseData(value: unknown): Record<string, unknown> {
if (!isRecord(value)) {
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
}
return value;
}
function requireJsonContentType(req: IncomingMessage): void {
const value = req.headers['content-type'];
if (typeof value !== 'string' || value.split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
throw new PreviewDataRouteError(415, 'invalid_content_type', 'Preview data requests require application/json');
}
}
function sendOptions(res: ServerResponse): void {
res.statusCode = 204;
res.end();
}
function operationsFor(ctx: HostApiContext) {
return ctx.codingProducts?.dataService;
}
export async function handlePreviewDataRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (!isPreviewDataPath(url.pathname)) return false;
const manager = managerFor(ctx);
applyCors(res, manager, req);
res.setHeader('Cache-Control', 'private, no-store');
if (!manager) {
sendError(res, unavailable());
return true;
}
const authorization = manager.authorizeRequest(req);
if (!authorization.ok) {
sendError(res, routeFailure(
authorization.status,
authorization.code,
authorization.message,
authorization.retryable,
authorization.retryAfterSeconds === undefined
? {}
: { retry_after_seconds: authorization.retryAfterSeconds },
));
return true;
}
const operations = operationsFor(ctx);
if (!operations && authorization.method !== 'OPTIONS') {
sendError(res, unavailable());
return true;
}
const runOperation = async <T>(operation: () => Promise<DataServiceHostResult<T>>): Promise<void> => {
if (!manager.isCurrentSession(authorization.session)) {
sendError(res, unavailable());
return;
}
const result = await operation();
if (!manager.isCurrentSession(authorization.session)) {
sendError(res, unavailable());
return;
}
sendResult(res, result);
};
try {
const method = authorization.method;
const collectionPath = url.pathname.match(
new RegExp(`^${PREVIEW_DATA_ROUTE_ROOT}/collections/([^/]+)/documents$`),
);
if (collectionPath) {
const collection = parseCollection(collectionPath[1]);
requireQueryKeys(url, ['limit', 'cursor']);
if (method === 'OPTIONS') {
sendOptions(res);
} else if (method === 'GET') {
await runOperation(() => operations!.listDocuments({
collection,
limit: parseLimit(url),
cursor: parseCursor(url),
}, authorization.session.projectPath));
} else {
sendError(res, routeFailure(405, 'method_not_allowed', 'Preview data method is not allowed'));
}
return true;
}
const documentPath = url.pathname.match(
new RegExp(`^${PREVIEW_DATA_ROUTE_ROOT}/collections/([^/]+)/documents/([^/]+)$`),
);
if (documentPath) {
const collection = parseCollection(documentPath[1]);
const documentId = parseDocumentId(documentPath[2]);
requireQueryKeys(url, []);
if (method === 'OPTIONS') {
sendOptions(res);
} else if (method === 'GET') {
await runOperation(() => operations!.getDocument(
{ collection, document_id: documentId },
authorization.session.projectPath,
));
} else if (method === 'PUT') {
requireJsonContentType(req);
const body = await readBoundedJson(req);
requireExactKeys(body, ['data']);
const ifRevision = parseIfMatch(req);
await runOperation(() => operations!.putDocument({
collection,
document_id: documentId,
data: parseData(body.data),
...(ifRevision === undefined ? {} : { if_revision: ifRevision }),
}, authorization.session.projectPath));
} else if (method === 'DELETE') {
const ifRevision = parseIfMatch(req);
await runOperation(() => operations!.deleteDocument({
collection,
document_id: documentId,
...(ifRevision === undefined ? {} : { if_revision: ifRevision }),
confirmed: true,
}, authorization.session.projectPath));
} else {
sendError(res, routeFailure(405, 'method_not_allowed', 'Preview data method is not allowed'));
}
return true;
}
sendError(res, routeFailure(404, 'route_not_found', 'Preview data route was not found'));
return true;
} catch (error) {
if (error instanceof PreviewDataRouteError) {
sendError(res, routeFailure(error.status, error.code, error.message, error.retryable));
return true;
}
sendError(res, unavailable());
return true;
}
}
export const isPreviewDataRoute = isPreviewDataPath;