UNPKG

alepha

Version:

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

453 lines (452 loc) 13.8 kB
import { $inject, $module, Alepha, AlephaError, TypeBoxError, isFileLike, z } from "alepha"; import { $cache } from "alepha/cache"; import { $logger } from "alepha/logger"; //#region ../../src/server/core/errors/HttpError.ts const isHttpError = (error, status) => { if (!(!!error && typeof error === "object" && "message" in error && typeof error.message === "string" && "status" in error && typeof error.status === "number")) return false; if (status) return error.status === status; return true; }; var HttpError = class extends AlephaError { name = "HttpError"; static is = isHttpError; static toJSON(error) { const json = { error: error.error, status: error.status, message: error.message }; if (error.details) json.details = error.details; if (error.requestId) json.requestId = error.requestId; if (error.reason) json.cause = error.reason; return json; } error; status; requestId; details; reason; constructor(options, cause) { super(options.message, { cause }); this.status = options.status ?? 500; this.details = options.details; this.requestId = options.requestId; if (typeof options.cause === "object") this.reason = { name: options.cause.name, message: options.cause.message }; else if (cause instanceof Error) this.reason = { name: cause.name, message: cause.message }; if (this.constructor.name === "HttpError") this.error = options.error ?? errorNameByStatus[this.status] ?? "HttpError"; else this.error = this.constructor.name; } }; const errorNameByStatus = { 400: "BadRequestError", 401: "UnauthorizedError", 403: "ForbiddenError", 404: "NotFoundError", 405: "MethodNotAllowedError", 409: "ConflictError", 410: "GoneError", 413: "PayloadTooLargeError", 415: "UnsupportedMediaTypeError", 429: "TooManyRequestsError", 500: "InternalServerError", 501: "NotImplementedError", 502: "BadGatewayError", 503: "ServiceUnavailableError", 504: "GatewayTimeoutError" }; //#endregion //#region ../../src/server/core/helpers/isMultipart.ts /** * Checks if the route has multipart/form-data request body. */ const isMultipart = (options) => { if (options.contentType === "multipart/form-data" || options.requestBodyType === "multipart/form-data") return true; if (options.schema?.body && "properties" in options.schema.body) { const properties = options.schema.body.properties; for (const key in properties) if (z.schema.format(properties[key]) === "binary") return true; } return false; }; //#endregion //#region ../../src/server/core/schemas/errorSchema.ts const errorSchema = z.object({ error: z.text({ description: "HTTP error name" }), status: z.integer().describe("HTTP status code"), message: z.text({ description: "Short text which describe the error", size: "rich" }), details: z.text({ description: "Detailed description of the error", size: "rich" }).optional(), requestId: z.text().optional(), cause: z.object({ name: z.text(), message: z.text({ description: "Cause Error message", size: "rich" }) }).optional() }).meta({ title: "HttpError" }).describe("Generic response after a failed operation"); //#endregion //#region ../../src/server/core/services/HttpClient.ts var HttpClient = class { log = $logger(); alepha = $inject(Alepha); cache = $cache({ provider: "memory", name: "http:client" }); pendingRequests = {}; async fetchAction(args) { const route = args.action; const options = args.options ?? {}; const config = args.config ?? {}; const host = args.host ?? ""; const request = { ...options.request }; const method = route.method; const headers = {}; 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 }); } async fetch(url, request = {}) { 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 }); 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, status: 200, statusText: "OK", headers: new Headers() }; await this.alepha.events.emit("client:beforeFetch", { url, options, request }); const isIdempotent = request.method === "GET" || request.method === "HEAD" || request.method === "OPTIONS"; const key = options.key ?? (isIdempotent ? JSON.stringify({ url, method: request.method }) : void 0); 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 = { 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" ? void 0 : options.cache); else if (!this.alepha.isBrowser()) { const etag = response.headers.get("etag") ?? void 0; 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; } url(host, action, args) { 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; } async body(init, headers, action, args = {}) { if (typeof init.headers === "object" && "content-type" in init.headers && init.headers["content-type"] === "multipart/form-data" || isMultipart(action)) { if (typeof init.headers === "object" && "content-type" in init.headers) delete init.headers["content-type"]; 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)) 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" }); } } async responseData(response, options) { 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; 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 error = new HttpError(this.alepha.codec.decode(errorSchema, json)); 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; } isMaybeFile(response) { const contentType = response.headers.get("Content-Type"); if (!contentType) return false; if (response.headers.get("Content-Disposition")?.includes("attachment")) return true; return contentType.startsWith("application/octet-stream") || contentType.startsWith("application/pdf") || contentType.startsWith("application/zip") || contentType.startsWith("image/") || contentType.startsWith("video/") || contentType.startsWith("audio/"); } createFileLike(response, defaultFileName = "") { 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(); } }; } pathVariables(url, action, args = {}) { if (typeof args.params === "object") { const params = action.schema?.params ? this.alepha.codec.decode(action.schema.params, args.params) : args.params; for (const key of Object.keys(params)) { url = url.replace(`:${key}`, params[key]); url = url.replace(`{${key}}`, params[key]); } } return url; } queryParams(url, action, args = {}) { 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] === void 0) delete query[key]; if (typeof query[key] === "object") query[key] = JSON.stringify(query[key]); } const params = new URLSearchParams(query).toString(); return params ? `${url}?${params}` : url; } return url; } }; //#endregion //#region ../../src/server/core/constants/routeMethods.ts const routeMethods = [ "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "CONNECT", "TRACE" ]; //#endregion //#region ../../src/server/core/errors/BadRequestError.ts var BadRequestError = class extends HttpError { constructor(message = "Invalid request body", cause) { super({ message, status: 400 }, cause); } }; //#endregion //#region ../../src/server/core/errors/ConflictError.ts var ConflictError = class extends HttpError { constructor(message = "Entity already exists", cause) { super({ message, status: 409 }, cause); } }; //#endregion //#region ../../src/server/core/errors/ForbiddenError.ts var ForbiddenError = class extends HttpError { constructor(message = "No permission to access this resource", cause) { super({ message, status: 403 }, cause); } }; //#endregion //#region ../../src/server/core/errors/NotFoundError.ts var NotFoundError = class extends HttpError { constructor(message = "Resource not found", cause) { super({ message, status: 404 }, cause); } }; //#endregion //#region ../../src/server/core/errors/UnauthorizedError.ts var UnauthorizedError = class extends HttpError { name = "UnauthorizedError"; constructor(message = "Not allowed to access this resource", cause) { super({ message, status: 401 }, cause); } }; //#endregion //#region ../../src/server/core/errors/ValidationError.ts var ValidationError = class extends HttpError { constructor(message = "Validation has failed", cause) { let fullMessage = message; let details; if (cause instanceof TypeBoxError) { const path = cause.cause.instancePath; fullMessage = `${message}: ${cause.cause.message}${path ? ` (${path})` : ""}`; if (path) details = path; } super({ message: fullMessage, status: 400, details }, cause); } }; //#endregion //#region ../../src/server/core/helpers/ServerReply.ts /** * Helper for building server replies. */ var ServerReply = class { headers = {}; status; body; /** * Redirect to a given URL with optional status code (default 302). */ redirect(url, status = 302) { this.status = status; this.headers.location = url; } /** * Set the response status code. */ setStatus(status) { this.status = status; return this; } /** * Set a response header. */ setHeader(name, value) { this.headers[name.toLowerCase()] = value; return this; } /** * Set the response body. */ setBody(body) { this.body = body; return this; } }; //#endregion //#region ../../src/server/core/schemas/okSchema.ts const okSchema = z.object({ ok: z.boolean().describe("True when operation succeed"), id: z.union([z.text(), z.integer()]).optional(), count: z.number().describe("Number of resources affected").optional() }).meta({ title: "Ok" }).describe("Generic response after a successful operation on a resource"); //#endregion //#region ../../src/server/core/index.browser.ts const AlephaServer = $module({ name: "alepha.server", primitives: [], services: [HttpClient] }); //#endregion export { AlephaServer, BadRequestError, ConflictError, ForbiddenError, HttpClient, HttpError, NotFoundError, ServerReply, UnauthorizedError, ValidationError, errorNameByStatus, errorSchema, isHttpError, isMultipart, okSchema, routeMethods }; //# sourceMappingURL=index.browser.js.map