UNPKG

jetpath

Version:

Jetpath - A fast, seamless and minimalist framework for Node, Deno and Bun.js. Embrace the speed and elegance of the next-gen server-side experience.

837 lines (836 loc) 28.7 kB
import { createReadStream, realpathSync } from "node:fs"; import { IncomingMessage } from "node:http"; import {} from "node:stream"; import { _JetPath_paths, abstractPluginCreator, getCtx, isNode, JetSocketInstance, parseRequest, runtime, validator, } from "./functions.js"; import { mime } from "../extracts/mimejs-extract.js"; import { resolve, sep } from "node:path"; export class JetPlugin { plugin; constructor(plugin) { this.plugin = plugin; } setup(init) { return this.plugin.executor(init); } } export class LOG { // Define ANSI escape codes for colors and styles static colors = { reset: "\x1b[0m", bright: "\x1b[1m", dim: "\x1b[2m", underscore: "\x1b[4m", blink: "\x1b[5m", reverse: "\x1b[7m", hidden: "\x1b[8m", fgBlack: "\x1b[30m", fgRed: "\x1b[31m", fgGreen: "\x1b[32m", fgYellow: "\x1b[33m", fgBlue: "\x1b[34m", fgMagenta: "\x1b[35m", fgCyan: "\x1b[36m", fgWhite: "\x1b[37m", bgBlack: "\x1b[40m", bgRed: "\x1b[41m", bgGreen: "\x1b[42m", bgYellow: "\x1b[43m", bgBlue: "\x1b[44m", bgMagenta: "\x1b[45m", bgCyan: "\x1b[46m", bgWhite: "\x1b[47m", }; static print(message, color) { console.log(`${color}%s${LOG.colors.reset}`, `${message}`); } static log(message, type) { LOG.print(message, type === "info" ? LOG.colors.fgBlue : type === "warn" ? LOG.colors.fgYellow : type === "success" ? LOG.colors.fgGreen : LOG.colors.fgRed); } } class Cookie { static parseCookieHeader(header) { return header .split("; ") .map((pair) => pair.split("=")) .reduce((acc, [key, value]) => ({ ...acc, [key.trim()]: value ? decodeURIComponent(value) : "", }), {}); } static serializeCookie(name, value, options) { const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`]; if (options.path) parts.push(`Path=${encodeURIComponent(options.path)}`); if (options.domain) { parts.push(`Domain=${encodeURIComponent(options.domain)}`); } if (options.secure) parts.push("Secure"); if (options.httpOnly) parts.push("HttpOnly"); if (options.sameSite) parts.push(`SameSite=${options.sameSite}`); if (options.maxAge) parts.push(`Max-Age=${options.maxAge}`); if (options.expires) parts.push(`Expires=${options.expires.toUTCString()}`); return parts.join("; "); } static parse(cookies) { return Cookie.parseCookieHeader(cookies); } static serialize(name, value, options = {}) { return Cookie.serializeCookie(name, value, options); } } class ctxState { state = {}; } export class Context { code = 200; request; params; /** * @internal */ $_internal_query; /** * @internal */ $_internal_body; path; connection; method; handler = null; __jet_pool = true; plugins; // ? state get state() { // ? auto clean up state object if (this._7.state["__state__"] === true) { for (const key in this._7.state) { delete this._7.state[key]; } } return this._7.state; } //? load payload = undefined; // ? header of response _2 = {}; // //? stream _3 = undefined; //? response _6 = false; //? original response res; //? state _7; constructor() { this.plugins = abstractPluginCreator(this); this._7 = new ctxState(); } send(data, statusCode, contentType, validate = true) { if (this._6 || this._3) { throw new Error("Response already set"); } if (this.handler.response && validate) { data = validator(this.handler.response, data || {}); if (typeof data === "string") { throw new Error(data); } } if (contentType) { this._2["Content-Type"] = contentType; this.payload = String(data); if (statusCode) this.code = statusCode; } else { if (typeof data === "object") { this._2["Content-Type"] = "application/json"; this.payload = JSON.stringify(data); } else { this.payload = data ? String(data) : ""; } if (statusCode) this.code = statusCode; } } redirect(url) { this.code = 301; this._2["Location"] = url; } get(field) { if (field) { if (runtime["node"]) { return this.request.headers[field]; } return this.request.headers.get(field); } return undefined; } set(field, value) { if (field && value) { this._2[field] = value; } } getCookie(name) { const cookieHeader = runtime["node"] ? this.request.headers.cookie : this.request.headers.get("cookie"); if (cookieHeader) { const cookies = Cookie.parse(cookieHeader); return cookies[name]; } return undefined; } getCookies() { const cookieHeader = runtime["node"] ? this.request.headers.cookie : this.request.headers.get("cookie"); return cookieHeader ? Cookie.parse(cookieHeader) : {}; } setCookie(name, value, options = {}) { const cookie = Cookie.serialize(name, value, options); const existingCookies = this._2["set-cookie"] || ""; const cookies = existingCookies ? existingCookies.split(",").map((c) => c.trim()) : []; cookies.push(cookie); this._2["set-cookie"] = cookies.join(", "); } clearCookie(name, options = {}) { this.setCookie(name, "", { ...options, maxAge: 0 }); } sendStream(stream, config = { folder: undefined, ContentType: "application/octet-stream", }) { if (typeof stream === "string") { if (config.folder) { let normalizedTarget; let normalizedBase; try { stream = resolve(config.folder, stream); normalizedTarget = realpathSync(stream); normalizedBase = realpathSync(config.folder); } catch (error) { throw new Error("File not found!"); } // ? prevent path traversal if (!normalizedTarget.startsWith(normalizedBase + sep)) { throw new Error("Path traversal detected!"); } } else { stream = resolve(stream); } config.ContentType = mime.getType(stream) || config.ContentType; this._2["Content-Disposition"] = `inline; filename="${stream.split("/").at(-1) || "unnamed.bin"}"`; if (runtime["bun"]) { stream = Bun.file(stream); } else if (runtime["deno"]) { // @ts-expect-error const file = Deno.open(stream).catch(() => { }); stream = file; } else { stream = createReadStream(resolve(stream), { autoClose: true, }); } } this._2["Content-Type"] = config.ContentType; this._3 = stream; } download(stream, config = { folder: undefined, ContentType: "application/octet-stream", }) { this.sendStream(stream, config); this._2["Content-Disposition"] = `attachment; filename="${stream.split("/").at(-1) || "unnamed.bin"}"`; } // Only for deno and bun sendResponse(Response) { if (!runtime["node"]) { // @ts-ignore this._6 = Response; } } // Only for deno and bun upgrade() { const req = this.request; const conn = req.headers?.["connection"] || req.headers?.get?.("connection"); if (conn?.includes("Upgrade")) { if (this.get("upgrade") != "websocket") { throw new Error("Invalid upgrade header"); } if (runtime["deno"]) { // @ts-expect-error const { socket, response } = Deno.upgradeWebSocket(req); // @ts-expect-error socket.addEventListener("open", (...p) => { JetSocketInstance.__binder("open", [socket, ...p]); }); // @ts-expect-error socket.addEventListener("message", (...p) => { JetSocketInstance.__binder("message", [socket, ...p]); }); // @ts-expect-error socket.addEventListener("drain", (...p) => { JetSocketInstance.__binder("drain", [socket, ...p]); }); // @ts-expect-error socket.addEventListener("close", (...p) => { JetSocketInstance.__binder("close", [socket, ...p]); }); this.connection = JetSocketInstance; return this.sendResponse(response); } if (runtime["bun"]) { if (this.res?.upgrade?.(req)) { this.connection = JetSocketInstance; return this.sendResponse(undefined); } } if (runtime["node"]) { throw new Error("No current websocket support for Nodejs! run with bun or deno."); } } throw new Error("Invalid upgrade headers"); } async parse(options = { validate: true }) { if (this.$_internal_body) { return this.$_internal_body; } this.$_internal_body = await parseRequest(this.request, options); //? validate body if (this.handler.body && options.validate) { this.$_internal_body = validator(this.handler.body, this.$_internal_body); } return this.$_internal_body; } parseQuery(options = { validate: true }) { if (this.$_internal_query) { return this.$_internal_query; } const queryIndex = this.request?.url?.indexOf("?"); if (queryIndex && queryIndex > -1) { const queryParams = new URLSearchParams(this.request?.url?.slice(queryIndex)); this.$_internal_query = {}; for (const [key, value] of queryParams.entries()) { const path = key .replace(/\]/g, "") .split("[") .map((k) => k.trim()); let curr = this.$_internal_query; for (let i = 0; i < path.length; i++) { const part = path[i]; if (i === path.length - 1) { curr[part] = decodeURIComponent(value); } else { curr[part] ||= {}; curr = curr[part]; } } } } if (this.handler?.query && options.validate) { this.$_internal_query = validator(this.handler.query, this.$_internal_query); } return this.$_internal_query; } } export class JetSocket { listeners = { "message": null, "close": null, "drain": null, "open": null, }; addEventListener(event, listener) { this.listeners[event] = listener; } /** * @internal */ __binder(eventName, data) { if (this.listeners[eventName]) { this.listeners[eventName]?.(...data); } } } /** * Schema builder classes */ export class SchemaBuilder { def; constructor(type, options = {}) { this.def = { type, required: false, ...options }; } required(err) { this.def.required = true; if (err) this.def.err = err; return this; } optional(err) { this.def.required = false; if (err) this.def.err = err; return this; } default(value) { this.def.inputDefaultValue = value; return this; } validate(fn) { this.def.validator = fn; return this; } regex(pattern, err) { this.def.RegExp = pattern; if (err) this.def.err = err; return this; } getDefinition() { return this.def; } } export class StringSchema extends SchemaBuilder { constructor(options = {}) { // @ts-expect-error options.inputType = "string"; super("string", options); } email(err) { return this.regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, err || "Invalid email"); } min(length, err) { return this.validate((value) => value.length >= length || err || `Minimum length is ${length}`); } max(length, err) { return this.validate((value) => value.length <= length || err || `Maximum length is ${length}`); } url(err) { return this.regex(/^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/, err || "Invalid URL"); } } export class NumberSchema extends SchemaBuilder { constructor(options = {}) { // @ts-expect-error options.inputType = "number"; super("number", options); } min(value, err) { return this.validate((val) => val >= value || err || `Minimum value is ${value}`); } max(value, err) { return this.validate((val) => val <= value || err || `Maximum value is ${value}`); } integer(err) { return this.validate((val) => Number.isInteger(val) || err || "Must be an integer"); } positive(err) { return this.validate((val) => val > 0 || err || "Must be positive"); } negative(err) { return this.validate((val) => val < 0 || err || "Must be negative"); } } export class BooleanSchema extends SchemaBuilder { constructor() { super("boolean"); } } export class ArraySchema extends SchemaBuilder { constructor(elementSchema) { super("array"); if (elementSchema) { const elementDef = elementSchema.getDefinition(); if (elementDef.type === "object" && elementDef.objectSchema) { this.def.arrayType = "object"; this.def.objectSchema = elementDef.objectSchema; } else { this.def.arrayType = elementDef.type; } } } min(length, err) { return this.validate((value) => (Array.isArray(value) && value.length >= length) || err || `Minimum length is ${length}`); } max(length, err) { return this.validate((value) => (Array.isArray(value) && value.length <= length) || err || `Maximum length is ${length}`); } nonempty(err) { return this.min(1, err || "Array cannot be empty"); } } export class ObjectSchema extends SchemaBuilder { constructor(shape) { super("object"); if (shape) { this.def.objectSchema = {}; for (const [key, builder] of Object.entries(shape)) { this.def.objectSchema[key] = builder.getDefinition(); } } } shape(shape) { this.def.objectSchema = {}; for (const [key, builder] of Object.entries(shape)) { this.def.objectSchema[key] = builder.getDefinition(); } return this; } } export class DateSchema extends SchemaBuilder { constructor() { super("date"); } min(date, err) { const minDate = new Date(date); return this.validate((value) => new Date(value) >= minDate || err || `Date must be after ${minDate}`); } max(date, err) { const maxDate = new Date(date); return this.validate((value) => new Date(value) <= maxDate || err || `Date must be before ${maxDate}`); } future(err) { return this.validate((value) => new Date(value) > new Date() || err || "Date must be in the future"); } past(err) { return this.validate((value) => new Date(value) < new Date() || err || "Date must be in the past"); } } export class FileSchema extends SchemaBuilder { constructor(options = {}) { // @ts-expect-error options.inputType = "file"; super("file", options); } maxSize(bytes, err) { return this.validate((value) => value.size <= bytes || err || `File size must be less than ${bytes} bytes`); } mimeType(types, err) { const allowedTypes = Array.isArray(types) ? types : [types]; return this.validate((value) => allowedTypes.includes(value.mimeType) || err || `File type must be one of: ${allowedTypes.join(", ")}`); } } export class SchemaCompiler { static compile(schema) { const compiled = {}; for (const [key, builder] of Object.entries(schema)) { compiled[key] = builder.getDefinition(); } return compiled; } } class TrieNode { // ? child nodes children = new Map(); // ? parameter node parameterChild; paramName; // ? wildcard node wildcardChild; // ? route handler handler; constructor() { this.parameterChild = undefined; this.paramName = undefined; this.wildcardChild = undefined; this.handler = undefined; } } /** * Represents the Trie data structure for storing and matching URL routes. */ export class Trie { root; method; hashmap = {}; constructor(method) { this.root = new TrieNode(); this.method = method; } /** * Inserts a route path and its associated handler into the Trie. */ insert(path, handler) { // ? remove leading/trailing slashes, handle empty path if (!/(\*|:)+/.test(path)) { this.hashmap[path] = handler; return; } let normalizedPath = path.trim(); if (normalizedPath.startsWith("/")) { normalizedPath = normalizedPath.slice(1); } if (normalizedPath.endsWith("/") && normalizedPath.length > 0) { normalizedPath = normalizedPath.slice(0, -1); } // ? Handle the root path explicitly if (normalizedPath === "") { if (this.root.handler) { LOG.log(`Warning: Duplicate route definition for path ${this.method} ${path}`, "warn"); } this.root.handler = handler; return; } const segments = normalizedPath.split("/"); let currentNode = this.root; for (let i = 0; i < segments.length; i++) { const segment = segments[i]; // ? Check for parameter segment (starts with :) if (segment.startsWith(":")) { const paramName = segment.slice(1); if (!paramName) { throw new Error(`Invalid route path: Parameter segment in ${this.method} ${path} '${segment}' is missing a name.`); } // ? Check if a parameter node already exists at this level if (currentNode.parameterChild) { if (currentNode.parameterChild.paramName !== paramName) { LOG.log(`Warning: Route path conflict at segment '${segment}' in ${this.method} ${path}. Parameter ': ${currentNode.parameterChild.paramName}' already defined at this level.`, "warn"); } currentNode = currentNode.parameterChild; // ? add parameter to same section child handlers. if (!handler.params) { handler.params = {}; } // @ts-ignore handler.params[paramName] = ""; } else if (currentNode.children.has(segment)) { throw new Error(`Route path conflict: Fixed segment '${segment}' already exists at this level in ${this.method} ${path}.`); } else if (currentNode.wildcardChild) { throw new Error(`Invalid route path: Parameter segment '${segment}' cannot follow a wildcard '*' at the same level in ${this.method} ${path}.`); } else { if (!handler.params) { handler.params = {}; } handler.params[paramName] = ""; const newNode = new TrieNode(); newNode.paramName = paramName; currentNode.parameterChild = newNode; currentNode = newNode; } } // ? Check for wildcard segment (*) - typically only allowed at the end else if (segment === "*") { if (i !== segments.length - 1) { throw new Error(`Invalid route path: Wildcard '*' is only allowed at the end of a path pattern in ${this.method} ${path}.`); } if (currentNode.wildcardChild) { LOG.log(`Warning: Duplicate wildcard definition at segment '${segment}' in ${this.method} ${path}.`, "warn"); currentNode = currentNode.wildcardChild; } else if (currentNode.parameterChild) { throw new Error(`Invalid route path: Wildcard '*' cannot follow a parameter at the same level in ${this.method} ${path}.`); } else if (currentNode.children.has(segment)) { throw new Error(`Route path conflict: Fixed segment '${segment}' already exists at this level in ${this.method} ${path}.`); } else { const newNode = new TrieNode(); currentNode.wildcardChild = newNode; currentNode = newNode; } //? No need to process further segments after a wildcard break; } //? Handle fixed segment else { if (currentNode.parameterChild) { throw new Error(`Route path conflict: Fixed segment '${segment}' conflicts with existing parameter ': ${currentNode.parameterChild.paramName}' at this level in ${this.method} ${path}.`); } if (currentNode.wildcardChild) { throw new Error(`Route path conflict: Fixed segment '${segment}' conflicts with existing wildcard '*' at this level in ${this.method} ${path}.`); } // Check if the fixed child node already exists if (!currentNode.children.has(segment)) { // Create a new node for the fixed segment currentNode.children.set(segment, new TrieNode()); } // Move to the next node currentNode = currentNode.children.get(segment); } } if (currentNode.handler) { LOG.log(`Warning: Duplicate route definition for path '${path}'.`, "warn"); } //? Set the handler and original path currentNode.handler = handler; } get_responder(req, res) { let normalizedPath = req.url; // ? Handle absolute paths in non-node environments if (!isNode) { const pathStart = normalizedPath.indexOf("/", 7); normalizedPath = pathStart >= 0 ? normalizedPath.slice(pathStart) : normalizedPath; } // ? Check if route is cached if (this.hashmap[normalizedPath]) { return getCtx(req, res, normalizedPath, this.hashmap[normalizedPath]); } //? Handle query parameters const queryIndex = normalizedPath.indexOf("?"); if (queryIndex > -1) { // ? Extract query parameters normalizedPath = normalizedPath.slice(0, queryIndex); if (this.hashmap[normalizedPath]) { return getCtx(req, res, normalizedPath, this.hashmap[normalizedPath], undefined); } } // ? Handle leading and trailing slashes if (normalizedPath.startsWith("/")) { normalizedPath = normalizedPath.slice(1); } // ? Handle trailing slash if (normalizedPath.endsWith("/") && normalizedPath.length > 0) { normalizedPath = normalizedPath.slice(0, -1); } // ? Handle empty path if (normalizedPath === "") { if (this.root.handler) { return getCtx(req, res, normalizedPath, this.root.handler, undefined); } } let currentNode = this.root; const params = {}; const segments = normalizedPath.split("/"); for (let i = 0; i < segments.length; i++) { const segment = segments[i]; if (currentNode.children.has(segment)) { // ? fixed segment match currentNode = currentNode.children.get(segment); } else if (currentNode.parameterChild) { // ? parameter segment match const name = currentNode.parameterChild.paramName; params[name] = decodeURIComponent(segment); currentNode = currentNode.parameterChild; } else if (currentNode.wildcardChild) { // ? wildcard segment match params["*"] = segments.slice(i).join("/"); currentNode = currentNode.wildcardChild; break; } else { // ? No match return undefined; } } if (currentNode.handler) { // ? Route found return getCtx(req, res, normalizedPath, currentNode.handler, params); } } } class MockRequest { method; url; headers; body; statusCode; statusMessage; bodyUsed; constructor(options = {}) { this.method = options.method || "GET"; this.url = options.url || "/"; this.headers = options.headers || new Map(); this.body = options.body || null; this.statusCode = 200; this.statusMessage = "OK"; this.bodyUsed = false; } } export class JetServer { /* internal method */ options = {}; constructor(options) { Object.assign(this.options, options || {}); } /* internal method */ async _run(func, ctx) { let returned; const r = func; if (!ctx) { ctx = getCtx(new MockRequest({ method: r.method, url: r.path, headers: new Map(), body: null, }), {}, r.path, r, {}); } try { //? pre-request middlewares here returned = r.jet_middleware?.length ? await Promise.all(r.jet_middleware.map((m) => m(ctx))) : undefined; //? route handler call await r(ctx); //? post-request middlewares here returned && await Promise.all(returned.map((m) => m?.(ctx, null))); // } catch (error) { console.log(error); try { //? report error to error middleware returned && await Promise.all(returned.map((m) => m?.(ctx, error))); } finally { if (!returned && ctx.code < 400) { ctx.code = 500; } // } } return { code: ctx.code, body: typeof ctx.payload !== "string" ? ctx.payload : JSON.parse(ctx.payload), headers: ctx._2, }; } runBare(func) { return this._run(func); } runWithCTX(func, ctx) { return this._run(func, ctx); } createCTX(req, res, path, handler, params) { return getCtx(req, res, path, handler, params); } }