alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
453 lines (452 loc) • 13.8 kB
JavaScript
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