UNPKG

@vaadin/hilla-frontend

Version:

Hilla core frontend utils

223 lines 6.98 kB
import csrfInfoSource from "./CsrfInfoSource.js"; import { EndpointError, EndpointResponseError, EndpointValidationError, ForbiddenResponseError, UnauthorizedResponseError } from "./EndpointErrors.js"; import { FluxConnection } from "./FluxConnection.js"; const $wnd = globalThis; $wnd.Vaadin ??= {}; $wnd.Vaadin.registrations ??= []; $wnd.Vaadin.registrations.push({ is: "endpoint" }); export const BODY_PART_NAME = "hilla_body_part"; /** * Throws a TypeError if the response is not 200 OK. * @param response - The response to assert. */ const assertResponseIsOk = async (response) => { if (!response.ok) { const errorText = await response.text(); let errorJson; try { errorJson = JSON.parse(errorText); } catch { errorJson = null; } const message = errorJson?.message ?? (errorText.length > 0 ? errorText : `expected "200 OK" response, but got ${response.status} ${response.statusText}`); const type = errorJson?.type; if (errorJson?.validationErrorData) { throw new EndpointValidationError(message, errorJson.validationErrorData, type); } if (type) { throw new EndpointError(message, type, errorJson?.detail); } switch (response.status) { case 401: throw new UnauthorizedResponseError(message, response); case 403: throw new ForbiddenResponseError(message, response); default: throw new EndpointResponseError(message, response); } } }; /** * Extracts file objects from the object that is used to build the request body. * * @param obj - The object to extract files from. * @returns A tuple with the object without files and a map of files. */ function extractFiles(obj) { const fileMap = new Map(); function recursiveExtract(prop, path) { if (prop !== null && typeof prop === "object") { if (prop instanceof File) { fileMap.set(path, prop); return null; } if (Array.isArray(prop)) { return prop.map((item, index) => recursiveExtract(item, `${path}/${index}`)); } return Object.entries(prop).reduce((acc, [key, value]) => { const newPath = `${path}/${key}`; if (value instanceof File) { fileMap.set(newPath, value); } else { acc[key] = recursiveExtract(value, newPath); } return acc; }, {}); } return prop; } return [recursiveExtract(obj, ""), fileMap]; } /** * A low-level network calling utility. It stores * a prefix and facilitates remote calls to endpoint class methods * on the Hilla backend. * * Example usage: * * ```js * const client = new ConnectClient(); * const responseData = await client.call('MyEndpoint', 'myMethod'); * ``` * * ### Prefix * * The client supports an `prefix` constructor option: * ```js * const client = new ConnectClient({prefix: '/my-connect-prefix'}); * ``` * * The default prefix is '/connect'. * */ export class ConnectClient { /** * The array of middlewares that are invoked during a call. */ middlewares = []; /** * The Hilla endpoint prefix */ prefix = "/connect"; /** * The Atmosphere options for the FluxConnection. */ atmosphereOptions = {}; #fluxConnection; /** * @param options - Constructor options. */ constructor(options = {}) { if (options.prefix) { this.prefix = options.prefix; } if (options.middlewares) { this.middlewares = options.middlewares; } if (options.atmosphereOptions) { this.atmosphereOptions = options.atmosphereOptions; } } /** * Gets a representation of the underlying persistent network connection used for subscribing to Flux type endpoint * methods. */ get fluxConnection() { if (!this.#fluxConnection) { this.#fluxConnection = new FluxConnection(this.prefix, this.atmosphereOptions); } return this.#fluxConnection; } /** * Calls the given endpoint method defined using the endpoint and method * parameters with the parameters given as params. * Asynchronously returns the parsed JSON response data. * * @param endpoint - Endpoint name. * @param method - Method name to call in the endpoint class. * @param params - Optional parameters to pass to the method. * @param init - Optional parameters for the request * @returns Decoded JSON response data. */ async call(endpoint, method, params, init) { if (arguments.length < 2) { throw new TypeError(`2 arguments required, but got only ${arguments.length}`); } const csrfInfo = await csrfInfoSource.get(); const headers = { Accept: "application/json", ...Object.fromEntries(csrfInfo.headerEntries) }; const [paramsWithoutFiles, files] = extractFiles(params ?? {}); let body; if (files.size > 0) { body = new FormData(); body.append(BODY_PART_NAME, JSON.stringify(paramsWithoutFiles, (_, value) => value === undefined ? null : value)); for (const [path, file] of files) { body.append(path, file); } } else { headers["Content-Type"] = "application/json"; if (params) { body = JSON.stringify(params, (_, value) => value === undefined ? null : value); } } const request = new Request(`${this.prefix}/${endpoint}/${method}`, { body, headers, method: "POST" }); const initialContext = { endpoint, method, params, request }; async function responseHandlerMiddleware(context, next) { const response = await next(context); await assertResponseIsOk(response); const text = await response.text(); return JSON.parse(text, (_, value) => value === null ? undefined : value); } async function fetchNext(context) { const connectionState = init?.mute ? undefined : $wnd.Vaadin?.connectionState; connectionState?.loadingStarted(); try { const response = await fetch(context.request, { signal: init?.signal }); connectionState?.loadingFinished(); return response; } catch (error) { if (error instanceof Error && error.name === "AbortError") { connectionState?.loadingFinished(); } else { connectionState?.loadingFailed(); } throw error; } } const middlewares = [responseHandlerMiddleware, ...this.middlewares]; const chain = middlewares.reduceRight( (next, middleware) => async (context) => { if (typeof middleware === "function") { return middleware(context, next); } return middleware.invoke(context, next); }, // Initialize reduceRight the accumulator with `fetchNext` fetchNext ); return chain(initialContext); } /** * Subscribes to the given method defined using the endpoint and method * parameters with the parameters given as params. The method must return a * compatible type such as a Flux. * Returns a subscription that is used to fetch values as they become available. * * @param endpoint - Endpoint name. * @param method - Method name to call in the endpoint class. * @param params - Optional parameters to pass to the method. * @returns A subscription used to handles values as they become available. */ subscribe(endpoint, method, params) { return this.fluxConnection.subscribe(endpoint, method, params ? Object.values(params) : []); } } //# sourceMappingURL=./Connect.js.map