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

Configure Feed

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

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