const DEFAULT_TIMEOUT_MS = 15_000; const MAX_PLAIN_TEXT_ERROR_LENGTH = 200; export type RestClientMessages = { invalidEndpoint: string; missingNonce: string; networkError: string; timeout: string; invalidResponse: string; statusError: (status: number) => string; }; export type RestJsonRequestOptions = { endpoint: string; nonce: string; expectedPathPrefix?: string; timeoutMs?: number; signal?: AbortSignal; init?: RequestInit; validate: (payload: unknown) => payload is T; messages: RestClientMessages; onValidatedResponse?: (metadata: RestResponseMetadata) => void; }; export type RestResponseMetadata = { status: number; etag: string | null; }; export type PublicRestJsonRequestOptions = Omit< RestJsonRequestOptions, "nonce" | "messages" > & { messages: Omit; }; export type RestClientErrorKind = "request" | "http" | "invalid-response" | "transport"; export class RestClientError extends Error { readonly kind: RestClientErrorKind; readonly status?: number; readonly code?: string; constructor( message: string, { kind, status, code, }: { kind: RestClientErrorKind; status?: number; code?: string; }, ) { super(message); this.name = "RestClientError"; this.kind = kind; this.status = status; this.code = code; } } function hasControlCharacters(value: string): boolean { for (let index = 0; index < value.length; index += 1) { const code = value.charCodeAt(index); if (code <= 0x1f || code === 0x7f) { return true; } } return false; } export function isValidRestNonce(value: unknown): value is string { return ( typeof value === "string" && Boolean(value) && value === value.trim() && !hasControlCharacters(value) ); } function isRootRelative(value: string): boolean { return value.startsWith("/") && !value.startsWith("//"); } function isAbsoluteUrl(value: string): boolean { return /^[a-z][a-z\d+.-]*:/i.test(value); } function hasPathPrefix(pathname: string, expectedPrefix: string): boolean { if (expectedPrefix === "/") { return true; } const normalizedPrefix = expectedPrefix.replace(/\/+$/, ""); return ( pathname === normalizedPrefix || pathname.startsWith(`${normalizedPrefix}/`) ); } /** * Resolve a localized REST endpoint without allowing a request to leave the * current origin. */ export function resolveRestEndpoint( endpoint: unknown, expectedPathPrefix?: string, ): string | null { if ( typeof endpoint !== "string" || !endpoint || endpoint !== endpoint.trim() ) { return null; } if ( hasControlCharacters(endpoint) || endpoint.includes("\\") || (!isRootRelative(endpoint) && !isAbsoluteUrl(endpoint)) ) { return null; } try { const currentOrigin = window.location.origin; const url = new URL(endpoint, currentOrigin); if ( (url.protocol !== "http:" && url.protocol !== "https:") || url.origin !== currentOrigin || url.username || url.password ) { return null; } if (expectedPathPrefix !== undefined) { if ( !isRootRelative(expectedPathPrefix) || expectedPathPrefix !== expectedPathPrefix.trim() || hasControlCharacters(expectedPathPrefix) || expectedPathPrefix.includes("\\") || expectedPathPrefix.includes("?") || expectedPathPrefix.includes("#") || !hasPathPrefix(url.pathname, expectedPathPrefix) ) { return null; } } return url.toString(); } catch { return null; } } function isCanonicalRestRoute(value: string): boolean { return ( isRootRelative(value) && value === value.trim() && !hasControlCharacters(value) && !value.includes("\\") && !value.includes("?") && !value.includes("#") ); } /** Resolve a nonce-bearing endpoint as an exact sibling of a REST anchor. */ export function resolvePinnedRestEndpoint( endpoint: unknown, anchorEndpoint: unknown, anchorRoute: string, expectedRoute: string, ): string | null { if ( !isCanonicalRestRoute(anchorRoute) || !isCanonicalRestRoute(expectedRoute) ) return null; const candidate = resolveRestEndpoint(endpoint); const anchor = resolveRestEndpoint(anchorEndpoint); if (!candidate || !anchor) return null; const candidateUrl = new URL(candidate); const anchorUrl = new URL(anchor); const anchorRestRoutes = anchorUrl.searchParams.getAll("rest_route"); const anchorRestRoute = anchorRestRoutes[0] ?? null; if (anchorRestRoute !== null) { if (anchorRestRoutes.length !== 1 || anchorRestRoute !== anchorRoute) return null; const candidateRestRoutes = candidateUrl.searchParams.getAll("rest_route"); const candidateRestRoute = candidateRestRoutes[0] ?? null; const anchorExtra = new URLSearchParams(anchorUrl.searchParams); const candidateExtra = new URLSearchParams(candidateUrl.searchParams); anchorExtra.delete("rest_route"); candidateExtra.delete("rest_route"); return candidateUrl.pathname === anchorUrl.pathname && candidateRestRoutes.length === 1 && candidateRestRoute === expectedRoute && candidateExtra.toString() === anchorExtra.toString() && !candidateUrl.hash ? candidateUrl.toString() : null; } if ( anchorUrl.search || anchorUrl.hash || !anchorUrl.pathname.endsWith(anchorRoute) || candidateUrl.search || candidateUrl.hash ) return null; const restPrefix = anchorUrl.pathname.slice(0, -anchorRoute.length); return candidateUrl.pathname === `${restPrefix}${expectedRoute}` ? candidateUrl.toString() : null; } function abortReason(signal: AbortSignal): unknown { return ( signal.reason ?? new DOMException("The operation was aborted.", "AbortError") ); } function parseJson(text: string): { parsed: boolean; payload: unknown } { if (!text.trim()) { return { parsed: false, payload: null }; } try { return { parsed: true, payload: JSON.parse(text) as unknown }; } catch { return { parsed: false, payload: text }; } } function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function wpRestErrorCode( payload: unknown, responseStatus: number, ): string | undefined { if (!isRecord(payload) || !isRecord(payload.data)) { return undefined; } const { code } = payload; const bodyStatus = payload.data.status; if ( typeof code !== "string" || !code || code !== code.trim() || code.length > 191 || hasControlCharacters(code) || !/^[a-z\d][a-z\d_.:-]*$/i.test(code) || !Number.isInteger(bodyStatus) || (bodyStatus as number) < 100 || (bodyStatus as number) > 599 || bodyStatus !== responseStatus ) { return undefined; } return code; } function responseErrorMessage( response: Response, text: string, payload: unknown, fallback: string, ): string { const contentType = ( response.headers.get("Content-Type") ?? "" ).toLowerCase(); if ( contentType.includes("application/json") && payload !== null && typeof payload === "object" && !Array.isArray(payload) && "message" in payload && typeof payload.message === "string" && payload.message.trim() ) { return payload.message.trim(); } if (contentType.includes("text/plain") && text.trim()) { return text.trim().slice(0, MAX_PLAIN_TEXT_ERROR_LENGTH); } return fallback; } /** * Send one same-origin WordPress REST request and validate its JSON response. * The timeout remains active through body consumption, parsing, and validation. */ export async function requestRestJson({ endpoint, nonce, expectedPathPrefix, timeoutMs = DEFAULT_TIMEOUT_MS, signal, init, validate, messages, onValidatedResponse, }: RestJsonRequestOptions): Promise { const url = resolveRestEndpoint(endpoint, expectedPathPrefix); if (!url) { throw new RestClientError(messages.invalidEndpoint, { kind: "request" }); } if (!isValidRestNonce(nonce)) { throw new RestClientError(messages.missingNonce, { kind: "request" }); } return requestResolvedRestJson({ url, nonce, timeoutMs, signal, init, validate, messages, onValidatedResponse, }); } /** * Send one public same-origin REST request without manufacturing or disclosing a * WordPress nonce. Public REST routes must enforce origin safety, but they * must not accidentally become authenticated through their localized payload. */ export async function requestPublicRestJson({ endpoint, expectedPathPrefix, timeoutMs = DEFAULT_TIMEOUT_MS, signal, init, validate, messages, onValidatedResponse, }: PublicRestJsonRequestOptions): Promise { const url = resolveRestEndpoint(endpoint, expectedPathPrefix); if (!url) { throw new RestClientError(messages.invalidEndpoint, { kind: "request" }); } return requestResolvedRestJson({ url, timeoutMs, signal, init, validate, messages, onValidatedResponse, }); } type ResolvedRestJsonRequestOptions = { url: string; nonce?: string; timeoutMs: number; signal?: AbortSignal; init?: RequestInit; validate: (payload: unknown) => payload is T; messages: Omit; onValidatedResponse?: (metadata: RestResponseMetadata) => void; }; async function requestResolvedRestJson({ url, nonce, timeoutMs, signal, init, validate, messages, onValidatedResponse, }: ResolvedRestJsonRequestOptions): Promise { if (signal?.aborted) { throw abortReason(signal); } const headers = new Headers(init?.headers); headers.set("Accept", "application/json"); if (nonce !== undefined) { headers.set("X-WP-Nonce", nonce); } else { headers.delete("X-WP-Nonce"); } const requestInit = { ...init }; delete requestInit.headers; delete requestInit.signal; delete requestInit.credentials; delete requestInit.mode; delete requestInit.redirect; const controller = new AbortController(); let timedOut = false; let callerAborted = false; const abortFromCaller = () => { callerAborted = true; controller.abort(signal ? abortReason(signal) : undefined); }; signal?.addEventListener("abort", abortFromCaller, { once: true }); const effectiveTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS; const timeout = window.setTimeout(() => { timedOut = true; controller.abort(); }, effectiveTimeout); try { const response = await fetch(url, { ...requestInit, headers, credentials: "same-origin", mode: "same-origin", redirect: "error", signal: controller.signal, }); const text = await response.text(); const { parsed, payload } = parseJson(text); if (!response.ok) { throw new RestClientError( responseErrorMessage( response, text, payload, messages.statusError(response.status), ), { kind: "http", status: response.status, code: parsed ? wpRestErrorCode(payload, response.status) : undefined, }, ); } let isValid = false; if (parsed) { try { isValid = validate(payload); } catch { isValid = false; } } if (!isValid) { throw new RestClientError(messages.invalidResponse, { kind: "invalid-response", status: response.status, }); } onValidatedResponse?.({ status: response.status, etag: response.headers.get("ETag"), }); return payload as T; } catch (error) { if (timedOut) { throw new RestClientError(messages.timeout, { kind: "transport" }); } if (callerAborted || signal?.aborted) { throw signal ? abortReason(signal) : error; } if (error instanceof RestClientError) { throw error; } throw new RestClientError(messages.networkError, { kind: "transport" }); } finally { window.clearTimeout(timeout); signal?.removeEventListener("abort", abortFromCaller); } }