Suite of AT Protocol TypeScript libraries built on web standards
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}