Skip to main content
Module

x/fastro/http/server.ts

The Web Framework for Full Stack Apps
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985
// deno-lint-ignore-fileimport { ConnInfo, contentType, extname, Handler, ReactDOMServer, Server, Status, STATUS_TEXT, toHashString,} from "./deps.ts";import { Render } from "./render.tsx";
type ServerHandler = Deno.ServeHandler | Handler;
type HandlerArgument = | ServerHandler | RequestHandler | MiddlewareArgument;
export interface Next { (error?: Error, data?: unknown): unknown;}
export type Hook = ( server: Fastro, request: Request, info: Info,) => Response | Promise<Response>;export class HttpRequest extends Request { record!: Record<string, any>; match?: URLPatternResult | null; params?: Record<string, string | undefined>; query?: Record<string, string>; [key: string]: any;}
export type Info = Deno.ServeHandlerInfo | ConnInfo;
type Meta = { name?: string; content?: string; charset?: string; property?: string; itemprop?: string;};
type Script = { type?: string; src?: string; crossorigin?: "anonymous" | "use-credentials" | "" | undefined; nonce?: string; integrity?: string;};
type Link = { href?: string; rel?: string; integrity?: string; media?: string; crossorigin?: "anonymous" | "use-credentials" | "" | undefined;};
type ModuleFunction = (f: Fastro) => Fastro;
export type RenderOptions = { build?: boolean; cache?: boolean; pageFolder?: string; status?: number; props?: unknown; development?: boolean; html?: { lang?: string; class?: string; style?: React.CSSProperties; head?: { title?: string; descriptions?: string; meta?: Meta[]; script?: Script[]; link?: Link[]; }; body?: { class?: string; style?: React.CSSProperties; script?: Script[]; root: { class?: string; style?: React.CSSProperties; }; }; };};
export const hydrateFolder = ".hydrate";
export class Context { server!: Fastro; info!: Info; [key: string]: any; render!: (options?: RenderOptions) => Response | Promise<Response>; send!: (data: unknown, status?: number) => Response | Promise<Response>;}
type RequestHandler = ( request: HttpRequest, ctx: Context,) => | Response | Promise<Response>;
type MiddlewareArgument = ( request: HttpRequest, ctx: Context, next: Next,) => | Promise<unknown> | unknown;
type RouteNest = { handler: HandlerArgument; method?: string; pathname?: string; params?: Record<string, string | undefined>; query?: Record<string, string>; match?: URLPatternResult; element?: Component; handlers?: Array<MiddlewareArgument>;};
type Route = { method: string; path: string; handler: HandlerArgument;};
type Middleware = { method?: string; path?: string; handler: MiddlewareArgument;};
export type Component = FunctionComponent | JSX.Element;
export type FunctionComponent = (props?: any) => JSX.Element;type Page = { path: string; element: Component; handlers: Array<MiddlewareArgument>;};
export interface Fastro { /** * Immediately close the server listeners and associated HTTP connections. * @returns void */ close: () => void; /** * Add application level middleware * * ### Example * * ```ts * import fastro from "../mod.ts"; * * const f = new fastro(); * f.use((req: HttpRequest, _ctx: Context, next: Next) => { * console.log(`${req.method} ${req.url}`); * return next(); * }); * * await f.serve(); * ``` * @param handler */ use(...handler: Array<HandlerArgument>): Fastro; get(path: string, ...handler: Array<HandlerArgument>): Fastro; post(path: string, ...handler: Array<HandlerArgument>): Fastro; put(path: string, ...handler: Array<HandlerArgument>): Fastro; delete(path: string, ...handler: Array<HandlerArgument>): Fastro; patch(path: string, ...handler: Array<HandlerArgument>): Fastro; options(path: string, ...handler: Array<HandlerArgument>): Fastro; head(path: string, ...handler: Array<HandlerArgument>): Fastro; /** * Allow you access Server, Request, and Info after Middleware, Routes, Pages, and Static File Processing. * It can return `Response`, `Promise<Response>` or `void`. * * ### Example * * ```ts * import fastro, { Fastro, Info } from "../mod.ts"; * * const f = new fastro(); * * f.hook((_f: Fastro, _r: Request, _i: Info) => new Response("Hello World")); * * await f.serve(); * ``` * * @param hook */ hook(hook: Hook): Fastro; /** * Allow you to access static files with custom `path`, `folder`, and `maxAge` * * ### Example * * ```ts * import fastro from "../mod.ts"; * * const f = new fastro(); * * f.static("/static", { folder: "static", maxAge: 90 }); * * await f.serve(); * ``` * @param path * @param options */ static(path: string, options?: { maxAge?: number; folder?: string }): Fastro; /** * Allow you to define SSR page with custom `path`, `element`, and `handler` * * ### Example * * ```ts * import fastro, { Context, HttpRequest } from "../mod.ts"; * import user from "../pages/user.tsx"; * * const f = new fastro(); * * f.static("/static", { folder: "static", maxAge: 90 }); * * f.page("/", user, (_req: HttpRequest, ctx: Context) => { * const options = { * props: { data: "Guest" }, * status: 200, * html: { head: { title: "React Component" } }, * }; * return ctx.render(options); * }, * ); * await f.serve(); * ``` * @param path * @param element * @param handler */ page( path: string, element: Component, ...handler: Array<MiddlewareArgument> ): Fastro; register(mf: ModuleFunction): Fastro; getStaticFolder(): string; getStaticPath(): string; getDevelopmentStatus(): boolean; /** * Add a handler directly * * @param method * @param path * @param handler */ push( method?: string, path?: string, ...handler: Array<HandlerArgument> ): Fastro; onListen(handler: ListenHandler): void; finished(): Promise<void> | undefined; getNest(): Nest; /** * Serves HTTP requests * * If the server was constructed without a specified port, 8000 is used. */ serve(): Promise<void>;}
type ListenHandler = (params: { hostname: string; port: number }) => void;
type Static = { file: string; contentType: string;};
type Nest = Record< string, any>;
export const BUILD_ID = Deno.env.get("DENO_DEPLOYMENT_ID") || toHashString( new Uint8Array( await crypto.subtle.digest( "sha-1", new TextEncoder().encode(crypto.randomUUID()), ), ), "hex",);
export default class HttpServer implements Fastro { #server: Deno.Server | Server | undefined; #routes: Route[]; #middlewares: Middleware[]; #pages: Page[]; #patterns: Record<string, URLPattern>; #root: URLPattern; #port; #ac; #staticUrl: string; #staticFolder: string; #maxAge: number; record: Record<string, any>; #development: boolean; #nest: Nest; #body: ReadableStream<any> | undefined; #listenHandler: ListenHandler | undefined; #hook: Hook | undefined;
constructor(options?: { port?: number }) { this.#port = options?.port ?? 8000; this.#routes = []; this.#middlewares = []; this.#pages = []; this.#hook = undefined; this.#patterns = {}; this.#nest = {}; this.record = {}; this.#root = new URLPattern({ pathname: "/*" }); this.#ac = new AbortController(); this.#staticUrl = ""; this.#staticFolder = ""; this.#maxAge = 0; this.#development = this.#getDevelopment(); if (this.#development) this.#handleDevelopment(); const status = this.#development ? "Development" : "Production"; console.log( `%cStatus %c${status}`, "color: blue", "color: white", ); }
getNest(): Nest { return this.#nest; }
#getDevelopment = () => { return Deno.env.get("DEVELOPMENT") === "false" ? false : Deno.args[0] === "--development"; };
serve = async (options?: { port: number }) => { const port = options?.port ?? this.#port; if (Deno.env.get("DENO_DEPLOYMENT_ID")) { this.#server = new Server({ port, handler: this.#handleRequest, onError: this.#handleError, });
if (this.#listenHandler) { this.#listenHandler({ hostname: "localhost", port: this.#port }); } else { console.info(`Listening on http://locahost:${this.#port}`); } return this.#server.listenAndServe(); }
const [s] = await this.#build(); if (s) return;
this.#server = Deno.serve({ port, handler: this.#handleRequest, onListen: this.#listenHandler, onError: this.#handleError, signal: this.#ac.signal, }); };
#hydrateExist = async () => { try { for await (const dirEntry of Deno.readDir(`${Deno.cwd()}`)) { if (dirEntry.name === hydrateFolder) { return true; } } } catch { return false; } return false; };
#build = async () => { if (!await this.#hydrateExist()) { await Deno.mkdir(`${Deno.cwd()}/${hydrateFolder}`); } for (let index = 0; index < this.#pages.length; index++) { const page = this.#pages[index]; if (this.#isJSX(page.element as JSX.Element)) continue; const c = page.element as FunctionComponent; this.#createHydrateFile(c.name); console.log( `%c${c.name.toLowerCase()}.hydrate.tsx %cCreated!`, "color: blue", "color: white", ); }
if (Deno.run == undefined) return []; return Deno.args.filter((v) => v === "--hydrate"); };
#createHydrateFile = async (elementName: string) => { try { const target = `${Deno.cwd()}/${hydrateFolder}/${elementName.toLowerCase()}.hydrate.tsx`; await Deno.writeTextFile( target, this.#createHydrate(elementName), ); } catch (error) { return; } };
#createHydrate(comp: string) { const [s] = Deno.args.filter((v) => v === "--development"); const dev = s === "--development" ? "?dev" : "";
return `import { hydrateRoot } from "https://esm.sh/react-dom@18.2.0/client${dev}";import React from "https://esm.sh/react@18.2.0${dev}";import ${comp} from "../pages/${comp.toLowerCase()}.tsx";declare global { interface Window { // deno-lint-ignore no-explicit-any __INITIAL_DATA__: any; }}const props = window.__INITIAL_DATA__ || {};delete window.__INITIAL_DATA__;hydrateRoot(document.getElementById("root") as Element, <${comp} {...props} />);`; }
onListen = (handler: ListenHandler) => { this.#listenHandler = handler; };
push( method?: string, path?: string, ...handler: Array<HandlerArgument> ) { if (method && path) { this.#patterns[path] = new URLPattern({ pathname: path, });
if (handler.length === 1) { return this.#pushHandler(method, path, handler[0]); } this.#pushHandler(method, path, handler[handler.length - 1]);
for (let i = 0; i < handler.length - 1; i++) { this.#pushMiddleware( <MiddlewareArgument> <unknown> handler[i], method, path, ); } } else { for (let i = 0; i < handler.length; i++) { this.#pushMiddleware( <MiddlewareArgument> <unknown> handler[i], ); } }
return this; }
#pushHandler( method: string, path: string, handler: HandlerArgument, ) { this.#routes.push({ method, path, handler }); return this; }
#pushMiddleware( handler: MiddlewareArgument, method?: string, path?: string, ) { this.#middlewares.push({ method, path, handler }); return this; }
use(...handler: HandlerArgument[]) { return this.push(undefined, undefined, ...handler); }
get(path: string, ...handler: Array<HandlerArgument>) { return this.push("GET", path, ...handler); }
post(path: string, ...handler: Array<HandlerArgument>) { return this.push("POST", path, ...handler); }
put(path: string, ...handler: Array<HandlerArgument>) { return this.push("PUT", path, ...handler); }
head(path: string, ...handler: Array<HandlerArgument>) { return this.push("HEAD", path, ...handler); }
options(path: string, ...handler: Array<HandlerArgument>) { return this.push("OPTIONS", path, ...handler); }
delete(path: string, ...handler: Array<HandlerArgument>) { return this.push("DELETE", path, ...handler); }
patch(path: string, ...handler: Array<HandlerArgument>) { return this.push("PATCH", path, ...handler); }
static(path: string, options?: { maxAge?: number; folder?: string }) { this.#staticUrl = path; if (options?.folder) this.#staticFolder = options?.folder; if (options?.maxAge) this.#maxAge = options.maxAge; return this; }
page( path: string, element: Component, ...handlers: MiddlewareArgument[] ): Fastro { this.#patterns[path] = new URLPattern({ pathname: path, }); this.#pages.push({ path, element, handlers }); return this; }
getDevelopmentStatus() { return this.#development; }
#handleDevelopment = () => { const refreshPath = `/___refresh___`; this.#patterns[refreshPath] = new URLPattern({ pathname: refreshPath, }); const refreshStream = (_req: Request) => { let timerId: number | undefined = undefined; this.#body = new ReadableStream({ start(controller) { controller.enqueue(`data: ${BUILD_ID}\nretry: 100\n\n`); timerId = setInterval(() => { controller.enqueue(`data: ${BUILD_ID}\n\n`); }, 1000); }, cancel() { if (timerId !== undefined) { clearInterval(timerId); } }, }); return new Response(this.#body.pipeThrough(new TextEncoderStream()), { headers: { "content-type": "text/event-stream", }, }); }; this.#pushHandler("GET", refreshPath, refreshStream); };
#handleError = (error: unknown) => { const err: Error = error as Error; console.error(error); return new Response(err.stack); };
#handleHook = async (h: Hook, r: Request, i: Info) => { const x = await h(this, r, i); if (this.#isResponse(x)) return x; };
#handleRequest = async ( req: Request, i: Info, ) => { const m = await this.#findMiddleware(req, i); if (m) return this.#handleResponse(m);
const r = this.#findRoute(req.method, req.url); if (r?.handler) { const h = r?.handler as RequestHandler; const res = await h( this.#transformRequest(this.record, req, r.params, r.query, r.match), this.#initContext(i), ); return this.#handleResponse(res); }
const p = this.#findPage(req); if (p && p.handlers && p.match && p.element) { const result = await this.#handlePageMiddleware( p.handlers, req, i, p.match, p.element, );
if (result) return this.#handleResponse(result); }
const s = (await this.#findStaticFiles(this.#staticUrl, req.url)) as Static; if (s) { return new Response(s.file, { headers: { "Content-Type": s.contentType, "Cache-Control": `max-age=${this.#maxAge}`, }, }); }
const b = await this.#handleBinary(this.#staticUrl, req.url); if (b) return this.#handleResponse(b);
const h = this.#findHook(); if (h) { const res = await this.#handleHook(h, req, i); if (res) return this.#handleResponse(res); }
return new Response(STATUS_TEXT[Status.NotFound], { status: Status.NotFound, }); };
hook = (hook: Hook) => { this.#hook = hook; return this; };
#findHook = () => { return this.#hook; };
#findPage = (r: Request) => { if (this.#pages.length === 0) return null; let page: Page | undefined = undefined; const nestID = `p${r.url}`; const p = this.#nest[nestID]; if (p) return p as RouteNest; let pattern: URLPattern | null = null; for (let index = 0; index < this.#pages.length; index++) { const p = this.#pages[index]; const m = this.#patterns[p.path].test(r.url); if (m) { page = p; pattern = this.#patterns[p.path]; break; } }
const e = pattern?.exec(r.url); if (!e) return this.#nest[nestID] = null; return this.#nest[nestID] = { params: e.pathname.groups, query: this.#iterableToRecord(new URL(r.url).searchParams), handler: () => {}, match: e, handlers: page?.handlers, element: page?.element, }; };
#handlePageMiddleware = async ( handlers: MiddlewareArgument[], r: Request, i: Info, match: URLPatternResult, element: Component, ) => { const searchParams = new URL(r.url).searchParams; const ctx = this.#initContext(i, element, r); let result: unknown;
for (let index = 0; index < handlers.length; index++) { const h = handlers[index]; const req = this.#transformRequest( this.record, r, match?.pathname.groups, this.#iterableToRecord(searchParams), match, );
const x = await h(req, ctx, (error, data) => { if (error) throw error; return data; });
if (this.#isResponse(x)) { result = x; break; } }
return result; };
#findMiddleware = async (r: Request, i: Info) => { if (this.#middlewares.length === 0) return undefined; const method: string = r.method, url: string = r.url; const ctx = this.#initContext(i); const searchParams = new URL(r.url).searchParams; let result: unknown;
for (let index = 0; index < this.#middlewares.length; index++) { const m = this.#middlewares[index]; const nestId = `${method}${m.method}${m.path}${url}`;
const match = this.#findMatch(m, nestId, url); if (!match) continue; const req = this.#transformRequest( this.record, r, match?.pathname.groups, this.#iterableToRecord(searchParams), match, ); const x = await m.handler( req, ctx, (error, data) => { if (error) throw error; return data; }, );
if (this.#isResponse(x)) { result = x; break; } }
return result; };
#findMatch( m: Middleware | Page, nestId: string, url: string, ) { const r = this.#nest[nestId]; if (r) return r as URLPatternResult;
const pattern = m.path ? this.#patterns[m.path] : this.#root; const result = pattern.exec(url); if (result) { return this.#nest[nestId] = result; } }
#findRoute(method: string, url: string) { const nestID = `route${method + url}`; const r = this.#nest[nestID]; if (r) return r;
let h: Route | undefined = undefined; let p: URLPattern | null = null; for (let index = 0; index < this.#routes.length; index++) { const r = this.#routes[index]; const m = this.#patterns[r.path].test(url); if ((r.method === method) && m) { h = r; p = this.#patterns[r.path]; break; } } if (!h) return this.#nest[nestID] = null;
const e = p?.exec(url); if (!e) return this.#nest[nestID] = null; return this.#nest[nestID] = { params: e.pathname.groups, query: this.#iterableToRecord(new URL(url).searchParams), handler: h?.handler, match: e, }; }
#handleResponse(res: any, status = 200) { if (this.#isString(res)) return new Response(res, { status }); if (this.#isResponse(res)) return res as Response; if (this.#isJSX(res)) return this.#renderToString(res, status); if (this.#isJSON(res) || Array.isArray(res)) { return Response.json(res, { status }); } return new Response(res, { status }); }
getStaticPath() { return this.#staticUrl; }
getStaticFolder() { return this.#staticFolder; }
#findStaticFiles = async (url: string, reqUrl: string) => { const [nestID, pathname] = this.#nestIDPathname(url, reqUrl); if (this.#nest[nestID]) return this.#nest[nestID];
const pattern = new URLPattern({ pathname }); const match = pattern.exec(reqUrl); if (!match) return this.#nest[nestID] = null;
const input = match?.pathname.groups["0"]; const filePath = `${this.#staticFolder}/${input}`; const ct = contentType(extname(filePath)) || "application/octet-stream";
const binary = ["png", "jpeg", "jpg", "gif", "pdf", "aac"]; const b = binary.filter((v) => ct.includes(v)); if (b.length > 0) return this.#nest[nestID] = null;
let file; try { file = await Deno.readTextFile(`./${filePath}`); } catch { return this.#nest[nestID] = null; }
return this.#nest[nestID] = { contentType: ct, file }; };
#nestIDPathname = (url: string, reqUrl: string) => { const staticUrl = url === "/" ? "" : url; const pathname = `${staticUrl}/*`; const nestID = `${pathname}${reqUrl}`;
return [nestID, pathname]; };
#handleBinary = async (url: string, reqUrl: string) => { const [nestID, pathname] = this.#nestIDPathname(url, reqUrl); try { const match = new URLPattern({ pathname }).exec(reqUrl); const filePath = `${this.#staticFolder}/${match?.pathname.groups["0"]}`; const ct = contentType(extname(filePath)) || "application/octet-stream";
if (filePath === "/") return this.#nest[nestID] = null; const file = await Deno.open(`./${filePath}`, { read: true }); return new Response(file.readable, { headers: { "Content-Type": ct, "Cache-Control": `max-age=${this.#maxAge}`, }, }); } catch { return this.#nest[nestID] = null; } };
#iterableToRecord( params: URLSearchParams, ) { const record: Record<string, string> = {}; params.forEach((v, k) => (record[k] = v)); return record; }
#initContext( info: Info, element?: Component, req?: Request, ) { const ctx = new Context(); ctx.server = this; ctx.info = info; ctx.render = (options?: RenderOptions) => { if (!element) return new Response("Component not found"); return this.#renderElement(element, options, req); }; ctx.send = (data: unknown, status?: number) => { return this.#handleResponse(data, status); }; return ctx; }
#transformRequest( record: Record<string, any>, r: Request, params?: Record<string, string | undefined> | undefined, query?: Record<string, string>, match?: URLPatternResult | null, ) { const req = r as HttpRequest; req.record = record; req.match = match; req.params = params; req.query = query; return req; }
#renderToString(element: JSX.Element, status?: number) { const component = ReactDOMServer.renderToString(element); return new Response(component, { status, headers: { "content-type": "text/html", }, }); }
#renderElement( element: Component, options?: RenderOptions, req?: Request, ) { const opt = options ?? { html: {} }; const r = new Render(element, opt, this.#nest, this, req); return r.render(); }
#isJSX(res: JSX.Element) { return res && res.props != undefined && res.type != undefined; }
#isString(res: any) { return typeof res === "string"; }
#isResponse(res: any) { return res instanceof Response; }
#isJSON(val: unknown) { try { const s = JSON.stringify(val); JSON.parse(s); return true; } catch { return false; } }
finished = () => { if (this.#server instanceof Server) return; return this.#server?.finished; };
register = (mf: ModuleFunction) => { return mf(this); };
close() { if (!this.#server) return; if (this.#server instanceof Server) { return this.#server.close(); }
this.#server.finished.then(() => console.log("Server closed")); console.log("Closing server..."); this.#ac.abort(); }}