UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

487 lines (414 loc) 12.7 kB
import { $inject, Alepha, AlephaError, type FileLike, isFileLike, type Static, type TObject, type TSchema, } from "alepha"; import { $cache } from "alepha/cache"; import type { DurationLike } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { HttpError } from "../errors/HttpError.ts"; import { isMultipart } from "../helpers/isMultipart.ts"; import type { ServerRequestConfigEntry, TRequestBody, TResponseBody, } from "../interfaces/ServerRequest.ts"; import type { ClientRequestOptions } from "../primitives/$action.ts"; import { errorSchema } from "../schemas/errorSchema.ts"; export class HttpClient { protected readonly log = $logger(); protected readonly alepha = $inject(Alepha); public readonly cache = $cache<HttpClientCache>({ provider: "memory", name: "http:client", }); protected readonly pendingRequests: HttpClientPendingRequests = {}; public async fetchAction(args: FetchActionArgs): Promise<FetchResponse> { const route = args.action; // our link to fetch const options = args.options ?? {}; // fetch standard options, cache, etc. const config = args.config ?? {}; // params, query, body, etc. const host = args.host ?? ""; // remote host, e.g. "https://api.example.com" or empty (for browser) const request: RequestInit = { ...options.request, }; const method = route.method; const headers: Record<string, string> = {}; const url = this.url(host, route, config); await this.alepha.events.emit("client:onRequest", { route, config, options, headers, request, }); request.method ??= method; await this.body(request, headers, route, config); request.headers = { ...config.headers, ...Object.fromEntries(new Headers(request.headers).entries()), ...headers, }; return await this.fetch(url, { ...request, schema: route.schema, ...options, }); } public async fetch<T extends TSchema>( url: string, request: RequestInitWithOptions<T> = {}, // standard options ): Promise<FetchResponse<Static<T>>> { const options = { cache: request.localCache, schema: request.schema?.response, key: request.key, }; request.method ??= "GET"; this.log.trace("Request", { url, method: request.method, body: request.body, headers: request.headers, options, }); // Only add automatic ETag if user didn't explicitly provide headers const cached = await this.cache.get(url); if (cached && request.method === "GET") { if (cached.etag) { request.headers = new Headers(request.headers); if (!request.headers.has("if-none-match")) { request.headers.set("if-none-match", cached.etag); } } else { return { data: cached.data as Static<T>, status: 200, statusText: "OK", headers: new Headers(), }; } } await this.alepha.events.emit("client:beforeFetch", { url, options, request, }); // Deduplicate concurrent identical requests for idempotent methods only. // Non-idempotent methods (POST, PUT, PATCH, DELETE) must always execute // since each call may produce a different side effect. const isIdempotent = request.method === "GET" || request.method === "HEAD" || request.method === "OPTIONS"; const key = options.key ?? (isIdempotent ? JSON.stringify({ url, method: request.method }) : undefined); if (key) { const existing = this.pendingRequests[key]; if (existing) { this.log.info("Request already pending", key); return existing; } } const promise = fetch(url, request) .then(async (response) => { this.log.debug("Response", { url, status: response.status, }); const fetchResponse: FetchResponse = { data: await this.responseData(response, options), status: response.status, statusText: response.statusText, headers: response.headers, raw: response, }; if (request.method === "GET") { if (options.cache) { await this.cache.set( url, { data: fetchResponse.data }, typeof options.cache === "boolean" ? undefined : options.cache, ); } else if (!this.alepha.isBrowser()) { // only cache etag on server, browser can handle etag itself const etag = response.headers.get("etag") ?? undefined; if (etag) { await this.cache.set(url, { data: fetchResponse.data, etag }); } } } return fetchResponse; }) .finally(() => { if (key) { delete this.pendingRequests[key]; } }); if (key) { this.pendingRequests[key] = promise; } return promise; } protected url( host: string, action: HttpAction, args: ServerRequestConfigEntry, ) { let url = host; if (action.prefix) { url += action.prefix; } url += action.path; url = this.pathVariables(url, action, args); url = this.queryParams(url, action, args); return url; } protected async body( init: RequestInit, headers: Record<string, string>, action: HttpAction, args: ServerRequestConfigEntry = {}, ) { const hasHeader = typeof init.headers === "object" && "content-type" in init.headers && init.headers["content-type"] === "multipart/form-data"; if (hasHeader || isMultipart(action)) { if (typeof init.headers === "object" && "content-type" in init.headers) { delete (init.headers as Record<string, unknown>)["content-type"]; // fetch() will fill this for us } const formData = new FormData(); for (const [key, value] of Object.entries(args.body ?? {})) { if (typeof value === "string") { formData.append(key, value); continue; } if (value instanceof Blob) { formData.append(key, value); continue; } if (isFileLike(value)) { // FileLike must be transformed to WebFile formData.append( key, new File([await value.arrayBuffer()], value.name, { type: value.type, }), ); } } init.body = formData; return; } if (!init.body && action.schema?.body) { headers["content-type"] = "application/json"; init.body = this.alepha.codec.encode(action.schema?.body, args.body, { as: "string", }); } } protected async responseData( response: Response, options: ResolvedFetchOptions, ): Promise<any> { if (response.status === 304) { let cacheKey = response.url; if (typeof window !== "undefined") { cacheKey = cacheKey.replace(window.location.origin, ""); } const cached = await this.cache.get(cacheKey); if (cached) { return cached.data; } // if no cached data (etag-only routes), return empty string return ""; } if (response.status === 204) { return; } if (this.isMaybeFile(response)) { return this.createFileLike(response); } if (response.headers.get("Content-Type")?.startsWith("text/")) { return await response.text(); } if (response.headers.get("Content-Type") === "application/json") { const json = await response.json(); if (response.status >= 400) { const jsonError = this.alepha.codec.decode(errorSchema, json); const error = new HttpError(jsonError); await this.alepha.events.emit("client:onError", { error, }); throw error; } if (options.schema) { return this.alepha.codec.decode(options.schema, json); } return json; } if (response.status >= 400) { const error = new HttpError({ status: response.status, message: `An error occurred while fetching the resource. (${response.statusText})`, }); await this.alepha.events.emit("client:onError", { error, }); throw error; } return response; } protected isMaybeFile(response: Response): boolean { const contentType = response.headers.get("Content-Type"); if (!contentType) { return false; } if (response.headers.get("Content-Disposition")?.includes("attachment")) { return true; // If Content-Disposition indicates an attachment, treat it as a file } return ( contentType.startsWith("application/octet-stream") || contentType.startsWith("application/pdf") || contentType.startsWith("application/zip") || contentType.startsWith("image/") || contentType.startsWith("video/") || contentType.startsWith("audio/") ); } protected createFileLike(response: Response, defaultFileName = ""): FileLike { const match = (response.headers.get("Content-Disposition") ?? "").match( /filename="(.+)"/, ); return { name: match?.[1] ? match[1] : defaultFileName, type: response.headers.get("Content-Type") ?? "application/octet-stream", size: Number(response.headers.get("Content-Length") ?? 0), lastModified: Date.now(), stream: () => { throw new AlephaError("Not implemented"); }, arrayBuffer: async () => { return await response.arrayBuffer(); }, text: async () => { return await response.text(); }, }; } public pathVariables( url: string, action: { schema?: { params?: TObject } }, args: ServerRequestConfigEntry = {}, ): string { if (typeof args.params === "object") { const params = action.schema?.params ? (this.alepha.codec.decode( action.schema.params, args.params, ) as Record<string, any>) : args.params; for (const key of Object.keys(params)) { url = url.replace(`:${key}`, params[key]); url = url.replace(`{${key}}`, params[key]); } } return url; } public queryParams( url: string, action: { schema?: { query?: TObject } }, args: ServerRequestConfigEntry = {}, ): string { if (typeof args.query === "object") { const query = action.schema?.query ? this.alepha.codec.decode(action.schema.query, args.query ?? {}) : args.query; for (const key of Object.keys(query)) { if (query[key] === undefined) { delete query[key]; } if (typeof query[key] === "object") { query[key] = JSON.stringify(query[key]); } } const params = new URLSearchParams( query as Record<string, string>, ).toString(); return params ? `${url}?${params}` : url; } return url; } } // --------------------------------------------------------------------------------------------------------------------- export interface FetchOptions<T extends TSchema = TSchema> { /** * Key to identify the request in the pending requests. */ key?: string; /** * The schema to validate the response against. */ schema?: { response?: T; }; /** * Built-in cache options. */ localCache?: boolean | number | DurationLike; } export type RequestInitWithOptions<T extends TSchema = TSchema> = RequestInit & FetchOptions<T>; /** * Internal resolved fetch options — built in {@link HttpClient.fetch}, * consumed by {@link HttpClient.responseData} and the `client:beforeFetch` * event. Distinct from the external {@link FetchOptions}: `schema` is the * already-unwrapped response schema and `cache` is the resolved cache directive. */ export interface ResolvedFetchOptions { key?: string; schema?: TSchema; cache?: boolean | number | DurationLike; } export interface FetchResponse<T = any> { data: T; status: number; statusText: string; headers: Headers; raw?: Response; } export type HttpClientPendingRequests = Record< string, Promise<any> | undefined >; interface HttpClientCache { data: any; etag?: string; } export interface FetchActionArgs { action: HttpAction; host?: string; config?: ServerRequestConfigEntry; options?: ClientRequestOptions; } export interface HttpAction { method?: string; prefix?: string; path: string; contentType?: string; requestBodyType?: string; schema?: { params?: TObject; query?: TObject; body?: TRequestBody; response?: TResponseBody; }; }