├── .vscode └── settings.json ├── LICENSE ├── README.md ├── lib ├── aqua.ts ├── branch.ts ├── event.ts ├── fake-aqua.ts ├── index.ts └── method.ts ├── mod.spec.ts ├── mod.ts ├── usage_playground.ts └── x └── get-pattern-pathname-groups.ts /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Aqua 2 | 3 | Aqua is a minimal and fast web framework. 4 | 5 | > :warning: This version is a WIP and has not yet been released. Please refer to the [main branch](https://github.com/grayliquid/aqua/tree/main) for the current documentation. 6 | 7 | ## Example usage 8 | 9 | ### It starts easy, 10 | 11 | ```typescript 12 | import { Aqua } from "..."; 13 | 14 | const app = new Aqua({ 15 | listen: { 16 | port: 80, 17 | }, 18 | }); 19 | 20 | app.route("/").respond(Method.GET, (_event) => { 21 | return new Response("Hello, World!"); 22 | }); 23 | ``` 24 | 25 | ### ... and stays easy. 26 | 27 | ```typescript 28 | const v1 = app.route("/v1").step(async (event) => { 29 | if (!event.request.headers.has("X-Api-Key")) { 30 | event.response = Response.json( 31 | { error: "MISSING_API_KEY" }, 32 | { 33 | status: 400, 34 | } 35 | ); 36 | return event.end(); 37 | } 38 | 39 | const user = await getUserByRequest(event.request); 40 | // ^ type User 41 | 42 | return { 43 | ...event, 44 | user, 45 | }; 46 | }); 47 | 48 | v1.route("/user").respond(Method.GET, (event) => { 49 | return Response.json({ data: { user: event.user } }); 50 | // ^ type User 51 | }); 52 | ``` 53 | -------------------------------------------------------------------------------- /lib/aqua.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "https://deno.land/std@0.185.0/http/server.ts"; 2 | import { Branch } from "./branch.ts"; 3 | import { Event, InternalizedEvent } from "./event.ts"; 4 | import { Method } from "./method.ts"; 5 | 6 | export type AquaOptionsCustomListenFn = ({ 7 | handlerFn, 8 | abortSignal, 9 | }: { 10 | handlerFn: (request: Request) => Response | Promise; 11 | abortSignal: AbortSignal; 12 | }) => void | Promise; 13 | 14 | export interface AquaOptions { 15 | /** 16 | * `listen` either takes options that will be passed 17 | * to the std/http `serve` function, or a custom function 18 | * that starts the listening process and makes use of 19 | * Aqua's request handler function. 20 | * 21 | * @example 22 | * { 23 | * port: 80 24 | * } 25 | * 26 | * @example 27 | * // `abortSignal` ignored for the sake of simplicity 28 | * async ({ handlerFn, abortSignal }) => { 29 | * const conn = Deno.listen({ port: 80 }); 30 | * const httpConn = Deno.serveHttp(await conn.accept()); 31 | * const e = await httpConn.nextRequest(); 32 | * if (e) e.respondWith(await handlerFn(e.request)); 33 | * } 34 | */ 35 | listen?: 36 | | { 37 | port?: number; 38 | hostname?: string; 39 | } 40 | | AquaOptionsCustomListenFn; 41 | /** 42 | * @default false 43 | */ 44 | shouldRepectTrailingSlash?: boolean; 45 | } 46 | 47 | export type StepFn<_Event extends Event> = ( 48 | event: _Event 49 | ) => _Event | void | Promise<_Event | void>; 50 | 51 | export type RespondFn<_Event extends Event> = ( 52 | event: _Event 53 | ) => _Event["response"] | Promise<_Event["response"]>; 54 | 55 | export interface RouteOptions<_Event extends Event> { 56 | steps?: StepFn<_Event>[]; 57 | } 58 | 59 | interface AquaInternals<_Event extends Event> { 60 | options: AquaOptions; 61 | setRoute<__Event extends _Event>( 62 | method: Method, 63 | path: string, 64 | steps: StepFn<__Event>[] 65 | ): void; 66 | } 67 | 68 | const URL_PATTERN_PREFIX = "http://0.0.0.0"; 69 | 70 | export function getDefaultResponse() { 71 | return new Response("Not found.", { status: 404 }); 72 | } 73 | 74 | export class Aqua<_Event extends Event = Event> { 75 | private abortController: AbortController; 76 | 77 | protected routes: Record< 78 | string, 79 | { 80 | path: string; 81 | method: Method; 82 | urlPattern: URLPattern; 83 | // @todo Solve this `any` situation 84 | steps: StepFn[]; 85 | } 86 | > = {}; 87 | 88 | public _internal: AquaInternals<_Event>; 89 | 90 | constructor(options: AquaOptions = {}) { 91 | this._internal = { 92 | options, 93 | setRoute: <__Event extends _Event>( 94 | method: Method, 95 | path: string, 96 | steps: StepFn<__Event>[] 97 | ) => { 98 | this.routes[method + path] = { 99 | path, 100 | method, 101 | urlPattern: new URLPattern(URL_PATTERN_PREFIX + path), 102 | steps, 103 | }; 104 | }, 105 | }; 106 | this.abortController = new AbortController(); 107 | 108 | this.listen(options?.listen); 109 | } 110 | 111 | protected async listen(listen: AquaOptions["listen"]) { 112 | const handlerFn = async (request: Request) => { 113 | try { 114 | return await this.handleRequest(this.createInternalEvent(request)); 115 | } catch (error) { 116 | console.error(error); 117 | 118 | return new Response(error, { status: 500 }); 119 | } 120 | }; 121 | 122 | if (typeof listen === "function") { 123 | await listen({ 124 | handlerFn, 125 | abortSignal: this.abortController.signal, 126 | }); 127 | return; 128 | } 129 | 130 | await serve(handlerFn, { 131 | hostname: listen?.hostname, 132 | port: listen?.port, 133 | signal: this.abortController.signal, 134 | }); 135 | } 136 | 137 | protected createInternalEvent(request: Request): InternalizedEvent<_Event> { 138 | return { 139 | _internal: { 140 | hasCalledEnd: false, 141 | urlPatternResult: null, 142 | }, 143 | request, 144 | response: getDefaultResponse(), 145 | end() { 146 | if (this._internal.hasCalledEnd) return; 147 | this._internal.hasCalledEnd = true; 148 | }, 149 | } as InternalizedEvent<_Event>; 150 | } 151 | 152 | protected async handleRequest(event: InternalizedEvent<_Event>) { 153 | let pathName = new URL(event.request.url).pathname; 154 | if ( 155 | !this._internal.options.shouldRepectTrailingSlash && 156 | !pathName.endsWith("/") 157 | ) { 158 | pathName += "/"; 159 | } 160 | 161 | const requestMethod = event.request.method.toUpperCase(); 162 | 163 | let route = this.routes[requestMethod + pathName]; 164 | 165 | if (!route) { 166 | // Try to find matching pattern if there was no direct match 167 | const urlPatternTestPath = URL_PATTERN_PREFIX + pathName; 168 | 169 | for (const _route of Object.values(this.routes)) { 170 | if ( 171 | _route.urlPattern.test(urlPatternTestPath) && 172 | _route.method === requestMethod 173 | ) { 174 | event._internal.urlPatternResult = 175 | _route.urlPattern.exec(urlPatternTestPath); 176 | route = _route; 177 | break; 178 | } 179 | } 180 | 181 | if (!route) { 182 | return event.response; 183 | } 184 | } 185 | 186 | for (const step of route.steps) { 187 | const returnedEvent = await step(event); 188 | 189 | // Called `event.end()`. Ignore all further statements. 190 | if (event._internal.hasCalledEnd) { 191 | break; 192 | } 193 | 194 | if (!returnedEvent) { 195 | continue; 196 | } 197 | 198 | event = returnedEvent as InternalizedEvent<_Event>; 199 | } 200 | 201 | return event.response; 202 | } 203 | 204 | public route<__Event extends _Event>( 205 | path: string, 206 | options: RouteOptions<__Event> = {} 207 | ): Branch<__Event> { 208 | if (!path.startsWith("/")) { 209 | throw new Error('Route paths must start with a "/".'); 210 | } 211 | 212 | if ( 213 | !this._internal.options.shouldRepectTrailingSlash && 214 | !path.endsWith("/") 215 | ) { 216 | path += "/"; 217 | } 218 | 219 | return new Branch<__Event>({ 220 | path, 221 | aquaInstance: this, 222 | steps: options.steps ?? [], 223 | }); 224 | } 225 | 226 | public kill() { 227 | this.abortController.abort(); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /lib/branch.ts: -------------------------------------------------------------------------------- 1 | import { Aqua, RespondFn, RouteOptions, StepFn } from "./aqua.ts"; 2 | import { Event } from "./event.ts"; 3 | import { Method } from "./method.ts"; 4 | 5 | export interface BranchOptions<_Event extends Event> 6 | extends Required, "steps">> { 7 | path: string; 8 | aquaInstance: Aqua; 9 | } 10 | 11 | interface BranchInternals<_Event extends Event> { 12 | options: BranchOptions<_Event>; 13 | path: string; 14 | } 15 | 16 | export class Branch<_Event extends Event> { 17 | private steps: StepFn<_Event>[] = []; 18 | 19 | public _internal: BranchInternals<_Event>; 20 | 21 | constructor(options: BranchOptions<_Event>) { 22 | this._internal = { 23 | options, 24 | path: options.path, 25 | }; 26 | this.steps = options.steps; 27 | } 28 | 29 | public route(path: string, options: RouteOptions<_Event> = {}) { 30 | if (!path.startsWith("/")) { 31 | throw new Error('Route paths must start with a "/".'); 32 | } 33 | 34 | const joinedPath = this._internal.options.path.replace(/\/$/, "") + path; 35 | 36 | return this._internal.options.aquaInstance.route<_Event>(joinedPath, { 37 | steps: [...this.steps, ...(options?.steps ?? [])], 38 | }); 39 | } 40 | 41 | /** 42 | * Injects a function into the event lifecycle. 43 | * 44 | * @example 45 | * // Check whether a header is set and throw otherwise 46 | * .step((event) => { 47 | * if (!event.request.headers.has("X-Api-Key")) { 48 | * event.response = Response.json( 49 | * { error: "MISSING_API_KEY" }, 50 | * { 51 | * status: 400, 52 | * } 53 | * ); 54 | * return event.end(); 55 | * } 56 | * }); 57 | * 58 | * @example 59 | * // Early-return 60 | * .step((event) => { 61 | * if (event.request.headers.has("early-return")) { 62 | * event.response = Response.json({ data: {} }); 63 | * return event.end(); 64 | * } 65 | * }); 66 | * 67 | * @example 68 | * // Provide additional event information 69 | * .step((event) => { 70 | * return { 71 | * ...event, 72 | * isTesting: event.request.url.startsWith("http://localhost"), 73 | * }; 74 | * }); 75 | */ 76 | public step<_StepFn extends StepFn<_Event>>(stepFn: _StepFn) { 77 | this.steps.push(stepFn); 78 | 79 | return this as Awaited> extends never 80 | ? never 81 | : Awaited> extends _Event 82 | ? Branch>> 83 | : this; 84 | } 85 | 86 | /** 87 | * @example 88 | * .respond(Method.GET, (_event) => new Response("Hello, World!")); 89 | */ 90 | public respond<_RespondFn extends RespondFn<_Event>>( 91 | method: Method, 92 | respondFn: _RespondFn 93 | ) { 94 | this._internal.options.aquaInstance._internal.setRoute( 95 | method, 96 | this._internal.path, 97 | [ 98 | ...this.steps, 99 | async (event: _Event) => { 100 | event.response = await respondFn(event); 101 | return event; 102 | }, 103 | ] 104 | ); 105 | 106 | return new ResponderBranch(this); 107 | } 108 | } 109 | 110 | /** 111 | * Used for everything after the first `respond(...)` call. 112 | */ 113 | export class ResponderBranch<_Event extends Event> 114 | implements Omit, "route" | "step"> 115 | { 116 | get _internal() { 117 | return this.branch._internal; 118 | } 119 | 120 | constructor(private branch: Branch<_Event>) {} 121 | 122 | /** 123 | * @example 124 | * .respond(Method.GET, (_event) => new Response("Hello, World!")); 125 | */ 126 | public respond<_RespondFn extends RespondFn<_Event>>( 127 | method: Method, 128 | respondFn: _RespondFn 129 | ) { 130 | return this.branch.respond(method, respondFn); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /lib/event.ts: -------------------------------------------------------------------------------- 1 | export interface Event { 2 | request: Request; 3 | response: Response; 4 | /** 5 | * Calling `end()` tells Aqua to respond to the event after running the 6 | * current step function. 7 | * Please make sure to return after calling `end()` to not accidentally modify 8 | * the event response any further. 9 | * 10 | * @example 11 | * .step((event) => { 12 | * if (event.request.headers.has("early-return")) { 13 | * event.response = Response.json({ data: {} }); 14 | * return event.end(); 15 | * } 16 | * }); 17 | */ 18 | end(): void; 19 | [key: string]: unknown; 20 | } 21 | 22 | export interface InternalEvent extends Event { 23 | _internal: { 24 | urlPatternResult: null | URLPatternResult; 25 | hasCalledEnd: boolean; 26 | }; 27 | } 28 | 29 | export type InternalizedEvent<_Event extends Event> = _Event & InternalEvent; 30 | -------------------------------------------------------------------------------- /lib/fake-aqua.ts: -------------------------------------------------------------------------------- 1 | import { Aqua, AquaOptions } from "./aqua.ts"; 2 | 3 | export class FakeAqua extends Aqua { 4 | constructor(options: AquaOptions = {}) { 5 | super(options); 6 | } 7 | 8 | protected async listen() {} 9 | 10 | public async fakeCall(request: Request): Promise { 11 | try { 12 | return await this.handleRequest(this.createInternalEvent(request)); 13 | } catch (error) { 14 | return new Response(error, { status: 500 }); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./aqua.ts"; 2 | export * from "./branch.ts"; 3 | export * from "./event.ts"; 4 | export * from "./fake-aqua.ts"; 5 | export * from "./method.ts"; 6 | -------------------------------------------------------------------------------- /lib/method.ts: -------------------------------------------------------------------------------- 1 | export enum Method { 2 | GET = "GET", 3 | HEAD = "HEAD", 4 | POST = "POST", 5 | PUT = "PUT", 6 | DELETE = "DELETE", 7 | CONNECT = "CONNECT", 8 | OPTIONS = "OPTIONS", 9 | TRACE = "TRACE", 10 | PATCH = "PATCH", 11 | } 12 | -------------------------------------------------------------------------------- /mod.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "https://deno.land/std@0.192.0/testing/asserts.ts"; 2 | import { FakeAqua, Method } from "./mod.ts"; 3 | import { getPatternPathnameGroups } from "./x/get-pattern-pathname-groups.ts"; 4 | 5 | Deno.test(async function notFound() { 6 | const app = new FakeAqua(); 7 | 8 | const res = await app.fakeCall(new Request("http://localhost/")); 9 | 10 | assert(res.status === 404); 11 | assert((await res.text()) === "Not found."); 12 | }); 13 | 14 | Deno.test(async function simpleDELETE() { 15 | const app = new FakeAqua(); 16 | 17 | app.route("/").respond(Method.DELETE, (_event) => new Response("worked!")); 18 | 19 | const res = await app.fakeCall( 20 | new Request("http://localhost/", { 21 | method: "DELETE", 22 | }) 23 | ); 24 | 25 | assert(res.status === 200); 26 | assert((await res.text()) === "worked!"); 27 | }); 28 | 29 | Deno.test(async function simpleGET() { 30 | const app = new FakeAqua(); 31 | 32 | app.route("/").respond(Method.GET, (_event) => new Response("worked!")); 33 | 34 | const res = await app.fakeCall(new Request("http://localhost/")); 35 | 36 | assert(res.status === 200); 37 | assert((await res.text()) === "worked!"); 38 | }); 39 | 40 | Deno.test(async function getWithUrlPattern() { 41 | const app = new FakeAqua(); 42 | 43 | app 44 | .route("/hello/:text") 45 | .respond( 46 | Method.GET, 47 | (event) => new Response(getPatternPathnameGroups(event).get("text")) 48 | ); 49 | 50 | const res = await app.fakeCall(new Request("http://localhost/hello/world")); 51 | 52 | assert(res.status === 200); 53 | assert((await res.text()) === "world"); 54 | }); 55 | 56 | Deno.test(async function simpleOPTIONS() { 57 | const app = new FakeAqua(); 58 | 59 | app.route("/").respond(Method.OPTIONS, (_event) => new Response("worked!")); 60 | 61 | const res = await app.fakeCall( 62 | new Request("http://localhost/", { 63 | method: "OPTIONS", 64 | }) 65 | ); 66 | 67 | assert(res.status === 200); 68 | assert((await res.text()) === "worked!"); 69 | }); 70 | 71 | Deno.test(async function simpleHEAD() { 72 | const app = new FakeAqua(); 73 | 74 | app.route("/").respond(Method.HEAD, (_event) => new Response("worked!")); 75 | 76 | const res = await app.fakeCall( 77 | new Request("http://localhost/", { 78 | method: "HEAD", 79 | }) 80 | ); 81 | 82 | assert(res.status === 200); 83 | assert((await res.text()) === "worked!"); 84 | }); 85 | 86 | Deno.test(async function simpleOPTIONS() { 87 | const app = new FakeAqua(); 88 | 89 | app.route("/").respond(Method.PATCH, (_event) => new Response("worked!")); 90 | 91 | const res = await app.fakeCall( 92 | new Request("http://localhost/", { 93 | method: "PATCH", 94 | }) 95 | ); 96 | 97 | assert(res.status === 200); 98 | assert((await res.text()) === "worked!"); 99 | }); 100 | 101 | Deno.test(async function simplePOST() { 102 | const app = new FakeAqua(); 103 | 104 | app.route("/").respond(Method.POST, (_event) => new Response("worked!")); 105 | 106 | const res = await app.fakeCall( 107 | new Request("http://localhost/", { method: "POST" }) 108 | ); 109 | 110 | assert(res.status === 200); 111 | assert((await res.text()) === "worked!"); 112 | }); 113 | 114 | Deno.test(async function simplePUT() { 115 | const app = new FakeAqua(); 116 | 117 | app.route("/").respond(Method.PUT, (_event) => new Response("worked!")); 118 | 119 | const res = await app.fakeCall( 120 | new Request("http://localhost/", { method: "PUT" }) 121 | ); 122 | 123 | assert(res.status === 200); 124 | assert((await res.text()) === "worked!"); 125 | }); 126 | 127 | Deno.test(async function addCustomPropertyStep() { 128 | const app = new FakeAqua(); 129 | 130 | app 131 | .route("/") 132 | .step((event) => { 133 | return { 134 | ...event, 135 | foo: "bar", 136 | }; 137 | }) 138 | .respond(Method.GET, (event) => new Response(event.foo)); 139 | 140 | const res = await app.fakeCall(new Request("http://localhost/")); 141 | 142 | assert(res.status === 200); 143 | assert((await res.text()) === "bar"); 144 | }); 145 | 146 | Deno.test(async function failEventInStep() { 147 | const app = new FakeAqua(); 148 | 149 | app 150 | .route("/") 151 | .step((event) => { 152 | // just so it doesn't infer `never` 153 | if (event.request.headers.has("test")) return event; 154 | 155 | event.response = new Response("failed", { status: 500 }); 156 | return event.end(); 157 | }) 158 | .respond(Method.GET, (_event) => new Response("succeeded")); 159 | 160 | const res = await app.fakeCall(new Request("http://localhost/")); 161 | 162 | assert(res.status === 500); 163 | assert((await res.text()) === "failed"); 164 | }); 165 | 166 | Deno.test(async function samePatternRouteWithMultipleResponds() { 167 | const app = new FakeAqua(); 168 | 169 | const route = app.route("/:hello"); 170 | 171 | route.respond(Method.GET, (_event) => new Response("GET")); 172 | route.respond(Method.DELETE, (_event) => new Response("DELETE")); 173 | 174 | for (const method of ["GET", "DELETE"]) { 175 | const res = await app.fakeCall( 176 | new Request("http://localhost/test", { 177 | method, 178 | }) 179 | ); 180 | 181 | assert(res.status === 200); 182 | assert((await res.text()) === method); 183 | } 184 | }); 185 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./lib/index.ts"; 2 | -------------------------------------------------------------------------------- /usage_playground.ts: -------------------------------------------------------------------------------- 1 | // @todo delete this file 2 | 3 | import { Aqua, Method } from "./mod.ts"; 4 | 5 | const app = new Aqua({ 6 | listen: { 7 | port: 80, 8 | }, 9 | }); 10 | 11 | app.route("/").respond(Method.GET, (_event) => { 12 | return new Response("Hello, World!"); 13 | }); 14 | 15 | // /v1 16 | const getUserByRequest = (_req: Request) => Promise.resolve({ name: "test" }); 17 | 18 | const v1 = app.route("/v1").step(async (event) => { 19 | if (!event.request.headers.has("X-Api-Key")) { 20 | event.response = Response.json( 21 | { error: "MISSING_API_KEY" }, 22 | { 23 | status: 400, 24 | } 25 | ); 26 | return event.end(); 27 | } 28 | 29 | const user = await getUserByRequest(event.request); 30 | // ^ type User 31 | 32 | return { 33 | ...event, 34 | user, 35 | }; 36 | }); 37 | 38 | v1.route("/user").respond(Method.GET, (event) => { 39 | return Response.json({ data: { user: event.user } }); 40 | // ^ type User 41 | }); 42 | 43 | // /test 44 | const test = app.route("/test").step((event) => { 45 | console.log("/test global step"); 46 | return event; 47 | }); 48 | 49 | test 50 | .route("/1") 51 | .step((e) => (console.log("/test/1 step"), e)) 52 | .respond(Method.GET, () => new Response("GET")) 53 | .respond(Method.POST, () => new Response("POST")); 54 | -------------------------------------------------------------------------------- /x/get-pattern-pathname-groups.ts: -------------------------------------------------------------------------------- 1 | import { Event, InternalizedEvent } from "../mod.ts"; 2 | 3 | export function getPatternPathnameGroups<_Event extends Event>(event: _Event) { 4 | const internalEvent = event as InternalizedEvent<_Event>; 5 | 6 | const groups = new Map(); 7 | for (const [name, value] of Object.entries( 8 | internalEvent._internal.urlPatternResult?.pathname?.groups ?? {} 9 | )) { 10 | groups.set(name, value); 11 | } 12 | 13 | return groups; 14 | } 15 | --------------------------------------------------------------------------------