diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e40716f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "deno.enable": true, + "deno.lint": true, + "deno.unstable": true +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..20caf81 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## v0.1.0 (2022-02-06) + +Initial release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e85e052 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) dragonwocky (https://dragonwocky.me) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfee78c --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# 🐍 nadder + +**nadder** is a HTTP and WebSocket router for Deno, +built primarily for personal use. + +- It can handle **any request method**. +- It matches routes based on the + **[URL Pattern API](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API)**. +- It includes a **PostgreSQL** connection wrapper. +- It includes in-memory or PostgresSQL **session storage** with garbage collection and expiry. +- It includes a **[Windi CSS](https://windicss.org/) processor**. +- It includes a **JSX transformer** (without React). +- It provides utilities for responding to requests with + **static files, HTML, JSON, or HTTP status codes**. +- It provides simple **reading and manipulation of cookies**. + +## Quick start + +```tsx +/** + * @jsx h + * @jsxFrag jsxFrag + */ + +import 'https://deno.land/x/dotenv/load.ts'; + +import { + h, + jsxFrag, + jsxResponse, + postgresConnection, + postgresSession, + route, + serve, + windiInstance, +} from 'https://deno.land/x/nadder/mod.ts'; + +const postgres = postgresConnection({ + password: Deno.env.get('POSTGRES_PWD'), + hostname: Deno.env.get('POSTGRES_HOST'), + }), + session = await postgresSession(postgres); + +route('GET', '/{index.html}?', async (ctx) => { + const count = (((await session.get(ctx, 'count')) as number) ?? -1) + 1; + await session.set(ctx, 'count', count); + + const { tw, sheet } = windiInstance(); + jsxResponse( + ctx, + <> +

+ Page load count: {count} +

+ {sheet()} + + ); +}); + +serve(); +``` + +All other features are also made available as exports of the `mod.ts` file +(inc. e.g. registering WebSocket listeners, sending JSON responses, serving files +and initialising in-memory session storage). + +For convenience, the following dependencies are re-exported: + +- `setCookie`, `deleteCookie`, `Cookie`, `HTTPStatus` and `HTTPStatusText` from [`std/http`](https://deno.land/std/http). +- `contentType` from [`https://deno.land/x/media_types`](https://deno.land/x/media_types) + +--- + +Changes to this project are recorded in the [CHANGELOG](CHANGELOG.md). + +This project is licensed under the [MIT License](LICENSE). + +To support future development of this project, please consider +[sponsoring the author](https://github.com/sponsors/dragonwocky). diff --git a/deps.ts b/deps.ts new file mode 100644 index 0000000..e0a97af --- /dev/null +++ b/deps.ts @@ -0,0 +1,23 @@ +export * as path from "https://deno.land/std@0.125.0/path/mod.ts"; +export { readableStreamFromReader } from "https://deno.land/std@0.125.0/streams/mod.ts"; + +export { serve as stdServe } from "https://deno.land/std@0.125.0/http/server.ts"; + +export type { Cookie } from "https://deno.land/std@0.125.0/http/cookie.ts"; +export { + deleteCookie, + getCookies, + setCookie, +} from "https://deno.land/std@0.125.0/http/cookie.ts"; + +export { + Status as HTTPStatus, + STATUS_TEXT as HTTPStatusText, +} from "https://deno.land/std@0.125.0/http/http_status.ts"; + +export { contentType } from "https://deno.land/x/media_types@v2.12.1/mod.ts"; + +export { default as WindiProcessor } from "https://esm.sh/windicss@3.4.3"; +export { StyleSheet } from "https://esm.sh/windicss@3.4.3/utils/style"; + +export * as postgres from "https://deno.land/x/postgres@v0.15.0/mod.ts"; diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..75d5acb --- /dev/null +++ b/mod.ts @@ -0,0 +1,27 @@ +/** + * nadder + * (c) 2022 dragonwocky (https://dragonwocky.me/) + * (https://github.com/dragonwocky/nadder) under the MIT license + */ + +export { postgresConnection } from "./postgres.ts"; +export { + fileResponse, + htmlResponse, + jsonResponse, + jsxResponse, + statusResponse, +} from "./response.ts"; +export { route, serve, ws } from "./server.ts"; +export { memorySession, postgresSession } from "./session.ts"; +export { h, jsxFrag, jsxToString, windiInstance } from "./ssr.tsx"; +export { + contentType, + deleteCookie, + HTTPStatus, + HTTPStatusText, + setCookie, +} from "./deps.ts"; + +export type { HTTPMethod, RouteContext, SocketContext } from "./server.ts"; +export type { Cookie } from "./deps.ts"; diff --git a/postgres.ts b/postgres.ts new file mode 100644 index 0000000..e2662da --- /dev/null +++ b/postgres.ts @@ -0,0 +1,36 @@ +/** + * nadder + * (c) 2022 dragonwocky (https://dragonwocky.me/) + * (https://github.com/dragonwocky/nadder) under the MIT license + */ + +import { postgres } from "./deps.ts"; + +export const postgresConnection = ({ + user = "postgres", + password = Deno.env.get("POSTGRES_PWD"), + hostname = Deno.env.get("POSTGRES_HOST"), + port = "6543", + database = "postgres", +} = {}) => { + // creates lazy/on-demand connections + // for concurrent query execution handling + // = more performant and reusable than a normal client + const config = { user, password, hostname, port, database }, + pool = new postgres.Pool(config, 3, true), + query = async (...args: unknown[]): Promise => { + const connection = await pool.connect(); + try { + // @ts-ignore pass all args + const res = await connection.queryObject(...args); + return res; + } catch (err) { + console.error(err); + return err; + } finally { + // returns connection to the pool for reuse + connection.release(); + } + }; + return query; +}; diff --git a/response.ts b/response.ts new file mode 100644 index 0000000..55732ae --- /dev/null +++ b/response.ts @@ -0,0 +1,50 @@ +/** + * nadder + * (c) 2022 dragonwocky (https://dragonwocky.me/) + * (https://github.com/dragonwocky/nadder) under the MIT license + */ + +import { RouteContext } from "./server.ts"; +import { jsxToString } from "./ssr.tsx"; +import { + contentType, + HTTPStatus, + HTTPStatusText, + path, + readableStreamFromReader, +} from "./deps.ts"; + +export const jsonResponse = (ctx: RouteContext, data: unknown) => { + if (data instanceof Map || data instanceof Set) data = [...data]; + ctx.res.body = JSON.stringify(data, null, 2); + ctx.res.status = HTTPStatus.OK; + ctx.res.headers.set("content-type", contentType("json")!); +}; + +export const statusResponse = (ctx: RouteContext, status: number) => { + ctx.res.body = `${status} ${HTTPStatusText.get(status) ?? ""}`; + ctx.res.status = status; +}; + +export const htmlResponse = (ctx: RouteContext, html: string) => { + ctx.res.body = html; + ctx.res.status = HTTPStatus.OK; + ctx.res.headers.set("content-type", contentType("html")!); +}; + +export const jsxResponse = (ctx: RouteContext, $: JSX.Element) => { + htmlResponse(ctx, `${jsxToString($)}`); +}; + +export const fileResponse = async (ctx: RouteContext, filepath: string) => { + try { + const stat = await Deno.stat(filepath); + if (stat.isDirectory) filepath = path.join(filepath, "index.html"); + const file = await Deno.open(filepath, { read: true }); + ctx.res.body = readableStreamFromReader(file); + ctx.res.headers.set("content-type", contentType(path.basename(filepath))!); + ctx.res.status = HTTPStatus.OK; + } catch { + statusResponse(ctx, HTTPStatus.NotFound); + } +}; diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..651251b --- /dev/null +++ b/server.ts @@ -0,0 +1,153 @@ +/** + * nadder + * (c) 2022 dragonwocky (https://dragonwocky.me/) + * (https://github.com/dragonwocky/nadder) under the MIT license + */ + +import { getCookies, HTTPStatus, stdServe } from "./deps.ts"; +import { statusResponse } from "./response.ts"; + +export type HTTPMethod = + | "GET" + | "HEAD" + | "POST" + | "PATCH" + | "PUT" + | "DELETE" + | "CONNECT" + | "OPTIONS" + | "TRACE"; + +interface RequestContext { + method: HTTPMethod; + ip: string | null; + url: URL; + body: + | string + | number + | boolean + | Record + | unknown[] + | Blob + | null + | undefined; + queryParams: URLSearchParams; + pathParams: Record; + cookies: Record; + headers: Headers; +} +interface ResponseContext { + body: + | string + | Blob + | BufferSource + | FormData + | ReadableStream + | URLSearchParams; + status: number; + headers: Headers; +} + +export interface RouteContext { + req: Readonly; + res: ResponseContext; +} +export interface SocketContext { + req: Readonly; + socket: WebSocket; +} + +type RouteHandler = (ctx: RouteContext) => void | Promise; +const _routes: { [k in HTTPMethod]?: [URLPattern, RouteHandler][] } = {}; +export const route = ( + method: HTTPMethod, + route: string, + handler: RouteHandler, +) => { + if (!_routes[method]) _routes[method] = []; + _routes[method]!.push([new URLPattern({ pathname: route }), handler]); +}; + +type SocketHandler = (ctx: SocketContext) => void | Promise; +const _ws: [URLPattern, SocketHandler][] = []; +export const ws = (route: string, handler: SocketHandler) => { + _ws.push([new URLPattern({ pathname: route }), handler]); +}; + +export const serve = (port = 3000) => { + console.log(""); + console.log(`✨ server started at http://localhost:${port}/`); + console.log("listening for requests..."); + console.log(""); + stdServe(async (req, conn) => { + const url = new URL(req.url), + ctx: { req: RequestContext; res: ResponseContext } = { + req: { + method: req.method, + ip: ( conn.remoteAddr).hostname, + url, + body: undefined, + queryParams: new URLSearchParams(url.search), + pathParams: {}, + cookies: getCookies(req.headers), + headers: req.headers, + }, + res: { + body: "", + status: HTTPStatus.OK, + headers: new Headers(), + }, + }; + + try { + console.log(`[${ctx.req.ip}] ${ctx.req.method} ${ctx.req.url.pathname}`); + + if (req.body) { + const contentType = req.headers.get("content-type") ?? "", + isJSON = contentType.includes("application/json"), + isText = contentType.includes("text/plain"), + isFormData = + contentType.includes("application/x-www-form-urlencoded") || + contentType.includes("multipart/form-data"); + if (isJSON) { + ctx.req.body = await req.json(); + } else if (isText) { + ctx.req.body = await req.text(); + } else if (isFormData) { + ctx.req.body = Object.fromEntries((await req.formData()).entries()); + } else ctx.req.body = await req.blob(); + } + + const ws = _ws.find(([pattern, _handler]) => { + return pattern.test(ctx.req.url.href); + }); + if (ws && req.headers.get("upgrade") === "websocket") { + const { socket, response } = Deno.upgradeWebSocket(req), + [pattern, handler] = ws; + ctx.req.pathParams = pattern.exec(ctx.req.url.href)?.pathname.groups ?? + {}; + await handler({ req: ctx.req, socket }); + return response; + } + + if (!_routes[ctx.req.method]) _routes[ctx.req.method] = []; + const route = _routes[ctx.req.method]!.find(([pattern, _handler]) => { + return pattern.test(ctx.req.url.href); + }); + if (route) { + const [pattern, handler] = route; + ctx.req.pathParams = pattern.exec(ctx.req.url.href)?.pathname.groups ?? + {}; + await handler(ctx); + } else statusResponse(ctx, HTTPStatus.NotFound); + } catch (err) { + console.error(`[${ctx.req.ip}]`, err); + statusResponse(ctx, HTTPStatus.InternalServerError); + } + + return new Response(ctx.res.body, { + status: ctx.res.status, + headers: ctx.res.headers, + }); + }, { port }); +}; diff --git a/session.ts b/session.ts new file mode 100644 index 0000000..7c6f02c --- /dev/null +++ b/session.ts @@ -0,0 +1,146 @@ +/** + * nadder + * (c) 2022 dragonwocky (https://dragonwocky.me/) + * (https://github.com/dragonwocky/nadder) under the MIT license + */ + +import { RouteContext, SocketContext } from "./server.ts"; +import { getCookies, setCookie } from "./deps.ts"; + +export interface Session { + get: ( + ctx: RouteContext | SocketContext, + key: string, + ) => unknown | Promise; + set: (ctx: RouteContext, key: string, value: unknown) => void | Promise; +} + +const validSession = (uuid: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i + .test(uuid) + ? uuid + : null, + extractSession = (ctx: RouteContext | SocketContext, cookie: string) => { + const headers = ( ctx).res?.headers, + id = validSession( + ctx.req.cookies[cookie] ?? + (headers ? getCookies(headers)[cookie] : ""), + ); + return id; + }; + +export const memorySession = ({ + cookie = "session_id", + expiry = 3600, +} = {}): Session => { + const sessions = new Map(), + expiries = new Map(); + + const sessionExists = (id: string) => sessions.has(id), + uniqueSession = (): string => { + const id = crypto.randomUUID(); + return sessionExists(id) ? uniqueSession() : id; + }, + collectGarbage = () => { + const now = Date.now(); + for (const [id, expiry] of expiries) { + if (now < expiry) continue; + expiries.delete(id); + sessions.delete(id); + } + }; + + const set = (id: string, key: string, value: unknown) => { + sessions.get(id)[key] = value; + }, + get = (id: string, key: string) => sessions.get(id)[key], + init = (ctx: RouteContext) => { + collectGarbage(); + const id = extractSession(ctx, cookie) ?? uniqueSession(); + if (!sessionExists(id)) { + expiries.set(id, Date.now() + 1000 * expiry); + sessions.set(id, {}); + } + setCookie(ctx.res.headers, { + name: cookie, + value: id, + httpOnly: true, + maxAge: expiry, + }); + return id; + }; + + return { + get: (ctx, key) => { + collectGarbage(); + const id = extractSession(ctx, cookie); + return id ? get(id, key) : undefined; + }, + set: (ctx, key, value) => set(init(ctx), key, value), + }; +}; + +export const postgresSession = async (query: CallableFunction, { + cookie = "session_id", + expiry = 3600, + table = "sessions", +} = {}): Promise => { + await query(` + CREATE TABLE IF NOT EXISTS ${table} ( + id UUID PRIMARY KEY, + expiry TIMESTAMP NOT NULL, + state JSONB NOT NULL + ) + `); + + const sessionExists = async (id: string) => { + const q = `SELECT EXISTS(SELECT 1 FROM ${table} where id = $id)`, + // deno-lint-ignore no-explicit-any + res: any = await query(q, { id }); + return res.rows?.[0]?.exists; + }, + uniqueSession = async (): Promise => { + const id = crypto.randomUUID(); + return await sessionExists(id) ? uniqueSession() : id; + }, + collectGarbage = () => query(`DELETE FROM ${table} WHERE expiry < NOW()`); + + const set = async (id: string, key: string, value: unknown) => { + const q = `UPDATE ${table} SET state = state || $state WHERE ID = $id`; + await query(q, { id, state: JSON.stringify({ [key]: value }) }); + }, + get = async (id: string, key: string) => { + const q = `SELECT state::json->$key FROM ${table} WHERE id = $id`, + // deno-lint-ignore no-explicit-any + res: any = await query(q, { id, key }); + return res.rows?.[0]?.["?column?"]; + }, + init = async (ctx: RouteContext) => { + await collectGarbage(); + const id = extractSession(ctx, cookie) ?? await uniqueSession(); + if (!(await sessionExists(id))) { + const timestamp = (new Date(Date.now() + 1000 * expiry)).toISOString(), + q = ` + INSERT INTO ${table} (id, expiry, state) + VALUES ($id, $expiry, $state) + `; + await query(q, { id, expiry: timestamp, state: {} }); + } + setCookie(ctx.res.headers, { + name: cookie, + value: id, + httpOnly: true, + maxAge: expiry, + }); + return id; + }; + + return { + get: async (ctx, key) => { + await collectGarbage(); + const id = extractSession(ctx, cookie); + return id ? await get(id, key) : undefined; + }, + set: async (ctx, key, value) => set(await init(ctx), key, value), + }; +}; diff --git a/ssr.tsx b/ssr.tsx new file mode 100644 index 0000000..3486919 --- /dev/null +++ b/ssr.tsx @@ -0,0 +1,116 @@ +/** + * nadder + * (c) 2022 dragonwocky (https://dragonwocky.me/) + * (https://github.com/dragonwocky/nadder) under the MIT license + */ + +/** + * @jsx h + * @jsxFrag jsxFrag + */ + +import { StyleSheet, WindiProcessor } from "./deps.ts"; +import { escapeHtml, reduceTemplate } from "./util.ts"; + +declare global { + namespace JSX { + type IntrinsicElements = { [k: string]: JSX.Props }; + type Props = { [k: string]: string | number | boolean }; + type Node = JSX.Element | string | number | boolean | undefined; + type Component = (props: Props, children: JSX.Node[]) => Element; + interface Element { + type: string; + props: JSX.Props; + children: JSX.Node[]; + } + } +} + +export const h = ( + type: JSX.Element["type"] | JSX.Component, + props: JSX.Props, + ...children: JSX.Node[] +): JSX.Element => { + props = props ?? {}; + children = children.flat(Infinity); + return type instanceof Function + ? type(props, children) + : { type, props, children }; +}; + +export const jsxFrag = (_: unknown, children: JSX.Node[]) => children; + +const renderToString = ($: JSX.Node, parentType = ""): string => { + if (!$) return ""; + if (typeof $ === "string") { + return ["script", "style"].includes(parentType) ? $ : escapeHtml($); + } + if (typeof $ === "number" || typeof $ === "boolean") return $.toString(); + const attrs = Object.entries($.props) + .filter(([k, v]) => !!v || v === 0) + .reduce((attrs, [k, v]) => { + return `${attrs} ` + + (v === true ? k : `${k}="${escapeHtml(v.toString())}"`); + }, ""), + innerHTML = $.children.map(($child) => renderToString($child, $.type)).join( + "", + ); + return [ + "area", + "base", + "basefont", + "br", + "col", + "embed", + "hr", + "img", + "input", + "keygen", + "link", + "meta", + "param", + "source", + "spacer", + "track", + "wbr", + ].includes($.type) + ? `<${$.type}${attrs}/>` + : `<${$.type}${attrs}>${innerHTML}`; +}; +export const jsxToString = ( + $: JSX.Node | JSX.Node[], +): string => { + if (Array.isArray($)) return $.map(($node) => renderToString($node)).join(""); + return renderToString($); +}; + +const preflight = + `/*! modern-normalize v1.1.0 | MIT License | https://github.com/sindresorhus/modern-normalize */*,::after,::before{box-sizing:border-box}html{-moz-tab-size:4;tab-size:4}html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}body{font-family:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji'}hr{height:0;color:inherit}abbr[title]{text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}::-moz-focus-inner{border-style:none;padding:0}:-moz-focusring{outline:1px dotted ButtonText}:-moz-ui-invalid{box-shadow:none}legend{padding:0}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}`; + +export const windiInstance = ( + mode: "interpret" | "compile" = "interpret", + config: Record = {}, +) => { + const processor = new WindiProcessor(); + config = processor.loadConfig(config); + let stylesheet = new StyleSheet(); + return { + tw: (t: TemplateStringsArray | string[], ...s: unknown[]) => { + const className = reduceTemplate(t, ...s); + if (mode === "compile") { + const compiled = processor.compile(className, config.prefix as string); + stylesheet = stylesheet.extend(compiled.styleSheet); + return [compiled.className, ...compiled.ignored].join(" "); + } + const interpreted = processor.interpret(className); + stylesheet = stylesheet.extend(interpreted.styleSheet); + return [...interpreted.success, ...interpreted.ignored].join(" "); + }, + sheet: () => ( + + ), + }; +}; diff --git a/util.ts b/util.ts new file mode 100644 index 0000000..e8242a0 --- /dev/null +++ b/util.ts @@ -0,0 +1,19 @@ +/** + * nadder + * (c) 2022 dragonwocky (https://dragonwocky.me/) + * (https://github.com/dragonwocky/nadder) under the MIT license + */ + +export const reduceTemplate = ( + t: TemplateStringsArray | string[], + ...s: unknown[] +) => t.reduce((p, v) => p + v + (s.shift() ?? ""), "").trim(); + +export const escapeHtml = (str: string) => + str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/'/g, "'") + .replace(/"/g, """) + .replace(/\\/g, "\");