import { jsonToLex, type Lexicons, type LexXrpcBody, type LexXrpcProcedure, type LexXrpcQuery, type LexXrpcSubscription, } from "@atp/lexicon"; import { Procedure, type Query, type Subscription } from "@atp/lex"; import { InternalServerError, InvalidRequestError, ResponseType, XRPCError, } from "./errors.ts"; import { handlerSuccess } from "./types.ts"; import type { Awaitable, HandlerSuccess, Input, LexMethodInput, LexMethodOutput, LexMethodParams, Params, RouteOptions, } from "./types.ts"; import type { Context, HonoRequest } from "hono"; import { createDecoders, MaxSizeChecker } from "@atp/common"; function assert(condition: unknown, message?: string): asserts condition { if (!condition) { throw new Error(message || "Assertion failed"); } } /** * Decodes query parameters from HTTP request into typed parameters. * Handles type conversion for strings, numbers, booleans, and arrays based on lexicon definitions. * @param def - The lexicon definition containing parameter schema * @param params - Raw query parameters from the HTTP request * @returns Decoded and type-converted parameters */ export function decodeQueryParams( def: LexXrpcProcedure | LexXrpcQuery | LexXrpcSubscription, params: Record, ): Params { const decoded: Params = {}; if (!def.parameters?.properties) { return decoded; } for (const k in def.parameters.properties) { const property = def.parameters.properties[k]; const val = params[k]; if (property && val !== undefined) { if (property.type === "array") { const vals = (Array.isArray(val) ? val : [val]).filter( (v) => v !== undefined, ); decoded[k] = vals .map((v) => decodeQueryParam(property.items?.type || "string", v)) .filter((v) => v !== undefined) as (string | number | boolean)[]; } else { const actualVal = Array.isArray(val) ? val[0] : val; decoded[k] = decodeQueryParam(property.type, actualVal); } } } return decoded; } /** * Decodes a single query parameter value based on its expected type. * Converts string values to appropriate JavaScript types (string, number, boolean). * @param type - The expected parameter type from the lexicon * @param value - The raw parameter value from the query string * @returns The decoded parameter value or undefined if conversion fails */ export function decodeQueryParam( type: string, value: unknown, ): string | number | boolean | undefined { if (!value) { return undefined; } if (type === "string" || type === "datetime") { return String(value); } if (type === "float") { return Number(String(value)); } else if (type === "integer") { return parseInt(String(value), 10) || 0; } else if (type === "boolean") { return value === "true"; } } /** * Extracts query parameters from a URL and returns them as arrays of strings. * @param url - The URL to parse (defaults to empty string) * @returns Object mapping parameter names to arrays of values */ export function getQueryParams(url = ""): Record { const { searchParams } = new URL(url ?? "", "http://x"); const result: Record = {}; for (const key of searchParams.keys()) { result[key] = searchParams.getAll(key); } return result; } function getSearchParams(url = ""): URLSearchParams { return new URL(url ?? "", "http://x").searchParams; } /** * Represents a request-like object with essential HTTP request properties. * Used for handling both standard HTTP requests and custom request implementations. */ export type RequestLike = { headers: Headers | { [key: string]: string | string[] | undefined }; body?: ReadableStream | unknown; method?: string; url?: string; signal?: AbortSignal; }; /** * Validates the output of an XRPC method against its lexicon definition. * Performs response body validation, content-type checks, and schema validation. * @param nsid - The namespace identifier of the method * @param def - The lexicon definition for the method * @param output - The handler output to validate * @param lexicons - The lexicon registry for schema validation * @throws {InternalServerError} If validation fails */ export function validateOutput( nsid: string, def: LexXrpcProcedure | LexXrpcQuery, output: HandlerSuccess | void, lexicons: Lexicons, ): void { if (def.output) { // An output is expected if (output === undefined) { throw new InternalServerError( `A response body is expected but none was provided`, ); } // Fool-proofing (should not be necessary due to type system) const result = handlerSuccess.safeParse(output); if (!result.success) { throw new InternalServerError(`Invalid handler output`, undefined, { cause: result.error, }); } // output mime const { encoding } = output; if (!encoding || !isValidEncoding(def.output, encoding)) { throw new InternalServerError(`Invalid response encoding: ${encoding}`); } // output schema if (def.output.schema) { try { output.body = lexicons.assertValidXrpcOutput(nsid, output.body); } catch (e) { throw new InternalServerError( e instanceof Error ? e.message : String(e), ); } } } else { // Expects no output if (output !== undefined) { throw new InternalServerError( `A response body was provided when none was expected`, ); } } } export function createLexiconParamsVerifier( nsid: string, def: LexXrpcQuery | LexXrpcProcedure | LexXrpcSubscription, lexicons: Lexicons, ): (req: Request) => Params { return (req) => { const queryParams = getQueryParams(req.url); const params = decodeQueryParams(def, queryParams); try { return lexicons.assertValidXrpcParams(nsid, params) as Params; } catch (e) { throw new InvalidRequestError(String(e)); } }; } export function createSchemaParamsVerifier< M extends Procedure | Query | Subscription, >( method: M, ): (req: Request) => LexMethodParams { return (req) => { try { return method.parameters.fromURLSearchParams( getSearchParams(req.url), ) as LexMethodParams; } catch (e) { throw new InvalidRequestError( e instanceof Error ? e.message : String(e), ); } }; } const ENCODING_ANY = "*/*"; function parseDefEncoding({ encoding }: LexXrpcBody) { return encoding.split(",").map(trimString); } function trimString(str: string): string { return str.trim(); } export function parseReqEncoding(req: Request): string { const contentType = req.headers.get("content-type"); if (!contentType) { throw new InvalidRequestError( `Request encoding (Content-Type) required but not provided`, ); } const encoding = normalizeMime(contentType); if (encoding) return encoding; throw new InvalidRequestError( `Request encoding (Content-Type) required but not provided`, ); } /** * Normalizes a MIME type by extracting the base type and converting to lowercase. * Removes parameters (e.g., charset) from the MIME type. * @param mime - The MIME type string to normalize * @returns The normalized MIME type (base type only) */ export function normalizeMime(mime: string): string { const [base] = mime.split(";"); return base.trim().toLowerCase(); } function isValidEncoding(output: LexXrpcBody, encoding: string) { const normalized = normalizeMime(encoding); if (!normalized) return false; const allowed = parseDefEncoding(output); return allowed.includes(ENCODING_ANY) || allowed.includes(normalized); } type BodyPresence = "missing" | "empty" | "present"; function getBodyPresence(req: Request): BodyPresence { if (req.headers.get("transfer-encoding") != null) return "present"; if (req.headers.get("content-length") === "0") return "empty"; if (req.headers.get("content-length") != null) return "present"; return "missing"; } function createBodyParser( inputEncoding: string, options: RouteOptions, ): ((req: Request, encoding: string) => Promise) | undefined { if ( inputEncoding === ENCODING_ANY || ( inputEncoding !== "application/json" && inputEncoding !== "json" && !inputEncoding.startsWith("text/") && inputEncoding !== "application/x-www-form-urlencoded" ) ) { return; } const { jsonLimit, textLimit } = options; return async (req: Request, encoding: string): Promise => { const contentLength = req.headers.get("content-length"); const bodySize = contentLength ? parseInt(contentLength, 10) : 0; if (encoding === "application/json" || encoding === "json") { if (jsonLimit && bodySize > jsonLimit) { throw new InvalidRequestError( `Request body too large: ${bodySize} bytes exceeds JSON limit of ${jsonLimit} bytes`, ); } const text = await req.text(); return JSON.parse(text); } else if ( encoding.startsWith("text/") || encoding === "application/x-www-form-urlencoded" ) { if (textLimit && bodySize > textLimit) { throw new InvalidRequestError( `Request body too large: ${bodySize} bytes exceeds text limit of ${textLimit} bytes`, ); } return await req.text(); } else { return; } }; } async function parseBodyForSchemaValidation( req: Request, encoding: string, options: RouteOptions, ): Promise { const contentLength = req.headers.get("content-length"); const bodySize = contentLength ? parseInt(contentLength, 10) : 0; if (encoding === "application/json" || encoding === "json") { if (options.jsonLimit && bodySize > options.jsonLimit) { throw new InvalidRequestError( `Request body too large: ${bodySize} bytes exceeds JSON limit of ${options.jsonLimit} bytes`, ); } return JSON.parse(await req.text()); } if ( encoding.startsWith("text/") || encoding === "application/x-www-form-urlencoded" ) { if (options.textLimit && bodySize > options.textLimit) { throw new InvalidRequestError( `Request body too large: ${bodySize} bytes exceeds text limit of ${options.textLimit} bytes`, ); } return await req.text(); } const body = decodeBodyStream(req, options.blobLimit); if (body === null) { return new Uint8Array(0); } return new Uint8Array(await new Response(body).arrayBuffer()); } function toLexBody(value: unknown): unknown { if (value === undefined || value instanceof Uint8Array) { return value; } return jsonToLex(value); } function decodeBodyStream( req: Request, maxSize: number | undefined, ): ReadableStream | null { const contentEncoding = req.headers.get("content-encoding"); const contentLength = req.headers.get("content-length"); if (!req.body) { return null; } const contentLengthParsed = contentLength ? parseInt(contentLength, 10) : undefined; if (Number.isNaN(contentLengthParsed)) { throw new XRPCError(ResponseType.InvalidRequest, "invalid content-length"); } if ( maxSize !== undefined && contentLengthParsed !== undefined && contentLengthParsed > maxSize ) { throw new XRPCError( ResponseType.PayloadTooLarge, "request entity too large", ); } let stream: ReadableStream = req.body; if (contentEncoding) { if (!contentLength) { throw new XRPCError( ResponseType.UnsupportedMediaType, "unsupported content-encoding", ); } let transforms: TransformStream[]; try { transforms = createDecoders(contentEncoding); } catch (cause) { throw new XRPCError( ResponseType.UnsupportedMediaType, "unsupported content-encoding", undefined, { cause }, ); } for (const transform of transforms) { stream = stream.pipeThrough(transform); } } if (maxSize !== undefined) { stream = stream.pipeThrough( new MaxSizeChecker( maxSize, () => new XRPCError( ResponseType.PayloadTooLarge, "request entity too large", ), ), ); } return stream; } /** * Formats server timing data into an HTTP Server-Timing header value. * Creates a header string with timing metrics for performance monitoring. * @param timings - Array of timing measurements * @returns Formatted Server-Timing header value */ export function serverTimingHeader(timings: ServerTiming[]): string { return timings .map((timing) => { let header = timing.name; if (timing.duration) header += `;dur=${timing.duration}`; if (timing.description) header += `;desc="${timing.description}"`; return header; }) .join(", "); } /** * Utility class for measuring server-side operation timings. * Provides start/stop functionality and implements the ServerTiming interface. */ export class ServerTimer implements ServerTiming { public duration?: number; private startMs?: number; /** * Creates a new ServerTimer instance. * @param name Identifier for the timing measurement * @param description Optional description of what is being timed */ constructor( public name: string, public description?: string, ) {} /** * Starts the timer by recording the current timestamp. * @returns This timer instance for method chaining */ start(): ServerTimer { this.startMs = Date.now(); return this; } /** * Stops the timer and calculates the duration. * @returns This timer instance for method chaining * @throws {Error} If the timer hasn't been started */ stop(): ServerTimer { assert(this.startMs, "timer hasn't been started"); this.duration = Date.now() - this.startMs; return this; } } /** * Represents timing information for server-side operations. * Used for performance monitoring and debugging. */ export interface ServerTiming { name: string; duration?: number; description?: string; } /** * Represents a minimal HTTP request with essential properties. * Used when full request information is not needed. */ export interface MinimalRequest { url?: string; method?: string; headers: Headers | { [key: string]: string | string[] | undefined }; } /** * Validates and extracts the NSID from a request object. * Convenience wrapper for parseUrlNsid that works with request objects. * @param req - The request object containing a URL * @returns The extracted NSID from the request URL * @throws {InvalidRequestError} If the URL doesn't contain a valid XRPC path */ export const parseReqNsid = ( req: MinimalRequest | HonoRequest, ): string => parseUrlNsid(req.url || "/"); /** * Validates and extracts the NSID (Namespace Identifier) from an XRPC URL. * Performs strict validation of the /xrpc/ path format and NSID syntax. * @param url - The URL or path to parse * @returns The extracted NSID * @throws {InvalidRequestError} If the URL doesn't contain a valid XRPC path or NSID */ export const parseUrlNsid = (url: string): string => { // Extract path from full URL if needed let path = url; try { const urlObj = new URL(url); path = urlObj.pathname; } catch { // If URL parsing fails, assume it's already a path } if ( // Ordered by likelihood of failure path.length <= 6 || path[5] !== "/" || path[4] !== "c" || path[3] !== "p" || path[2] !== "r" || path[1] !== "x" || path[0] !== "/" ) { throw new InvalidRequestError("invalid xrpc path"); } const startOfNsid = 6; let curr = startOfNsid; let char: number; let alphaNumRequired = true; for (; curr < path.length; curr++) { char = path.charCodeAt(curr); if ( (char >= 48 && char <= 57) || // 0-9 (char >= 65 && char <= 90) || // A-Z (char >= 97 && char <= 122) // a-z ) { alphaNumRequired = false; } else if (char === 45 /* "-" */ || char === 46 /* "." */) { if (alphaNumRequired) { throw new InvalidRequestError("invalid xrpc path"); } alphaNumRequired = true; } else if (char === 47 /* "/" */) { // Allow trailing slash (next char is either EOS or "?") if (curr === path.length - 1 || path.charCodeAt(curr + 1) === 63) { break; } throw new InvalidRequestError("invalid xrpc path"); } else if (char === 63 /* "?"" */) { break; } else { throw new InvalidRequestError("invalid xrpc path"); } } // last char was one of: '-', '.', '/' if (alphaNumRequired) { throw new InvalidRequestError("invalid xrpc path"); } // A domain name consists of minimum two characters if (curr - startOfNsid < 2) { throw new InvalidRequestError("invalid xrpc path"); } // @TODO is there a max ? return path.slice(startOfNsid, curr); }; /** * Alias for parseUrlNsid for backward compatibility. * @deprecated Use parseUrlNsid instead */ export const extractUrlNsid = parseUrlNsid; /** * Creates an input verifier function for XRPC methods. * Returns a function that validates and processes request input based on lexicon definitions. * @param lexicons - The lexicon registry for validation * @param nsid - The namespace identifier of the method * @param def - The lexicon definition for the method * @returns A function that verifies request input */ export function createLexiconInputVerifier( nsid: string, def: LexXrpcProcedure | LexXrpcQuery, options: RouteOptions, lexicons: Lexicons, ): (req: Request) => Awaitable { if (def.type === "query" || !def.input) { return (req) => { // @NOTE We allow (and ignore) "empty" bodies if (getBodyPresence(req) === "present") { throw new InvalidRequestError( `A request body was provided when none was expected`, ); } return undefined; }; } // Lexicon definition expects a request body const { input } = def; const { blobLimit } = options; const allowedEncodings = parseDefEncoding(input); const checkEncoding = allowedEncodings.includes(ENCODING_ANY) ? undefined // No need to check : (encoding: string) => allowedEncodings.includes(encoding); const bodyParser = createBodyParser(input.encoding, options); return async (req) => { if (getBodyPresence(req) === "missing") { throw new InvalidRequestError( `A request body is expected but none was provided`, ); } const reqEncoding = parseReqEncoding(req); if (checkEncoding && !checkEncoding(reqEncoding)) { throw new InvalidRequestError( `Wrong request encoding (Content-Type): ${reqEncoding}`, ); } let parsedBody: unknown = undefined; // Parse body with size limits if (bodyParser) { try { parsedBody = await bodyParser(req, reqEncoding); } catch (e) { throw new InvalidRequestError( e instanceof Error ? e.message : String(e), ); } } // Validate against schema if defined if (input.schema) { try { if (parsedBody === undefined) { parsedBody = await parseBodyForSchemaValidation( req, reqEncoding, options, ); } const lexBody = toLexBody(parsedBody); parsedBody = lexicons.assertValidXrpcInput(nsid, lexBody); } catch (e) { throw new InvalidRequestError( e instanceof Error ? e.message : String(e), ); } } // if we parsed the body for schema validation, use that // otherwise, we pass along a decoded readable stream const body = parsedBody !== undefined ? parsedBody : decodeBodyStream(req, blobLimit); return { encoding: reqEncoding, body }; }; } export function createSchemaInputVerifier( method: M, options: RouteOptions, ): (req: Request) => Awaitable> { const input = method instanceof Procedure ? method.input : undefined; if (!input?.encoding) { return (req) => { if (getBodyPresence(req) === "present") { throw new InvalidRequestError( `A request body was provided when none was expected`, ); } return undefined as LexMethodInput; }; } const { blobLimit } = options; const allowedEncodings = parseDefEncoding(input as LexXrpcBody); const checkEncoding = allowedEncodings.includes(ENCODING_ANY) ? undefined : (encoding: string) => allowedEncodings.includes(encoding); const bodyParser = createBodyParser(input.encoding, options); return async (req) => { if (getBodyPresence(req) === "missing") { throw new InvalidRequestError( `A request body is expected but none was provided`, ); } const reqEncoding = parseReqEncoding(req); if (checkEncoding && !checkEncoding(reqEncoding)) { throw new InvalidRequestError( `Wrong request encoding (Content-Type): ${reqEncoding}`, ); } let parsedBody: unknown = undefined; if (bodyParser) { try { parsedBody = await bodyParser(req, reqEncoding); } catch (e) { throw new InvalidRequestError( e instanceof Error ? e.message : String(e), ); } } if (input.schema) { try { if (parsedBody === undefined) { parsedBody = await parseBodyForSchemaValidation( req, reqEncoding, options, ); } const lexBody = toLexBody(parsedBody); parsedBody = input.schema.parse(lexBody); } catch (e) { throw new InvalidRequestError( e instanceof Error ? e.message : String(e), ); } } const body = parsedBody !== undefined ? parsedBody : decodeBodyStream(req, blobLimit); return { encoding: reqEncoding, body } as LexMethodInput; }; } export function createSchemaOutputVerifier( method: M, ): (output: LexMethodOutput) => void { const output = method.output; if (!output.encoding) { return (handlerOutput) => { if (handlerOutput !== undefined) { throw new InternalServerError( `A response body was provided when none was expected`, ); } }; } return (handlerOutput) => { if (handlerOutput === undefined) { throw new InternalServerError( `A response body is expected but none was provided`, ); } const result = handlerSuccess.safeParse(handlerOutput); if (!result.success) { throw new InternalServerError(`Invalid handler output`, undefined, { cause: result.error, }); } const successOutput = handlerOutput as HandlerSuccess; if (!isValidEncoding(output as LexXrpcBody, successOutput.encoding)) { throw new InternalServerError( `Invalid response encoding: ${successOutput.encoding}`, ); } if (output.schema) { const bodyResult = output.schema.safeParse(successOutput.body); if (!bodyResult.success) { throw new InternalServerError(bodyResult.error.message, undefined, { cause: bodyResult.error, }); } successOutput.body = bodyResult.value; } }; } export { createLexiconInputVerifier as createInputVerifier }; /** * Sets headers on a Hono context response. * Iterates through the provided headers and sets them on the response. * @param c - The Hono context object * @param headers - Optional headers to set as key-value pairs */ export function setHeaders(c: Context, headers?: Record) { if (headers) { for (const [key, value] of Object.entries(headers)) { c.header(key, value); } } } /** * Converts a value to an array. * If the value is already an array, returns it as-is. Otherwise, wraps it in an array. * @template T - The type of the value * @param value - The value to convert to an array * @returns An array containing the value(s) */ export function asArray(value: T | T[]): T[] { return Array.isArray(value) ? value : [value]; } /** * Decodes query parameters from URL search params into a typed parameter object. * Converts arrays of single values to single values, preserves multiple values as arrays. * @param params - Raw query parameters as arrays of strings * @returns Decoded parameters with single values or arrays */ export function decodeUrlQueryParams(params: Record): Params { const decoded: Params = {}; for (const [key, values] of Object.entries(params)) { if (values.length === 1) { decoded[key] = values[0]; } else if (values.length > 1) { decoded[key] = values; } } return decoded; }