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'; import { isRecord, parseCollection as parseCollectionSegment, parseCursor as parseCursorValue, parseData as parseDataValue, parseDocumentId as parseDocumentIdValue, parseIfMatch as parseIfMatchValue, parseLimit as parseLimitValue, readBoundedJson as readBoundedJsonBody, requireExactKeys as requireExactKeysValue, requireQueryKeys as requireQueryKeysValue, type ParserMessages, } from './data-service-parsers'; const CORS_METHODS = 'GET, PUT, DELETE, OPTIONS'; const CORS_HEADERS = 'Authorization, Content-Type, If-Match'; const PARSER_MESSAGES: ParserMessages = { invalidRequest: 'Preview data request is invalid', invalidRevision: 'Data Service document revision is invalid', requestTooLarge: 'Preview data request is too large', }; class PreviewDataRouteError extends Error { constructor( readonly status: number, readonly code: string, message: string, readonly retryable = false, ) { super(message); this.name = 'PreviewDataRouteError'; } } function routeFailure( status: number, code: string, message: string, retryable = false, extra: { retry_after_seconds?: number; context?: DataServiceErrorContext } = {}, ): DataServiceHostResult { 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(): DataServiceHostResult { return routeFailure(503, 'runtime_unavailable', 'Preview data runtime is unavailable', true); } function invalidResponse(): DataServiceHostResult { 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): 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(res: ServerResponse, result: DataServiceHostResult): void { res.setHeader('Cache-Control', 'private, no-store'); if (!result.success) { sendError(res, result as DataServiceHostResult); 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()); } } function parserFailure(status: number, code: string, message: string): never { throw new PreviewDataRouteError(status, code, message); } async function readBoundedJson(req: IncomingMessage): Promise> { return readBoundedJsonBody(req, PREVIEW_DATA_MAX_REQUEST_BYTES, parserFailure, PARSER_MESSAGES); } function requireExactKeys(value: Record, keys: readonly string[]): void { requireExactKeysValue(value, keys, parserFailure, PARSER_MESSAGES.invalidRequest); } function parseCollection(value: string): string { return parseCollectionSegment(value, parserFailure, PARSER_MESSAGES.invalidRequest); } function parseDocumentId(value: string): string { return parseDocumentIdValue(value, parserFailure, PARSER_MESSAGES.invalidRequest); } function requireQueryKeys(url: URL, allowed: readonly string[]): void { requireQueryKeysValue(url, allowed, parserFailure, PARSER_MESSAGES.invalidRequest); } function parseLimit(url: URL): number | undefined { return parseLimitValue(url, parserFailure, PARSER_MESSAGES.invalidRequest); } function parseCursor(url: URL): string | undefined { return parseCursorValue(url, parserFailure, PARSER_MESSAGES.invalidRequest); } function parseIfMatch(req: IncomingMessage): number | undefined { return parseIfMatchValue(req, parserFailure, PARSER_MESSAGES.invalidRevision); } function parseData(value: unknown): Record { return parseDataValue(value, parserFailure, PARSER_MESSAGES.invalidRequest); } 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 { 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 (operation: () => Promise>): Promise => { 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;