332 lines
11 KiB
TypeScript
332 lines
11 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';
|
|
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<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());
|
|
}
|
|
}
|
|
|
|
function parserFailure(status: number, code: string, message: string): never {
|
|
throw new PreviewDataRouteError(status, code, message);
|
|
}
|
|
|
|
async function readBoundedJson(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
return readBoundedJsonBody(req, PREVIEW_DATA_MAX_REQUEST_BYTES, parserFailure, PARSER_MESSAGES);
|
|
}
|
|
|
|
function requireExactKeys(value: Record<string, unknown>, 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<string, unknown> {
|
|
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<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;
|