/** Bounded REST transport. Retain raw JSON text for exact customer numbers. */ export class TransportError extends Error {} export interface RequestOptions { body?: string | Uint8Array; query?: Record; contentType?: string; idempotencyKey?: string; ifMatch?: string; createOnly?: boolean; } export interface HotelResponse { status: number; headers: Headers; body: Uint8Array; } const maxBytes = 32 * 1024 * 1024; const encoder = new TextEncoder(); export function segment(value: string): string { if (!value || value === "." || value === ".." || /[\x00-\x1f\x7f/\\]/u.test(value)) { throw new Error("invalid path segment"); } return encodeURIComponent(value); } export class Client { #origin: string; #credential?: string; #timeout: number; constructor(baseURL: string, credential?: string, options: { timeoutSeconds?: number; allowLocalHTTP?: boolean } = {}) { if (typeof baseURL !== "string" || /[\x00-\x20\\]/u.test(baseURL)) throw new Error("invalid service origin"); let url: URL; try { url = new URL(baseURL); } catch { throw new Error("invalid service origin"); } const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); if ((url.protocol !== "https:" && !(url.protocol === "http:" && options.allowLocalHTTP && local)) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) { throw new Error("invalid service origin"); } const timeout = options.timeoutSeconds ?? 30; if (!Number.isFinite(timeout) || timeout < 1 || timeout > 60) throw new Error("invalid timeout"); if (credential !== undefined && (!credential || /[^\x21-\x7e]/u.test(credential))) throw new Error("invalid credential"); this.#origin = url.origin; this.#credential = credential; this.#timeout = timeout * 1000; } async request(method: string, path: string, options: RequestOptions = {}): Promise { if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method) || !/^\/(api\/v1|hooks\/v1)\//u.test(path) || /[\x00-\x20\x7f-\uffff?#\\]/u.test(path)) { throw new Error("invalid relative API request"); } const url = new URL(path, this.#origin); if (url.origin !== this.#origin || url.pathname !== path) throw new Error("ambiguous API path"); for (const [key, value] of Object.entries(options.query ?? {})) url.searchParams.set(key, value); if (options.body !== undefined && typeof options.body !== "string" && !(options.body instanceof Uint8Array)) throw new Error("body must be exact UTF-8 text or bytes"); if (options.body !== undefined && options.body.length > maxBytes) throw new Error("request exceeds client limit"); // Own the bytes for Fetch's ArrayBuffer-backed BodyInit and prevent the caller // mutating a shared backing buffer while the request is in flight. const body = typeof options.body === "string" ? encoder.encode(options.body) : options.body === undefined ? undefined : new Uint8Array(options.body); if (body && body.byteLength > maxBytes) throw new Error("request exceeds client limit"); if (options.ifMatch !== undefined && options.createOnly) throw new Error("conflicting preconditions"); for (const value of [options.contentType, options.idempotencyKey, options.ifMatch]) { if (value !== undefined && (typeof value !== "string" || !value || /[^\x20-\x7e]/u.test(value))) throw new Error("invalid request header"); } const headers = new Headers({ Accept: "application/json, application/octet-stream", "Accept-Encoding": "identity" }); if (body !== undefined) headers.set("Content-Type", options.contentType ?? "application/json"); if (this.#credential !== undefined) headers.set("Authorization", "Bearer " + this.#credential); if (options.idempotencyKey !== undefined) headers.set("Idempotency-Key", options.idempotencyKey); if (options.ifMatch !== undefined) headers.set("If-Match", options.ifMatch); if (options.createOnly) headers.set("If-None-Match", "*"); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.#timeout); try { const response = await fetch(url, { method, headers, body, redirect: "error", credentials: "omit", cache: "no-store", signal: controller.signal }); const length = response.headers.get("Content-Length"); const encoding = response.headers.get("Content-Encoding"); if ((encoding && encoding !== "identity") || (length !== null && (!/^\d+$/u.test(length) || BigInt(length) > BigInt(maxBytes)))) { controller.abort(); throw new TransportError("invalid or oversized response"); } const reader = response.body?.getReader(); const chunks: Uint8Array[] = []; let size = 0; if (reader) { try { while (true) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > maxBytes) { controller.abort(); throw new TransportError("response exceeds client limit"); } chunks.push(value); } } finally { reader.releaseLock(); } } const bytes = new Uint8Array(size); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } return { status: response.status, headers: response.headers, body: bytes }; } catch (error) { if (error instanceof TransportError) throw error; throw new TransportError("request failed; mutation outcome may be unknown"); } finally { clearTimeout(timer); } } }