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 {
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}