From 00f8635414ce74f50c91f5fac6c8baa4e21d5f89 Mon Sep 17 00:00:00 2001 From: Ethan Davidson <31261035+EthanThatOneKid@users.noreply.github.com> Date: Mon, 11 Mar 2024 17:45:59 -0700 Subject: [PATCH] wip --- .github/workflows/check.yaml | 18 ++ .github/workflows/publish.yaml | 17 ++ .vscode/settings.json | 19 +++ README.md | 33 +++- deno.jsonc | 11 ++ examples/farm/farm.ts | 13 ++ lib/mod.ts | 1 + lib/rtx.ts | 300 +++++++++++++++++++++++++++++++++ 8 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/check.yaml create mode 100644 .github/workflows/publish.yaml create mode 100644 .vscode/settings.json create mode 100644 deno.jsonc create mode 100644 examples/farm/farm.ts create mode 100644 lib/mod.ts create mode 100644 lib/rtx.ts diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 0000000..79cff88 --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,18 @@ +name: Check +"on": + push: + branches: + - main + pull_request: + branches: + - main +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: denoland/setup-deno@v1 + - name: Format + run: deno fmt && git diff-index --quiet HEAD + - name: Lint + run: deno lint && git diff-index --quiet HEAD diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..24b0f5e --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,17 @@ +name: Publish +"on": + push: + branches: + - main + workflow_dispatch: {} +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: denoland/setup-deno@v1 + - name: Publish package + run: deno publish diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0d99cbf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,19 @@ +{ + "deno.enable": true, + "deno.unstable": true, + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[markdown]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "[jsonc]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "files.eol": "\n" +} diff --git a/README.md b/README.md index b3bceaa..7819a62 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,33 @@ # rtx -Simple HTTP library engineered for developer maintainability and convenience. + +Minimal HTTP router library based on the `URLPattern` API. + +## Usage + +### Deno + +1\. [Install Deno](https://docs.deno.com/runtime/manual). + +2\. Start a new Deno project. + +```sh +deno init +``` + +3\. Add rtx as a project dependency. + +```sh +deno add @fartlabs/rtx +``` + +## Contribute + +### Style + +Run `deno fmt` to format the code. + +Run `deno lint` to lint the code. + +--- + +Developed with ❤️ [**@FartLabs**](https://github.com/FartLabs) diff --git a/deno.jsonc b/deno.jsonc new file mode 100644 index 0000000..3aadb83 --- /dev/null +++ b/deno.jsonc @@ -0,0 +1,11 @@ +{ + "lock": false, + "name": "@fartlabs/rtx", + "version": "0.0.0", + "imports": { + "rtx/": "./lib/" + }, + "tasks": { + "example": "deno run --allow-net examples/farm/farm.ts" + } +} diff --git a/examples/farm/farm.ts b/examples/farm/farm.ts new file mode 100644 index 0000000..e57d853 --- /dev/null +++ b/examples/farm/farm.ts @@ -0,0 +1,13 @@ +import { createRouter } from "rtx/mod.ts"; + +if (import.meta.main) { + const router = createRouter() + .with<"id">( + { method: "GET", pattern: new URLPattern({ pathname: "/farms/:id" }) }, + ({ params }) => { + return new Response(`Farm ID: ${params.id}`); + }, + ); + + Deno.serve(router.fetch.bind(router)); +} diff --git a/lib/mod.ts b/lib/mod.ts new file mode 100644 index 0000000..9093b42 --- /dev/null +++ b/lib/mod.ts @@ -0,0 +1 @@ +export * from "./rtx.ts"; diff --git a/lib/rtx.ts b/lib/rtx.ts new file mode 100644 index 0000000..c541496 --- /dev/null +++ b/lib/rtx.ts @@ -0,0 +1,300 @@ +/** + * METHODS is the list of HTTP methods. + */ +export const METHODS = [ + "CONNECT", + "DELETE", + "GET", + "HEAD", + "OPTIONS", + "PATCH", + "POST", + "PUT", + "TRACE", +] as const; + +/** + * Method is a type which represents an HTTP method. + */ +export type Method = typeof METHODS[number]; + +/** + * Match is a type which matches a Request object. + */ +export type Match = + | ((detail: { request: Request; url: URL }) => Promise) + | { + /** + * pattern is the URL pattern to match on. + */ + pattern?: URLPattern; + + /** + * method is the HTTP method to match on. + */ + method?: Method; + }; + +/** + * Handle is a function which handles a request. + */ +export interface Handle { + (r: HandlerRequest): Promise | Response; +} + +/** + * Handler represents a function which can handle a request. + */ +export interface Handler { + /** + * handle is called to handle a request. + */ + handle: Handle; + + /** + * match is called to match a request. + */ + match?: Match; +} + +/** + * Handlers is a collection of handlers. + */ +export type Handlers = Handler[]; + +/** + * HandlerRequest is the input to a handler. + */ +export interface HandlerRequest { + /** + * request is the original request object. + */ + request: Request; + + /** + * url is the parsed fully qualified URL of the request. + */ + url: URL; + + /** + * params is a map of matched parameters from the URL pattern. + */ + params: { [key in T]: string }; +} + +/** + * createRouter creates a new router. + */ +export function createRouter(): Router { + return new Router(); +} + +/** + * RouterInterface is the interface for a router. + */ +type RouterInterface = Record< + Lowercase, + ((p: URLPattern, r: Handle) => Router) +>; + +/** + * Router is a collection of routes. + */ +export class Router implements RouterInterface { + public handlers: Handlers = []; + public fallbackResponse: Response | undefined; + + /** + * fetch invokes the router for the given request. + */ + public async fetch(request: Request): Promise { + const url = new URL(request.url); + for (const handler of this.handlers) { + if (typeof handler.match === "function") { + if (await handler.match({ request, url })) { + return await handler.handle({ + request, + url, + params: {}, + }); + } + } else if (handler.match !== undefined) { + if ( + handler.match.method !== undefined && + handler.match.method !== request.method + ) { + continue; + } + + let match: URLPatternResult | null = null; + if (handler.match.pattern !== undefined) { + match = handler.match.pattern?.exec(request.url); + if (match === null) { + continue; + } + } + + return await handler.handle({ + request, + url, + params: match?.pathname + ? Object.entries(match.pathname.groups) + .reduce( + (groups, [key, value]) => { + if (value !== undefined) { + groups[key] = value; + } + + return groups; + }, + {} as { [key: string]: string }, + ) + : {}, + }); + } + } + + if (this.fallbackResponse !== undefined) { + return this.fallbackResponse; + } + + throw new Error("Not found"); + } + + /** + * with appends a handler to the router. + */ + public with( + handle: Handle, + ): this; + public with( + match: Match, + handle: Handle, + ): this; + public with( + matchOrHandler: Match | Handler["handle"], + handle?: Handle, + ): this { + if (typeof matchOrHandler === "function" && handle === undefined) { + this.handlers.push({ handle: matchOrHandler as Handle }); + return this; + } + + this.handlers.push({ + handle: handle!, + match: matchOrHandler as Match, + }); + return this; + } + + /** + * extend appends additional handlers to the router. + */ + public use(data: Handlers | Router): this { + if (data instanceof Router) { + this.handlers.push(...data.handlers); + } else { + this.handlers.push(...data); + } + + return this; + } + + /** + * fallback sets the fallback response for the router. + */ + public fallback(response: Response | undefined): this { + this.fallbackResponse = response; + return this; + } + + /** + * connect appends a handler for the CONNECT method to the router. + */ + public connect( + pattern: URLPattern, + handle: Handle, + ): this { + return this.with({ method: "CONNECT", pattern }, handle); + } + + /** + * delete appends a handler for the DELETE method to the router. + */ + public delete( + pattern: URLPattern, + handle: Handle, + ): this { + return this.with({ method: "DELETE", pattern }, handle); + } + + /** + * get appends a handler for the GET method to the router. + */ + public get( + pattern: URLPattern, + handle: Handle, + ): this { + return this.with({ method: "GET", pattern }, handle); + } + + /** + * head appends a handler for the HEAD method to the router. + */ + public head( + pattern: URLPattern, + handle: Handle, + ): this { + return this.with({ method: "HEAD", pattern }, handle); + } + + /** + * options appends a handler for the OPTIONS method to the router. + */ + public options( + pattern: URLPattern, + handle: Handle, + ): this { + return this.with({ method: "OPTIONS", pattern }, handle); + } + + /** + * patch appends a handler for the PATCH method to the router. + */ + public patch( + pattern: URLPattern, + handle: Handle, + ): this { + return this.with({ method: "PATCH", pattern }, handle); + } + + /** + * post appends a handler for the POST method to the router. + */ + public post( + pattern: URLPattern, + handle: Handle, + ): this { + return this.with({ method: "POST", pattern }, handle); + } + + /** + * put appends a handler for the PUT method to the router. + */ + public put( + pattern: URLPattern, + handle: Handle, + ): this { + return this.with({ method: "PUT", pattern }, handle); + } + + /** + * trace appends a handler for the TRACE method to the router. + */ + public trace( + pattern: URLPattern, + handle: Handle, + ): this { + return this.with({ method: "TRACE", pattern }, handle); + } +}