Suite of AT Protocol TypeScript libraries built on web standards
21
fork

Configure Feed

Select the types of activity you want to include in your feed.

at lex 634 lines 19 kB view raw
1import { 2 jsonToLex, 3 type Lexicons, 4 type LexXrpcBody, 5 type LexXrpcProcedure, 6 type LexXrpcQuery, 7 type LexXrpcSubscription, 8} from "@atp/lexicon"; 9import { 10 InternalServerError, 11 InvalidRequestError, 12 ResponseType, 13 XRPCError, 14} from "./errors.ts"; 15import { handlerSuccess } from "./types.ts"; 16import type { 17 Awaitable, 18 HandlerSuccess, 19 Input, 20 Params, 21 RouteOptions, 22} from "./types.ts"; 23import type { Context, HonoRequest } from "hono"; 24import { createDecoders, MaxSizeChecker } from "@atp/common"; 25 26function assert(condition: unknown, message?: string): asserts condition { 27 if (!condition) { 28 throw new Error(message || "Assertion failed"); 29 } 30} 31 32/** 33 * Decodes query parameters from HTTP request into typed parameters. 34 * Handles type conversion for strings, numbers, booleans, and arrays based on lexicon definitions. 35 * @param def - The lexicon definition containing parameter schema 36 * @param params - Raw query parameters from the HTTP request 37 * @returns Decoded and type-converted parameters 38 */ 39export function decodeQueryParams( 40 def: LexXrpcProcedure | LexXrpcQuery | LexXrpcSubscription, 41 params: Record<string, string | string[]>, 42): Params { 43 const decoded: Params = {}; 44 if (!def.parameters?.properties) { 45 return decoded; 46 } 47 48 for (const k in def.parameters.properties) { 49 const property = def.parameters.properties[k]; 50 const val = params[k]; 51 if (property && val !== undefined) { 52 if (property.type === "array") { 53 const vals = (Array.isArray(val) ? val : [val]).filter( 54 (v) => v !== undefined, 55 ); 56 decoded[k] = vals 57 .map((v) => decodeQueryParam(property.items?.type || "string", v)) 58 .filter((v) => v !== undefined) as (string | number | boolean)[]; 59 } else { 60 const actualVal = Array.isArray(val) ? val[0] : val; 61 decoded[k] = decodeQueryParam(property.type, actualVal); 62 } 63 } 64 } 65 return decoded; 66} 67 68/** 69 * Decodes a single query parameter value based on its expected type. 70 * Converts string values to appropriate JavaScript types (string, number, boolean). 71 * @param type - The expected parameter type from the lexicon 72 * @param value - The raw parameter value from the query string 73 * @returns The decoded parameter value or undefined if conversion fails 74 */ 75export function decodeQueryParam( 76 type: string, 77 value: unknown, 78): string | number | boolean | undefined { 79 if (!value) { 80 return undefined; 81 } 82 if (type === "string" || type === "datetime") { 83 return String(value); 84 } 85 if (type === "float") { 86 return Number(String(value)); 87 } else if (type === "integer") { 88 return parseInt(String(value), 10) || 0; 89 } else if (type === "boolean") { 90 return value === "true"; 91 } 92} 93 94/** 95 * Extracts query parameters from a URL and returns them as arrays of strings. 96 * @param url - The URL to parse (defaults to empty string) 97 * @returns Object mapping parameter names to arrays of values 98 */ 99export function getQueryParams(url = ""): Record<string, string[]> { 100 const { searchParams } = new URL(url ?? "", "http://x"); 101 const result: Record<string, string[]> = {}; 102 for (const key of searchParams.keys()) { 103 result[key] = searchParams.getAll(key); 104 } 105 return result; 106} 107 108/** 109 * Represents a request-like object with essential HTTP request properties. 110 * Used for handling both standard HTTP requests and custom request implementations. 111 */ 112export type RequestLike = { 113 headers: Headers | { [key: string]: string | string[] | undefined }; 114 body?: ReadableStream | unknown; 115 method?: string; 116 url?: string; 117 signal?: AbortSignal; 118}; 119 120/** 121 * Validates the output of an XRPC method against its lexicon definition. 122 * Performs response body validation, content-type checks, and schema validation. 123 * @param nsid - The namespace identifier of the method 124 * @param def - The lexicon definition for the method 125 * @param output - The handler output to validate 126 * @param lexicons - The lexicon registry for schema validation 127 * @throws {InternalServerError} If validation fails 128 */ 129export function validateOutput( 130 nsid: string, 131 def: LexXrpcProcedure | LexXrpcQuery, 132 output: HandlerSuccess | void, 133 lexicons: Lexicons, 134): void { 135 if (def.output) { 136 // An output is expected 137 if (output === undefined) { 138 throw new InternalServerError( 139 `A response body is expected but none was provided`, 140 ); 141 } 142 143 // Fool-proofing (should not be necessary due to type system) 144 const result = handlerSuccess.safeParse(output); 145 if (!result.success) { 146 throw new InternalServerError(`Invalid handler output`, undefined, { 147 cause: result.error, 148 }); 149 } 150 151 // output mime 152 const { encoding } = output; 153 if (!encoding || !isValidEncoding(def.output, encoding)) { 154 throw new InternalServerError(`Invalid response encoding: ${encoding}`); 155 } 156 157 // output schema 158 if (def.output.schema) { 159 try { 160 output.body = lexicons.assertValidXrpcOutput(nsid, output.body); 161 } catch (e) { 162 throw new InternalServerError( 163 e instanceof Error ? e.message : String(e), 164 ); 165 } 166 } 167 } else { 168 // Expects no output 169 if (output !== undefined) { 170 throw new InternalServerError( 171 `A response body was provided when none was expected`, 172 ); 173 } 174 } 175} 176 177const ENCODING_ANY = "*/*"; 178 179function parseDefEncoding({ encoding }: LexXrpcBody) { 180 return encoding.split(",").map(trimString); 181} 182 183function trimString(str: string): string { 184 return str.trim(); 185} 186 187export function parseReqEncoding(req: Request): string { 188 const contentType = req.headers.get("content-type"); 189 if (!contentType) { 190 throw new InvalidRequestError( 191 `Request encoding (Content-Type) required but not provided`, 192 ); 193 } 194 const encoding = normalizeMime(contentType); 195 if (encoding) return encoding; 196 throw new InvalidRequestError( 197 `Request encoding (Content-Type) required but not provided`, 198 ); 199} 200 201/** 202 * Normalizes a MIME type by extracting the base type and converting to lowercase. 203 * Removes parameters (e.g., charset) from the MIME type. 204 * @param mime - The MIME type string to normalize 205 * @returns The normalized MIME type (base type only) 206 */ 207export function normalizeMime(mime: string): string { 208 const [base] = mime.split(";"); 209 return base.trim().toLowerCase(); 210} 211 212function isValidEncoding(output: LexXrpcBody, encoding: string) { 213 const normalized = normalizeMime(encoding); 214 if (!normalized) return false; 215 216 const allowed = parseDefEncoding(output); 217 return allowed.includes(ENCODING_ANY) || allowed.includes(normalized); 218} 219 220type BodyPresence = "missing" | "empty" | "present"; 221 222function getBodyPresence(req: Request): BodyPresence { 223 if (req.headers.get("transfer-encoding") != null) return "present"; 224 if (req.headers.get("content-length") === "0") return "empty"; 225 if (req.headers.get("content-length") != null) return "present"; 226 return "missing"; 227} 228 229function createBodyParser( 230 inputEncoding: string, 231 options: RouteOptions, 232): ((req: Request, encoding: string) => Promise<unknown>) | undefined { 233 if (inputEncoding === ENCODING_ANY) { 234 // When the lexicon's input encoding is */*, the handler will determine how to process it 235 return; 236 } 237 const { jsonLimit, textLimit } = options; 238 239 return async (req: Request, encoding: string): Promise<unknown> => { 240 const contentLength = req.headers.get("content-length"); 241 const bodySize = contentLength ? parseInt(contentLength, 10) : 0; 242 243 if (encoding === "application/json" || encoding === "json") { 244 if (jsonLimit && bodySize > jsonLimit) { 245 throw new InvalidRequestError( 246 `Request body too large: ${bodySize} bytes exceeds JSON limit of ${jsonLimit} bytes`, 247 ); 248 } 249 const text = await req.text(); 250 return JSON.parse(text); 251 } else if ( 252 encoding.startsWith("text/") || 253 encoding === "application/x-www-form-urlencoded" 254 ) { 255 if (textLimit && bodySize > textLimit) { 256 throw new InvalidRequestError( 257 `Request body too large: ${bodySize} bytes exceeds text limit of ${textLimit} bytes`, 258 ); 259 } 260 return await req.text(); 261 } else { 262 return; 263 } 264 }; 265} 266 267function decodeBodyStream( 268 req: Request, 269 maxSize: number | undefined, 270): ReadableStream | null { 271 const contentEncoding = req.headers.get("content-encoding"); 272 const contentLength = req.headers.get("content-length"); 273 274 if (!req.body) { 275 return null; 276 } 277 278 if (!contentEncoding) { 279 return req.body; 280 } 281 282 if (!contentLength) { 283 throw new XRPCError( 284 ResponseType.UnsupportedMediaType, 285 "unsupported content-encoding", 286 ); 287 } 288 289 const contentLengthParsed = contentLength 290 ? parseInt(contentLength, 10) 291 : undefined; 292 293 if (Number.isNaN(contentLengthParsed)) { 294 throw new XRPCError(ResponseType.InvalidRequest, "invalid content-length"); 295 } 296 297 if ( 298 maxSize !== undefined && 299 contentLengthParsed !== undefined && 300 contentLengthParsed > maxSize 301 ) { 302 throw new XRPCError( 303 ResponseType.PayloadTooLarge, 304 "request entity too large", 305 ); 306 } 307 308 let transforms: TransformStream[]; 309 try { 310 transforms = createDecoders(contentEncoding); 311 } catch (cause) { 312 throw new XRPCError( 313 ResponseType.UnsupportedMediaType, 314 "unsupported content-encoding", 315 undefined, 316 { cause }, 317 ); 318 } 319 320 if (maxSize !== undefined) { 321 const maxSizeChecker = new MaxSizeChecker( 322 maxSize, 323 () => 324 new XRPCError(ResponseType.PayloadTooLarge, "request entity too large"), 325 ); 326 transforms.push(maxSizeChecker); 327 } 328 329 let stream: ReadableStream = req.body; 330 for (const transform of transforms) { 331 stream = stream.pipeThrough(transform); 332 } 333 334 return stream; 335} 336 337/** 338 * Formats server timing data into an HTTP Server-Timing header value. 339 * Creates a header string with timing metrics for performance monitoring. 340 * @param timings - Array of timing measurements 341 * @returns Formatted Server-Timing header value 342 */ 343export function serverTimingHeader(timings: ServerTiming[]): string { 344 return timings 345 .map((timing) => { 346 let header = timing.name; 347 if (timing.duration) header += `;dur=${timing.duration}`; 348 if (timing.description) header += `;desc="${timing.description}"`; 349 return header; 350 }) 351 .join(", "); 352} 353 354/** 355 * Utility class for measuring server-side operation timings. 356 * Provides start/stop functionality and implements the ServerTiming interface. 357 */ 358export class ServerTimer implements ServerTiming { 359 public duration?: number; 360 private startMs?: number; 361 /** 362 * Creates a new ServerTimer instance. 363 * @param name Identifier for the timing measurement 364 * @param description Optional description of what is being timed 365 */ 366 constructor( 367 public name: string, 368 public description?: string, 369 ) {} 370 /** 371 * Starts the timer by recording the current timestamp. 372 * @returns This timer instance for method chaining 373 */ 374 start(): ServerTimer { 375 this.startMs = Date.now(); 376 return this; 377 } 378 /** 379 * Stops the timer and calculates the duration. 380 * @returns This timer instance for method chaining 381 * @throws {Error} If the timer hasn't been started 382 */ 383 stop(): ServerTimer { 384 assert(this.startMs, "timer hasn't been started"); 385 this.duration = Date.now() - this.startMs; 386 return this; 387 } 388} 389 390/** 391 * Represents timing information for server-side operations. 392 * Used for performance monitoring and debugging. 393 */ 394export interface ServerTiming { 395 name: string; 396 duration?: number; 397 description?: string; 398} 399 400/** 401 * Represents a minimal HTTP request with essential properties. 402 * Used when full request information is not needed. 403 */ 404export interface MinimalRequest { 405 url?: string; 406 method?: string; 407 headers: Headers | { [key: string]: string | string[] | undefined }; 408} 409 410/** 411 * Validates and extracts the NSID from a request object. 412 * Convenience wrapper for parseUrlNsid that works with request objects. 413 * @param req - The request object containing a URL 414 * @returns The extracted NSID from the request URL 415 * @throws {InvalidRequestError} If the URL doesn't contain a valid XRPC path 416 */ 417export const parseReqNsid = ( 418 req: MinimalRequest | HonoRequest, 419): string => parseUrlNsid(req.url || "/"); 420 421/** 422 * Validates and extracts the NSID (Namespace Identifier) from an XRPC URL. 423 * Performs strict validation of the /xrpc/ path format and NSID syntax. 424 * @param url - The URL or path to parse 425 * @returns The extracted NSID 426 * @throws {InvalidRequestError} If the URL doesn't contain a valid XRPC path or NSID 427 */ 428export const parseUrlNsid = (url: string): string => { 429 // Extract path from full URL if needed 430 let path = url; 431 try { 432 const urlObj = new URL(url); 433 path = urlObj.pathname; 434 } catch { 435 // If URL parsing fails, assume it's already a path 436 } 437 438 if ( 439 // Ordered by likelihood of failure 440 path.length <= 6 || 441 path[5] !== "/" || 442 path[4] !== "c" || 443 path[3] !== "p" || 444 path[2] !== "r" || 445 path[1] !== "x" || 446 path[0] !== "/" 447 ) { 448 throw new InvalidRequestError("invalid xrpc path"); 449 } 450 451 const startOfNsid = 6; 452 453 let curr = startOfNsid; 454 let char: number; 455 let alphaNumRequired = true; 456 for (; curr < path.length; curr++) { 457 char = path.charCodeAt(curr); 458 if ( 459 (char >= 48 && char <= 57) || // 0-9 460 (char >= 65 && char <= 90) || // A-Z 461 (char >= 97 && char <= 122) // a-z 462 ) { 463 alphaNumRequired = false; 464 } else if (char === 45 /* "-" */ || char === 46 /* "." */) { 465 if (alphaNumRequired) { 466 throw new InvalidRequestError("invalid xrpc path"); 467 } 468 alphaNumRequired = true; 469 } else if (char === 47 /* "/" */) { 470 // Allow trailing slash (next char is either EOS or "?") 471 if (curr === path.length - 1 || path.charCodeAt(curr + 1) === 63) { 472 break; 473 } 474 throw new InvalidRequestError("invalid xrpc path"); 475 } else if (char === 63 /* "?"" */) { 476 break; 477 } else { 478 throw new InvalidRequestError("invalid xrpc path"); 479 } 480 } 481 482 // last char was one of: '-', '.', '/' 483 if (alphaNumRequired) { 484 throw new InvalidRequestError("invalid xrpc path"); 485 } 486 487 // A domain name consists of minimum two characters 488 if (curr - startOfNsid < 2) { 489 throw new InvalidRequestError("invalid xrpc path"); 490 } 491 492 // @TODO is there a max ? 493 494 return path.slice(startOfNsid, curr); 495}; 496 497/** 498 * Alias for parseUrlNsid for backward compatibility. 499 * @deprecated Use parseUrlNsid instead 500 */ 501export const extractUrlNsid = parseUrlNsid; 502 503/** 504 * Creates an input verifier function for XRPC methods. 505 * Returns a function that validates and processes request input based on lexicon definitions. 506 * @param lexicons - The lexicon registry for validation 507 * @param nsid - The namespace identifier of the method 508 * @param def - The lexicon definition for the method 509 * @returns A function that verifies request input 510 */ 511export function createInputVerifier( 512 nsid: string, 513 def: LexXrpcProcedure | LexXrpcQuery, 514 options: RouteOptions, 515 lexicons: Lexicons, 516): (req: Request) => Awaitable<Input> { 517 if (def.type === "query" || !def.input) { 518 return (req) => { 519 // @NOTE We allow (and ignore) "empty" bodies 520 if (getBodyPresence(req) === "present") { 521 throw new InvalidRequestError( 522 `A request body was provided when none was expected`, 523 ); 524 } 525 526 return undefined; 527 }; 528 } 529 530 // Lexicon definition expects a request body 531 532 const { input } = def; 533 const { blobLimit } = options; 534 535 const allowedEncodings = parseDefEncoding(input); 536 const checkEncoding = allowedEncodings.includes(ENCODING_ANY) 537 ? undefined // No need to check 538 : (encoding: string) => allowedEncodings.includes(encoding); 539 540 const bodyParser = createBodyParser(input.encoding, options); 541 542 return async (req) => { 543 if (getBodyPresence(req) === "missing") { 544 throw new InvalidRequestError( 545 `A request body is expected but none was provided`, 546 ); 547 } 548 549 const reqEncoding = parseReqEncoding(req); 550 if (checkEncoding && !checkEncoding(reqEncoding)) { 551 throw new InvalidRequestError( 552 `Wrong request encoding (Content-Type): ${reqEncoding}`, 553 ); 554 } 555 556 let parsedBody: unknown = undefined; 557 558 // Parse body with size limits 559 if (bodyParser) { 560 try { 561 parsedBody = await bodyParser(req, reqEncoding); 562 } catch (e) { 563 throw new InvalidRequestError( 564 e instanceof Error ? e.message : String(e), 565 ); 566 } 567 } 568 569 // Validate against schema if defined 570 if (input.schema) { 571 try { 572 const lexBody = parsedBody ? jsonToLex(parsedBody) : parsedBody; 573 parsedBody = lexicons.assertValidXrpcInput(nsid, lexBody); 574 } catch (e) { 575 throw new InvalidRequestError( 576 e instanceof Error ? e.message : String(e), 577 ); 578 } 579 } 580 581 // if we parsed the body for schema validation, use that 582 // otherwise, we pass along a decoded readable stream 583 const body = parsedBody !== undefined 584 ? parsedBody 585 : decodeBodyStream(req, blobLimit); 586 587 return { encoding: reqEncoding, body }; 588 }; 589} 590 591/** 592 * Sets headers on a Hono context response. 593 * Iterates through the provided headers and sets them on the response. 594 * @param c - The Hono context object 595 * @param headers - Optional headers to set as key-value pairs 596 */ 597export function setHeaders(c: Context, headers?: Record<string, string>) { 598 if (headers) { 599 for (const [key, value] of Object.entries(headers)) { 600 c.header(key, value); 601 } 602 } 603} 604 605/** 606 * Converts a value to an array. 607 * If the value is already an array, returns it as-is. Otherwise, wraps it in an array. 608 * @template T - The type of the value 609 * @param value - The value to convert to an array 610 * @returns An array containing the value(s) 611 */ 612export function asArray<T>(value: T | T[]): T[] { 613 return Array.isArray(value) ? value : [value]; 614} 615 616/** 617 * Decodes query parameters from URL search params into a typed parameter object. 618 * Converts arrays of single values to single values, preserves multiple values as arrays. 619 * @param params - Raw query parameters as arrays of strings 620 * @returns Decoded parameters with single values or arrays 621 */ 622export function decodeUrlQueryParams(params: Record<string, string[]>): Params { 623 const decoded: Params = {}; 624 625 for (const [key, values] of Object.entries(params)) { 626 if (values.length === 1) { 627 decoded[key] = values[0]; 628 } else if (values.length > 1) { 629 decoded[key] = values; 630 } 631 } 632 633 return decoded; 634}