@vaadin/hilla-frontend
Version:
Hilla core frontend utils
223 lines • 6.98 kB
JavaScript
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