├── .eslintrc.js ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── LICENSE ├── README.md ├── assets ├── code │ └── roadmap.png └── social │ └── banner.png ├── bun.lockb ├── deno_dist ├── core │ ├── constants │ │ ├── codes.ts │ │ ├── index.ts │ │ └── mime │ │ │ ├── application.ts │ │ │ ├── audio.ts │ │ │ ├── font.ts │ │ │ ├── image.ts │ │ │ ├── index.ts │ │ │ ├── text.ts │ │ │ └── video.ts │ ├── context.ts │ ├── errors │ │ ├── config.ts │ │ ├── error.ts │ │ ├── handler.ts │ │ └── index.ts │ ├── middleware.ts │ ├── response.ts │ ├── response │ │ ├── created.ts │ │ └── creator.ts │ ├── server.ts │ ├── types.ts │ └── utils │ │ ├── app.ts │ │ ├── choose.ts │ │ ├── console │ │ └── colors.ts │ │ ├── is.ts │ │ ├── mime.ts │ │ ├── parsers │ │ ├── base64.ts │ │ ├── buffer.ts │ │ ├── form-data.ts │ │ ├── json.ts │ │ ├── query-string.ts │ │ └── req-body.ts │ │ ├── sanitize.ts │ │ └── types.ts ├── index.ts ├── middlewares │ ├── README.md │ ├── index.ts │ ├── mw-body-parser.ts │ ├── mw-cors.ts │ ├── mw-request-id.ts │ └── mw-uploads.ts └── mod.ts ├── example ├── app-with-adv-route.ts ├── app-with-middleware.ts ├── app-with-mounting.ts ├── app-with-mw-body-parser.ts ├── app-with-mw-cors.ts ├── app-with-mw-request-id.ts ├── app-with-mw-uploads.ts ├── app-with-route-grouping.ts ├── app.ts ├── deno │ ├── .vscode │ │ └── settings.json │ └── app.ts └── deps │ ├── mw-logger.ts │ └── sub-app.ts ├── nodemon.json ├── package.json ├── src ├── core │ ├── constants │ │ ├── codes.ts │ │ ├── index.ts │ │ └── mime │ │ │ ├── application.ts │ │ │ ├── audio.ts │ │ │ ├── font.ts │ │ │ ├── image.ts │ │ │ ├── index.ts │ │ │ ├── text.ts │ │ │ └── video.ts │ ├── context.ts │ ├── errors │ │ ├── config.ts │ │ ├── error.ts │ │ ├── handler.ts │ │ └── index.ts │ ├── middleware.ts │ ├── response.ts │ ├── response │ │ ├── created.ts │ │ └── creator.ts │ ├── server.ts │ ├── types.ts │ └── utils │ │ ├── app.ts │ │ ├── choose.ts │ │ ├── console │ │ └── colors.ts │ │ ├── is.ts │ │ ├── mime.ts │ │ ├── oss │ │ └── escape_html.ts │ │ ├── parsers │ │ ├── base64.ts │ │ ├── buffer.ts │ │ ├── form-data.ts │ │ ├── html.ts │ │ ├── json.ts │ │ ├── query-string.ts │ │ └── req-body.ts │ │ ├── sanitize.ts │ │ └── types.ts ├── index.ts └── middlewares │ ├── README.md │ ├── index.ts │ ├── mw-body-parser.ts │ ├── mw-cors.ts │ ├── mw-request-id.ts │ └── mw-uploads.ts ├── tests ├── .gitkeep ├── mime.test.ts └── server.test.ts ├── tsconfig.json ├── tsup.config.ts └── vite.config.ts /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es2021: true, 4 | node: true 5 | }, 6 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 7 | parser: '@typescript-eslint/parser', 8 | parserOptions: { 9 | ecmaVersion: 'latest', 10 | sourceType: 'module' 11 | }, 12 | plugins: ['@typescript-eslint'], 13 | rules: { 14 | '@typescript-eslint/ban-types': 'off', 15 | '@typescript-eslint/no-explicit-any': 'off', 16 | 'no-mixed-spaces-and-tabs': 'off' 17 | }, 18 | ignorePatterns: ['example/*', 'tests/**/*'] 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | with: 14 | fetch-depth: 0 15 | 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: 16.x 19 | 20 | - run: npx changelogithub 21 | env: 22 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | 15 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 16 | 17 | # Runtime data 18 | 19 | pids 20 | _.pid 21 | _.seed 22 | \*.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | 30 | coverage 31 | \*.lcov 32 | 33 | # nyc test coverage 34 | 35 | .nyc_output 36 | 37 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 38 | 39 | .grunt 40 | 41 | # Bower dependency directory (https://bower.io/) 42 | 43 | bower_components 44 | 45 | # node-waf configuration 46 | 47 | .lock-wscript 48 | 49 | # Compiled binary addons (https://nodejs.org/api/addons.html) 50 | 51 | build/Release 52 | 53 | # Dependency directories 54 | 55 | node_modules/ 56 | jspm_packages/ 57 | 58 | # Snowpack dependency directory (https://snowpack.dev/) 59 | 60 | web_modules/ 61 | 62 | # TypeScript cache 63 | 64 | \*.tsbuildinfo 65 | 66 | # Optional npm cache directory 67 | 68 | .npm 69 | 70 | # Optional eslint cache 71 | 72 | .eslintcache 73 | 74 | # Optional stylelint cache 75 | 76 | .stylelintcache 77 | 78 | # Microbundle cache 79 | 80 | .rpt2_cache/ 81 | .rts2_cache_cjs/ 82 | .rts2_cache_es/ 83 | .rts2_cache_umd/ 84 | 85 | # Optional REPL history 86 | 87 | .node_repl_history 88 | 89 | # Output of 'npm pack' 90 | 91 | \*.tgz 92 | 93 | # Yarn Integrity file 94 | 95 | .yarn-integrity 96 | 97 | # dotenv environment variable files 98 | 99 | .env 100 | .env.development.local 101 | .env.test.local 102 | .env.production.local 103 | .env.local 104 | 105 | # parcel-bundler cache (https://parceljs.org/) 106 | 107 | .cache 108 | .parcel-cache 109 | 110 | # Next.js build output 111 | 112 | .next 113 | out 114 | 115 | # Nuxt.js build / generate output 116 | 117 | .nuxt 118 | dist 119 | 120 | # Gatsby files 121 | 122 | .cache/ 123 | 124 | # Comment in the public line in if your project uses Gatsby and not Next.js 125 | 126 | # https://nextjs.org/blog/next-9-1#public-directory-support 127 | 128 | # public 129 | 130 | # vuepress build output 131 | 132 | .vuepress/dist 133 | 134 | # vuepress v2.x temp and cache directory 135 | 136 | .temp 137 | .cache 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.\* 170 | 171 | # WIP resources 172 | .idea 173 | local_store.sqlite 174 | 175 | # Deno 176 | # deno_dist 177 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | example/ 2 | docs/ 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "trailingComma": "none" 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Aftab Alam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zarf 2 | Fast, Bun-powered, and Bun-only(for now) Web API framework with full Typescript support. 3 | 4 | ## Quickstart 5 | Starting with `Zarf` is as simple as instantiating the `Zarf` class, attaching route handlers and finally starting the server 6 | ```ts 7 | import { Zarf } from "@zarfjs/zarf" 8 | 9 | const app = new Zarf() 10 | 11 | app.get("/hello", (ctx) => { 12 | return ctx.json({ 13 | hello: "hello" 14 | }) 15 | }) 16 | 17 | app.get("/", (ctx) => { 18 | return ctx.html(`Welcome to Zarf App server`) 19 | }) 20 | 21 | app.listen({ 22 | port: 3000 23 | }, (server) => { 24 | console.log(`Server started on ${server.port}`) 25 | }) 26 | ``` 27 | ## App and Routing 28 | Routes are how you tell where/when/what to respond when somebody visits your app's URLs, and `@zarfjs/zarf` lets you easily register routes, with all the commonly used HTTP verbs like `GET`, `POST`, `PUT`, `DELETE`, etc. 29 | 30 | Here's how you'd define your app routes - 31 | 32 | ```ts 33 | // GET 34 | app.get("/posts", (ctx) => { 35 | return ctx.json({ 36 | posts: [/* all of the posts */] 37 | }) 38 | }) 39 | 40 | // POST 41 | app.post("/posts", async(ctx) => { 42 | const { request } = ctx 43 | const body = await request?.json() 44 | // ... validate the post body 45 | // ... create a post entry 46 | return ctx.json(body) 47 | }) 48 | 49 | // PUT 50 | app.put("/posts/:id", async(ctx, params) => { 51 | const { request } = ctx 52 | const id = params.id 53 | const body = await request?.json() 54 | // ... validate the post body 55 | // ... upadte the post entry 56 | return ctx.json(body) 57 | }) 58 | 59 | // DELETE 60 | app.del("/posts/:id", async(ctx, params) => { 61 | const id = params.id 62 | // ... validate the del op 63 | // ... delete the post entry 64 | return ctx.json({ deleted: 1 }) 65 | }) 66 | 67 | ``` 68 | ## Routing: Context 69 | `Context` available as the first argument to your route handlers is a special object made available to all the route handlers which 70 | - lets you access vaious details w.r.t `Request` object 71 | - provides convenience methods like `json`, `text`, `html` to send `Response` to the client 72 | 73 | The most accessed/useful object could be the `Request` object itself(available at `ctx.request`), but it offers few other methods too 74 | - `setHeader` 75 | - `setType` 76 | - `setVary` 77 | - `isType` 78 | - `accepts` 79 | to determine things about the current request, or change few things about the response that's send to the client. 80 | 81 | ## Routing: Params 82 | `Params` is the second argument available to your route handlers, that lets you access the route parameters easily. 83 | ```ts 84 | app.get("/products/:id", (ctx, params) => { 85 | // params.id ? // 86 | // Pull the details 87 | return ctx.json({ 88 | product: {/* all of the posts */} 89 | }) 90 | }) 91 | ``` 92 | `@zarfjs/zarf` supports all the common URL patterns you'd expect in a Web-App/API framework 93 | ```ts 94 | app.get("/user/:name/books/:title", (ctx, params) => { 95 | const { name, title } = params 96 | return ctx.json({ 97 | name, 98 | title 99 | }) 100 | }) 101 | 102 | app.get("/user/:name?", (ctx, params) => { 103 | return ctx.json({ 104 | name: params.name || 'No name found' 105 | }) 106 | }) 107 | 108 | // /admin/feature/path/goes/here 109 | app.get("/admin/*all", (ctx, params) => { 110 | return ctx.json({ 111 | supPath: params.all // -> /feature/path/goes/here 112 | }) 113 | }) 114 | 115 | // /v1/nike/shop/uk 116 | // /v1/nike/uk/shop/shop-at... 117 | app.get("/v1/*brand/shop/*name", (ctx, params) => { 118 | return ctx.json({ 119 | params // -> { brand: 'nike', ...}, { brand: 'nike/uk', ...} 120 | }) 121 | }) 122 | ``` 123 | 124 | ## RoadMap 125 | A lot of great stuff is actually planned for the project. The Alpha version is majorly focussing on making the core stable and provide all the essential features. Here's snapshot of the [roadmap](https://github.com/users/one-aalam/projects/3/views/1).(private) 126 | 127 | Zarf Roadmap 128 | 129 | 130 | 131 | ## Meta 132 | The project is developed on 133 | - OS - MacOS Monterey 134 | - Bun - v0.1.13 135 | -------------------------------------------------------------------------------- /assets/code/roadmap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zarfjs/zarf/56bd2fafd61729b85a7b054654da1fba2ff62f3d/assets/code/roadmap.png -------------------------------------------------------------------------------- /assets/social/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zarfjs/zarf/56bd2fafd61729b85a7b054654da1fba2ff62f3d/assets/social/banner.png -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zarfjs/zarf/56bd2fafd61729b85a7b054654da1fba2ff62f3d/bun.lockb -------------------------------------------------------------------------------- /deno_dist/core/constants/codes.ts: -------------------------------------------------------------------------------- 1 | import type { Replace } from '../utils/types.ts' 2 | export const HTTP_STATUS_CODES = { 3 | // Informational 4 | 100: 'Continue', 5 | 101: 'Switching Protocols', 6 | // Successful 7 | 200: 'OK', 8 | 201: 'Created', 9 | 202: 'Accepted', 10 | 203: 'Non-Authoritative Information', 11 | 204: 'No Content', 12 | 205: 'Reset Content', 13 | 206: 'Partial Content', 14 | // Redirection 15 | 301: 'Moved Permanently', 16 | 302: 'Found', 17 | 303: 'See Other', 18 | 304: 'Not Modified', 19 | 307: 'Temporary Redirect', 20 | 308: 'Permanent Redirect', 21 | // Client Error 22 | 400: 'Bad Request', 23 | 401: 'Unauthorized', 24 | 403: 'Forbidden', 25 | 404: 'Not Found', 26 | 405: 'Method Not Allowed', 27 | 406: 'Not Acceptable', 28 | 407: 'Proxy Authentication Required', 29 | 408: 'Request Timeout', 30 | 409: 'Conflict', 31 | 410: 'Gone', 32 | 422: 'Unprocessable Entity', 33 | // Server Error 34 | 500: 'Internal Server Error', 35 | 501: 'Not Implemented', 36 | 502: 'Bad Gateway', 37 | 503: 'Service Unavailable', 38 | 504: 'Gateway Timeout' 39 | } as const 40 | 41 | export type HTTPStatusCode = keyof typeof HTTP_STATUS_CODES 42 | export type HTTPStatusCodeMesssage = typeof HTTP_STATUS_CODES[HTTPStatusCode] 43 | export type HTTPStatusCodeMesssageKey = Replace, '-', '', { all: true }> 44 | -------------------------------------------------------------------------------- /deno_dist/core/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const ROUTE_OPTION_DEFAULT = Symbol('default') 2 | -------------------------------------------------------------------------------- /deno_dist/core/constants/mime/application.ts: -------------------------------------------------------------------------------- 1 | import { MimeTypeConfig } from './index.ts' 2 | 3 | export type AppType = 'application' 4 | export type AppMimeType = `${AppType}/${string}` 5 | 6 | export const MIME_APP: Record = { 7 | 'application/atom+xml': { 8 | cmp: true, 9 | ext: ['atom'] 10 | }, 11 | 'application/calendar+json': { 12 | cmp: true 13 | }, 14 | 'application/calendar+xml': { 15 | cmp: true, 16 | ext: ['xcs'] 17 | }, 18 | 'application/epub+zip': { 19 | cmp: false, 20 | ext: ['epub'] 21 | }, 22 | 'application/font-woff': { 23 | cmp: false 24 | }, 25 | 'application/geo+json': { 26 | cmp: true, 27 | ext: ['geojson'] 28 | }, 29 | 'application/inkml+xml': { 30 | cmp: true, 31 | ext: ['ink', 'inkml'] 32 | }, 33 | 34 | 'application/java-archive': { 35 | cmp: false, 36 | ext: ['jar', 'war', 'ear'] 37 | }, 38 | 'application/java-serialized-object': { 39 | cmp: false, 40 | ext: ['ser'] 41 | }, 42 | 'application/java-vm': { 43 | cmp: false, 44 | ext: ['class'] 45 | }, 46 | 'application/javascript': { 47 | charset: 'UTF-8', 48 | cmp: true, 49 | ext: ['js', 'mjs'] 50 | }, 51 | 'application/jf2feed+json': { 52 | cmp: true 53 | }, 54 | 'application/json': { 55 | charset: 'UTF-8', 56 | cmp: true, 57 | ext: ['json', 'map'] 58 | }, 59 | 'application/json-patch+json': { 60 | cmp: true 61 | }, 62 | 'application/json5': { 63 | ext: ['json5'] 64 | }, 65 | 'application/jsonml+json': { 66 | cmp: true, 67 | ext: ['jsonml'] 68 | }, 69 | 'application/ld+json': { 70 | cmp: true, 71 | ext: ['jsonld'] 72 | }, 73 | 'application/manifest+json': { 74 | charset: 'UTF-8', 75 | cmp: true, 76 | ext: ['webmanifest'] 77 | }, 78 | 'application/msword': { 79 | cmp: false, 80 | ext: ['doc', 'dot'] 81 | }, 82 | 'application/node': { 83 | ext: ['cjs'] 84 | }, 85 | 'application/octet-stream': { 86 | cmp: false, 87 | ext: [ 88 | 'bin', 89 | 'dms', 90 | 'lrf', 91 | 'mar', 92 | 'so', 93 | 'dist', 94 | 'distz', 95 | 'pkg', 96 | 'bpk', 97 | 'dump', 98 | 'elc', 99 | 'deploy', 100 | 'exe', 101 | 'dll', 102 | 'deb', 103 | 'dmg', 104 | 'iso', 105 | 'img', 106 | 'msi', 107 | 'msp', 108 | 'msm', 109 | 'buffer' 110 | ] 111 | }, 112 | 'application/pdf': { 113 | cmp: false, 114 | ext: ['pdf'] 115 | }, 116 | 'application/gzip': { 117 | cmp: false, 118 | ext: ['gz'] 119 | }, 120 | 'application/x-bzip': { 121 | cmp: false, 122 | ext: ['bz'] 123 | }, 124 | 'application/x-bzip2': { 125 | cmp: false, 126 | ext: ['bz2', 'boz'] 127 | }, 128 | 129 | 'application/x-mobipocket-ebook': { 130 | ext: ['prc', 'mobi'] 131 | }, 132 | 'application/x-mpegurl': { 133 | cmp: false 134 | }, 135 | 'application/x-ms-application': { 136 | ext: ['application'] 137 | }, 138 | 'application/x-msdos-program': { 139 | ext: ['exe'] 140 | }, 141 | 'application/x-msdownload': { 142 | ext: ['exe', 'dll', 'com', 'bat', 'msi'] 143 | }, 144 | 'application/x-rar-compressed': { 145 | cmp: false, 146 | ext: ['rar'] 147 | }, 148 | 'application/x-shockwave-flash': { 149 | cmp: false, 150 | ext: ['swf'] 151 | }, 152 | 'application/x-sql': { 153 | ext: ['sql'] 154 | }, 155 | 'application/x-web-app-manifest+json': { 156 | cmp: true, 157 | ext: ['webapp'] 158 | }, 159 | 'application/x-www-form-urlencoded': { 160 | cmp: true 161 | }, 162 | 'application/xhtml+xml': { 163 | cmp: true, 164 | ext: ['xhtml', 'xht'] 165 | }, 166 | 'application/xml': { 167 | cmp: true, 168 | ext: ['xml', 'xsl', 'xsd', 'rng'] 169 | }, 170 | 'application/zip': { 171 | cmp: false, 172 | ext: ['zip'] 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /deno_dist/core/constants/mime/audio.ts: -------------------------------------------------------------------------------- 1 | import { MimeTypeConfig } from "./index.ts" 2 | export type AudioType = 'audio' 3 | export type AudioMimeType = `${AudioType}/${string}` 4 | 5 | export const MIME_AUDIO: Record = { 6 | "audio/3gpp": { 7 | "cmp": false, 8 | "ext": ["3gpp"] 9 | }, 10 | "audio/3gpp2": { 11 | }, 12 | "audio/aac": { 13 | }, 14 | "audio/ac3": { 15 | }, 16 | "audio/midi": { 17 | "ext": ["mid","midi","kar","rmi"] 18 | }, 19 | "audio/mp3": { 20 | "cmp": false, 21 | "ext": ["mp3"] 22 | }, 23 | "audio/mp4": { 24 | "cmp": false, 25 | "ext": ["m4a","mp4a"] 26 | }, 27 | "audio/mpeg": { 28 | "cmp": false, 29 | "ext": ["mpga","mp2","mp2a","mp3","m2a","m3a"] 30 | }, 31 | "audio/ogg": { 32 | "cmp": false, 33 | "ext": ["oga","ogg","spx","opus"] 34 | }, 35 | 36 | "audio/wav": { 37 | "cmp": false, 38 | "ext": ["wav"] 39 | }, 40 | "audio/wave": { 41 | "cmp": false, 42 | "ext": ["wav"] 43 | }, 44 | "audio/webm": { 45 | 46 | "cmp": false, 47 | "ext": ["weba"] 48 | }, 49 | "audio/x-aac": { 50 | 51 | "cmp": false, 52 | "ext": ["aac"] 53 | }, 54 | "audio/x-flac": { 55 | "ext": ["flac"] 56 | }, 57 | "audio/x-m4a": { 58 | "ext": ["m4a"] 59 | }, 60 | "audio/x-matroska": { 61 | "ext": ["mka"] 62 | }, 63 | "audio/x-mpegurl": { 64 | "ext": ["m3u"] 65 | }, 66 | "audio/x-ms-wma": { 67 | "ext": ["wma"] 68 | }, 69 | "audio/x-wav": { 70 | "ext": ["wav"] 71 | }, 72 | "audio/xm": { 73 | "ext": ["xm"] 74 | }, 75 | } 76 | -------------------------------------------------------------------------------- /deno_dist/core/constants/mime/font.ts: -------------------------------------------------------------------------------- 1 | import { MimeTypeConfig } from './index.ts' 2 | 3 | export type FontType = 'font' 4 | export type FontMimeType = `${FontType}/${string}` 5 | 6 | export const MIME_FONT: Record = { 7 | 'font/collection': { 8 | ext: ['ttc'] 9 | }, 10 | 'font/otf': { 11 | cmp: true, 12 | ext: ['otf'] 13 | }, 14 | 'font/ttf': { 15 | cmp: true, 16 | ext: ['ttf'] 17 | }, 18 | 'font/woff': { 19 | ext: ['woff'] 20 | }, 21 | 'font/woff2': { 22 | ext: ['woff2'] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /deno_dist/core/constants/mime/image.ts: -------------------------------------------------------------------------------- 1 | import { MimeTypeConfig } from './index.ts' 2 | 3 | export type ImageType = 'image' 4 | export type ImageMimeType = `${ImageType}/${string}` 5 | 6 | export const MIME_IMAGE: Record = { 7 | 'image/apng': { 8 | cmp: false, 9 | ext: ['apng'] 10 | }, 11 | 'image/avci': { 12 | ext: ['avci'] 13 | }, 14 | 'image/avcs': { 15 | ext: ['avcs'] 16 | }, 17 | 'image/avif': { 18 | cmp: false, 19 | ext: ['avif'] 20 | }, 21 | 'image/bmp': { 22 | cmp: true, 23 | ext: ['bmp'] 24 | }, 25 | 26 | 'image/gif': { 27 | cmp: false, 28 | ext: ['gif'] 29 | }, 30 | 31 | 'image/jp2': { 32 | cmp: false, 33 | ext: ['jp2', 'jpg2'] 34 | }, 35 | 'image/jpeg': { 36 | cmp: false, 37 | ext: ['jpeg', 'jpg', 'jpe'] 38 | }, 39 | 40 | 'image/pjpeg': { 41 | cmp: false 42 | }, 43 | 'image/png': { 44 | cmp: false, 45 | ext: ['png'] 46 | }, 47 | 48 | 'image/svg+xml': { 49 | cmp: true, 50 | ext: ['svg', 'svgz'] 51 | }, 52 | 'image/t38': { 53 | ext: ['t38'] 54 | }, 55 | 'image/tiff': { 56 | cmp: false, 57 | ext: ['tif', 'tiff'] 58 | }, 59 | 'image/tiff-fx': { 60 | ext: ['tfx'] 61 | }, 62 | 'image/vnd.adobe.photoshop': { 63 | cmp: true, 64 | ext: ['psd'] 65 | }, 66 | 67 | 'image/vnd.microsoft.icon': { 68 | cmp: true, 69 | ext: ['ico'] 70 | }, 71 | 72 | 'image/vnd.ms-photo': { 73 | ext: ['wdp'] 74 | }, 75 | 'image/vnd.net-fpx': { 76 | ext: ['npx'] 77 | }, 78 | 79 | 'image/webp': { 80 | ext: ['webp'] 81 | }, 82 | 'image/wmf': { 83 | ext: ['wmf'] 84 | }, 85 | 86 | 'image/x-icon': { 87 | cmp: true, 88 | ext: ['ico'] 89 | }, 90 | 91 | 'image/x-ms-bmp': { 92 | cmp: true, 93 | ext: ['bmp'] 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /deno_dist/core/constants/mime/index.ts: -------------------------------------------------------------------------------- 1 | import { TextMimeType, MIME_TEXT } from './text.ts' 2 | import { FontMimeType, MIME_FONT } from './font.ts' 3 | import { ImageMimeType, MIME_IMAGE } from './image.ts' 4 | import { AudioMimeType, MIME_AUDIO } from './audio.ts' 5 | import { VideoMimeType, MIME_VIDEO } from './video.ts' 6 | import { AppMimeType , MIME_APP } from './application.ts' 7 | 8 | export type MultipartMimeType = `multipart/${string}` 9 | export type MimeType = TextMimeType | FontMimeType | ImageMimeType | AudioMimeType | VideoMimeType | MultipartMimeType | AppMimeType 10 | export type MimeTypeConfig = { 11 | ext?: Array, 12 | cmp?: boolean, 13 | charset?: "UTF-8" 14 | } 15 | 16 | export const MIME_TYPES_CONFIG: Record = { 17 | ...MIME_TEXT, 18 | ...MIME_FONT, 19 | ...MIME_IMAGE, 20 | ...MIME_AUDIO, 21 | ...MIME_VIDEO, 22 | ...MIME_APP, 23 | "multipart/form-data": { 24 | "cmp": false 25 | } 26 | } 27 | 28 | function populateExtensionsMimeTypes() { 29 | Object.entries(MIME_TYPES_CONFIG).forEach(([mimeType, mimeConfig]) => { 30 | if(mimeConfig.ext && mimeConfig.ext.length) { 31 | MIME_TYPE_EXT[mimeType as MimeType] = mimeConfig.ext 32 | mimeConfig.ext.forEach(ext => { 33 | EXT_MIME_TYPES[ext] = mimeType as MimeType 34 | }) 35 | } 36 | }) 37 | } 38 | 39 | // Runtime consts 40 | export const EXT_MIME_TYPES: Record = Object.create(null); 41 | export const MIME_TYPE_EXT: Record> = Object.create(null); 42 | 43 | populateExtensionsMimeTypes() 44 | -------------------------------------------------------------------------------- /deno_dist/core/constants/mime/text.ts: -------------------------------------------------------------------------------- 1 | import { MimeTypeConfig } from './index.ts' 2 | 3 | export type TextType = 'text' 4 | export type TextMimeType = `${TextType}/${string}` 5 | 6 | export const MIME_TEXT: Record = { 7 | 'text/cache-manifest': { 8 | cmp: true, 9 | ext: ['appcache', 'manifest'] 10 | }, 11 | 'text/calendar': { 12 | ext: ['ics', 'ifb'] 13 | }, 14 | 'text/calender': { 15 | cmp: true 16 | }, 17 | 'text/cmd': { 18 | cmp: true 19 | }, 20 | 'text/coffeescript': { 21 | ext: ['coffee', 'litcoffee'] 22 | }, 23 | 'text/css': { 24 | charset: 'UTF-8', 25 | cmp: true, 26 | ext: ['css'] 27 | }, 28 | 'text/csv': { 29 | cmp: true, 30 | ext: ['csv'] 31 | }, 32 | 'text/html': { 33 | cmp: true, 34 | ext: ['html', 'htm', 'shtml'] 35 | }, 36 | 'text/jade': { 37 | ext: ['jade'] 38 | }, 39 | 'text/javascript': { 40 | cmp: true 41 | }, 42 | 'text/jsx': { 43 | cmp: true, 44 | ext: ['jsx'] 45 | }, 46 | 'text/less': { 47 | cmp: true, 48 | ext: ['less'] 49 | }, 50 | 'text/markdown': { 51 | cmp: true, 52 | ext: ['markdown', 'md'] 53 | }, 54 | 'text/mdx': { 55 | cmp: true, 56 | ext: ['mdx'] 57 | }, 58 | 'text/plain': { 59 | cmp: true, 60 | ext: ['txt', 'text', 'conf', 'def', 'list', 'log', 'in', 'ini'] 61 | }, 62 | 'text/richtext': { 63 | cmp: true, 64 | ext: ['rtx'] 65 | }, 66 | 'text/rtf': { 67 | cmp: true, 68 | ext: ['rtf'] 69 | }, 70 | 'text/stylus': { 71 | ext: ['stylus', 'styl'] 72 | }, 73 | 'text/tab-separated-values': { 74 | cmp: true, 75 | ext: ['tsv'] 76 | }, 77 | 'text/uri-list': { 78 | cmp: true, 79 | ext: ['uri', 'uris', 'urls'] 80 | }, 81 | 'text/vcard': { 82 | cmp: true, 83 | ext: ['vcard'] 84 | }, 85 | 'text/vnd.dvb.subtitle': { 86 | ext: ['sub'] 87 | }, 88 | 'text/vtt': { 89 | charset: 'UTF-8', 90 | cmp: true, 91 | ext: ['vtt'] 92 | }, 93 | 'text/x-handlebars-template': { 94 | ext: ['hbs'] 95 | }, 96 | 'text/xml': { 97 | cmp: true, 98 | ext: ['xml'] 99 | }, 100 | 'text/yaml': { 101 | ext: ['yaml', 'yml'] 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /deno_dist/core/constants/mime/video.ts: -------------------------------------------------------------------------------- 1 | import { MimeTypeConfig } from './index.ts' 2 | 3 | export type VideoType = 'video' 4 | export type VideoMimeType = `${VideoType}/${string}` 5 | 6 | export const MIME_VIDEO: Record = { 7 | 'video/h264': { 8 | ext: ['h264'] 9 | }, 10 | 'video/h265': {}, 11 | 'video/mp4': { 12 | cmp: false, 13 | ext: ['mp4', 'mp4v', 'mpg4'] 14 | }, 15 | 'video/mpeg': { 16 | cmp: false, 17 | ext: ['mpeg', 'mpg', 'mpe', 'm1v', 'm2v'] 18 | }, 19 | 'video/ogg': { 20 | cmp: false, 21 | ext: ['ogv'] 22 | }, 23 | 'video/quicktime': { 24 | cmp: false, 25 | ext: ['qt', 'mov'] 26 | }, 27 | 'video/vnd.mpegurl': { 28 | ext: ['mxu', 'm4u'] 29 | }, 30 | 'video/webm': { 31 | cmp: false, 32 | ext: ['webm'] 33 | }, 34 | 'video/x-f4v': { 35 | ext: ['f4v'] 36 | }, 37 | 'video/x-fli': { 38 | ext: ['fli'] 39 | }, 40 | 'video/x-flv': { 41 | cmp: false, 42 | ext: ['flv'] 43 | }, 44 | 'video/x-m4v': { 45 | ext: ['m4v'] 46 | }, 47 | 'video/x-matroska': { 48 | cmp: false, 49 | ext: ['mkv', 'mk3d', 'mks'] 50 | }, 51 | 'video/x-msvideo': { 52 | ext: ['avi'] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /deno_dist/core/context.ts: -------------------------------------------------------------------------------- 1 | import type { ContextMeta, ZarfConfig, RouteMethod, HeaderVaryContent, HeaderTypeContent } from './types.ts' 2 | import type { ParsedBody } from './utils/parsers/req-body.ts' 3 | import { json, text, head, send, html } from './response.ts' 4 | import { getContentType } from './utils/mime.ts' 5 | import { MiddlewareFunction } from './middleware.ts' 6 | 7 | // @ts-ignore 8 | const NEEDS_WARMUP = globalThis && globalThis.process && globalThis.process.isBun ? true : false 9 | 10 | /** 11 | * Execution context for handlers and all the middlewares 12 | */ 13 | export class AppContext = {}> { 14 | private _response: Response | null 15 | private _config: ZarfConfig = {} 16 | private _error: any 17 | private _code: number | undefined; 18 | private _middlewares: Array> = [] 19 | 20 | private readonly _request: Request | null 21 | readonly url: URL; 22 | readonly method: RouteMethod 23 | readonly host: string | undefined; 24 | readonly path: string; 25 | readonly query: URLSearchParams | undefined; 26 | readonly headers: Request["headers"] 27 | 28 | private _locals: S = {} as S 29 | readonly meta: ContextMeta = { 30 | startTime: 0 31 | } 32 | private _isImmediate: boolean = false 33 | 34 | public body: ParsedBody | null = null 35 | 36 | constructor(req: Request, config: ZarfConfig) { 37 | this.meta.startTime = Date.now() 38 | this._config = config 39 | this._request = req 40 | this._response = new Response('') 41 | 42 | this.method = req.method.toLowerCase() as RouteMethod 43 | this.url = new URL(req.url) 44 | this.host = this.url.host 45 | this.path = this._config?.strictRouting || this.url.pathname === '/' ? 46 | this.url.pathname : 47 | this.url.pathname.endsWith('/') ? 48 | this.url.pathname.substring(0, this.url.pathname.length -1) : 49 | this.url.pathname 50 | 51 | this.query = new Proxy(new URLSearchParams(req.url), { 52 | get: (params, param) => params.get(param as string), 53 | }) 54 | this.headers = new Proxy(this._request.headers, { 55 | get: (headers, header) => headers.get(header as string), 56 | }) 57 | 58 | if(this._config?.serverHeader) { 59 | this._response.headers.set('Server', this._config.serverHeader) 60 | } 61 | 62 | /** 63 | * Currently needed for reading request body using `json`, `text` or `arrayBuffer` 64 | * without taking forever to resolve when any of them are accessed later 65 | * 66 | * But, needed only for `Bun`. If left applied for all the cases, this creates an issue with Node.js, Deno, etc. 67 | */ 68 | if(NEEDS_WARMUP) { 69 | this._request.blob(); 70 | } 71 | } 72 | 73 | /** 74 | * Get the current request in the raw form 75 | */ 76 | get request() { 77 | return this._request 78 | } 79 | 80 | /** 81 | * Get the `Response` if any 82 | */ 83 | get response() { 84 | return this._response 85 | } 86 | /** 87 | * Set the `Response` 88 | */ 89 | set response(resp) { 90 | this._response = resp 91 | } 92 | 93 | /** 94 | * Get the current status code 95 | */ 96 | get status() { 97 | return this._code! 98 | } 99 | /** 100 | * Set the current status code 101 | */ 102 | set status(code: number) { 103 | this._code = code; 104 | } 105 | 106 | get isImmediate() { 107 | return this._isImmediate 108 | } 109 | 110 | /// HEADER HELPERS /// 111 | setHeader(headerKey: string, headerVal: string) { 112 | return this._response?.headers.set(headerKey, headerVal) 113 | } 114 | 115 | setType(headerVal: HeaderTypeContent) { 116 | const contentType = getContentType(headerVal) 117 | if(contentType) { 118 | this.setHeader('Content-Type', getContentType(headerVal) as string) 119 | } 120 | return 121 | } 122 | 123 | isType(headerVal: HeaderTypeContent) { 124 | return this._request?.headers.get('Content-Type') === getContentType(headerVal) 125 | } 126 | 127 | accepts(headerVal: HeaderTypeContent) { 128 | return this._request?.headers.get('Accepts')?.includes(getContentType(headerVal) || '') 129 | } 130 | 131 | // https://www.smashingmagazine.com/2017/11/understanding-vary-header/ 132 | setVary(...headerVals: Array) { 133 | if(headerVals.length) { 134 | const varies = (this._response?.headers.get('Vary') || '').split(',') 135 | this._response?.headers.set('Vary', [...new Set([...varies ,...headerVals])].join(',')) 136 | } 137 | } 138 | 139 | /** 140 | * Get Error 141 | */ 142 | get error() { 143 | return this._error 144 | } 145 | /** 146 | * Set Error 147 | */ 148 | set error(err) { 149 | this._error = err 150 | } 151 | 152 | // Getter/Setter for App-specific details 153 | /** 154 | * Get available App-specific details 155 | */ 156 | get locals() { 157 | return this._locals as S 158 | } 159 | /** 160 | * Set App-specific details 161 | */ 162 | set locals(value: S) { 163 | this._locals = value 164 | } 165 | 166 | // Context helpers to send the `Response` in all the supported formats 167 | /** 168 | * Send the response as string, json, etc. - One sender to rule `em all! 169 | * @param body 170 | * @returns 171 | */ 172 | async send(body: any, args: ResponseInit = {}): Promise { 173 | if(this._request?.method === 'HEAD') return this.head() 174 | return await send(body, {...this.getResponseInit(), ...args}) 175 | } 176 | 177 | /** 178 | * Send the provided values as `json` 179 | * @param body 180 | * @returns 181 | */ 182 | json(body: any, args: ResponseInit = {}): Response { 183 | return json(body, {...this.getResponseInit(), ...args}) 184 | } 185 | 186 | /** 187 | * Send the provided value as `text` 188 | * @param _text 189 | * @returns 190 | */ 191 | text(_text: string, args: ResponseInit = {}): Response { 192 | return text(_text, {...this.getResponseInit(), ...args}) 193 | } 194 | 195 | /** 196 | * Send the provided value as `html` 197 | * @param _text 198 | * @returns 199 | */ 200 | html(text: string, args: ResponseInit = {}): Response { 201 | return html(text, {...this.getResponseInit(), ...args}) 202 | } 203 | 204 | /** 205 | * Just return with `head` details 206 | * @returns 207 | */ 208 | head(args: ResponseInit = {}): Response { 209 | return head({...this.getResponseInit(), ...args}) 210 | } 211 | 212 | 213 | /** 214 | * Halt flow, and return with currently provided status 215 | * 216 | * @param statusCode status code to use 217 | * @param body the body to send in response. Could be `json`, `string`, etc. 218 | * @returns 219 | */ 220 | async halt(statusCode: number, body: any): Promise { 221 | this._isImmediate = true 222 | return this.send(body, { ...this.getResponseInit(), status: statusCode }) 223 | } 224 | 225 | /** 226 | * Redirect to the given URL 227 | * 228 | * @param url 229 | * @param statusCode 230 | * @returns 231 | */ 232 | redirect(url: string, statusCode: number = 302): Response { 233 | this._isImmediate = true 234 | let loc = url 235 | if(loc ==='back') loc = this._request?.headers.get('Referrer') || '/' 236 | return Response.redirect(encodeURI(loc), statusCode) 237 | } 238 | 239 | /** 240 | * Use the settings from available `Response` details, if there's one (as an outcome of handler processing and middleware execution) 241 | * @returns 242 | */ 243 | private getResponseInit(): ResponseInit { 244 | if(this._response) { 245 | const { status, statusText, headers } = this._response 246 | 247 | const _headers: Record = {} 248 | headers.forEach((val, key) => { 249 | _headers[key] = val 250 | }) 251 | 252 | return { 253 | headers: _headers, 254 | status, 255 | statusText 256 | } 257 | } 258 | else { 259 | return {} 260 | } 261 | } 262 | 263 | after(func: MiddlewareFunction) { 264 | this._middlewares.push(func) 265 | } 266 | 267 | get afterMiddlewares() { 268 | return this._middlewares 269 | } 270 | 271 | } 272 | -------------------------------------------------------------------------------- /deno_dist/core/errors/config.ts: -------------------------------------------------------------------------------- 1 | import { badRequest, internalServerError, methodNotAllowed, notFound, unprocessableEntity } from "../response/created.ts"; 2 | import { RespCreatorRequestInit } from "../response/creator.ts"; 3 | import { HTTPStatusCodeMesssageKey } from '../constants/codes.ts' 4 | 5 | export const serverErrorFns: Partial Response>> = { 6 | 'InternalServerError': internalServerError, 7 | 'NotFound': notFound, 8 | 'BadRequest': badRequest, 9 | 'UnprocessableEntity': unprocessableEntity, 10 | 'MethodNotAllowed': methodNotAllowed 11 | } 12 | -------------------------------------------------------------------------------- /deno_dist/core/errors/error.ts: -------------------------------------------------------------------------------- 1 | import type { Errorlike } from "bun DENOIFY: UNKNOWN NODE BUILTIN"; 2 | import { pick } from '../utils/choose.ts' 3 | import { HTTPStatusCodeMesssageKey } from '../constants/codes.ts' 4 | import { serverErrorFns } from './config.ts' 5 | 6 | type ErrorData = { [key: string]: any }; 7 | 8 | export class ZarfCustomError extends Error implements Errorlike { 9 | constructor( 10 | readonly message: string, 11 | readonly code: HTTPStatusCodeMesssageKey = 'InternalServerError', 12 | readonly data: ErrorData = {}, 13 | ) { 14 | super(); 15 | } 16 | } 17 | 18 | export class ZarfNotFoundError extends ZarfCustomError { 19 | constructor(originalUrl: string) { 20 | super(`Route '${originalUrl}' does not exist.`, 'NotFound'); 21 | this.name = this.constructor.name; 22 | } 23 | } 24 | 25 | export class ZarfMethodNotAllowedError extends ZarfCustomError { 26 | constructor() { 27 | super(`Requested method is not allowed for the server.`, 'MethodNotAllowed'); 28 | this.name = this.constructor.name; 29 | } 30 | } 31 | 32 | export class ZarfBadRequestError extends ZarfCustomError { 33 | constructor(errorData: ErrorData) { 34 | super('There were validation errors.', 'BadRequest', errorData); 35 | this.name = this.constructor.name; 36 | } 37 | } 38 | 39 | export class ZarfUnprocessableEntityError extends ZarfCustomError { 40 | constructor(errorData: ErrorData) { 41 | super('Unprocessable Entity', 'UnprocessableEntity', errorData); 42 | this.name = this.constructor.name; 43 | } 44 | } 45 | 46 | export const sendError = (error: Errorlike) => { 47 | const isErrorSafeForClient = error instanceof ZarfCustomError; 48 | const send = error.code ? serverErrorFns[error.code as HTTPStatusCodeMesssageKey] : serverErrorFns['InternalServerError'] 49 | const { message, data } = isErrorSafeForClient 50 | ? pick(error, ['message', 'data']) 51 | : { 52 | message: 'Something went wrong!', 53 | data: {}, 54 | }; 55 | return send?.(JSON.stringify({ message, errors: data }, null, 0)) 56 | } 57 | -------------------------------------------------------------------------------- /deno_dist/core/errors/handler.ts: -------------------------------------------------------------------------------- 1 | import type { Errorlike } from 'bun DENOIFY: UNKNOWN NODE BUILTIN' 2 | import { AppContext } from '../context.ts'; 3 | import { sendError } from './error.ts' 4 | /** 5 | * Special handlers 6 | * / 7 | /* 8 | * Default error handler 9 | * @param ctx 10 | * @param err 11 | * @returns 12 | */ 13 | export function defaultErrorHandler = {}>(ctx: AppContext, err: Errorlike): Response { 14 | return sendError(err)! 15 | } 16 | /** 17 | * Not Found Error handler 18 | * @param ctx 19 | * @returns 20 | */ 21 | export function notFoundHandler = {}>(ctx: AppContext): Response | Promise{ 22 | return new Response(`No matching ${ctx.method.toUpperCase()} routes discovered for the path: ${ctx.path}`, { 23 | status: 404, 24 | }); 25 | } 26 | 27 | /** 28 | * Not found verb error handler 29 | * @param ctx 30 | * @returns 31 | */ 32 | export function notFoundVerbHandler = {}>(ctx: AppContext): Response | Promise { 33 | return new Response(`No implementations found for the verb: ${ctx.method.toUpperCase()}`, { 34 | status: 404, 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /deno_dist/core/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error.ts' 2 | export * from './handler.ts' 3 | -------------------------------------------------------------------------------- /deno_dist/core/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { AppContext } from "./context.ts"; 2 | 3 | // `Middleware` configs 4 | export type MiddlewareType = 'before' | 'after' | 'error' 5 | export const NoOpMiddleware: MiddlewareFunction = (_, next) => next(); 6 | export type MiddlewareMeta = { 7 | isFirst: boolean, 8 | isLast: boolean 9 | } 10 | 11 | // `Middleware` return types 12 | export type MiddlewareFuncResp = void | Response; 13 | export type MiddlewareNextFunc = () => Promise; 14 | 15 | // `Middleware` function types 16 | export type MiddlewareFunction = {}> = ( 17 | context: AppContext, 18 | next: MiddlewareNextFunc, 19 | meta?: MiddlewareMeta 20 | ) => Promise; 21 | export type MiddlewareFunctionInitializer< 22 | T extends Record = {}, 23 | S extends Record = {}> = (options?: T) => MiddlewareFunction 24 | 25 | /** 26 | * Execute a sequence of middlewares 27 | * @param context 28 | * @param middlewares 29 | * @returns 30 | */ 31 | export async function exec = {}>(context: AppContext, middlewares: Array>) { 32 | let prevIndexAt: number = -1; 33 | 34 | async function runner(indexAt: number): Promise { 35 | if (indexAt <= prevIndexAt) { 36 | throw new Error(`next() called multiple times by middleware #${indexAt}`) 37 | } 38 | 39 | prevIndexAt = indexAt; 40 | const middleware = middlewares[indexAt]; 41 | if (middleware) { 42 | const resp = await middleware(context, () => { 43 | return runner(indexAt + 1); 44 | }, { 45 | isFirst: indexAt === 0, 46 | isLast: indexAt === middlewares.length - 1, 47 | }); 48 | if (resp) return resp; 49 | } 50 | } 51 | 52 | return runner(0) 53 | } 54 | -------------------------------------------------------------------------------- /deno_dist/core/response.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from "https://deno.land/std@0.158.0/node/buffer.ts"; 2 | import { getContentType } from './utils/mime.ts' 3 | import { HTTP_STATUS_CODES } from './constants/codes.ts' 4 | 5 | /** 6 | * Response helper: Sends the provided data as `json` to the client 7 | * 8 | * @param data `any` or provided type which could be JSON, string, etc. 9 | * @param args 10 | * @returns 11 | */ 12 | export function json(data: T, args: ResponseInit = {}): Response { 13 | const headers = new Headers(args.headers || {}) 14 | headers.set('Content-Type', getContentType('json') as string) 15 | const status = args.status || 200 16 | const statusText = args.statusText || HTTP_STATUS_CODES[status] 17 | if(typeof data === 'object' && data != null) { 18 | return new Response( JSON.stringify(data, null, 0), { ...args, status, statusText, headers } ) 19 | } else if(typeof data === 'string') { 20 | return text(data, { ...args, status, statusText, headers} ) 21 | } else { 22 | headers.delete('Content-Length') 23 | headers.delete('Transfer-Encoding') 24 | return text("", { ...args, status, statusText, headers} ) 25 | } 26 | } 27 | 28 | /** 29 | * Response helper: Sends the provided data as `text` to the client 30 | * @param text 31 | * @param args 32 | * @returns 33 | */ 34 | export function text(text: string, args: ResponseInit = {}): Response { 35 | const headers = new Headers(args.headers || {}) 36 | headers?.set('Content-Type', getContentType('text') as string) 37 | const status = args.status || 200 38 | const statusText = args.statusText || HTTP_STATUS_CODES[status] 39 | return new Response(text.toString(), { ...args, status, statusText, headers }); 40 | } 41 | 42 | export function html(text: string, args: ResponseInit = {}): Response { 43 | const headers = new Headers(args.headers || {}) 44 | headers?.set('Content-Type', getContentType('html') as string) 45 | const status = args.status || 200 46 | const statusText = args.statusText || HTTP_STATUS_CODES[status] 47 | return new Response(text.toString(), { ...args, status, statusText, headers }); 48 | } 49 | 50 | export function head(args: ResponseInit = {}): Response { 51 | const status = args.status || 204 52 | const statusText = args.statusText || HTTP_STATUS_CODES[status] 53 | return new Response('', {...args, status, statusText }) 54 | } 55 | 56 | export async function send(body: any, args: ResponseInit = {}): Promise { 57 | let sendable = body 58 | const headers = new Headers(args.headers || {}) 59 | 60 | // Do any required header tweaks 61 | if(Buffer.isBuffer(body)) { 62 | sendable = body 63 | } else if(typeof body === 'object' && body !== null) { 64 | // `json` updates its header, so no changes required 65 | } else if(typeof body === 'string') { 66 | headers.set('Content-Type', getContentType('text') as string) 67 | } else { 68 | headers.set('Content-Type', getContentType('html') as string) 69 | } 70 | 71 | // @TODO: populate Etag 72 | 73 | // strip irrelevant headers 74 | if (args?.status === 204 || args?.status === 304) { 75 | headers.delete('Content-Type') 76 | headers.delete('Content-Length') 77 | headers.delete('Transfer-Encoding') 78 | sendable = '' 79 | } 80 | 81 | // if(this._request?.method === 'HEAD') { 82 | // return head({ 83 | // ...args, 84 | // headers 85 | // }) 86 | // } 87 | 88 | if (typeof sendable === 'object') { 89 | if (sendable == null) { 90 | return new Response('', { 91 | ...args, 92 | headers 93 | }) 94 | } else { 95 | return json(sendable, { 96 | ...args, 97 | headers 98 | }) 99 | } 100 | } 101 | else if(Buffer.isBuffer(sendable)){ 102 | if(!headers.get('Content-Type')) { 103 | headers.set('Content-Type', getContentType('octet-stream') as string) 104 | } 105 | return new Response(sendable, { 106 | ...args, 107 | headers 108 | }) 109 | } else if(typeof sendable === 'string') { 110 | return text(sendable, { 111 | ...args, 112 | headers 113 | }) 114 | } else if(sendable instanceof Blob) { 115 | if(sendable.type.includes('json')) { 116 | // @ts-ignore 117 | return json(await sendable.json(), { 118 | ...args, 119 | headers 120 | }) 121 | } else if (sendable.type.includes('text')) { 122 | return text(await sendable.text(), { 123 | ...args, 124 | headers 125 | }) 126 | } else { 127 | 128 | } 129 | } else { 130 | 131 | if(typeof sendable !== 'string') sendable = sendable.toString() 132 | return new Response(sendable, { 133 | ...args, 134 | headers 135 | }) 136 | } 137 | return new Response(sendable, { ...args, headers }) 138 | } 139 | 140 | export async function sendFile(path: string, args: ResponseInit = {}): Promise { 141 | const headers = new Headers(args.headers || {}) 142 | const file = Bun.file(path) 143 | const fileName = path.substring(path.lastIndexOf('/') + 1, path.length) 144 | headers.set('Content-Disposition', `attachment; filename=${fileName}`) 145 | headers.set('Content-Transfer-Encoding', 'binary') 146 | headers.set('Content-Type', getContentType('octet-stream') as string) 147 | return new Response(new Blob([ 148 | await file.arrayBuffer() 149 | ], { 150 | type: file.type 151 | }), { 152 | ...args, 153 | headers 154 | }) 155 | } 156 | -------------------------------------------------------------------------------- /deno_dist/core/response/created.ts: -------------------------------------------------------------------------------- 1 | import { createResp, createRedirect, createNotModified, createUnauthorized } from "./creator.ts"; 2 | import { HTTP_STATUS_CODES } from '../constants/codes.ts' 3 | 4 | export const informContinue = createResp(100, HTTP_STATUS_CODES['100']) 5 | export const informSwitchingProtocols = createResp(101, HTTP_STATUS_CODES['101']) 6 | 7 | export const ok = createResp(200, HTTP_STATUS_CODES['200']) 8 | export const created = createResp(201, HTTP_STATUS_CODES['201']) 9 | export const accepted = createResp(202, HTTP_STATUS_CODES['202']) 10 | export const nonAuthorititiveInfo = createResp(203, HTTP_STATUS_CODES['203']) 11 | export const noContent = createResp(204, HTTP_STATUS_CODES['204']) 12 | export const resetContent = createResp(205, HTTP_STATUS_CODES['205']) 13 | export const partialContent = createResp(206, HTTP_STATUS_CODES['206']) 14 | 15 | 16 | export const movedPermanently = createRedirect(301, HTTP_STATUS_CODES['301']) 17 | export const found = createRedirect(302, HTTP_STATUS_CODES['302']) 18 | export const seeOther = createRedirect(303, HTTP_STATUS_CODES['303']) 19 | export const notModified = createNotModified(304, HTTP_STATUS_CODES['304']) 20 | export const tempRedirect = createRedirect(307, HTTP_STATUS_CODES['307']) 21 | export const permRedirect = createRedirect(308, HTTP_STATUS_CODES['308']) 22 | 23 | export const badRequest = createResp(400, HTTP_STATUS_CODES['400']) 24 | export const unauthorized = createUnauthorized(401, HTTP_STATUS_CODES['401']) 25 | export const forbidden = createResp(403, HTTP_STATUS_CODES['403']) 26 | export const notFound = createResp(404, HTTP_STATUS_CODES['404']) 27 | export const methodNotAllowed = createResp(405, HTTP_STATUS_CODES['405']) 28 | export const notAcceptable = createResp(406, HTTP_STATUS_CODES['406']) 29 | export const proxyAuthRequired = createResp(407, HTTP_STATUS_CODES['407']) 30 | export const requestTimeout = createResp(408, HTTP_STATUS_CODES['408']) 31 | export const conflict = createResp(409, HTTP_STATUS_CODES['409']) 32 | export const gone = createResp(410, HTTP_STATUS_CODES['410']) 33 | export const unprocessableEntity = createResp(422, HTTP_STATUS_CODES['422']) 34 | 35 | export const internalServerError = createResp(500, HTTP_STATUS_CODES['500']) 36 | export const notImplemented = createResp(501, HTTP_STATUS_CODES['501']) 37 | export const badGateway = createResp(502, HTTP_STATUS_CODES['502']) 38 | export const serviceUnavailable = createResp(503, HTTP_STATUS_CODES['503']) 39 | export const gatewayTimeout = createResp(504, HTTP_STATUS_CODES['504']) 40 | -------------------------------------------------------------------------------- /deno_dist/core/response/creator.ts: -------------------------------------------------------------------------------- 1 | export type RespCreatorRequestInit= Omit 2 | 3 | /** 4 | * Create a Response 5 | * @param status number 6 | * @param statusText string 7 | * @returns Response 8 | */ 9 | export const createResp = (status: number, statusText: string) => (body: BodyInit | null = null, init: RespCreatorRequestInit = {}) => new Response(body, { 10 | ...init, 11 | status, 12 | statusText, 13 | }) 14 | 15 | /** 16 | * Create a redirect response 17 | * @param status number 18 | * @param statusText string 19 | * @returns Response 20 | */ 21 | export const createRedirect = (status: number, statusText: string) => (location: string | URL, init: RespCreatorRequestInit = {}): Response => new Response(null, { 22 | ...init, 23 | status, 24 | statusText, 25 | headers: [ 26 | // @ts-ignore 27 | ...init?.headers ? Array.isArray(init.headers) ? init.headers : new Headers(init.headers) : [], 28 | ['Location', location.toString()] 29 | ], 30 | }); 31 | 32 | /** 33 | * Create Unauthorized Response 34 | * @param status number 35 | * @param statusText string 36 | * @returns Response 37 | */ 38 | export const createUnauthorized = (status: number, statusText: string) => (realm = '', init: RespCreatorRequestInit = {}): Response => new Response(null, { 39 | ...init, 40 | status, 41 | statusText, 42 | headers: [ 43 | ...init?.headers ? Array.isArray(init.headers) ? init.headers : new Headers(init.headers) : [], 44 | ['WWW-Authenticate', `Basic realm="${realm}", charset="UTF-8"`], 45 | ], 46 | }); 47 | 48 | 49 | 50 | type CreateNotModifiedOptions = { 51 | ifNoneMatch: string, 52 | ifModifiedSince: Date 53 | } 54 | 55 | /** 56 | * Create `NotModified` Response 57 | * @param status number 58 | * @param statusText string 59 | * @returns Response 60 | */ 61 | export const createNotModified = (status: number, statusText: string) => (options: CreateNotModifiedOptions, init: RespCreatorRequestInit = {}): Response => new Response(null, { 62 | ...init, 63 | status, 64 | statusText, 65 | headers: [ 66 | ...init?.headers ? Array.isArray(init.headers) ? init.headers : new Headers(init.headers) : [], 67 | ['If-None-Match', options.ifNoneMatch], 68 | ['If-Modified-Since', options.ifModifiedSince], 69 | ], 70 | }) 71 | -------------------------------------------------------------------------------- /deno_dist/core/server.ts: -------------------------------------------------------------------------------- 1 | import type { Errorlike, Server } from 'bun DENOIFY: UNKNOWN NODE BUILTIN' 2 | import type { ZarfConfig, ZarfOptions, RouteStack, RouteHandler, Route, RouteMethod, RouteParams } from './types.ts' 3 | 4 | import { AppContext } from './context.ts' 5 | import { MiddlewareFunction, exec, MiddlewareType } from './middleware.ts' 6 | 7 | import { isObject } from './utils/is.ts' 8 | import { getMountPath } from './utils/app.ts' 9 | 10 | import { ROUTE_OPTION_DEFAULT } from './constants/index.ts' 11 | import { 12 | ZarfCustomError, ZarfMethodNotAllowedError, ZarfUnprocessableEntityError, 13 | sendError, 14 | defaultErrorHandler, notFoundHandler, notFoundVerbHandler 15 | } from './errors/index.ts' 16 | 17 | export class Zarf = {}, M extends Record = {}> { 18 | /** 19 | * App Config 20 | */ 21 | private readonly config: ZarfConfig 22 | private server?: Server 23 | /** 24 | * App stores 25 | */ 26 | private appList: Record> = {} 27 | /** 28 | * Route stores 29 | */ 30 | private routes: Partial>>> = {} 31 | 32 | /** 33 | * Middleware stores 34 | */ 35 | private middlewares: Record>> = { 36 | 'before': [], 37 | 'after': [], 38 | 'error': [] 39 | } 40 | 41 | private pathMiddlewares: Record>>> = {} 42 | private pathWithMiddlewares: Array = [] 43 | 44 | /** 45 | * App Constructor 46 | * @param param0 47 | */ 48 | constructor({ 49 | appName = 'ZarfRoot', 50 | serverHeader = `Bun-Tea`, 51 | strictRouting = false, 52 | getOnly = false, 53 | errorHandler = defaultErrorHandler 54 | }: ZarfConfig = {}){ 55 | this.config = { 56 | appName, 57 | serverHeader, 58 | strictRouting, 59 | getOnly, 60 | errorHandler 61 | } 62 | this.handle = this.handle.bind(this) 63 | } 64 | 65 | // wrapper around the application's error handler method. 66 | // It maps a set of errors to app errors before calling the application's error handler method. 67 | // @TODO: Intercept all the error types 68 | serverErrorHandler = (error: Errorlike): Response | Promise | Promise | undefined => { 69 | // check for errors like 70 | // - Header fields too large 71 | // - Request/Processing Timeout 72 | // - Request Body/Entity too large 73 | // - Method not allowed (for GET only requests) 74 | if(error instanceof ZarfMethodNotAllowedError) { 75 | // do something... 76 | return sendError(error) 77 | } 78 | // - Or, just Bad request 79 | else if(error instanceof ZarfUnprocessableEntityError) { 80 | // do something 81 | return sendError(error) 82 | } else { 83 | return sendError(error) 84 | } 85 | } 86 | 87 | /** 88 | * Central error handler 89 | * @param ctx 90 | * @param error 91 | * @returns 92 | */ 93 | errorHandler = (ctx: AppContext, error: Errorlike): Response | Promise | Promise | undefined => { 94 | const path = ctx.request?.url || '' 95 | const basePath = path.substring(0, path.lastIndexOf('/')) 96 | if(basePath && this.appList[basePath]) { 97 | return this.appList[basePath].errorHandler(ctx, error) 98 | } else { 99 | if(error instanceof ZarfCustomError) { 100 | return this.serverErrorHandler(error) 101 | } else { 102 | return this.config.errorHandler?.(ctx, error) 103 | } 104 | } 105 | } 106 | 107 | /// ROUTE METHODS /// 108 | 109 | /** 110 | * Register a new route 111 | * @param method the `verb` to listen to 112 | * @param url the relative path to listen to 113 | * @param args all the other arguments like 114 | * @returns 115 | */ 116 | private register(method: RouteMethod, url: string, ...args: any): Error | undefined { 117 | 118 | let id = url.toString() 119 | 120 | // Prepare route options 121 | let options: Record> = {} 122 | if(isObject(args.at(-1))) { 123 | options = args.pop() 124 | } 125 | options[ROUTE_OPTION_DEFAULT] = options[ROUTE_OPTION_DEFAULT] || {} 126 | 127 | // Identify Handler and and/or middlewares 128 | let handler: RouteHandler<{}, S>; 129 | let middlewares: Array> = [] 130 | if(args.length === 1) { 131 | handler = args.pop() 132 | } else if(args.length === 2) { 133 | handler = args.pop() 134 | middlewares = args.pop() 135 | } else { 136 | return new Error('too many arguments provided') 137 | } 138 | 139 | // Prepare RegExp for path matching 140 | const parts = url.split("/").filter(part => part !== "") 141 | const vars: Array = [] 142 | 143 | // @TODO: Optimize RegExp creation approach 144 | const regExpParts = parts.map(part => { 145 | if(part[0] === ':') { 146 | if(part[part.length - 1] === '?') { 147 | part = part.replace("?", '') 148 | vars.push(part.slice(1)) 149 | return `([a-zA-Z0-9_-]*)` 150 | } else if(part.includes('.')) { 151 | const subParts = part.split('.') 152 | if(subParts[1][0] === ':') { 153 | vars.push(`${subParts[0].slice(1)}_${subParts[1].slice(1)}`) 154 | return `([a-zA-Z0-9_-]+.[a-zA-Z0-9_-]+)` 155 | } else { 156 | vars.push(subParts[0]) 157 | return `([a-zA-Z0-9_-]+.${subParts[1].slice(1)})` 158 | } 159 | } else if(part.includes('-')) { 160 | const subParts = part.split('-') 161 | if(subParts[1][0] === ':') { 162 | vars.push(`${subParts[0].slice(1)}_${subParts[1].slice(1)}`) 163 | return `([a-zA-Z0-9_-]+-[a-zA-Z0-9_-]+)` 164 | } else { 165 | vars.push(subParts[0].slice(1)) 166 | return `([a-zA-Z0-9_-]+-${subParts[1]})` 167 | } 168 | } else { 169 | vars.push(part.slice(1)) 170 | return `([a-zA-Z0-9_-]+)` 171 | } 172 | } else if (part[0] === '*') { 173 | vars.push(part.slice(1)) 174 | return `(.*)` 175 | } else if (part.includes('.')) { 176 | const subParts = part.split('.') 177 | vars.push(subParts[1].slice(1)) 178 | return `(${subParts[0]}.[a-zA-Z0-9_-]+)` 179 | } else if(part.includes('::')) { 180 | const subParts = part.split('::') 181 | vars.push(subParts[0]) 182 | return `(${subParts[0]}:[a-zA-Z0-9_-]+)` 183 | } else { 184 | return part 185 | } 186 | }) 187 | const regExp = regExpParts.join("/") 188 | 189 | 190 | if(!this.routes[method]) this.routes[method] = [] 191 | this.routes[method]!.push({ 192 | id, 193 | matcher: new RegExp(`^/${regExp}$`), 194 | vars, 195 | handler, 196 | options, 197 | middlewares 198 | }) 199 | } 200 | 201 | /** 202 | * Resolve, or identify a matching route 203 | * @param method 204 | * @param path 205 | * @returns 206 | */ 207 | private resolve(method: RouteMethod, path: string) { 208 | const route = this.routes[method]!.find(route => route.matcher.test(path)) 209 | if(!route) return undefined 210 | 211 | const matches = route.matcher.exec(path) 212 | 213 | const options = route.options 214 | const params: {[key: string]: string } = options[ROUTE_OPTION_DEFAULT] || {} 215 | 216 | if(matches) { 217 | route.vars.forEach((val, index) => { 218 | const match = matches[index + 1] 219 | if(match){ 220 | if(val.includes('_')) { 221 | const matchParts = match.includes('-') ? match.split('-') : match.includes('.') ? match.split('.') : [ match ] 222 | const valParts = val.split('_') 223 | if(valParts.length === matchParts.length) { 224 | valParts.forEach((val, index) => { 225 | params[val] = matchParts[index] 226 | }) 227 | } 228 | } else { 229 | params[val] = decodeURIComponent( 230 | match.startsWith(`${val}:`) ? match.replace(`${val}:`, '') : 231 | match.includes('.') ? match.substring(match.lastIndexOf('.') + 1, match.length) : match 232 | ) 233 | } 234 | } 235 | }) 236 | } 237 | 238 | return { 239 | ...route, 240 | params 241 | } 242 | } 243 | public async fetch(req: RequestInfo, requestInit?: RequestInit): Promise { 244 | const request = req instanceof Request ? req : new Request(req, requestInit) 245 | return await this.handle(request) 246 | } 247 | 248 | /** 249 | * Handle a route 250 | * @param req 251 | * @returns 252 | */ 253 | public async handle(req: Request, adapterCtx?: any): Promise { 254 | const ctx = new AppContext(req, this.config) 255 | const { path , method } = ctx 256 | if(this.config.getOnly && method !== 'get') { 257 | return this.errorHandler(ctx, new ZarfMethodNotAllowedError()) as Response 258 | } 259 | /** 260 | * Global "App-level" middlewares 261 | */ 262 | try { 263 | if(this.middlewares?.before && this.middlewares?.before.length) { 264 | const _response = await exec(ctx, this.middlewares.before) 265 | if(ctx.isImmediate || _response) return _response! 266 | } 267 | } catch (error: unknown) { 268 | if (error instanceof Error) { 269 | return this.errorHandler(ctx, error) 270 | } 271 | } 272 | /** 273 | * Group-level middlewars. Could halt execution, or respond 274 | */ 275 | try { 276 | const basePath = path.substring(0, path.lastIndexOf('/')) 277 | if(this.pathMiddlewares[path] && this.pathMiddlewares[path].before.length) { 278 | const _response = await exec(ctx, this.pathMiddlewares[path].before) 279 | if(ctx.isImmediate || _response) return _response! 280 | } else if(basePath && this.pathMiddlewares[basePath] && this.pathMiddlewares[basePath].before.length) { 281 | // middlewares discovered for a base path on a route cannot halt execution 282 | await exec(ctx, this.pathMiddlewares[basePath].before) 283 | } 284 | } catch (error: unknown) { 285 | if (error instanceof Error) { 286 | return this.errorHandler(ctx, error) 287 | } 288 | } 289 | 290 | if(!this.routes[method]) return notFoundVerbHandler(ctx) 291 | 292 | /** 293 | * Identify if there's a handler 294 | */ 295 | const route = this.resolve(method, path) 296 | if(!route) return notFoundHandler(ctx) 297 | // use middlewares 298 | if(route.middlewares && route.middlewares.length) { 299 | try { 300 | const resp = await exec(ctx, route.middlewares) 301 | if(resp || ctx.isImmediate) return resp as Response 302 | } catch (error: unknown) { 303 | if (error instanceof Error) { 304 | return this.errorHandler(ctx, error) 305 | } 306 | } 307 | } 308 | if(route.handler) { 309 | try { 310 | const response = await route.handler(ctx as AppContext, {...route.params, ...{}}) 311 | if(ctx.isImmediate) { 312 | return response 313 | } else if(this.middlewares.after.length) { 314 | ctx.response = response 315 | const _response = await exec(ctx, this.middlewares.after) 316 | if(_response && !ctx.afterMiddlewares.length) return _response 317 | } 318 | if(ctx.afterMiddlewares.length) { 319 | ctx.response = response 320 | const _response = await exec(ctx, ctx.afterMiddlewares) 321 | return _response ? _response : ctx.response 322 | } 323 | return response 324 | } catch (error: unknown) { 325 | if (error instanceof Error) { 326 | return this.errorHandler(ctx, error) 327 | } 328 | } 329 | } 330 | } 331 | 332 | /** 333 | * 334 | * @param path 335 | * @param handler 336 | */ 337 | get(path: Path, handler: RouteHandler, S>): void; 338 | get(path: Path, middlewares: Array>>, handler: RouteHandler, S>): void; 339 | get(path: Path, ...args: Array, S> | Array>>>) { 340 | this.register('get', path, ...args); 341 | return this 342 | } 343 | 344 | /** 345 | * 346 | * @param path 347 | * @param handler 348 | */ 349 | post(path: Path, handler: RouteHandler, S>): void; 350 | post(path: Path, middlewares: Array>>, handler: RouteHandler, S>): void; 351 | post(path: Path, ...args: Array, S> | Array>>>) { 352 | this.register('post', path, ...args); 353 | return this 354 | } 355 | 356 | /** 357 | * 358 | * @param path 359 | * @param handler 360 | */ 361 | put(path: Path, handler: RouteHandler, S>): void; 362 | put(path: Path, middlewares: Array>>, handler: RouteHandler, S>): void; 363 | put(path: Path, ...args: Array, S> | Array>>>) { 364 | this.register('put', path, ...args) 365 | return this 366 | } 367 | 368 | /** 369 | * 370 | * @param path 371 | * @param handler 372 | */ 373 | patch(path: Path, handler: RouteHandler, S>): void; 374 | patch(path: Path, middlewares: Array>>, handler: RouteHandler, S>): void; 375 | patch(path: Path, ...args: Array, S> | Array>>>) { 376 | this.register('patch', path, ...args) 377 | return this 378 | } 379 | 380 | /** 381 | * 382 | * @param path 383 | * @param handler 384 | */ 385 | del(path: Path, handler: RouteHandler, S>): void; 386 | del(path: Path, middlewares: Array>>, handler: RouteHandler, S>): void; 387 | del(path: Path, ...args: Array, S> | Array>>>) { 388 | this.register('delete', path, ...args) 389 | return this 390 | } 391 | 392 | /** 393 | * 394 | * @param path 395 | * @param handler 396 | */ 397 | opt(path: Path, handler: RouteHandler, S>): void; 398 | opt(path: Path, middlewares: Array>>, handler: RouteHandler, S>): void; 399 | opt(path: Path, ...args: Array, S> | Array>>>) { 400 | this.register('options', path, ...args) 401 | return this 402 | } 403 | 404 | // ADVANCED: ROUTE ORCHESTRATION 405 | /** 406 | * Mount a sub-app routes, on the parent app routes 407 | * 408 | * This method just copies over all the routes, from the mounted app to the root app, and re-registers all the handlers and 409 | * middlewares on the newly formed path 410 | * @param prefix 411 | * @param app 412 | */ 413 | mount = {}>(prefix: string, app: Zarf) { 414 | for(const routeType in app.routes as RouteStack) { 415 | for(const route of app.routes[routeType as RouteMethod] as Array>) { 416 | this.register( 417 | routeType as RouteMethod, 418 | getMountPath(prefix, route.id), 419 | route.middlewares, route.handler 420 | ) 421 | } 422 | } 423 | this.appList[getMountPath(prefix, '')] = app 424 | } 425 | 426 | /** 427 | * Create and return Route groups 428 | * @param prefix 429 | * @param args 430 | * @returns 431 | */ 432 | group(prefix: string = '/', ...args: Array>) { 433 | return new ZarfRouteGroup(this.register.bind(this), this.useOnPath.bind(this), prefix, ...args) 434 | } 435 | 436 | /// MIDDLEWARES /// 437 | 438 | /** 439 | * Register app middlewares 440 | * @param middleware 441 | * @param type 442 | * @returns 443 | */ 444 | use (middleware: MiddlewareFunction, type: MiddlewareType = 'before') { 445 | this.middlewares[type].push(middleware as unknown as MiddlewareFunction) 446 | return this 447 | } 448 | 449 | private useOnPath(path: string, ...args: Array>) { 450 | if(!this.pathMiddlewares[path]) this.pathMiddlewares[path] = { before: [], after: [], error: [] } 451 | this.pathMiddlewares[path].before.push(...args) 452 | this.pathWithMiddlewares.push(path) 453 | } 454 | 455 | /// SERVER METHODS /// 456 | 457 | shutdown() { 458 | // do any pre-shutdown process 459 | if(!this.server) { 460 | return console.error(`Can't shutdown as server isn't up currently`) 461 | } 462 | if(this.server?.pendingRequests) { 463 | return console.error(`Can't shutdown as there are pending requests. You might wanna wait or forcibly shut the server instance?`) 464 | } 465 | return this.server?.stop() 466 | } 467 | 468 | // async fetch(req: Request, env?: any, ctx?: any) { 469 | // return await this.handle(req, { 470 | // waitUntil: ctx?.waitUntil?.bind(ctx) ?? ((_f: any) => { }), 471 | // env, 472 | // ctx, 473 | // }) 474 | // } 475 | 476 | // async request(input: RequestInfo, requestInit?: RequestInit) { 477 | // const req = input instanceof Request ? input : new Request(input, requestInit) 478 | // return await this.fetch(req) 479 | // } 480 | 481 | listen({ 482 | port = 3333, 483 | development = false, 484 | hostname = '0.0.0.0' 485 | }: ZarfOptions, startedCb?: (server: Server) => void) { 486 | const self = this 487 | if (!Bun) throw new Error('Bun-Tea requires Bun to run') 488 | 489 | // do things, before initializing the server 490 | this.server = Bun.serve({ 491 | port, 492 | hostname, 493 | development: process.env.NODE_ENV !== "production" || development, 494 | // @ts-ignore 495 | fetch: this.handle.bind(this), 496 | error(err: Errorlike) { 497 | return self.serverErrorHandler(err) 498 | } 499 | }) 500 | 501 | if(startedCb && typeof startedCb === 'function') { 502 | startedCb(this.server!) 503 | } 504 | 505 | return this.server 506 | } 507 | } 508 | 509 | class ZarfRouteGroup> { 510 | private prefix = '' 511 | private register: (method: RouteMethod, url: string, ...args: any) => void 512 | private registerMw: (path: string, ...args: Array>) => void 513 | 514 | constructor( 515 | register: (method: RouteMethod, url: string, ...args: any) => void, 516 | registerMw: (path: string, ...args: Array>) => void, 517 | prefix: string = '/', 518 | ...args: Array> 519 | 520 | ) { 521 | this.prefix = prefix === '' ? '/' : prefix 522 | this.register = register 523 | this.registerMw = registerMw 524 | 525 | if(args.length) registerMw(this.prefix, ...args) 526 | } 527 | 528 | get(path: Path, handler: RouteHandler, S>) { 529 | this.register('get', getMountPath(this.prefix, path), handler); 530 | return this as Omit 531 | } 532 | post(path: string, handler: RouteHandler, S>) { 533 | this.register('post', getMountPath(this.prefix, path), handler); 534 | return this as Omit 535 | } 536 | put(path: string, handler: RouteHandler, S>) { 537 | this.register('put', getMountPath(this.prefix, path), handler); 538 | return this as Omit 539 | } 540 | patch(path: string, handler: RouteHandler, S>) { 541 | this.register('patch', getMountPath(this.prefix, path), handler); 542 | return this as Omit 543 | } 544 | del(path: string, handler: RouteHandler, S>) { 545 | this.register('delete', getMountPath(this.prefix, path), handler); 546 | return this as Omit 547 | } 548 | all(path: string, handler: RouteHandler, S>) { 549 | ['get', 'post', 'put', 'patch', 'delete'].forEach(verb => { 550 | this.register(verb as RouteMethod, getMountPath(this.prefix, path), handler); 551 | }) 552 | return this as Omit 553 | } 554 | group(path: string = '/', ...args: Array>) { 555 | return new ZarfRouteGroup(this.register, this.registerMw, getMountPath(this.prefix, path), ...args) 556 | } 557 | } 558 | -------------------------------------------------------------------------------- /deno_dist/core/types.ts: -------------------------------------------------------------------------------- 1 | import type { Errorlike } from "bun DENOIFY: UNKNOWN NODE BUILTIN"; 2 | import { AppContext as PrivateAppContext } from './context.ts' 3 | import { MiddlewareFunction } from './middleware.ts'; 4 | import type { Replace } from './utils/types.ts' 5 | 6 | // Global Type aliases 7 | export type RouteMethod = "get" | "post" | "put" | "patch" | "delete" | "head" | "options" 8 | export type HeaderVaryContent = 'Origin' | 'User-Agent' | 'Accept-Encoding' | 'Accept' | 'Accept-Language' 9 | export type HeaderTypeContent = 'text' | 'json' | 'html' 10 | 11 | // Context interfaces/types 12 | // The context that could be shared to `RouteHandler` 13 | export type AppContext = {}> = Omit, 14 | 'after' | 15 | 'afterMiddlewares' 16 | > 17 | // Context-internal interfaces/types 18 | export interface ContextMeta { 19 | startTime: number 20 | } 21 | 22 | // `Zarf` Application config 23 | export interface ZarfConfig = {}> { 24 | appName?: string 25 | serverHeader?: string 26 | strictRouting?: boolean 27 | getOnly?: boolean 28 | errorHandler?: (ctx: PrivateAppContext, error: Errorlike) => Response | Promise | Promise | undefined 29 | } 30 | 31 | // `Zarf` lister options 32 | export interface ZarfOptions { 33 | port?: number, 34 | development?: boolean, 35 | hostname?: string 36 | } 37 | 38 | // `Route` types 39 | export interface Route = {}> { 40 | id: string 41 | matcher: RegExp 42 | handler: RouteHandler<{}, S> 43 | vars: Array 44 | options: any, 45 | middlewares?: Array> 46 | } 47 | export type RouteStack = {}> = Record>> 48 | export interface ResolvedRoute { 49 | handler: RouteHandler 50 | params: Record 51 | } 52 | export interface RouteProps { 53 | context: PrivateAppContext; 54 | request: Request; 55 | params: Record; 56 | } 57 | 58 | type RouteParamNames = 59 | string extends Route 60 | ? string 61 | : Route extends `${string}:${infer Param}-:${infer Rest}` 62 | ? (RouteParamNames<`/:${Param}/:${Rest}`>) 63 | : Route extends `${string}:${infer Param}.:${infer Rest}` 64 | ? (RouteParamNames<`/:${Param}/:${Rest}`>) 65 | : Route extends `${string}:${infer Param}/${infer Rest}` 66 | ? (Replace | RouteParamNames) 67 | : Route extends `${string}*${infer Param}/${infer Rest}` 68 | ? (Replace | RouteParamNames) 69 | : ( 70 | Route extends `${string}:${infer LastParam}?` ? 71 | Replace, '?', ''> : 72 | Route extends `${string}:${infer LastParam}` ? 73 | Replace : 74 | Route extends `${string}*${infer LastParam}` ? 75 | LastParam : 76 | Route extends `${string}:${infer LastParam}?` ? 77 | Replace : 78 | never 79 | ); 80 | 81 | 82 | export type RouteParams = { 83 | [key in RouteParamNames]: string 84 | } 85 | 86 | // `Route Handler` types 87 | 88 | type Path = string; 89 | export type RegisterRoute = {}> = ( method: RouteMethod, path: Path, handler: RouteHandler ) => void; 90 | export type RouteHandler = {}, S extends Record = {}> = (context: AppContext, params: T) => Response | Promise 91 | -------------------------------------------------------------------------------- /deno_dist/core/utils/app.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export function getMountPath(prefix: string, path: string): string { 4 | if(prefix.length === 0 || prefix === '/') { 5 | return path[0] === '/' ? path : `/${path}` 6 | } else { 7 | return `${prefix}${path}` 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /deno_dist/core/utils/choose.ts: -------------------------------------------------------------------------------- 1 | type O = { [key: string]: any }; 2 | 3 | export const omit = (obj: O, keys: string[]) => { 4 | return Object.keys(obj).reduce((target: O, key) => { 5 | if (!keys.includes(key)) { 6 | target[key] = obj[key]; 7 | } 8 | return target; 9 | }, {}); 10 | }; 11 | 12 | export const pick = (obj: O, keys: string[]) => { 13 | return Object.keys(obj).reduce((target: O, key) => { 14 | if (keys.includes(key)) { 15 | target[key] = obj[key]; 16 | } 17 | return target; 18 | }, {}); 19 | }; 20 | -------------------------------------------------------------------------------- /deno_dist/core/utils/console/colors.ts: -------------------------------------------------------------------------------- 1 | // @TODO: Read through the env vars 2 | const enabled = true 3 | 4 | interface Code { 5 | open: string; 6 | close: string; 7 | regexp: RegExp; 8 | } 9 | 10 | const code = (open: number[], close: number): Code => { 11 | return { 12 | open: `\x1b[${open.join(";")}m`, 13 | close: `\x1b[${close}m`, 14 | regexp: new RegExp(`\\x1b\\[${close}m`, "g"), 15 | }; 16 | } 17 | const style = (str: string, code: Code) => enabled ? `${code.open}${str.replace(code.regexp, code.open)}${code.close}` : str; 18 | 19 | /** Color presets for `console` */ 20 | export const bold = (str: string) => style(str, code([1], 22)) 21 | export const dim = (str: string) => style(str, code([2], 22)) 22 | export const italic = (str: string) => style(str, code([3], 23)) 23 | export const underline = (str: string) => style(str, code([4], 24)) 24 | export const inverse = (str: string) => style(str, code([7], 27)) 25 | export const hidden = (str: string) => style(str, code([8], 28)) 26 | export const strikethrough = (str: string) => style(str, code([9], 29)) 27 | export const black = (str: string) => style(str, code([30], 39)) 28 | export const red = (str: string) => style(str, code([31], 39)) 29 | export const green = (str: string) => style(str, code([32], 39)) 30 | export const yellow = (str: string) => style(str, code([33], 39)) 31 | export const blue = (str: string) => style(str, code([34], 39)) 32 | export const magenta = (str: string) => style(str, code([35], 39)) 33 | export const cyan = (str: string) => style(str, code([36], 39)) 34 | export const white = (str: string) => style(str, code([37], 39)) 35 | export const gray = (str: string) => brightBlack(str) 36 | 37 | export const brightBlack = (str: string) => style(str, code([90], 39)) 38 | export const brightRed = (str: string) => style(str, code([91], 39)) 39 | export const brightGreen = (str: string) => style(str, code([92], 39)) 40 | export const brightYellow = (str: string) => style(str, code([93], 39)) 41 | export const brightBlue = (str: string) => style(str, code([94], 39)) 42 | export const brightMagenta = (str: string) => style(str, code([95], 39)) 43 | export const brightCyan = (str: string) => style(str, code([96], 39)) 44 | export const brightWhite = (str: string) => style(str, code([97], 39)) 45 | 46 | export const bgBlack = (str: string) => style(str, code([40], 49)) 47 | export const bgRed = (str: string) => style(str, code([41], 49)) 48 | export const bgGreen = (str: string) => style(str, code([42], 49)) 49 | export const bgYellow = (str: string) => style(str, code([43], 49)) 50 | export const bgBlue = (str: string) => style(str, code([44], 49)) 51 | export const bgMagenta = (str: string) => style(str, code([45], 49)) 52 | export const bgCyan = (str: string) => style(str, code([46], 49)) 53 | export const bgWhite = (str: string) => style(str, code([47], 49)) 54 | export const bgBrightBlack = (str: string) => style(str, code([100], 49)) 55 | export const bgBrightRed = (str: string) => style(str, code([101], 49)) 56 | export const bgBrightGreen = (str: string) => style(str, code([102], 49)) 57 | export const bgBrightYellow = (str: string) => style(str, code([103], 49)) 58 | export const bgBrightBlue = (str: string) => style(str, code([104], 49)) 59 | export const bgBrightMagenta = (str: string) => style(str, code([105], 49)) 60 | export const bgBrightCyan = (str: string) => style(str, code([106], 49)) 61 | export const bgBrightWhite = (str: string) => style(str, code([107], 49)) 62 | -------------------------------------------------------------------------------- /deno_dist/core/utils/is.ts: -------------------------------------------------------------------------------- 1 | export const isPromise = (promise: any) => typeof promise?.then === 'function' 2 | export const isObject = (item: any) => typeof item === "object" && !Array.isArray(item) && item !== null 3 | export const isAsyncFn = (fn: Function) => isFn(fn) && fn.constructor.name === 'AsyncFunction' 4 | export const isFn = (fn: Function) => typeof fn === 'function' 5 | -------------------------------------------------------------------------------- /deno_dist/core/utils/mime.ts: -------------------------------------------------------------------------------- 1 | import { extname } from 'https://deno.land/std@0.158.0/node/path.ts'; 2 | import { MimeType, MIME_TYPES_CONFIG, EXT_MIME_TYPES } from '../constants/mime/index.ts' 3 | 4 | export function getContentType(str: string) { 5 | if(str && typeof str === 'string') { 6 | const isMimeType = str.indexOf('/') !== -1 7 | if(isMimeType) { 8 | const mimeConfig = MIME_TYPES_CONFIG[str as MimeType] 9 | return mimeConfig?.charset ? `${str}; charset=${mimeConfig.charset.toLowerCase()}` : false 10 | } else { 11 | const mimeType = getMimeType(str) 12 | if(mimeType && typeof mimeType !== 'boolean') { 13 | const mimeConfig = MIME_TYPES_CONFIG[mimeType as MimeType] 14 | return mimeConfig && mimeConfig.charset ? 15 | `${mimeType}; charset=${mimeConfig.charset.toLowerCase()}` : 16 | mimeType.startsWith('text/') ? 17 | `${mimeType}; charset=utf-8` : 18 | false 19 | }else{ 20 | return false 21 | } 22 | } 23 | } 24 | return false 25 | } 26 | 27 | export function getMimeExt(str: MimeType): string | boolean { 28 | if(str && typeof str === 'string') { 29 | const config = MIME_TYPES_CONFIG[str as MimeType] 30 | return config?.ext?.length ? config.ext[0] : false 31 | } 32 | return false 33 | } 34 | 35 | export function getMimeType(path: string): string | boolean { 36 | return path && typeof path === 'string' && path.trim() !== '' ? EXT_MIME_TYPES[extname('x.' + path.trim()).toLowerCase().substring(1)] || false : false 37 | } 38 | -------------------------------------------------------------------------------- /deno_dist/core/utils/parsers/base64.ts: -------------------------------------------------------------------------------- 1 | import { asBuffer } from './buffer.ts' 2 | 3 | export const encodeBase64 = (buffer: Uint8Array): Uint8Array => { 4 | return Buffer.from(asBuffer(buffer).toString('base64'), 'utf8'); 5 | } 6 | 7 | export const decodeBase64 = (buffer: Uint8Array): Uint8Array => { 8 | return Buffer.from(asBuffer(buffer).toString('utf8'), 'base64'); 9 | } 10 | -------------------------------------------------------------------------------- /deno_dist/core/utils/parsers/buffer.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from "https://deno.land/std@0.158.0/node/buffer.ts"; 2 | /** 3 | * Converts `ArrayBuffer` to string (currently the default one) 4 | * @param buffer ArrayBuffer 5 | * @returns 6 | */ 7 | export function asString(buffer: ArrayBuffer): string { 8 | return String.fromCharCode.apply(null, Array.from(new Uint16Array(buffer))); 9 | } 10 | 11 | /** 12 | * Consistent `Buffer` 13 | * @param input 14 | * @returns 15 | */ 16 | export function asBuffer(input: Buffer | Uint8Array | ArrayBuffer): Buffer { 17 | if (Buffer.isBuffer(input)) { 18 | return input; 19 | } else if (input instanceof ArrayBuffer) { 20 | return Buffer.from(input); 21 | } else { 22 | // Offset & length allow us to support all sorts of buffer views: 23 | return Buffer.from(input.buffer, input.byteOffset, input.byteLength); 24 | } 25 | }; 26 | 27 | /** 28 | * Converts `ArrayBuffer` to string (currently not used) 29 | * @param buffer ArrayBuffer 30 | * @returns 31 | */ 32 | function arrayBufferToString(buffer: Buffer){ 33 | 34 | var bufView = new Uint16Array(buffer); 35 | var length = bufView.length; 36 | var result = ''; 37 | var addition = Math.pow(2,16)-1; 38 | 39 | for(var i = 0;i length){ 42 | addition = length - i; 43 | } 44 | result += String.fromCharCode.apply(null, Array.from(bufView.subarray(i,i+addition))); 45 | } 46 | 47 | return result; 48 | 49 | } 50 | -------------------------------------------------------------------------------- /deno_dist/core/utils/parsers/form-data.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from "https://deno.land/std@0.158.0/node/buffer.ts"; 2 | import { asString } from './buffer.ts' 3 | 4 | export async function getFormDataFromRequest(request: Request) { 5 | const boundary = getBoundary(request?.headers.get('Content-Type') || '') 6 | if (boundary) { 7 | return await getParsedFormData(request, boundary) 8 | } else { 9 | return {} 10 | } 11 | } 12 | 13 | function getBoundary(header: string) { 14 | var items = header.split(';'); 15 | if (items) { 16 | for (var i = 0; i < items.length; i++) { 17 | var item = new String(items[i]).trim(); 18 | if (item.indexOf('boundary') >= 0) { 19 | var k = item.split('='); 20 | return new String(k[1]).trim().replace(/^["']|["']$/g, ""); 21 | } 22 | } 23 | } 24 | return ''; 25 | } 26 | 27 | const normalizeLf = (str: string) => str.replace(/\r?\n|\r/g, '\r\n') 28 | const escape = (str: string) => normalizeLf(str).replace(/\n/g, '%0A').replace(/\r/g, '%0D').replace(/"/g, '%22') 29 | const replaceTrailingBoundary = (value: string, boundary: string) => { 30 | const boundaryStr = `--${boundary.trim()}--` 31 | return value.replace(boundaryStr, '') 32 | } 33 | 34 | export interface ParsedFileField { 35 | filename: string 36 | type: string, 37 | data: Buffer, 38 | size: number, 39 | } 40 | export type ParsedFormData = Record 41 | 42 | export function isFileField(fieldTuple: [string, string | ParsedFileField ]): fieldTuple is [string, ParsedFileField] { 43 | return typeof fieldTuple[1] !== 'string'; 44 | } 45 | /** 46 | * Get parsed form data 47 | * @param data 48 | * @param boundary 49 | * @param spotText 50 | * @returns 51 | */ 52 | 53 | async function getParsedFormData(request: Request, boundary: string, spotText?: string): Promise { 54 | const _boundary = ' ' + `${boundary}` 55 | const result: Record = {}; 56 | const prefix = `--${_boundary.trim()}\r\nContent-Disposition: form-data; name="` 57 | const data = asString(Buffer.from(await request?.arrayBuffer())) 58 | const multiParts = data.split(prefix).filter( 59 | part => part.includes('"') 60 | ).map( 61 | part => [ 62 | part.substring(0, part.indexOf('"')), 63 | part.slice(part.indexOf('"') + 1, -1) 64 | ] 65 | ) 66 | multiParts.forEach(item => { 67 | if (/filename=".+"/g.test(item[1])) { 68 | const fileNameMatch = item[1].match(/filename=".+"/g) 69 | const contentTypeMatch = item[1].match(/Content-Type:\s.+/g) 70 | if(contentTypeMatch && fileNameMatch) { 71 | result[item[0]] = { 72 | filename: fileNameMatch?.[0].slice(10, -1), 73 | type: contentTypeMatch[0].slice(14), 74 | data: spotText? Buffer.from(item[1].slice(item[1].search(/Content-Type:\s.+/g) + contentTypeMatch[0].length + 4, -4), 'binary'): 75 | Buffer.from(item[1].slice(item[1].search(/Content-Type:\s.+/g) + contentTypeMatch[0].length + 4, -4), 'binary'), 76 | }; 77 | result[item[0]]['size'] = Buffer.byteLength(result[item[0]].data) 78 | } 79 | } else { 80 | result[item[0]] = normalizeLf(replaceTrailingBoundary(item[1], _boundary)).trim() 81 | } 82 | }); 83 | return result 84 | } 85 | -------------------------------------------------------------------------------- /deno_dist/core/utils/parsers/json.ts: -------------------------------------------------------------------------------- 1 | export const jsonSafeParse = (text: string | Record, reviver: ((this: any, key: string, value: any) => any) | undefined = undefined) => { 2 | if (typeof text !== 'string') return text 3 | const firstChar = text[0] 4 | if (firstChar !== '{' && firstChar !== '[' && firstChar !== '"') return text 5 | try { 6 | return JSON.parse(text, reviver) 7 | } catch (e) {} 8 | 9 | return text 10 | } 11 | 12 | export const jsonSafeStringify = (value: any, replacer: any, space: any) => { 13 | try { 14 | return JSON.stringify(value, replacer, space) 15 | } catch (e) {} 16 | return value 17 | } 18 | -------------------------------------------------------------------------------- /deno_dist/core/utils/parsers/query-string.ts: -------------------------------------------------------------------------------- 1 | export type ParsedUrlQuery = { 2 | [key: string]: string; 3 | } 4 | /** 5 | * 6 | * @param query 7 | * @returns 8 | */ 9 | 10 | export const parseQueryParams = (searchParamsStr = ''): ParsedUrlQuery => Object.fromEntries(new URLSearchParams(searchParamsStr).entries()) 11 | -------------------------------------------------------------------------------- /deno_dist/core/utils/parsers/req-body.ts: -------------------------------------------------------------------------------- 1 | import type { ParsedFormData, ParsedFileField } from './form-data.ts' 2 | import type { ParsedUrlQuery } from './query-string.ts' 3 | import { getFormDataFromRequest } from "./form-data.ts" 4 | import { parseQueryParams } from './query-string.ts' 5 | 6 | export type ParsedBody = string | ParsedUrlQuery | ParsedFormData 7 | 8 | export async function parseBody(request: Request): Promise { 9 | const contentType = request?.headers.get('Content-Type') || '' 10 | if(contentType.includes('application/json')) { 11 | // for `json` type 12 | let body = {} 13 | try { 14 | body = await request!.json() 15 | } catch { 16 | // do nothing 17 | } 18 | return body 19 | } else if (contentType.includes('application/text') || contentType.startsWith('text')) { 20 | // for `application/text` and `text/plain` 21 | return await request!.text() 22 | } else if (contentType.includes('application/x-www-form-urlencoded')) { 23 | const urlEncodedForm = await request.text() 24 | return parseQueryParams(urlEncodedForm) 25 | } else { 26 | return await getFormDataFromRequest(request) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /deno_dist/core/utils/sanitize.ts: -------------------------------------------------------------------------------- 1 | const sanitizeKeyPrefixLeadingNumber = /^([0-9])/ 2 | const sanitizeKeyRemoveDisallowedChar = /[^a-zA-Z0-9]+/g 3 | 4 | export const sanitizeKey = (key: string) => { 5 | return key 6 | .replace(sanitizeKeyPrefixLeadingNumber, '_$1') 7 | .replace(sanitizeKeyRemoveDisallowedChar, '_') 8 | } 9 | 10 | export const sanitzeValue = (value: string) => { 11 | return value 12 | .replace(/&/g, "&") 13 | .replace(//g, ">") 15 | .replace(/"/g, """) 16 | .replace(/'/g, "'"); 17 | } 18 | -------------------------------------------------------------------------------- /deno_dist/core/utils/types.ts: -------------------------------------------------------------------------------- 1 | type ReplaceOptions = { 2 | all?: boolean; 3 | }; 4 | 5 | export type Replace< 6 | Input extends string, 7 | Search extends string, 8 | Replacement extends string, 9 | Options extends ReplaceOptions = {}, 10 | > = Input extends `${infer Head}${Search}${infer Tail}` 11 | ? Options['all'] extends true 12 | ? `${Head}${Replacement}${Replace}` 13 | : `${Head}${Replacement}${Tail}` 14 | : Input; 15 | 16 | export type DecorateAsObject< 17 | Input extends string, 18 | Search extends string, 19 | Replacement extends string, 20 | > = Input extends `${infer Head}${Search}${infer Tail}` 21 | ? `${Head}${Replace}` 22 | : Input; 23 | 24 | export type Split< 25 | S extends string, 26 | Delimiter extends string, 27 | > = S extends `${infer Head}${Delimiter}${infer Tail}` 28 | ? [Head, ...Split] 29 | : S extends Delimiter 30 | ? [] 31 | : [S]; 32 | 33 | export type ObjectFromList, S = string> = { 34 | [K in (L extends ReadonlyArray ? U : never)]: S 35 | }; 36 | 37 | export type KeysAsList = K extends `${infer Head}-${infer Tail}` ? [ Head, Tail ] : K extends `${infer Head}.${infer Tail}` ? [ Head, Tail ] : [K] 38 | -------------------------------------------------------------------------------- /deno_dist/index.ts: -------------------------------------------------------------------------------- 1 | export * from './core/server.ts' 2 | -------------------------------------------------------------------------------- /deno_dist/middlewares/README.md: -------------------------------------------------------------------------------- 1 | ## `Zarf` middlewares 2 | 3 | Soon this folder is gonna be populated with tasty, slick, `Zarf` middlewares 4 | -------------------------------------------------------------------------------- /deno_dist/middlewares/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zarfjs/zarf/56bd2fafd61729b85a7b054654da1fba2ff62f3d/deno_dist/middlewares/index.ts -------------------------------------------------------------------------------- /deno_dist/middlewares/mw-body-parser.ts: -------------------------------------------------------------------------------- 1 | import type { MiddlewareFunctionInitializer } from '../core/middleware.ts' 2 | import type { ParsedFileField, ParsedFormData } from '../core/utils/parsers/form-data.ts' 3 | import { parseBody } from '../core/utils/parsers/req-body.ts' 4 | import { isObject } from '../core/utils/is.ts' 5 | import { ZarfUnprocessableEntityError } from '~/core/errors DENOIFY: UNKNOWN NODE BUILTIN' 6 | 7 | type MiddlewareOptions = { 8 | extensions?: Array, 9 | maxSizeBytes?: number, 10 | maxFileSizeBytes?: number 11 | } 12 | 13 | const MW_OPTION_DEFAULTS: MiddlewareOptions = { 14 | extensions: [], 15 | maxSizeBytes: Number.MAX_SAFE_INTEGER, 16 | maxFileSizeBytes: Number.MAX_SAFE_INTEGER 17 | } 18 | 19 | function isFileField(fieldTuple: [string, string | ParsedFileField ]): fieldTuple is [string, ParsedFileField] { 20 | return typeof fieldTuple[1] !== 'string'; 21 | } 22 | 23 | function validateFormData(formData: ParsedFormData, options: Required) { 24 | const { maxFileSizeBytes, maxSizeBytes, extensions } = options 25 | const fileFields = Object.entries(formData).filter(isFileField) 26 | let totalBytes = 0; 27 | let errors: Record = {} 28 | for (const [_, file] of fileFields) { 29 | totalBytes += file.size; 30 | if (file.size > maxFileSizeBytes!) { 31 | if(!errors['size']) errors['size'] = {} 32 | errors['size'] = { 33 | [file.filename]: { 34 | size: file.size, 35 | allowed: maxFileSizeBytes, 36 | message: `Unsupported file upload size: ${file.size} bytes, for file: ${file.filename} (maximum: ${maxFileSizeBytes} bytes).` 37 | } 38 | } 39 | } 40 | 41 | if (extensions.length) { 42 | const fileExt = (file.filename || '').split(".").pop()! 43 | if(!extensions.includes(fileExt)) { 44 | if(!errors['ext']) errors['ext'] = {} 45 | errors['ext'] = { 46 | [file.filename]: { 47 | ext: fileExt, 48 | allowed: extensions, 49 | message: `Unsupported file extension: ${fileExt} (allowed: ${extensions}).` 50 | } 51 | } 52 | } 53 | } 54 | } 55 | if (totalBytes > maxSizeBytes!) { 56 | if(!errors['size']) errors['size'] = {} 57 | if(!errors['size']['total']) errors['size']['total'] = {} 58 | errors['size']['total'] = { 59 | size: totalBytes, 60 | allowed: maxSizeBytes, 61 | message: `Unspported total upload size: ${totalBytes} bytes (maximum: ${maxSizeBytes} bytes).` 62 | } 63 | } 64 | return errors 65 | } 66 | 67 | 68 | export const bodyParser: MiddlewareFunctionInitializer = (opts) => { 69 | const options = { ...MW_OPTION_DEFAULTS, ...opts } 70 | return async (ctx, next) => { 71 | if(ctx.method === 'post' || ctx.method === 'put' || ctx.method === 'patch') { 72 | const body = await parseBody(ctx.request!) 73 | if(isObject(body)) { 74 | // @ts-ignore 75 | const errors = validateFormData(body, options) 76 | if (Object.keys(errors).length) { 77 | throw new ZarfUnprocessableEntityError(errors); 78 | } 79 | } 80 | ctx.body = body 81 | } 82 | await next() 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /deno_dist/middlewares/mw-cors.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareFunctionInitializer } from '../core/middleware.ts' 2 | 3 | const getOrigin = (reqOrigin: string, options: MiddlewareOptions) => { 4 | if(options.origins) { 5 | const { origins, credentials } = options 6 | if (origins.length > 0) { 7 | if (reqOrigin && origins.includes(reqOrigin)) { 8 | return reqOrigin 9 | } else { 10 | return origins[0] 11 | } 12 | } else { 13 | if (reqOrigin && credentials && origins[0] === '*') { 14 | return reqOrigin 15 | } 16 | return origins[0] 17 | } 18 | } else { 19 | return MW_OPTION_DEFAULTS.origins![0] 20 | } 21 | } 22 | 23 | type AllowedMethod = typeof MW_OPTION_METHOD_DEFAULTS[number] 24 | type HeaderVaryContent = "Origin" | "User-Agent" | "Accept-Encoding" | "Accept" | "Accept-Language" 25 | type MiddlewareOptions = { 26 | origins?: Array, 27 | credentials?: boolean, 28 | allowedHeaders?: Array, 29 | exposedHeaders?: Array, 30 | methods?: Array, 31 | vary?: HeaderVaryContent, 32 | maxAge?: number, 33 | preflightContinue?: boolean, 34 | cacheControl?: string 35 | } 36 | 37 | const MW_OPTION_METHOD_DEFAULTS = [ 'GET', 'PUT', 'POST', 'DELETE', 'PATCH', 'OPTIONS' ] as const 38 | const MW_OPTION_DEFAULTS: MiddlewareOptions = { 39 | origins: ['*'], 40 | credentials: false, 41 | allowedHeaders: [], 42 | exposedHeaders: [], 43 | methods: [ ...MW_OPTION_METHOD_DEFAULTS ], 44 | preflightContinue: false 45 | } 46 | 47 | const HEADERS = { 48 | ACAC: 'Access-Control-Allow-Credentials', 49 | ACAH: 'Access-Control-Allow-Headers', 50 | ACAM: 'Access-Control-Allow-Methods', 51 | ACAO: 'Access-Control-Allow-Origin', 52 | 53 | ACEH: 'Access-Control-Expose-Headers', 54 | ACRH: 'Access-Control-Request-Headers', 55 | ACRM: 'Access-Control-Request-Methods' 56 | } 57 | 58 | export const cors: MiddlewareFunctionInitializer = (opts) => { 59 | const options = { ...MW_OPTION_DEFAULTS, ...opts } 60 | return async (ctx, next: Function) => { 61 | const headers = ctx.response?.headers || new Map() 62 | 63 | if(headers.has(HEADERS.ACAC)) { 64 | options.credentials = headers.get(HEADERS.ACAC) === 'true' 65 | } 66 | if(options.credentials) ctx.setHeader(HEADERS.ACAC, String(options.credentials)) 67 | 68 | if(options.allowedHeaders && options.allowedHeaders.length && !headers.has(HEADERS.ACAH)) { 69 | ctx.setHeader(HEADERS.ACAH, options.allowedHeaders.join(', ')) 70 | } 71 | if(options.exposedHeaders && options.exposedHeaders.length && !headers.has(HEADERS.ACEH)) { 72 | ctx.setHeader(HEADERS.ACEH, options.exposedHeaders.join(', ')) 73 | } 74 | 75 | if(options.methods && !headers.has(HEADERS.ACAM)) { 76 | ctx.setHeader(HEADERS.ACAM, options.methods.join(',')) 77 | } 78 | 79 | if(options.origins?.length && !headers.has(HEADERS.ACAO)) { 80 | ctx.setHeader(HEADERS.ACAO, getOrigin(ctx.url.origin, options)) 81 | } 82 | 83 | if(headers.has(HEADERS.ACAO) && headers.get(HEADERS.ACAO) !== '*') { 84 | options.vary = 'Origin' 85 | } 86 | if(options.vary && !headers.has('Vary')){ 87 | ctx.setVary(options.vary) 88 | } 89 | 90 | if(options.maxAge && !headers.has('maxAge')) { 91 | ctx.setHeader('maxAge', String(options.maxAge)) 92 | } 93 | 94 | if(ctx.method === 'options') { 95 | 96 | if(options.cacheControl && !headers.has('Cache-Control')) { 97 | ctx.setHeader('Cache-Control', options.cacheControl) 98 | } 99 | 100 | if (options.preflightContinue) { 101 | await next() 102 | } else { 103 | ctx.setHeader('Content-Length', '0') 104 | return ctx.halt(200, '') 105 | } 106 | } else { 107 | await next() 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /deno_dist/middlewares/mw-request-id.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareFunctionInitializer } from '../core/middleware.ts' 2 | 3 | type MiddlewareOptions = { 4 | header?: string, 5 | keygenFunc?: () => string, 6 | } 7 | 8 | const MW_OPTION_DEFAULTS: MiddlewareOptions = { 9 | header: 'X-Request-ID', 10 | keygenFunc: () => crypto.randomUUID() 11 | } 12 | 13 | const REQ_LOCALS_ID = 'request_id' 14 | export type RequestIdLocals = {[REQ_LOCALS_ID]?: string} 15 | 16 | export const requestId: MiddlewareFunctionInitializer = (opts) => { 17 | const { header, keygenFunc } = { ...MW_OPTION_DEFAULTS, ...opts } 18 | return async (ctx, next) => { 19 | if(header && keygenFunc && !ctx.request?.headers?.has(header)){ 20 | const reqId = keygenFunc() 21 | ctx.setHeader(header, reqId) 22 | ctx.locals['request_id'] = reqId 23 | } 24 | await next() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /deno_dist/middlewares/mw-uploads.ts: -------------------------------------------------------------------------------- 1 | import type { MiddlewareFunctionInitializer } from '../core/middleware.ts' 2 | import { isFileField } from '../core/utils/parsers/form-data.ts' 3 | 4 | import * as fs from 'node:fs/promises DENOIFY: UNKNOWN NODE BUILTIN' 5 | import { constants } from 'node:fs DENOIFY: UNKNOWN NODE BUILTIN' 6 | import { join, extname, basename } from 'https://deno.land/std@0.158.0/node/path.ts' 7 | 8 | type Slash = '/' 9 | type MiddlewareOptions = { 10 | path?: '.' | '..' | `${Slash}${string}` 11 | useTempDir?: boolean 12 | useOriginalFileName?: boolean 13 | useLocals?: boolean 14 | } 15 | 16 | const MW_UPLOAD_DIR = 'uploads' 17 | const MW_OPTION_DEFAULTS: MiddlewareOptions = { 18 | useTempDir: false, 19 | useOriginalFileName: false, 20 | useLocals: false 21 | } 22 | 23 | const REQ_LOCALS_ID = 'uploads' 24 | export type RequestIdLocals = {[REQ_LOCALS_ID]?: Record} 25 | 26 | function getRandomizedFileName(filename: string) { 27 | return `${Date.now()}-${crypto.randomUUID()}${extname(filename)}`; 28 | } 29 | 30 | function isValidPath(path: string) { 31 | const _path = path.trim() 32 | return _path === '.' || _path === '..' || basename(_path) 33 | } 34 | 35 | export const uploads: MiddlewareFunctionInitializer = (opts) => { 36 | const options = { ...MW_OPTION_DEFAULTS, ...opts } 37 | return async (ctx, next) => { 38 | // The entire middleware runs only when `body-parser` has run before. 39 | // `body-parser` middleware already makes sure that `ctx.body` is 40 | // populated only when the request is one of the `POST`ables 41 | if(ctx.body) { 42 | const uploadDir = options.path && isValidPath(options.path) ? join(options.path, MW_UPLOAD_DIR) : options.useTempDir && process.env.TMPDIR ? join(process.env.TMPDIR, MW_UPLOAD_DIR) : join(process.cwd(), MW_UPLOAD_DIR) 43 | 44 | try { 45 | await fs.access(uploadDir, constants.R_OK) 46 | } catch(e: any) { 47 | if(e?.code && e.code === 'ENOENT') { 48 | await fs.mkdir(uploadDir) 49 | } else { 50 | throw new Error('Upload middleware cannot proceed') 51 | } 52 | } 53 | const locals: Record = {} 54 | Object.entries(ctx.body).filter(isFileField).forEach(async([name, file]) => { 55 | if(file.filename) { 56 | const filename = options.useOriginalFileName ? file.filename : getRandomizedFileName(file.filename) 57 | const filepath = join(uploadDir, filename) 58 | if(options.useLocals) locals[name] = filepath 59 | await Bun.write(filepath, new Blob([ file.data ], { 60 | type: file.type 61 | })) 62 | } 63 | }) 64 | if(options.useLocals) ctx.locals[REQ_LOCALS_ID] = locals 65 | } else { 66 | console.debug(`Please configure body parser module to run before`) 67 | } 68 | await next() 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /deno_dist/mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./index.ts"; -------------------------------------------------------------------------------- /example/app-with-adv-route.ts: -------------------------------------------------------------------------------- 1 | import { Zarf } from "../src" 2 | 3 | interface AppLocals { 4 | user: string 5 | } 6 | 7 | const app = new Zarf() 8 | 9 | 10 | app.get("/flights/:from-:to/", (ctx, params) => { 11 | return ctx.json({ 12 | params 13 | }) 14 | }) 15 | 16 | app.get("/folder/:file.:ext", (ctx, params) => { 17 | return ctx.json({ 18 | params 19 | }) 20 | }) 21 | 22 | app.get("/api/users.:ext", (ctx, params) => { 23 | return ctx.json({ 24 | params 25 | }) 26 | }) 27 | 28 | app.get("/shop/product/color::color/size::size/dep::dep", (ctx, params) => { 29 | return ctx.json({ 30 | color: params.color, 31 | size: params.size 32 | }) 33 | }) 34 | 35 | app.get("/", (ctx) => { 36 | return ctx.html(`Welcome to Zarf App - Advanced Route Example Server`) 37 | }) 38 | 39 | 40 | app.listen({ 41 | port: 3000 42 | }, (server) => { 43 | console.log(`Server started on ${server.port}`) 44 | }) 45 | -------------------------------------------------------------------------------- /example/app-with-middleware.ts: -------------------------------------------------------------------------------- 1 | import { Zarf } from "../src" 2 | import { logger, loggerAfter } from './deps/mw-logger' 3 | 4 | interface AppLocals { 5 | user: string 6 | } 7 | 8 | const app = new Zarf() 9 | 10 | app.get("/hello/:name", [ logger() ], (ctx, params) => { 11 | console.log('before:', ctx.locals) 12 | ctx.locals.user = "John" 13 | console.log('after:', ctx.locals) 14 | return ctx.json({ 15 | message: `Hello World! ${params.name}`, 16 | }); 17 | }) 18 | 19 | app.get("/", (ctx) => { 20 | return ctx.html(`Welcome to Zarf App - Middleware Example Server`) 21 | }) 22 | 23 | app.use(logger()).use(loggerAfter, 'after') 24 | 25 | app.listen({ 26 | port: 3000 27 | }, (server) => { 28 | console.log(`Server started on ${server.port}`) 29 | }) 30 | -------------------------------------------------------------------------------- /example/app-with-mounting.ts: -------------------------------------------------------------------------------- 1 | import { Zarf } from "../src" 2 | import { logger, loggerAfter } from './deps/mw-logger' 3 | 4 | /** 5 | * Main App 6 | */ 7 | interface AppLocals { 8 | user: string 9 | } 10 | 11 | const app = new Zarf() 12 | 13 | app.get("/", (ctx) => { 14 | return ctx.html(`Welcome to Zarf App - App Mounting Example Server`) 15 | }) 16 | 17 | app.get("/hello", (ctx) => { 18 | return ctx.html(`Welcome to Zarf App - App Mounting Example Server`) 19 | }) 20 | 21 | 22 | /** 23 | * Sub-App 24 | */ 25 | 26 | interface SubAppLocals { 27 | user: string 28 | } 29 | 30 | const subApp = new Zarf({ 31 | appName: 'ZarfSubApp' 32 | }) 33 | 34 | subApp.get("/goodbye/:name", [ logger() ], (ctx, params) => { 35 | return ctx.json({ 36 | message: `Goodbye, ${params.name}`, 37 | }); 38 | }) 39 | 40 | subApp.use(logger()).use(loggerAfter, 'after') 41 | 42 | /** 43 | * Mount 44 | */ 45 | 46 | app.mount("/sub", subApp) 47 | 48 | 49 | /** 50 | * Listen 51 | */ 52 | app.listen({ 53 | port: 3000 54 | }) 55 | -------------------------------------------------------------------------------- /example/app-with-mw-body-parser.ts: -------------------------------------------------------------------------------- 1 | import { Zarf } from '../src' 2 | import { bodyParser } from '../src/middlewares/mw-body-parser' 3 | 4 | const app = new Zarf() 5 | 6 | app.post("/users", [ bodyParser({ 7 | extensions: ['jpg'], 8 | maxFileSizeBytes: 2_4000, 9 | maxSizeBytes: 2_6000 10 | }) ], (ctx) => { 11 | // console.log(ctx.body) 12 | return ctx.json({ 13 | message: "users created", 14 | }); 15 | }) 16 | 17 | app.get("/", (ctx) => { 18 | return ctx.json({ 19 | message: "Hello World! I'm ready to take all yo data and uploads.", 20 | }); 21 | }) 22 | 23 | app.listen({ 24 | port: 3000 25 | }, (server) => { 26 | console.log(`Server started on ${server.port}`) 27 | }) 28 | -------------------------------------------------------------------------------- /example/app-with-mw-cors.ts: -------------------------------------------------------------------------------- 1 | import { Zarf } from '../src' 2 | import { cors } from '../src/middlewares/mw-cors' 3 | 4 | const app = new Zarf() 5 | 6 | app.use(cors(), 'after') 7 | 8 | app.get("/", (ctx) => { 9 | return ctx.json({ 10 | message: "Hello World! I'm CORS enabled", 11 | }); 12 | }) 13 | 14 | app.listen({ 15 | port: 3000 16 | }, (server) => { 17 | console.log(`Server started on ${server.port}`) 18 | }) 19 | -------------------------------------------------------------------------------- /example/app-with-mw-request-id.ts: -------------------------------------------------------------------------------- 1 | import { Zarf } from '../src' 2 | import { requestId, RequestIdLocals } from '../src/middlewares/mw-request-id' 3 | 4 | const app = new Zarf() 5 | 6 | app.use(requestId()) 7 | 8 | app.get("/", (ctx) => { 9 | if(ctx.locals.request_id) { 10 | return ctx.json({ 11 | message: `Hello, mate! here's your request id: ${ctx.locals.request_id}`, 12 | }); 13 | } 14 | return ctx.json({ 15 | message: `Hello, world!`, 16 | }); 17 | }) 18 | 19 | app.listen({ 20 | port: 3000 21 | }, (server) => { 22 | console.log(`Server started on ${server.port}`) 23 | }) 24 | -------------------------------------------------------------------------------- /example/app-with-mw-uploads.ts: -------------------------------------------------------------------------------- 1 | import { Zarf } from '../src' 2 | import { bodyParser } from '~/middlewares/mw-body-parser' 3 | import { uploads } from '~/middlewares/mw-uploads' 4 | 5 | const app = new Zarf() 6 | 7 | app.post("/users", [ bodyParser({ 8 | extensions: ['jpg', 'jpeg'], 9 | maxFileSizeBytes: 3_4000, 10 | maxSizeBytes: 5_6000 11 | }), uploads({ 12 | useOriginalFileName: false, 13 | useLocals: true 14 | }) ], async (ctx) => { 15 | // your upload details here if `useLocals` is true 16 | // console.log(ctx.locals) 17 | return ctx.json({ 18 | message: "users created", 19 | }); 20 | }) 21 | 22 | app.get("/", (ctx) => { 23 | return ctx.json({ 24 | message: "Hello World! I'm ready to keep your uploads.", 25 | }); 26 | }) 27 | 28 | app.listen({ 29 | port: 3000 30 | }, (server) => { 31 | console.log(`Server started on ${server.port}`) 32 | }) 33 | -------------------------------------------------------------------------------- /example/app-with-route-grouping.ts: -------------------------------------------------------------------------------- 1 | import { Zarf } from "../src" 2 | import { logger, loggerAfter } from './deps/mw-logger' 3 | 4 | interface AppLocals { 5 | user: string 6 | } 7 | 8 | const app = new Zarf() 9 | 10 | const api = app.group("/api", (_, next) => { 11 | console.log("called from API") 12 | return next() 13 | }, async (ctx) => { 14 | console.log("called from API again") 15 | return ctx.html(`You've visited a group route. Please try going to some child route`) 16 | }) 17 | 18 | const apiV1 = api.group('/v1', (_, next) => { 19 | console.log("called from API v1") 20 | return next() 21 | }) 22 | 23 | apiV1.get('/list', (ctx) => { 24 | return ctx.json({ 25 | list: 'list' 26 | }) 27 | }) 28 | apiV1.get('/user', (ctx) => { 29 | return ctx.json({ 30 | user: 'user' 31 | }) 32 | }) 33 | 34 | const apiV2 = api.group('/v2') 35 | apiV2.get('/list', (ctx) => { 36 | return ctx.json({ 37 | list: 'list' 38 | }) 39 | }) 40 | apiV2.get('/user', (ctx) => { 41 | return ctx.json({ 42 | user: 'user' 43 | }) 44 | }) 45 | 46 | app.get("/", (ctx) => { 47 | return ctx.html(`Welcome to Zarf App - Route Grouping Example Server`) 48 | }) 49 | 50 | app.use(logger()).use(loggerAfter, 'after') 51 | 52 | app.listen({ 53 | port: 3000 54 | }) 55 | -------------------------------------------------------------------------------- /example/app.ts: -------------------------------------------------------------------------------- 1 | import { Zarf } from "../src" 2 | import { JSONValue } from "~/core/types" 3 | import { html } from "~/core/utils/parsers/html" 4 | 5 | interface AppLocals { 6 | user: string 7 | } 8 | 9 | const app = new Zarf() 10 | 11 | app.get("/hello", (ctx) => { 12 | return ctx.json({ 13 | hello: "hello" 14 | }) 15 | }) 16 | 17 | const timeEl = (ts = new Date()) => html` 18 | 19 | `; 20 | 21 | app.get("/hello/:user", async (ctx, params) => { 22 | return ctx.html(html`Hello, ${params.user}! ${[1, 2, 3]} - ${timeEl}`) 23 | }) 24 | 25 | app.post("/hello", async(ctx) => { 26 | const { request } = ctx 27 | // `FormData` is not available in `Bun`, if you need this today, you might wanna give `BodyParser` a shot 28 | const body = await request?.json() 29 | // do something with the body 30 | return ctx.json(body!) 31 | }) 32 | 33 | 34 | app.get("/text", (ctx) => { 35 | return ctx.text("lorem ipsum", { 36 | status: 404, 37 | statusText: "created" 38 | }) 39 | }) 40 | 41 | app.get("/user/:name/books/:title", (ctx, params) => { 42 | const { name, title } = params 43 | return ctx.json({ 44 | name, 45 | title 46 | }) 47 | }) 48 | 49 | app.get("/user/:name?", (ctx, params) => { 50 | return ctx.json({ 51 | 52 | }) 53 | }) 54 | 55 | app.get("/admin/*all", (ctx, params) => { 56 | 57 | return ctx.json({ 58 | name: params.all 59 | }) 60 | }) 61 | 62 | app.get("/v1/*brand/shop/*name", (ctx, params) => { 63 | return ctx.json({ 64 | params 65 | }) 66 | }) 67 | 68 | app.get("/send", async (ctx) => { 69 | return ctx.send(Bun.file("./README.md")) 70 | }) 71 | 72 | app.get("/", (ctx) => { 73 | return ctx.html(`Welcome to Zarf App server`) 74 | }) 75 | 76 | app.listen({ 77 | port: 3000, 78 | }, (server) => { 79 | console.log(`Server started on ${server.port}`) 80 | }) 81 | -------------------------------------------------------------------------------- /example/deno/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.unstable": true 4 | } 5 | -------------------------------------------------------------------------------- /example/deno/app.ts: -------------------------------------------------------------------------------- 1 | import { Zarf } from "../../deno_dist/core/server.ts" 2 | import { serve } from "https://deno.land/std@0.158.0/http/server.ts" 3 | 4 | interface AppLocals { 5 | user: string 6 | } 7 | 8 | const app = new Zarf({ 9 | appName: 'ZarfRoot', 10 | serverHeader: `Zarf`, 11 | strictRouting: false, 12 | getOnly: false, 13 | }) 14 | 15 | app.get("/hello", (ctx) => { 16 | return ctx.json({ 17 | hello: "hello" 18 | }) 19 | }) 20 | 21 | app.post("/hello", async(ctx) => { 22 | const { request } = ctx 23 | const body = await request?.json() // await request.text() 24 | // do something with the body 25 | return ctx.json(body) 26 | }) 27 | 28 | 29 | app.get("/text", (ctx) => { 30 | return ctx.text("lorem ipsum", { 31 | status: 404, 32 | statusText: "created" 33 | }) 34 | }) 35 | 36 | app.get("/user/:name/books/:title", (ctx, params) => { 37 | const { name, title } = params 38 | return ctx.json({ 39 | name, 40 | title 41 | }) 42 | }) 43 | 44 | app.get("/admin/*all", (ctx, params) => { 45 | return ctx.json({ 46 | name: params.all 47 | }) 48 | }) 49 | 50 | app.get("/v1/*brand/shop/*name", (ctx, params) => { 51 | return ctx.json({ 52 | params 53 | }) 54 | }) 55 | 56 | app.get("/", (ctx) => { 57 | return ctx.html(`Welcome to Zarf Deno App server`) 58 | }) 59 | 60 | console.log(`Server started on 3000`) 61 | await serve(app.handle, { port: 3000 }); 62 | -------------------------------------------------------------------------------- /example/deps/mw-logger.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareFunction, MiddlewareFunctionInitializer } from '../../src/core/middleware' 2 | 3 | const MW_LOGGER_DEFAULTS = { 4 | logger: console.error 5 | } 6 | 7 | export const logger: MiddlewareFunctionInitializer = (options) => { 8 | let { logger } = { ...MW_LOGGER_DEFAULTS, ...options } 9 | if (typeof logger !== 'function') logger = console.log 10 | return async (context, next: Function) => { 11 | logger(`Request [${context.method}]: ${context.path}`) 12 | await next() 13 | } 14 | } 15 | 16 | const MW_LOGGER_ERR_DEFAULTS = { 17 | logger: console.error 18 | } 19 | 20 | export const errorLogger: MiddlewareFunctionInitializer = (options) => { 21 | let { logger } = { ...MW_LOGGER_ERR_DEFAULTS, ...options } 22 | if (typeof logger !== 'function') logger = console.error 23 | return async (context, next: Function) => { 24 | logger(context.error) 25 | await next() 26 | } 27 | } 28 | 29 | export const loggerAfter: MiddlewareFunction = async (context, next) => { 30 | console.info(`After response for [${context.method}]: ${context.path}`); 31 | const ms = Date.now() - context.meta.startTime; 32 | context.response?.headers.set("X-Response-Time", `${ms}ms`); 33 | await next() 34 | } 35 | -------------------------------------------------------------------------------- /example/deps/sub-app.ts: -------------------------------------------------------------------------------- 1 | import { Zarf } from "../../src" 2 | 3 | interface AppLocals { 4 | user: string 5 | } 6 | 7 | export const subApp = new Zarf() 8 | 9 | subApp.get("/goodbye", (ctx) => { 10 | return ctx.json({ 11 | hello: "goodbye" 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "restartable": "rs", 3 | "ignore": [".git", "node_modules/", "dist/"], 4 | "exec": "bun example/app.ts", 5 | "ext": "js, json, ts" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zarfjs/zarf", 3 | "version": "0.0.1-alpha.23", 4 | "author": "Aftab Alam ", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/zarfjs/zarf.git" 8 | }, 9 | "main": "dist/index.js", 10 | "module": "dist/index.mjs", 11 | "devDependencies": { 12 | "@typescript-eslint/eslint-plugin": "^5.33.1", 13 | "@typescript-eslint/parser": "^5.33.1", 14 | "bumpp": "^8.2.1", 15 | "bun-types": "^0.1.8", 16 | "denoify": "^1.1.4", 17 | "eslint": "^8.22.0", 18 | "nodemon": "^2.0.19", 19 | "publint": "^0.1.1", 20 | "rimraf": "^3.0.2", 21 | "tsup": "^6.2.2", 22 | "typescript": "^4.7.4", 23 | "vitest": "^0.23.4" 24 | }, 25 | "exports": { 26 | ".": { 27 | "require": "./dist/index.js", 28 | "import": "./dist/index.mjs" 29 | }, 30 | "./utils/is": { 31 | "require": "./dist/utils/is/index.js", 32 | "import": "./dist/utils/is/index.mjs" 33 | }, 34 | "./utils/mime": { 35 | "require": "./dist/utils/mime/index.js", 36 | "import": "./dist/utils/mime/index.mjs" 37 | }, 38 | "./utils/sanitize": { 39 | "require": "./dist/utils/sanitize/index.js", 40 | "import": "./dist/utils/sanitize/index.mjs" 41 | }, 42 | "./utils/*": { 43 | "require": "./dist/utils/*.js", 44 | "import": "./dist/utils/*.mjs" 45 | }, 46 | "./parsers/body": { 47 | "require": "./dist/parsers/body/index.js", 48 | "import": "./dist/parsers/body/index.mjs" 49 | }, 50 | "./parsers/json": { 51 | "require": "./dist/parsers/json/index.js", 52 | "import": "./dist/parsers/json/index.mjs" 53 | }, 54 | "./parsers/qs": { 55 | "require": "./dist/parsers/qs/index.js", 56 | "import": "./dist/parsers/qs/index.mjs" 57 | }, 58 | "./parsers/*": { 59 | "require": "./dist/parsers/*.js", 60 | "import": "./dist/parsers/*.mjs" 61 | }, 62 | "./mw/cors": { 63 | "require": "./dist/mw/cors/index.js", 64 | "import": "./dist/mw/cors/index.mjs" 65 | }, 66 | "./mw/request-id": { 67 | "require": "./dist/mw/request-id/index.js", 68 | "import": "./dist/mw/request-id/index.mjs" 69 | }, 70 | "./mw/body-parser": { 71 | "require": "./dist/mw/body-parser/index.js", 72 | "import": "./dist/mw/body-parser/index.mjs" 73 | }, 74 | "./mw/uploads": { 75 | "require": "./dist/mw/uploads/index.js", 76 | "import": "./dist/mw/uploads/index.mjs" 77 | }, 78 | "./mw/*": { 79 | "require": "./dist/mw/*.js", 80 | "import": "./dist/mw/*.mjs" 81 | }, 82 | "./package.json": "./package.json" 83 | }, 84 | "bugs": "https://github.com/zarfjs/zarf/issues", 85 | "denoify": { 86 | "index": "src/index.ts", 87 | "includes": [ 88 | "!src/core/utils/mime.test.ts" 89 | ] 90 | }, 91 | "description": "Fast, Bun-powered, and Bun-only(for now) Web API framework with full Typescript support.", 92 | "files": [ 93 | "dist", 94 | "utils", 95 | "parsers" 96 | ], 97 | "homepage": "https://github.com/zarfjs/zarf", 98 | "keywords": [ 99 | "bun", 100 | "web", 101 | "framework", 102 | "http", 103 | "middleware" 104 | ], 105 | "license": "MIT", 106 | "publishConfig": { 107 | "access": "public" 108 | }, 109 | "scripts": { 110 | "dev": "nodemon --config nodemon.json", 111 | "build": "bun run build:tsup --dts-resolve", 112 | "build:tsup": "tsup", 113 | "build:deno": "rimraf deno_dist && denoify", 114 | "release": "bumpp --commit --push --tag && npm publish", 115 | "prepublishOnly": "bun run build", 116 | "test": "bun run test:wip && bun run test:pkg", 117 | "test:wip": "bun wiptest", 118 | "test:unit": "vitest run --reporter=verbose", 119 | "test:pkg": "publint", 120 | "dev:deno": "deno run --unstable --reload --allow-read --allow-env --allow-net example/deno/app.ts" 121 | }, 122 | "types": "dist/index.d.ts", 123 | "typesVersions": { 124 | "*": { 125 | "utils/is": [ 126 | "./dist/utils/is" 127 | ], 128 | "utils/mime": [ 129 | "./dist/utils/mime" 130 | ], 131 | "utils/sanitize": [ 132 | "./dist/utils/sanitize" 133 | ], 134 | "utils/*": [ 135 | "./dist/utils/*" 136 | ], 137 | "parsers/body": [ 138 | "./dist/parsers/body" 139 | ], 140 | "parsers/json": [ 141 | "./dist/parsers/json" 142 | ], 143 | "parsers/qs": [ 144 | "./dist/parsers/qs" 145 | ], 146 | "parsers/*": [ 147 | "./dist/parsers/*" 148 | ], 149 | "mw/cors": [ 150 | "./dist/mw/cors" 151 | ], 152 | "mw/request-id": [ 153 | "./dist/mw/request-id" 154 | ], 155 | "mw/body-parser": [ 156 | "./dist/mw/body-parser" 157 | ], 158 | "mw/uploads": [ 159 | "./dist/mw/uploads" 160 | ], 161 | "mw/*": [ 162 | "./dist/mw/*" 163 | ] 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/core/constants/codes.ts: -------------------------------------------------------------------------------- 1 | import type { Replace } from '../utils/types' 2 | export const HTTP_STATUS_CODES = { 3 | // Informational 4 | 100: 'Continue', 5 | 101: 'Switching Protocols', 6 | // Successful 7 | 200: 'OK', 8 | 201: 'Created', 9 | 202: 'Accepted', 10 | 203: 'Non-Authoritative Information', 11 | 204: 'No Content', 12 | 205: 'Reset Content', 13 | 206: 'Partial Content', 14 | // Redirection 15 | 301: 'Moved Permanently', 16 | 302: 'Found', 17 | 303: 'See Other', 18 | 304: 'Not Modified', 19 | 307: 'Temporary Redirect', 20 | 308: 'Permanent Redirect', 21 | // Client Error 22 | 400: 'Bad Request', 23 | 401: 'Unauthorized', 24 | 403: 'Forbidden', 25 | 404: 'Not Found', 26 | 405: 'Method Not Allowed', 27 | 406: 'Not Acceptable', 28 | 407: 'Proxy Authentication Required', 29 | 408: 'Request Timeout', 30 | 409: 'Conflict', 31 | 410: 'Gone', 32 | 422: 'Unprocessable Entity', 33 | // Server Error 34 | 500: 'Internal Server Error', 35 | 501: 'Not Implemented', 36 | 502: 'Bad Gateway', 37 | 503: 'Service Unavailable', 38 | 504: 'Gateway Timeout' 39 | } as const 40 | 41 | export type HTTPStatusCode = keyof typeof HTTP_STATUS_CODES 42 | export type HTTPStatusCodeMesssage = typeof HTTP_STATUS_CODES[HTTPStatusCode] 43 | export type HTTPStatusCodeMesssageKey = Replace, '-', '', { all: true }> 44 | -------------------------------------------------------------------------------- /src/core/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const ROUTE_OPTION_DEFAULT = Symbol('default') 2 | -------------------------------------------------------------------------------- /src/core/constants/mime/application.ts: -------------------------------------------------------------------------------- 1 | import { MimeTypeConfig } from '.' 2 | 3 | export type AppType = 'application' 4 | export type AppMimeType = `${AppType}/${string}` 5 | 6 | export const MIME_APP: Record = { 7 | 'application/atom+xml': { 8 | cmp: true, 9 | ext: ['atom'] 10 | }, 11 | 'application/calendar+json': { 12 | cmp: true 13 | }, 14 | 'application/calendar+xml': { 15 | cmp: true, 16 | ext: ['xcs'] 17 | }, 18 | 'application/epub+zip': { 19 | cmp: false, 20 | ext: ['epub'] 21 | }, 22 | 'application/font-woff': { 23 | cmp: false 24 | }, 25 | 'application/geo+json': { 26 | cmp: true, 27 | ext: ['geojson'] 28 | }, 29 | 'application/inkml+xml': { 30 | cmp: true, 31 | ext: ['ink', 'inkml'] 32 | }, 33 | 34 | 'application/java-archive': { 35 | cmp: false, 36 | ext: ['jar', 'war', 'ear'] 37 | }, 38 | 'application/java-serialized-object': { 39 | cmp: false, 40 | ext: ['ser'] 41 | }, 42 | 'application/java-vm': { 43 | cmp: false, 44 | ext: ['class'] 45 | }, 46 | 'application/javascript': { 47 | charset: 'UTF-8', 48 | cmp: true, 49 | ext: ['js', 'mjs'] 50 | }, 51 | 'application/jf2feed+json': { 52 | cmp: true 53 | }, 54 | 'application/json': { 55 | charset: 'UTF-8', 56 | cmp: true, 57 | ext: ['json', 'map'] 58 | }, 59 | 'application/json-patch+json': { 60 | cmp: true 61 | }, 62 | 'application/json5': { 63 | ext: ['json5'] 64 | }, 65 | 'application/jsonml+json': { 66 | cmp: true, 67 | ext: ['jsonml'] 68 | }, 69 | 'application/ld+json': { 70 | cmp: true, 71 | ext: ['jsonld'] 72 | }, 73 | 'application/manifest+json': { 74 | charset: 'UTF-8', 75 | cmp: true, 76 | ext: ['webmanifest'] 77 | }, 78 | 'application/msword': { 79 | cmp: false, 80 | ext: ['doc', 'dot'] 81 | }, 82 | 'application/node': { 83 | ext: ['cjs'] 84 | }, 85 | 'application/octet-stream': { 86 | cmp: false, 87 | ext: [ 88 | 'bin', 89 | 'dms', 90 | 'lrf', 91 | 'mar', 92 | 'so', 93 | 'dist', 94 | 'distz', 95 | 'pkg', 96 | 'bpk', 97 | 'dump', 98 | 'elc', 99 | 'deploy', 100 | 'exe', 101 | 'dll', 102 | 'deb', 103 | 'dmg', 104 | 'iso', 105 | 'img', 106 | 'msi', 107 | 'msp', 108 | 'msm', 109 | 'buffer' 110 | ] 111 | }, 112 | 'application/pdf': { 113 | cmp: false, 114 | ext: ['pdf'] 115 | }, 116 | 'application/gzip': { 117 | cmp: false, 118 | ext: ['gz'] 119 | }, 120 | 'application/x-bzip': { 121 | cmp: false, 122 | ext: ['bz'] 123 | }, 124 | 'application/x-bzip2': { 125 | cmp: false, 126 | ext: ['bz2', 'boz'] 127 | }, 128 | 129 | 'application/x-mobipocket-ebook': { 130 | ext: ['prc', 'mobi'] 131 | }, 132 | 'application/x-mpegurl': { 133 | cmp: false 134 | }, 135 | 'application/x-ms-application': { 136 | ext: ['application'] 137 | }, 138 | 'application/x-msdos-program': { 139 | ext: ['exe'] 140 | }, 141 | 'application/x-msdownload': { 142 | ext: ['exe', 'dll', 'com', 'bat', 'msi'] 143 | }, 144 | 'application/x-rar-compressed': { 145 | cmp: false, 146 | ext: ['rar'] 147 | }, 148 | 'application/x-shockwave-flash': { 149 | cmp: false, 150 | ext: ['swf'] 151 | }, 152 | 'application/x-sql': { 153 | ext: ['sql'] 154 | }, 155 | 'application/x-web-app-manifest+json': { 156 | cmp: true, 157 | ext: ['webapp'] 158 | }, 159 | 'application/x-www-form-urlencoded': { 160 | cmp: true 161 | }, 162 | 'application/xhtml+xml': { 163 | cmp: true, 164 | ext: ['xhtml', 'xht'] 165 | }, 166 | 'application/xml': { 167 | cmp: true, 168 | ext: ['xml', 'xsl', 'xsd', 'rng'] 169 | }, 170 | 'application/zip': { 171 | cmp: false, 172 | ext: ['zip'] 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/core/constants/mime/audio.ts: -------------------------------------------------------------------------------- 1 | import { MimeTypeConfig } from "." 2 | export type AudioType = 'audio' 3 | export type AudioMimeType = `${AudioType}/${string}` 4 | 5 | export const MIME_AUDIO: Record = { 6 | "audio/3gpp": { 7 | "cmp": false, 8 | "ext": ["3gpp"] 9 | }, 10 | "audio/3gpp2": { 11 | }, 12 | "audio/aac": { 13 | }, 14 | "audio/ac3": { 15 | }, 16 | "audio/midi": { 17 | "ext": ["mid","midi","kar","rmi"] 18 | }, 19 | "audio/mp3": { 20 | "cmp": false, 21 | "ext": ["mp3"] 22 | }, 23 | "audio/mp4": { 24 | "cmp": false, 25 | "ext": ["m4a","mp4a"] 26 | }, 27 | "audio/mpeg": { 28 | "cmp": false, 29 | "ext": ["mpga","mp2","mp2a","mp3","m2a","m3a"] 30 | }, 31 | "audio/ogg": { 32 | "cmp": false, 33 | "ext": ["oga","ogg","spx","opus"] 34 | }, 35 | 36 | "audio/wav": { 37 | "cmp": false, 38 | "ext": ["wav"] 39 | }, 40 | "audio/wave": { 41 | "cmp": false, 42 | "ext": ["wav"] 43 | }, 44 | "audio/webm": { 45 | 46 | "cmp": false, 47 | "ext": ["weba"] 48 | }, 49 | "audio/x-aac": { 50 | 51 | "cmp": false, 52 | "ext": ["aac"] 53 | }, 54 | "audio/x-flac": { 55 | "ext": ["flac"] 56 | }, 57 | "audio/x-m4a": { 58 | "ext": ["m4a"] 59 | }, 60 | "audio/x-matroska": { 61 | "ext": ["mka"] 62 | }, 63 | "audio/x-mpegurl": { 64 | "ext": ["m3u"] 65 | }, 66 | "audio/x-ms-wma": { 67 | "ext": ["wma"] 68 | }, 69 | "audio/x-wav": { 70 | "ext": ["wav"] 71 | }, 72 | "audio/xm": { 73 | "ext": ["xm"] 74 | }, 75 | } 76 | -------------------------------------------------------------------------------- /src/core/constants/mime/font.ts: -------------------------------------------------------------------------------- 1 | import { MimeTypeConfig } from '.' 2 | 3 | export type FontType = 'font' 4 | export type FontMimeType = `${FontType}/${string}` 5 | 6 | export const MIME_FONT: Record = { 7 | 'font/collection': { 8 | ext: ['ttc'] 9 | }, 10 | 'font/otf': { 11 | cmp: true, 12 | ext: ['otf'] 13 | }, 14 | 'font/ttf': { 15 | cmp: true, 16 | ext: ['ttf'] 17 | }, 18 | 'font/woff': { 19 | ext: ['woff'] 20 | }, 21 | 'font/woff2': { 22 | ext: ['woff2'] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/core/constants/mime/image.ts: -------------------------------------------------------------------------------- 1 | import { MimeTypeConfig } from '.' 2 | 3 | export type ImageType = 'image' 4 | export type ImageMimeType = `${ImageType}/${string}` 5 | 6 | export const MIME_IMAGE: Record = { 7 | 'image/apng': { 8 | cmp: false, 9 | ext: ['apng'] 10 | }, 11 | 'image/avci': { 12 | ext: ['avci'] 13 | }, 14 | 'image/avcs': { 15 | ext: ['avcs'] 16 | }, 17 | 'image/avif': { 18 | cmp: false, 19 | ext: ['avif'] 20 | }, 21 | 'image/bmp': { 22 | cmp: true, 23 | ext: ['bmp'] 24 | }, 25 | 26 | 'image/gif': { 27 | cmp: false, 28 | ext: ['gif'] 29 | }, 30 | 31 | 'image/jp2': { 32 | cmp: false, 33 | ext: ['jp2', 'jpg2'] 34 | }, 35 | 'image/jpeg': { 36 | cmp: false, 37 | ext: ['jpeg', 'jpg', 'jpe'] 38 | }, 39 | 40 | 'image/pjpeg': { 41 | cmp: false 42 | }, 43 | 'image/png': { 44 | cmp: false, 45 | ext: ['png'] 46 | }, 47 | 48 | 'image/svg+xml': { 49 | cmp: true, 50 | ext: ['svg', 'svgz'] 51 | }, 52 | 'image/t38': { 53 | ext: ['t38'] 54 | }, 55 | 'image/tiff': { 56 | cmp: false, 57 | ext: ['tif', 'tiff'] 58 | }, 59 | 'image/tiff-fx': { 60 | ext: ['tfx'] 61 | }, 62 | 'image/vnd.adobe.photoshop': { 63 | cmp: true, 64 | ext: ['psd'] 65 | }, 66 | 67 | 'image/vnd.microsoft.icon': { 68 | cmp: true, 69 | ext: ['ico'] 70 | }, 71 | 72 | 'image/vnd.ms-photo': { 73 | ext: ['wdp'] 74 | }, 75 | 'image/vnd.net-fpx': { 76 | ext: ['npx'] 77 | }, 78 | 79 | 'image/webp': { 80 | ext: ['webp'] 81 | }, 82 | 'image/wmf': { 83 | ext: ['wmf'] 84 | }, 85 | 86 | 'image/x-icon': { 87 | cmp: true, 88 | ext: ['ico'] 89 | }, 90 | 91 | 'image/x-ms-bmp': { 92 | cmp: true, 93 | ext: ['bmp'] 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/core/constants/mime/index.ts: -------------------------------------------------------------------------------- 1 | import { TextMimeType, MIME_TEXT } from './text' 2 | import { FontMimeType, MIME_FONT } from './font' 3 | import { ImageMimeType, MIME_IMAGE } from './image' 4 | import { AudioMimeType, MIME_AUDIO } from './audio' 5 | import { VideoMimeType, MIME_VIDEO } from './video' 6 | import { AppMimeType , MIME_APP } from './application' 7 | 8 | export type MultipartMimeType = `multipart/${string}` 9 | export type MimeType = TextMimeType | FontMimeType | ImageMimeType | AudioMimeType | VideoMimeType | MultipartMimeType | AppMimeType 10 | export type MimeTypeConfig = { 11 | ext?: Array, 12 | cmp?: boolean, 13 | charset?: "UTF-8" 14 | } 15 | 16 | export const MIME_TYPES_CONFIG: Record = { 17 | ...MIME_TEXT, 18 | ...MIME_FONT, 19 | ...MIME_IMAGE, 20 | ...MIME_AUDIO, 21 | ...MIME_VIDEO, 22 | ...MIME_APP, 23 | "multipart/form-data": { 24 | "cmp": false 25 | } 26 | } 27 | 28 | function populateExtensionsMimeTypes() { 29 | Object.entries(MIME_TYPES_CONFIG).forEach(([mimeType, mimeConfig]) => { 30 | if(mimeConfig.ext && mimeConfig.ext.length) { 31 | MIME_TYPE_EXT[mimeType as MimeType] = mimeConfig.ext 32 | mimeConfig.ext.forEach(ext => { 33 | EXT_MIME_TYPES[ext] = mimeType as MimeType 34 | }) 35 | } 36 | }) 37 | } 38 | 39 | // Runtime consts 40 | export const EXT_MIME_TYPES: Record = Object.create(null); 41 | export const MIME_TYPE_EXT: Record> = Object.create(null); 42 | 43 | populateExtensionsMimeTypes() 44 | -------------------------------------------------------------------------------- /src/core/constants/mime/text.ts: -------------------------------------------------------------------------------- 1 | import { MimeTypeConfig } from '.' 2 | 3 | export type TextType = 'text' 4 | export type TextMimeType = `${TextType}/${string}` 5 | 6 | export const MIME_TEXT: Record = { 7 | 'text/cache-manifest': { 8 | cmp: true, 9 | ext: ['appcache', 'manifest'] 10 | }, 11 | 'text/calendar': { 12 | ext: ['ics', 'ifb'] 13 | }, 14 | 'text/calender': { 15 | cmp: true 16 | }, 17 | 'text/cmd': { 18 | cmp: true 19 | }, 20 | 'text/coffeescript': { 21 | ext: ['coffee', 'litcoffee'] 22 | }, 23 | 'text/css': { 24 | charset: 'UTF-8', 25 | cmp: true, 26 | ext: ['css'] 27 | }, 28 | 'text/csv': { 29 | cmp: true, 30 | ext: ['csv'] 31 | }, 32 | 'text/html': { 33 | cmp: true, 34 | ext: ['html', 'htm', 'shtml'] 35 | }, 36 | 'text/jade': { 37 | ext: ['jade'] 38 | }, 39 | 'text/javascript': { 40 | cmp: true 41 | }, 42 | 'text/jsx': { 43 | cmp: true, 44 | ext: ['jsx'] 45 | }, 46 | 'text/less': { 47 | cmp: true, 48 | ext: ['less'] 49 | }, 50 | 'text/markdown': { 51 | cmp: true, 52 | ext: ['markdown', 'md'] 53 | }, 54 | 'text/mdx': { 55 | cmp: true, 56 | ext: ['mdx'] 57 | }, 58 | 'text/plain': { 59 | cmp: true, 60 | ext: ['txt', 'text', 'conf', 'def', 'list', 'log', 'in', 'ini'] 61 | }, 62 | 'text/richtext': { 63 | cmp: true, 64 | ext: ['rtx'] 65 | }, 66 | 'text/rtf': { 67 | cmp: true, 68 | ext: ['rtf'] 69 | }, 70 | 'text/stylus': { 71 | ext: ['stylus', 'styl'] 72 | }, 73 | 'text/tab-separated-values': { 74 | cmp: true, 75 | ext: ['tsv'] 76 | }, 77 | 'text/uri-list': { 78 | cmp: true, 79 | ext: ['uri', 'uris', 'urls'] 80 | }, 81 | 'text/vcard': { 82 | cmp: true, 83 | ext: ['vcard'] 84 | }, 85 | 'text/vnd.dvb.subtitle': { 86 | ext: ['sub'] 87 | }, 88 | 'text/vtt': { 89 | charset: 'UTF-8', 90 | cmp: true, 91 | ext: ['vtt'] 92 | }, 93 | 'text/x-handlebars-template': { 94 | ext: ['hbs'] 95 | }, 96 | 'text/xml': { 97 | cmp: true, 98 | ext: ['xml'] 99 | }, 100 | 'text/yaml': { 101 | ext: ['yaml', 'yml'] 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/core/constants/mime/video.ts: -------------------------------------------------------------------------------- 1 | import { MimeTypeConfig } from '.' 2 | 3 | export type VideoType = 'video' 4 | export type VideoMimeType = `${VideoType}/${string}` 5 | 6 | export const MIME_VIDEO: Record = { 7 | 'video/h264': { 8 | ext: ['h264'] 9 | }, 10 | 'video/h265': {}, 11 | 'video/mp4': { 12 | cmp: false, 13 | ext: ['mp4', 'mp4v', 'mpg4'] 14 | }, 15 | 'video/mpeg': { 16 | cmp: false, 17 | ext: ['mpeg', 'mpg', 'mpe', 'm1v', 'm2v'] 18 | }, 19 | 'video/ogg': { 20 | cmp: false, 21 | ext: ['ogv'] 22 | }, 23 | 'video/quicktime': { 24 | cmp: false, 25 | ext: ['qt', 'mov'] 26 | }, 27 | 'video/vnd.mpegurl': { 28 | ext: ['mxu', 'm4u'] 29 | }, 30 | 'video/webm': { 31 | cmp: false, 32 | ext: ['webm'] 33 | }, 34 | 'video/x-f4v': { 35 | ext: ['f4v'] 36 | }, 37 | 'video/x-fli': { 38 | ext: ['fli'] 39 | }, 40 | 'video/x-flv': { 41 | cmp: false, 42 | ext: ['flv'] 43 | }, 44 | 'video/x-m4v': { 45 | ext: ['m4v'] 46 | }, 47 | 'video/x-matroska': { 48 | cmp: false, 49 | ext: ['mkv', 'mk3d', 'mks'] 50 | }, 51 | 'video/x-msvideo': { 52 | ext: ['avi'] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/core/context.ts: -------------------------------------------------------------------------------- 1 | import type { ContextMeta, ZarfConfig, RouteMethod, HeaderVaryContent, HeaderTypeContent, JSONValue } from './types' 2 | import type { ParsedBody } from './utils/parsers/req-body' 3 | import { json, text, head, send, html } from './response' 4 | import { getContentType } from './utils/mime' 5 | import { MiddlewareFunction } from './middleware' 6 | import { HTTP_STATUS_CODES, HTTPStatusCode } from './constants/codes' 7 | import { ZarfHaltError } from './errors/error' 8 | 9 | // @ts-ignore 10 | const NEEDS_WARMUP = globalThis && globalThis.process && globalThis.process.isBun ? true : false 11 | 12 | /** 13 | * Execution context for handlers and all the middlewares 14 | */ 15 | export class AppContext = {}> { 16 | private _response: Response | null 17 | private _config: ZarfConfig = {} 18 | private _relativeConfig: Record = {} 19 | private _error: any 20 | private _code: HTTPStatusCode | undefined; 21 | private _middlewares: Array> = [] 22 | 23 | private readonly _request: Request | null 24 | readonly url: URL; 25 | readonly method: RouteMethod 26 | readonly host: string | undefined; 27 | readonly path: string; 28 | readonly query: URLSearchParams | undefined; 29 | readonly headers: Request["headers"] 30 | 31 | private _locals: S = {} as S 32 | readonly meta: ContextMeta = { 33 | startTime: 0 34 | } 35 | 36 | private _isImmediate: boolean = false 37 | public body: ParsedBody | null = null 38 | 39 | constructor(req: Request, config: ZarfConfig) { 40 | this.meta.startTime = Date.now() 41 | 42 | // Config 43 | this._config = config 44 | 45 | // Core Entities - `Request` and `Response` 46 | this._request = req 47 | this._response = new Response('') 48 | 49 | // Convenience Wrappers/Properties around the `Request` 50 | // Request: Verb 51 | this.method = req.method.toLowerCase() as RouteMethod 52 | // Request: URL 53 | this.url = new URL(req.url) 54 | // Request: Host 55 | this.host = this.url.host 56 | // Request: Path 57 | this.path = this._config?.strictRouting || this.url.pathname === '/' ? 58 | this.url.pathname : 59 | this.url.pathname.endsWith('/') ? 60 | this.url.pathname.substring(0, this.url.pathname.length -1) : 61 | this.url.pathname 62 | // Request: Query Params 63 | this.query = new Proxy(new URLSearchParams(req.url), { 64 | get: (params, param) => params.get(param as string), 65 | }) 66 | // Request: Headers (Proxy) 67 | this.headers = new Proxy(this._request.headers, { 68 | get: (headers, header) => headers.get(header as string), 69 | }) 70 | 71 | // Response: Warm-up! 72 | if(this._config?.serverHeader) { 73 | this._response.headers.set('Server', this._config.serverHeader) 74 | } 75 | 76 | /** 77 | * Currently needed for reading request body using `json`, `text` or `arrayBuffer` 78 | * without taking forever to resolve when any of them are accessed later 79 | * 80 | * But, needed only for `Bun`. If left applied for all the cases, this creates an issue with Node.js, Deno, etc. 81 | */ 82 | if(NEEDS_WARMUP) { 83 | this._request.blob(); 84 | } 85 | } 86 | 87 | /** 88 | * Get the current request in the raw form 89 | */ 90 | get request() { 91 | return this._request 92 | } 93 | 94 | /** 95 | * Get the `Response` if any 96 | */ 97 | get response() { 98 | return this._response 99 | } 100 | /** 101 | * Set the `Response` 102 | */ 103 | set response(resp) { 104 | this._response = resp 105 | } 106 | 107 | /** 108 | * Get the current status code 109 | */ 110 | get status() { 111 | return this._code as HTTPStatusCode 112 | } 113 | /** 114 | * Set the current status code 115 | */ 116 | set status(code: HTTPStatusCode) { 117 | this._code = code; 118 | } 119 | 120 | get isImmediate() { 121 | return this._isImmediate 122 | } 123 | 124 | /// HEADER HELPERS /// 125 | setHeader(headerKey: string, headerVal: string) { 126 | return this._response?.headers.set(headerKey, headerVal) 127 | } 128 | 129 | setType(headerVal: HeaderTypeContent) { 130 | const contentType = getContentType(headerVal) 131 | if(contentType) { 132 | this.setHeader('Content-Type', getContentType(headerVal) as string) 133 | } 134 | return 135 | } 136 | 137 | isType(headerVal: HeaderTypeContent) { 138 | return this._request?.headers.get('Content-Type') === getContentType(headerVal) 139 | } 140 | 141 | accepts(headerVal: HeaderTypeContent) { 142 | return this._request?.headers.get('Accepts')?.includes(getContentType(headerVal) || '') 143 | } 144 | 145 | // https://www.smashingmagazine.com/2017/11/understanding-vary-header/ 146 | setVary(...headerVals: Array) { 147 | if(headerVals.length) { 148 | const varies = (this._response?.headers.get('Vary') || '').split(',') 149 | this._response?.headers.set('Vary', [...new Set([...varies ,...headerVals])].join(',')) 150 | } 151 | } 152 | 153 | /** 154 | * Get Error 155 | */ 156 | get error() { 157 | return this._error 158 | } 159 | /** 160 | * Set Error 161 | */ 162 | set error(err) { 163 | this._error = err 164 | } 165 | 166 | // Getter/Setter for App-specific details 167 | /** 168 | * Get available App-specific details 169 | */ 170 | get locals() { 171 | return this._locals as S 172 | } 173 | /** 174 | * Set App-specific details 175 | */ 176 | set locals(value: S) { 177 | this._locals = value 178 | } 179 | 180 | // Context helpers to send the `Response` in all the supported formats 181 | /** 182 | * Send the response as string, json, etc. - One sender to rule `em all! 183 | * @param body 184 | * @returns 185 | */ 186 | async send(body: JSONValue | Blob | BufferSource , args: ResponseInit = {}): Promise { 187 | if(this._request?.method === 'HEAD') return this.head() 188 | return await send(body, {...this.getResponseInit(), ...args}) 189 | } 190 | 191 | /** 192 | * Send the provided values as `json` 193 | * @param body 194 | * @returns 195 | */ 196 | json(body: JSONValue, args: ResponseInit = {}): Response { 197 | return json(body, {...this.getResponseInit(), ...args}) 198 | } 199 | 200 | /** 201 | * Send the provided value as `text` 202 | * @param _text 203 | * @returns 204 | */ 205 | text(_text: string, args: ResponseInit = {}): Response { 206 | return text(_text, {...this.getResponseInit(), ...args}) 207 | } 208 | 209 | /** 210 | * Send the provided value as `html` 211 | * @param _text 212 | * @returns 213 | */ 214 | html(text: string, args: ResponseInit = {}): Response { 215 | return html(text, {...this.getResponseInit(), ...args}) 216 | } 217 | 218 | /** 219 | * Just return with `head` details 220 | * @returns 221 | */ 222 | head(args: ResponseInit = {}): Response { 223 | return head({...this.getResponseInit(), ...args}) 224 | } 225 | 226 | /** 227 | * Halt flow, and immediately return with provided HTTP status code 228 | * 229 | * @param {number} statusCode - a valid HTTP status code 230 | * @param {JSONValue} body - to send in response. Could be `json`, `string`, etc. or nothing at all 231 | * @returns 232 | * 233 | * 234 | * @example HTTP Status 235 | * app.get("/authorized", (ctx) => { 236 | * // do something to check user's authenticity 237 | * ctx.halt(401) 238 | * // this line, and lines next to this will never be reached 239 | * return ctx.text("Authorized") 240 | * }) 241 | * 242 | * @example HTTP Status and Body 243 | * app.get("/authorized", (ctx) => { 244 | * // do something to check user's authenticity 245 | * ctx.halt(401, 'You shall not pass') 246 | * // this line, and lines next to this will never be reached 247 | * return ctx.text("Authorized") 248 | * }) 249 | * 250 | */ 251 | halt(statusCode: HTTPStatusCode, body?: JSONValue) { 252 | throw new ZarfHaltError( 253 | statusCode, 254 | body || HTTP_STATUS_CODES[statusCode], 255 | { 256 | ...this.getResponseInit(), 257 | status: statusCode 258 | } 259 | ) 260 | } 261 | 262 | /** 263 | * Redirect to the given URL 264 | * 265 | * @param url 266 | * @param statusCode 267 | * @returns 268 | */ 269 | redirect(url: string, statusCode: HTTPStatusCode = 302): Response { 270 | this._isImmediate = true 271 | let loc = url 272 | if(loc ==='back') loc = this._request?.headers.get('Referrer') || '/' 273 | return Response.redirect(encodeURI(loc), statusCode) 274 | } 275 | 276 | /** 277 | * Use the settings from available `Response` details, 278 | * if there's one (as an outcome of handler processing and middleware execution) 279 | * @returns 280 | */ 281 | private getResponseInit(): ResponseInit { 282 | if(this._response) { 283 | const { status, statusText, headers } = this._response 284 | 285 | const _headers: Record = {} 286 | headers.forEach((val, key) => { 287 | _headers[key] = val 288 | }) 289 | 290 | return { 291 | headers: _headers, 292 | status: this._code || status, 293 | statusText 294 | } 295 | } 296 | else { 297 | return {} 298 | } 299 | } 300 | 301 | // MIDDLEWARE METHODS (MIDDLEWARE-ONLY USE) 302 | 303 | /** 304 | * Add a middleware that's invoked post the `Request` processing. 305 | * 306 | * IT'S INTENDED TO BE USED ONLY BY MIDDLEWARE DEVELOPERS. PLEASE DON'T 307 | * THIS IF YOU'RE A FRAMEWORK USER/CONSUMER 308 | * 309 | * @param func {MiddlewareFunction} 310 | */ 311 | after(func: MiddlewareFunction) { 312 | this._middlewares.push(func) 313 | } 314 | 315 | /** 316 | * Returns a list of all the POST middlewares. 317 | * 318 | * IT'S INTENDED TO BE USED ONLY BY MIDDLEWARE DEVELOPERS. PLEASE DON'T 319 | * THIS IF YOU'RE A FRAMEWORK USER/CONSUMER 320 | * 321 | * @param func {MiddlewareFunction} 322 | */ 323 | get afterMiddlewares() { 324 | return this._middlewares 325 | } 326 | 327 | // MOUNT METHODS (PRIVATE USE) 328 | 329 | public useAppConfigOnPath(prefix: string, config: ZarfConfig) { 330 | this._relativeConfig[prefix] = config 331 | } 332 | 333 | private getAppPrefix() { 334 | const path = this.path.substring(1) 335 | return '/' + path.substring(0, path.indexOf('/')) 336 | } 337 | 338 | // MOUNT METHODS (PUBLIC USE) 339 | 340 | /** 341 | * Get the current app's configuration, as provided while instantiating 342 | * the `Zarf` app 343 | * @returns {ZarfConfig} 344 | * 345 | * @example Access inside a route handler 346 | * app.get("/a-path-inside-main-or-mounted-app", (ctx) => { 347 | * // get the config 348 | * ctx.config 349 | * // go ahead... 350 | * }) 351 | */ 352 | public get config(): ZarfConfig { 353 | const appPrefix = this.getAppPrefix() 354 | return (this._relativeConfig[appPrefix] ? this._relativeConfig[appPrefix] : this._config) as ZarfConfig 355 | } 356 | 357 | /** 358 | * Get the relative path for the current app. Unlike `path` it isn't the 359 | * full relative path from the base URL of the site, but the actual relative 360 | * path you'd probably expect w.r.t a mounted app 361 | * 362 | * @returns {string} the relative path if it's a mounted app, or the original path if it's the main app 363 | * 364 | * @example Access inside a handler 365 | * app.get("/a-path-inside-main-or-mounted-app", (ctx) => { 366 | * // get the relativeUrl 367 | * ctx.relativeUrl 368 | * // go ahead... 369 | * }) 370 | */ 371 | public get relativePath() { 372 | const appPrefix = this.getAppPrefix() 373 | return this._relativeConfig[appPrefix] ? this.path.replace(appPrefix, '') : this.path 374 | } 375 | } 376 | -------------------------------------------------------------------------------- /src/core/errors/config.ts: -------------------------------------------------------------------------------- 1 | import { badRequest, internalServerError, methodNotAllowed, notFound, unprocessableEntity } from "../response/created"; 2 | import { RespCreatorRequestInit } from "../response/creator"; 3 | import { HTTPStatusCodeMesssageKey } from '../constants/codes' 4 | 5 | export const serverErrorFns: Partial Response>> = { 6 | 'InternalServerError': internalServerError, 7 | 'NotFound': notFound, 8 | 'BadRequest': badRequest, 9 | 'UnprocessableEntity': unprocessableEntity, 10 | 'MethodNotAllowed': methodNotAllowed 11 | } 12 | -------------------------------------------------------------------------------- /src/core/errors/error.ts: -------------------------------------------------------------------------------- 1 | import type { Errorlike } from "bun"; 2 | import type { JSONValue } from '../types' 3 | import { pick } from '../utils/choose' 4 | import { HTTPStatusCode, HTTPStatusCodeMesssageKey } from '../constants/codes' 5 | import { serverErrorFns } from './config' 6 | 7 | type ErrorData = { [key: string]: any }; 8 | 9 | export class ZarfCustomError extends Error implements Errorlike { 10 | constructor( 11 | readonly message: string, 12 | readonly code: HTTPStatusCodeMesssageKey = 'InternalServerError', 13 | readonly data: ErrorData = {}, 14 | ) { 15 | super(); 16 | } 17 | } 18 | 19 | export class ZarfHaltError extends Error { 20 | constructor( 21 | readonly code: HTTPStatusCode, 22 | readonly body: JSONValue, 23 | readonly init: ResponseInit = {}, 24 | ) { 25 | super(); 26 | } 27 | } 28 | 29 | export class ZarfNotFoundError extends ZarfCustomError { 30 | constructor(originalUrl: string) { 31 | super(`Route '${originalUrl}' does not exist.`, 'NotFound'); 32 | this.name = this.constructor.name; 33 | } 34 | } 35 | 36 | export class ZarfMethodNotAllowedError extends ZarfCustomError { 37 | constructor() { 38 | super(`Requested method is not allowed for the server.`, 'MethodNotAllowed'); 39 | this.name = this.constructor.name; 40 | } 41 | } 42 | 43 | export class ZarfBadRequestError extends ZarfCustomError { 44 | constructor(errorData: ErrorData) { 45 | super('There were validation errors.', 'BadRequest', errorData); 46 | this.name = this.constructor.name; 47 | } 48 | } 49 | 50 | export class ZarfUnprocessableEntityError extends ZarfCustomError { 51 | constructor(errorData: ErrorData) { 52 | super('Unprocessable Entity', 'UnprocessableEntity', errorData); 53 | this.name = this.constructor.name; 54 | } 55 | } 56 | 57 | export const sendError = (error: Errorlike) => { 58 | const isErrorSafeForClient = error instanceof ZarfCustomError; 59 | const send = error.code ? serverErrorFns[error.code as HTTPStatusCodeMesssageKey] : serverErrorFns['InternalServerError'] 60 | const { message, data } = isErrorSafeForClient 61 | ? pick(error, ['message', 'data']) 62 | : { 63 | message: 'Something went wrong!', 64 | data: {}, 65 | }; 66 | return send?.(JSON.stringify({ message, errors: data }, null, 0)) 67 | } 68 | -------------------------------------------------------------------------------- /src/core/errors/handler.ts: -------------------------------------------------------------------------------- 1 | import type { Errorlike } from 'bun' 2 | import { AppContext } from '../context'; 3 | import { sendError } from './error' 4 | /** 5 | * Special handlers 6 | * / 7 | /* 8 | * Default error handler 9 | * @param ctx 10 | * @param err 11 | * @returns 12 | */ 13 | export function defaultErrorHandler = {}>(ctx: AppContext, err: Errorlike): Response { 14 | return sendError(err)! 15 | } 16 | /** 17 | * Not Found Error handler 18 | * @param ctx 19 | * @returns 20 | */ 21 | export function notFoundHandler = {}>(ctx: AppContext): Response | Promise{ 22 | return new Response(`No matching ${ctx.method.toUpperCase()} routes discovered for the path: ${ctx.path}`, { 23 | status: 404, 24 | }); 25 | } 26 | 27 | /** 28 | * Not found verb error handler 29 | * @param ctx 30 | * @returns 31 | */ 32 | export function notFoundVerbHandler = {}>(ctx: AppContext): Response | Promise { 33 | return new Response(`No implementations found for the verb: ${ctx.method.toUpperCase()}`, { 34 | status: 404, 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /src/core/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error' 2 | export * from './handler' 3 | -------------------------------------------------------------------------------- /src/core/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { AppContext } from "./context"; 2 | 3 | // `Middleware` configs 4 | export type MiddlewareType = 'before' | 'after' | 'error' 5 | export const NoOpMiddleware: MiddlewareFunction = (_, next) => next(); 6 | export type MiddlewareMeta = { 7 | isFirst: boolean, 8 | isLast: boolean 9 | } 10 | 11 | // `Middleware` return types 12 | export type MiddlewareFuncResp = void | Response; 13 | export type MiddlewareNextFunc = () => Promise; 14 | 15 | // `Middleware` function types 16 | export type MiddlewareFunction = {}> = ( 17 | context: AppContext, 18 | next: MiddlewareNextFunc, 19 | meta?: MiddlewareMeta 20 | ) => Promise; 21 | export type MiddlewareFunctionInitializer< 22 | T extends Record = {}, 23 | S extends Record = {}> = (options?: T) => MiddlewareFunction 24 | 25 | /** 26 | * Execute a sequence of middlewares 27 | * @param context 28 | * @param middlewares 29 | * @returns 30 | */ 31 | export async function exec = {}>(context: AppContext, middlewares: Array>) { 32 | let prevIndexAt: number = -1; 33 | 34 | async function runner(indexAt: number): Promise { 35 | if (indexAt <= prevIndexAt) { 36 | throw new Error(`next() called multiple times by middleware #${indexAt}`) 37 | } 38 | 39 | prevIndexAt = indexAt; 40 | const middleware = middlewares[indexAt]; 41 | if (middleware) { 42 | const resp = await middleware(context, () => { 43 | return runner(indexAt + 1); 44 | }, { 45 | isFirst: indexAt === 0, 46 | isLast: indexAt === middlewares.length - 1, 47 | }); 48 | if (resp) return resp; 49 | } 50 | } 51 | 52 | return runner(0) 53 | } 54 | -------------------------------------------------------------------------------- /src/core/response.ts: -------------------------------------------------------------------------------- 1 | import { getContentType } from './utils/mime' 2 | import { HTTPStatusCode, HTTP_STATUS_CODES } from './constants/codes' 3 | 4 | /** 5 | * Response helper: Sends the provided data as `json` to the client 6 | * 7 | * @param data `any` or provided type which could be JSON, string, etc. 8 | * @param args 9 | * @returns 10 | */ 11 | export function json(data: T, args: ResponseInit = {}): Response { 12 | const headers = new Headers(args.headers || {}) 13 | headers.set('Content-Type', getContentType('json') as string) 14 | const status = args.status || 200 15 | const statusText = args.statusText || HTTP_STATUS_CODES[status as HTTPStatusCode] 16 | if(typeof data === 'object' && data != null) { 17 | return new Response( JSON.stringify(data, null, 0), { ...args, status, statusText, headers } ) 18 | } else if(typeof data === 'string') { 19 | return text(data, { ...args, status, statusText, headers} ) 20 | } else { 21 | headers.delete('Content-Length') 22 | headers.delete('Transfer-Encoding') 23 | return text("", { ...args, status, statusText, headers} ) 24 | } 25 | } 26 | 27 | /** 28 | * Response helper: Sends the provided data as `text` to the client 29 | * @param text 30 | * @param args 31 | * @returns 32 | */ 33 | export function text(text: string, args: ResponseInit = {}): Response { 34 | const headers = new Headers(args.headers || {}) 35 | headers?.set('Content-Type', getContentType('text') as string) 36 | const status = args.status || 200 37 | const statusText = args.statusText || HTTP_STATUS_CODES[status as HTTPStatusCode] 38 | return new Response(text.toString(), { ...args, status, statusText, headers }); 39 | } 40 | 41 | export function html(text: string, args: ResponseInit = {}): Response { 42 | const headers = new Headers(args.headers || {}) 43 | headers?.set('Content-Type', getContentType('html') as string) 44 | const status = args.status || 200 45 | const statusText = args.statusText || HTTP_STATUS_CODES[status as HTTPStatusCode] 46 | return new Response(text.toString(), { ...args, status, statusText, headers }); 47 | } 48 | 49 | export function head(args: ResponseInit = {}): Response { 50 | const status = args.status || 204 51 | const statusText = args.statusText || HTTP_STATUS_CODES[status as HTTPStatusCode] 52 | return new Response('', {...args, status, statusText }) 53 | } 54 | 55 | export async function send(body: any, args: ResponseInit = {}): Promise { 56 | let sendable = body 57 | const headers = new Headers(args.headers || {}) 58 | 59 | // Do any required header tweaks 60 | if(Buffer.isBuffer(body)) { 61 | sendable = body 62 | } else if(typeof body === 'object' && body !== null) { 63 | // `json` updates its header, so no changes required 64 | } else if(typeof body === 'string') { 65 | headers.set('Content-Type', getContentType('text') as string) 66 | } else { 67 | headers.set('Content-Type', getContentType('html') as string) 68 | } 69 | 70 | // @TODO: populate Etag 71 | 72 | // strip irrelevant headers 73 | if (args?.status === 204 || args?.status === 304) { 74 | headers.delete('Content-Type') 75 | headers.delete('Content-Length') 76 | headers.delete('Transfer-Encoding') 77 | sendable = '' 78 | } 79 | 80 | // if(this._request?.method === 'HEAD') { 81 | // return head({ 82 | // ...args, 83 | // headers 84 | // }) 85 | // } 86 | 87 | if (typeof sendable === 'object') { 88 | if (sendable == null) { 89 | return new Response('', { 90 | ...args, 91 | headers 92 | }) 93 | } else { 94 | return json(sendable, { 95 | ...args, 96 | headers 97 | }) 98 | } 99 | } 100 | else if(Buffer.isBuffer(sendable)){ 101 | if(!headers.get('Content-Type')) { 102 | headers.set('Content-Type', getContentType('octet-stream') as string) 103 | } 104 | return new Response(sendable, { 105 | ...args, 106 | headers 107 | }) 108 | } else if(typeof sendable === 'string') { 109 | return text(sendable, { 110 | ...args, 111 | headers 112 | }) 113 | } else if(sendable instanceof Blob) { 114 | if(sendable.type.includes('json')) { 115 | // @ts-ignore 116 | return json(await sendable.json(), { 117 | ...args, 118 | headers 119 | }) 120 | } else if (sendable.type.includes('text')) { 121 | return text(await sendable.text(), { 122 | ...args, 123 | headers 124 | }) 125 | } else { 126 | 127 | } 128 | } else { 129 | 130 | if(typeof sendable !== 'string') sendable = sendable.toString() 131 | return new Response(sendable, { 132 | ...args, 133 | headers 134 | }) 135 | } 136 | return new Response(sendable, { ...args, headers }) 137 | } 138 | 139 | export async function sendFile(path: string, args: ResponseInit = {}): Promise { 140 | const headers = new Headers(args.headers || {}) 141 | const file = Bun.file(path) 142 | const fileName = path.substring(path.lastIndexOf('/') + 1, path.length) 143 | headers.set('Content-Disposition', `attachment; filename=${fileName}`) 144 | headers.set('Content-Transfer-Encoding', 'binary') 145 | headers.set('Content-Type', getContentType('octet-stream') as string) 146 | return new Response(new Blob([ 147 | await file.arrayBuffer() 148 | ], { 149 | type: file.type 150 | }), { 151 | ...args, 152 | headers 153 | }) 154 | } 155 | -------------------------------------------------------------------------------- /src/core/response/created.ts: -------------------------------------------------------------------------------- 1 | import { createResp, createRedirect, createNotModified, createUnauthorized } from "./creator"; 2 | import { HTTP_STATUS_CODES } from '../constants/codes' 3 | 4 | export const informContinue = createResp(100, HTTP_STATUS_CODES['100']) 5 | export const informSwitchingProtocols = createResp(101, HTTP_STATUS_CODES['101']) 6 | 7 | export const ok = createResp(200, HTTP_STATUS_CODES['200']) 8 | export const created = createResp(201, HTTP_STATUS_CODES['201']) 9 | export const accepted = createResp(202, HTTP_STATUS_CODES['202']) 10 | export const nonAuthorititiveInfo = createResp(203, HTTP_STATUS_CODES['203']) 11 | export const noContent = createResp(204, HTTP_STATUS_CODES['204']) 12 | export const resetContent = createResp(205, HTTP_STATUS_CODES['205']) 13 | export const partialContent = createResp(206, HTTP_STATUS_CODES['206']) 14 | 15 | 16 | export const movedPermanently = createRedirect(301, HTTP_STATUS_CODES['301']) 17 | export const found = createRedirect(302, HTTP_STATUS_CODES['302']) 18 | export const seeOther = createRedirect(303, HTTP_STATUS_CODES['303']) 19 | export const notModified = createNotModified(304, HTTP_STATUS_CODES['304']) 20 | export const tempRedirect = createRedirect(307, HTTP_STATUS_CODES['307']) 21 | export const permRedirect = createRedirect(308, HTTP_STATUS_CODES['308']) 22 | 23 | export const badRequest = createResp(400, HTTP_STATUS_CODES['400']) 24 | export const unauthorized = createUnauthorized(401, HTTP_STATUS_CODES['401']) 25 | export const forbidden = createResp(403, HTTP_STATUS_CODES['403']) 26 | export const notFound = createResp(404, HTTP_STATUS_CODES['404']) 27 | export const methodNotAllowed = createResp(405, HTTP_STATUS_CODES['405']) 28 | export const notAcceptable = createResp(406, HTTP_STATUS_CODES['406']) 29 | export const proxyAuthRequired = createResp(407, HTTP_STATUS_CODES['407']) 30 | export const requestTimeout = createResp(408, HTTP_STATUS_CODES['408']) 31 | export const conflict = createResp(409, HTTP_STATUS_CODES['409']) 32 | export const gone = createResp(410, HTTP_STATUS_CODES['410']) 33 | export const unprocessableEntity = createResp(422, HTTP_STATUS_CODES['422']) 34 | 35 | export const internalServerError = createResp(500, HTTP_STATUS_CODES['500']) 36 | export const notImplemented = createResp(501, HTTP_STATUS_CODES['501']) 37 | export const badGateway = createResp(502, HTTP_STATUS_CODES['502']) 38 | export const serviceUnavailable = createResp(503, HTTP_STATUS_CODES['503']) 39 | export const gatewayTimeout = createResp(504, HTTP_STATUS_CODES['504']) 40 | -------------------------------------------------------------------------------- /src/core/response/creator.ts: -------------------------------------------------------------------------------- 1 | export type RespCreatorRequestInit= Omit 2 | 3 | /** 4 | * Create a Response 5 | * @param status number 6 | * @param statusText string 7 | * @returns Response 8 | */ 9 | export const createResp = (status: number, statusText: string) => (body: BodyInit | null = null, init: RespCreatorRequestInit = {}) => new Response(body, { 10 | ...init, 11 | status, 12 | statusText, 13 | }) 14 | 15 | /** 16 | * Create a redirect response 17 | * @param status number 18 | * @param statusText string 19 | * @returns Response 20 | */ 21 | export const createRedirect = (status: number, statusText: string) => (location: string | URL, init: RespCreatorRequestInit = {}): Response => new Response(null, { 22 | ...init, 23 | status, 24 | statusText, 25 | headers: [ 26 | // @ts-ignore 27 | ...init?.headers ? Array.isArray(init.headers) ? init.headers : new Headers(init.headers) : [], 28 | ['Location', location.toString()] 29 | ], 30 | }); 31 | 32 | /** 33 | * Create Unauthorized Response 34 | * @param status number 35 | * @param statusText string 36 | * @returns Response 37 | */ 38 | export const createUnauthorized = (status: number, statusText: string) => (realm = '', init: RespCreatorRequestInit = {}): Response => new Response(null, { 39 | ...init, 40 | status, 41 | statusText, 42 | headers: [ 43 | ...init?.headers ? Array.isArray(init.headers) ? init.headers : new Headers(init.headers) : [], 44 | ['WWW-Authenticate', `Basic realm="${realm}", charset="UTF-8"`], 45 | ], 46 | }); 47 | 48 | 49 | 50 | type CreateNotModifiedOptions = { 51 | ifNoneMatch: string, 52 | ifModifiedSince: Date 53 | } 54 | 55 | /** 56 | * Create `NotModified` Response 57 | * @param status number 58 | * @param statusText string 59 | * @returns Response 60 | */ 61 | export const createNotModified = (status: number, statusText: string) => (options: CreateNotModifiedOptions, init: RespCreatorRequestInit = {}): Response => new Response(null, { 62 | ...init, 63 | status, 64 | statusText, 65 | headers: [ 66 | ...init?.headers ? Array.isArray(init.headers) ? init.headers : new Headers(init.headers) : [], 67 | ['If-None-Match', options.ifNoneMatch], 68 | ['If-Modified-Since', options.ifModifiedSince], 69 | ], 70 | }) 71 | -------------------------------------------------------------------------------- /src/core/types.ts: -------------------------------------------------------------------------------- 1 | import type { Errorlike } from "bun"; 2 | import { ROUTE_OPTION_DEFAULT } from "./constants"; 3 | import { AppContext as PrivateAppContext } from './context' 4 | import { MiddlewareFunction } from './middleware'; 5 | import type { Replace } from './utils/types' 6 | 7 | // Utility types 8 | export type JSONValue = 9 | | string 10 | | number 11 | | boolean 12 | | { [x: string]: JSONValue } 13 | | Array; 14 | 15 | // Global Type aliases 16 | export type RouteMethod = "get" | "post" | "put" | "patch" | "delete" | "head" | "options" 17 | export type HeaderVaryContent = 'Origin' | 'User-Agent' | 'Accept-Encoding' | 'Accept' | 'Accept-Language' 18 | export type HeaderTypeContent = 'text' | 'json' | 'html' 19 | 20 | // Context interfaces/types 21 | // The context that could be shared to `RouteHandler` 22 | export type AppContext = {}> = Omit, 23 | 'after' | 24 | 'afterMiddlewares' | 25 | 'useAppConfigOnPath' | 26 | 'isImmediate' 27 | > 28 | // Context-internal interfaces/types 29 | export interface ContextMeta { 30 | startTime: number 31 | } 32 | 33 | // `Zarf` Application config 34 | export interface ZarfConfig = {}> { 35 | appName?: string 36 | serverHeader?: string 37 | strictRouting?: boolean 38 | getOnly?: boolean 39 | errorHandler?: (ctx: PrivateAppContext, error: Errorlike) => Response | Promise | Promise | undefined 40 | } 41 | 42 | // `Zarf` lister options 43 | export interface ZarfOptions { 44 | port?: number, 45 | development?: boolean, 46 | hostname?: string 47 | } 48 | 49 | // `Route` types 50 | export interface RouteOptions { 51 | [ROUTE_OPTION_DEFAULT]?: {}, 52 | meta: { 53 | name: string, 54 | alias: string 55 | } 56 | } 57 | 58 | export interface Route = {}> { 59 | id: string 60 | matcher: RegExp 61 | handler: RouteHandler<{}, S> 62 | vars: Array 63 | options: RouteOptions, 64 | middlewares?: Array> 65 | } 66 | export type RouteStack = {}> = Record>> 67 | export interface ResolvedRoute { 68 | handler: RouteHandler 69 | params: Record 70 | } 71 | export interface RouteProps { 72 | context: PrivateAppContext; 73 | request: Request; 74 | params: Record; 75 | } 76 | 77 | type RouteParamNames = 78 | string extends Route 79 | ? string 80 | : Route extends `${string}:${infer Param}-:${infer Rest}` 81 | ? (RouteParamNames<`/:${Param}/:${Rest}`>) 82 | : Route extends `${string}:${infer Param}.:${infer Rest}` 83 | ? (RouteParamNames<`/:${Param}/:${Rest}`>) 84 | : Route extends `${string}:${infer Param}/${infer Rest}` 85 | ? (Replace | RouteParamNames) 86 | : Route extends `${string}*${infer Param}/${infer Rest}` 87 | ? (Replace | RouteParamNames) 88 | : ( 89 | Route extends `${string}:${infer LastParam}?` ? 90 | Replace, '?', ''> : 91 | Route extends `${string}:${infer LastParam}` ? 92 | Replace : 93 | Route extends `${string}*${infer LastParam}` ? 94 | LastParam : 95 | Route extends `${string}:${infer LastParam}?` ? 96 | Replace : 97 | never 98 | ); 99 | 100 | 101 | export type RouteParams = { 102 | [key in RouteParamNames]: string 103 | } 104 | 105 | // `Route Handler` types 106 | 107 | type Path = string; 108 | export type RegisterRoute = {}> = ( method: RouteMethod, path: Path, handler: RouteHandler ) => void; 109 | export type RouteHandler = {}, S extends Record = {}> = (context: AppContext, params: T) => Response | Promise 110 | 111 | // Adapter Context 112 | export interface AdapterContext = {}> { 113 | waitUntil?(promise: Promise): void; 114 | signal?: { 115 | aborted: boolean; 116 | }; 117 | passThrough?(promise: Promise): void; 118 | platform: T, 119 | [key: string | number]: unknown; 120 | } 121 | 122 | export interface AdapterServerListenerOptions { 123 | port?: number, 124 | development?: boolean, 125 | hostname?: string, 126 | log?: boolean 127 | } 128 | 129 | type CertFileExt = 'crt' | 'pem' 130 | type KeyFileExt = 'key' | 'pem' 131 | type CertFilePath = `./${string}.${CertFileExt}` 132 | type KeyFilePath = `./${string}.${KeyFileExt}` 133 | 134 | export interface AdapterServerHttpsOptions { 135 | certFile: CertFilePath, 136 | keyFile: KeyFilePath, 137 | } 138 | -------------------------------------------------------------------------------- /src/core/utils/app.ts: -------------------------------------------------------------------------------- 1 | import type { ZarfConfig } from '../types' 2 | import type { MiddlewareFunctionInitializer } from "../middleware" 3 | 4 | export type MountConfigMiddlewareOptions = { 5 | prefix: string 6 | config: ZarfConfig 7 | } 8 | 9 | export function getMountPath(prefix: string, path: string): string { 10 | if(prefix.length === 0 || prefix === '/') { 11 | return path[0] === '/' ? path : `/${path}` 12 | } else { 13 | return `${prefix}${path}` 14 | } 15 | } 16 | 17 | export const mountConfigMiddleware: MiddlewareFunctionInitializer = (options) => { 18 | return async (ctx, next) => { 19 | ctx.useAppConfigOnPath(options?.prefix!, options?.config!) 20 | await next() 21 | } 22 | } 23 | 24 | export function getRouteName(id: string, vars: Array, appName?: string) { 25 | if(id === '/' && appName) return appName 26 | let str = id.replace(/\//g,"_").substring(1) 27 | vars.forEach(vars => { 28 | str = str.includes(`*${vars}`) ? str.replace(`*${vars}`, `_${vars}`) : str.includes(`:${vars}`) ? str.replace(`:${vars}`, `_${vars}`) : str.includes(`${vars}?`) ? str.replace(`${vars}?`, `${vars}`) : str 29 | }) 30 | return str.replace(/\?/g,"") 31 | } 32 | -------------------------------------------------------------------------------- /src/core/utils/choose.ts: -------------------------------------------------------------------------------- 1 | type O = { [key: string]: any }; 2 | 3 | export const omit = (obj: O, keys: string[]) => { 4 | return Object.keys(obj).reduce((target: O, key) => { 5 | if (!keys.includes(key)) { 6 | target[key] = obj[key]; 7 | } 8 | return target; 9 | }, {}); 10 | }; 11 | 12 | export const pick = (obj: O, keys: string[]) => { 13 | return Object.keys(obj).reduce((target: O, key) => { 14 | if (keys.includes(key)) { 15 | target[key] = obj[key]; 16 | } 17 | return target; 18 | }, {}); 19 | }; 20 | -------------------------------------------------------------------------------- /src/core/utils/console/colors.ts: -------------------------------------------------------------------------------- 1 | // @TODO: Read through the env vars 2 | const enabled = true 3 | 4 | interface Code { 5 | open: string; 6 | close: string; 7 | regexp: RegExp; 8 | } 9 | 10 | const code = (open: number[], close: number): Code => { 11 | return { 12 | open: `\x1b[${open.join(";")}m`, 13 | close: `\x1b[${close}m`, 14 | regexp: new RegExp(`\\x1b\\[${close}m`, "g"), 15 | }; 16 | } 17 | const style = (str: string, code: Code) => enabled ? `${code.open}${str.replace(code.regexp, code.open)}${code.close}` : str; 18 | 19 | /** Color presets for `console` */ 20 | export const bold = (str: string) => style(str, code([1], 22)) 21 | export const dim = (str: string) => style(str, code([2], 22)) 22 | export const italic = (str: string) => style(str, code([3], 23)) 23 | export const underline = (str: string) => style(str, code([4], 24)) 24 | export const inverse = (str: string) => style(str, code([7], 27)) 25 | export const hidden = (str: string) => style(str, code([8], 28)) 26 | export const strikethrough = (str: string) => style(str, code([9], 29)) 27 | export const black = (str: string) => style(str, code([30], 39)) 28 | export const red = (str: string) => style(str, code([31], 39)) 29 | export const green = (str: string) => style(str, code([32], 39)) 30 | export const yellow = (str: string) => style(str, code([33], 39)) 31 | export const blue = (str: string) => style(str, code([34], 39)) 32 | export const magenta = (str: string) => style(str, code([35], 39)) 33 | export const cyan = (str: string) => style(str, code([36], 39)) 34 | export const white = (str: string) => style(str, code([37], 39)) 35 | export const gray = (str: string) => brightBlack(str) 36 | 37 | export const brightBlack = (str: string) => style(str, code([90], 39)) 38 | export const brightRed = (str: string) => style(str, code([91], 39)) 39 | export const brightGreen = (str: string) => style(str, code([92], 39)) 40 | export const brightYellow = (str: string) => style(str, code([93], 39)) 41 | export const brightBlue = (str: string) => style(str, code([94], 39)) 42 | export const brightMagenta = (str: string) => style(str, code([95], 39)) 43 | export const brightCyan = (str: string) => style(str, code([96], 39)) 44 | export const brightWhite = (str: string) => style(str, code([97], 39)) 45 | 46 | export const bgBlack = (str: string) => style(str, code([40], 49)) 47 | export const bgRed = (str: string) => style(str, code([41], 49)) 48 | export const bgGreen = (str: string) => style(str, code([42], 49)) 49 | export const bgYellow = (str: string) => style(str, code([43], 49)) 50 | export const bgBlue = (str: string) => style(str, code([44], 49)) 51 | export const bgMagenta = (str: string) => style(str, code([45], 49)) 52 | export const bgCyan = (str: string) => style(str, code([46], 49)) 53 | export const bgWhite = (str: string) => style(str, code([47], 49)) 54 | export const bgBrightBlack = (str: string) => style(str, code([100], 49)) 55 | export const bgBrightRed = (str: string) => style(str, code([101], 49)) 56 | export const bgBrightGreen = (str: string) => style(str, code([102], 49)) 57 | export const bgBrightYellow = (str: string) => style(str, code([103], 49)) 58 | export const bgBrightBlue = (str: string) => style(str, code([104], 49)) 59 | export const bgBrightMagenta = (str: string) => style(str, code([105], 49)) 60 | export const bgBrightCyan = (str: string) => style(str, code([106], 49)) 61 | export const bgBrightWhite = (str: string) => style(str, code([107], 49)) 62 | -------------------------------------------------------------------------------- /src/core/utils/is.ts: -------------------------------------------------------------------------------- 1 | export const isPromise = (promise: any) => typeof promise?.then === 'function' 2 | export const isObject = (value: unknown) => typeof value === "object" && !Array.isArray(value) && value !== null 3 | export const isAsyncFn = (fn: Function) => isFn(fn) && fn.constructor.name === 'AsyncFunction' 4 | export const isFn = (fn: Function) => typeof fn === 'function' 5 | export const isIterable = (value?: unknown): value is (object & Iterable) => typeof value === 'object' && value !== null && Symbol.iterator in value; 6 | export const isAsyncIterable = (value?: unknown): value is (object & AsyncIterable) => typeof value === 'object' && value !== null && Symbol.asyncIterator in value; 7 | -------------------------------------------------------------------------------- /src/core/utils/mime.ts: -------------------------------------------------------------------------------- 1 | import { extname } from 'path'; 2 | import { MimeType, MIME_TYPES_CONFIG, EXT_MIME_TYPES } from '../constants/mime' 3 | 4 | export function getContentType(str: string) { 5 | if(str && typeof str === 'string') { 6 | const isMimeType = str.indexOf('/') !== -1 7 | if(isMimeType) { 8 | const mimeConfig = MIME_TYPES_CONFIG[str as MimeType] 9 | return mimeConfig?.charset ? `${str}; charset=${mimeConfig.charset.toLowerCase()}` : false 10 | } else { 11 | const mimeType = getMimeType(str) 12 | if(mimeType && typeof mimeType !== 'boolean') { 13 | const mimeConfig = MIME_TYPES_CONFIG[mimeType as MimeType] 14 | return mimeConfig && mimeConfig.charset ? 15 | `${mimeType}; charset=${mimeConfig.charset.toLowerCase()}` : 16 | mimeType.startsWith('text/') ? 17 | `${mimeType}; charset=utf-8` : 18 | false 19 | }else{ 20 | return false 21 | } 22 | } 23 | } 24 | return false 25 | } 26 | 27 | export function getMimeExt(str: MimeType): string | boolean { 28 | if(str && typeof str === 'string') { 29 | const config = MIME_TYPES_CONFIG[str as MimeType] 30 | return config?.ext?.length ? config.ext[0] : false 31 | } 32 | return false 33 | } 34 | 35 | export function getMimeType(path: string): string | boolean { 36 | return path && typeof path === 'string' && path.trim() !== '' ? EXT_MIME_TYPES[extname('x.' + path.trim()).toLowerCase().substring(1)] || false : false 37 | } 38 | -------------------------------------------------------------------------------- /src/core/utils/oss/escape_html.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * escape-html 3 | * Copyright(c) 2012-2013 TJ Holowaychuk 4 | * Copyright(c) 2015 Andreas Lubbe 5 | * Copyright(c) 2015 Tiancheng "Timothy" Gu 6 | * Copyright (c) 2020 Henry Zhuang 7 | * MIT Licensed 8 | */ 9 | 10 | const matchHtmlRegExp = /["'&<>]/; 11 | 12 | /** 13 | * Escape special characters in the given string of text. 14 | * 15 | * @param {string} string The string to escape for inserting into HTML 16 | * @return {string} 17 | * @public 18 | */ 19 | 20 | export function escapeHtml(string: string): string { 21 | const str = "" + string; 22 | const match = matchHtmlRegExp.exec(str); 23 | 24 | if (!match) { 25 | return str; 26 | } 27 | 28 | let escape; 29 | let html = ""; 30 | let index = 0; 31 | let lastIndex = 0; 32 | 33 | for (index = match.index; index < str.length; index++) { 34 | switch (str.charCodeAt(index)) { 35 | case 34: // " 36 | escape = """; 37 | break; 38 | case 38: // & 39 | escape = "&"; 40 | break; 41 | case 39: // ' 42 | escape = "'"; 43 | break; 44 | case 60: // < 45 | escape = "<"; 46 | break; 47 | case 62: // > 48 | escape = ">"; 49 | break; 50 | default: 51 | continue; 52 | } 53 | 54 | if (lastIndex !== index) { 55 | html += str.substring(lastIndex, index); 56 | } 57 | 58 | lastIndex = index + 1; 59 | html += escape; 60 | } 61 | 62 | return lastIndex !== index ? html + str.substring(lastIndex, index) : html; 63 | } 64 | -------------------------------------------------------------------------------- /src/core/utils/parsers/base64.ts: -------------------------------------------------------------------------------- 1 | import { asBuffer } from './buffer' 2 | 3 | export const encodeBase64 = (buffer: Uint8Array): Uint8Array => { 4 | return Buffer.from(asBuffer(buffer).toString('base64'), 'utf8'); 5 | } 6 | 7 | export const decodeBase64 = (buffer: Uint8Array): Uint8Array => { 8 | return Buffer.from(asBuffer(buffer).toString('utf8'), 'base64'); 9 | } 10 | -------------------------------------------------------------------------------- /src/core/utils/parsers/buffer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts `ArrayBuffer` to string (currently the default one) 3 | * @param buffer ArrayBuffer 4 | * @returns 5 | */ 6 | export function asString(buffer: ArrayBuffer): string { 7 | return String.fromCharCode.apply(null, Array.from(new Uint16Array(buffer))); 8 | } 9 | 10 | /** 11 | * Consistent `Buffer` 12 | * @param input 13 | * @returns 14 | */ 15 | export function asBuffer(input: Buffer | Uint8Array | ArrayBuffer): Buffer { 16 | if (Buffer.isBuffer(input)) { 17 | return input; 18 | } else if (input instanceof ArrayBuffer) { 19 | return Buffer.from(input); 20 | } else { 21 | // Offset & length allow us to support all sorts of buffer views: 22 | return Buffer.from(input.buffer, input.byteOffset, input.byteLength); 23 | } 24 | }; 25 | 26 | /** 27 | * Converts `ArrayBuffer` to string (currently not used) 28 | * @param buffer ArrayBuffer 29 | * @returns 30 | */ 31 | function arrayBufferToString(buffer: Buffer){ 32 | 33 | var bufView = new Uint16Array(buffer); 34 | var length = bufView.length; 35 | var result = ''; 36 | var addition = Math.pow(2,16)-1; 37 | 38 | for(var i = 0;i length){ 41 | addition = length - i; 42 | } 43 | result += String.fromCharCode.apply(null, Array.from(bufView.subarray(i,i+addition))); 44 | } 45 | 46 | return result; 47 | 48 | } 49 | -------------------------------------------------------------------------------- /src/core/utils/parsers/form-data.ts: -------------------------------------------------------------------------------- 1 | import { asString } from './buffer' 2 | 3 | export async function getFormDataFromRequest(request: Request) { 4 | const boundary = getBoundary(request?.headers.get('Content-Type') || '') 5 | if (boundary) { 6 | return await getParsedFormData(request, boundary) 7 | } else { 8 | return {} 9 | } 10 | } 11 | 12 | function getBoundary(header: string) { 13 | var items = header.split(';'); 14 | if (items) { 15 | for (var i = 0; i < items.length; i++) { 16 | var item = new String(items[i]).trim(); 17 | if (item.indexOf('boundary') >= 0) { 18 | var k = item.split('='); 19 | return new String(k[1]).trim().replace(/^["']|["']$/g, ""); 20 | } 21 | } 22 | } 23 | return ''; 24 | } 25 | 26 | const normalizeLf = (str: string) => str.replace(/\r?\n|\r/g, '\r\n') 27 | const escape = (str: string) => normalizeLf(str).replace(/\n/g, '%0A').replace(/\r/g, '%0D').replace(/"/g, '%22') 28 | const replaceTrailingBoundary = (value: string, boundary: string) => { 29 | const boundaryStr = `--${boundary.trim()}--` 30 | return value.replace(boundaryStr, '') 31 | } 32 | 33 | export interface ParsedFileField { 34 | filename: string 35 | type: string, 36 | data: Buffer, 37 | size: number, 38 | } 39 | export type ParsedFormData = Record 40 | 41 | export function isFileField(fieldTuple: [string, string | ParsedFileField ]): fieldTuple is [string, ParsedFileField] { 42 | return typeof fieldTuple[1] !== 'string'; 43 | } 44 | /** 45 | * Get parsed form data 46 | * @param data 47 | * @param boundary 48 | * @param spotText 49 | * @returns 50 | */ 51 | 52 | async function getParsedFormData(request: Request, boundary: string, spotText?: string): Promise { 53 | const _boundary = ' ' + `${boundary}` 54 | const result: Record = {}; 55 | const prefix = `--${_boundary.trim()}\r\nContent-Disposition: form-data; name="` 56 | const data = asString(Buffer.from(await request?.arrayBuffer())) 57 | const multiParts = data.split(prefix).filter( 58 | part => part.includes('"') 59 | ).map( 60 | part => [ 61 | part.substring(0, part.indexOf('"')), 62 | part.slice(part.indexOf('"') + 1, -1) 63 | ] 64 | ) 65 | multiParts.forEach(item => { 66 | if (/filename=".+"/g.test(item[1])) { 67 | const fileNameMatch = item[1].match(/filename=".+"/g) 68 | const contentTypeMatch = item[1].match(/Content-Type:\s.+/g) 69 | if(contentTypeMatch && fileNameMatch) { 70 | result[item[0]] = { 71 | filename: fileNameMatch?.[0].slice(10, -1), 72 | type: contentTypeMatch[0].slice(14), 73 | data: spotText? Buffer.from(item[1].slice(item[1].search(/Content-Type:\s.+/g) + contentTypeMatch[0].length + 4, -4), 'binary'): 74 | Buffer.from(item[1].slice(item[1].search(/Content-Type:\s.+/g) + contentTypeMatch[0].length + 4, -4), 'binary'), 75 | }; 76 | result[item[0]]['size'] = Buffer.byteLength(result[item[0]].data) 77 | } 78 | } else { 79 | result[item[0]] = normalizeLf(replaceTrailingBoundary(item[1], _boundary)).trim() 80 | } 81 | }); 82 | return result 83 | } 84 | -------------------------------------------------------------------------------- /src/core/utils/parsers/html.ts: -------------------------------------------------------------------------------- 1 | import { escapeHtml } from '../oss/escape_html' 2 | export type EscapedString = string & { isEscaped: true } 3 | 4 | export const html = (strings: TemplateStringsArray, ...values: any[]): EscapedString => { 5 | const html = [''] 6 | for (let i = 0, length = strings.length - 1; i < length; i++) { 7 | html[0] += strings[i] 8 | let valueEntries = values[i] instanceof Array ? values[i].flat(Infinity) : [values[i]] 9 | for (let i = 0, len = valueEntries.length; i < len; i++) { 10 | const value = valueEntries[i] 11 | if (typeof value === 'string') { 12 | html[0] += escapeHtml(value) 13 | } else if (typeof value === 'boolean' || value === null || value === undefined) { 14 | continue 15 | } else if ( 16 | (typeof value === 'object' && (value as EscapedString).isEscaped) || 17 | typeof value === 'number' 18 | ) { 19 | html[0] += value 20 | } else if (typeof value === 'function') { 21 | html[0] += value() 22 | } else { 23 | html[0] += escapeHtml(value.toString()) 24 | } 25 | } 26 | } 27 | 28 | const escapedString = new String(html[0]) as EscapedString 29 | escapedString.isEscaped = true 30 | 31 | return escapedString 32 | } 33 | -------------------------------------------------------------------------------- /src/core/utils/parsers/json.ts: -------------------------------------------------------------------------------- 1 | export const jsonSafeParse = (text: string | Record, reviver: ((this: any, key: string, value: any) => any) | undefined = undefined) => { 2 | if (typeof text !== 'string') return text 3 | const firstChar = text[0] 4 | if (firstChar !== '{' && firstChar !== '[' && firstChar !== '"') return text 5 | try { 6 | return JSON.parse(text, reviver) 7 | } catch (e) {} 8 | 9 | return text 10 | } 11 | 12 | export const jsonSafeStringify = (value: any, replacer: any, space: any) => { 13 | try { 14 | return JSON.stringify(value, replacer, space) 15 | } catch (e) {} 16 | return value 17 | } 18 | -------------------------------------------------------------------------------- /src/core/utils/parsers/query-string.ts: -------------------------------------------------------------------------------- 1 | export type ParsedUrlQuery = { 2 | [key: string]: string; 3 | } 4 | /** 5 | * 6 | * @param query 7 | * @returns 8 | */ 9 | 10 | export const parseQueryParams = (searchParamsStr = ''): ParsedUrlQuery => Object.fromEntries(new URLSearchParams(searchParamsStr).entries()) 11 | -------------------------------------------------------------------------------- /src/core/utils/parsers/req-body.ts: -------------------------------------------------------------------------------- 1 | import type { ParsedFormData, ParsedFileField } from './form-data' 2 | import type { ParsedUrlQuery } from './query-string' 3 | import { getFormDataFromRequest } from "./form-data" 4 | import { parseQueryParams } from './query-string' 5 | 6 | export type ParsedBody = string | ParsedUrlQuery | ParsedFormData 7 | 8 | export async function parseBody(request: Request): Promise { 9 | const contentType = request?.headers.get('Content-Type') || '' 10 | if(contentType.includes('application/json')) { 11 | // for `json` type 12 | let body = {} 13 | try { 14 | body = await request!.json() 15 | } catch { 16 | // do nothing 17 | } 18 | return body 19 | } else if (contentType.includes('application/text') || contentType.startsWith('text')) { 20 | // for `application/text` and `text/plain` 21 | return await request!.text() 22 | } else if (contentType.includes('application/x-www-form-urlencoded')) { 23 | const urlEncodedForm = await request.text() 24 | return parseQueryParams(urlEncodedForm) 25 | } else { 26 | return await getFormDataFromRequest(request) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/core/utils/sanitize.ts: -------------------------------------------------------------------------------- 1 | const sanitizeKeyPrefixLeadingNumber = /^([0-9])/ 2 | const sanitizeKeyRemoveDisallowedChar = /[^a-zA-Z0-9]+/g 3 | 4 | export const sanitizeKey = (key: string) => { 5 | return key 6 | .replace(sanitizeKeyPrefixLeadingNumber, '_$1') 7 | .replace(sanitizeKeyRemoveDisallowedChar, '_') 8 | } 9 | 10 | export const sanitzeValue = (value: string) => { 11 | return value 12 | .replace(/&/g, "&") 13 | .replace(//g, ">") 15 | .replace(/"/g, """) 16 | .replace(/'/g, "'"); 17 | } 18 | -------------------------------------------------------------------------------- /src/core/utils/types.ts: -------------------------------------------------------------------------------- 1 | type ReplaceOptions = { 2 | all?: boolean; 3 | }; 4 | 5 | export type Replace< 6 | Input extends string, 7 | Search extends string, 8 | Replacement extends string, 9 | Options extends ReplaceOptions = {}, 10 | > = Input extends `${infer Head}${Search}${infer Tail}` 11 | ? Options['all'] extends true 12 | ? `${Head}${Replacement}${Replace}` 13 | : `${Head}${Replacement}${Tail}` 14 | : Input; 15 | 16 | export type DecorateAsObject< 17 | Input extends string, 18 | Search extends string, 19 | Replacement extends string, 20 | > = Input extends `${infer Head}${Search}${infer Tail}` 21 | ? `${Head}${Replace}` 22 | : Input; 23 | 24 | export type Split< 25 | S extends string, 26 | Delimiter extends string, 27 | > = S extends `${infer Head}${Delimiter}${infer Tail}` 28 | ? [Head, ...Split] 29 | : S extends Delimiter 30 | ? [] 31 | : [S]; 32 | 33 | export type ObjectFromList, S = string> = { 34 | [K in (L extends ReadonlyArray ? U : never)]: S 35 | }; 36 | 37 | export type KeysAsList = K extends `${infer Head}-${infer Tail}` ? [ Head, Tail ] : K extends `${infer Head}.${infer Tail}` ? [ Head, Tail ] : [K] 38 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './core/server' 2 | -------------------------------------------------------------------------------- /src/middlewares/README.md: -------------------------------------------------------------------------------- 1 | ## `Zarf` middlewares 2 | 3 | Soon this folder is gonna be populated with tasty, slick, `Zarf` middlewares 4 | -------------------------------------------------------------------------------- /src/middlewares/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zarfjs/zarf/56bd2fafd61729b85a7b054654da1fba2ff62f3d/src/middlewares/index.ts -------------------------------------------------------------------------------- /src/middlewares/mw-body-parser.ts: -------------------------------------------------------------------------------- 1 | import type { MiddlewareFunctionInitializer } from '../core/middleware' 2 | import type { ParsedFileField, ParsedFormData } from '../core/utils/parsers/form-data' 3 | import { parseBody } from '../core/utils/parsers/req-body' 4 | import { isObject } from '../core/utils/is' 5 | import { ZarfUnprocessableEntityError } from '~/core/errors' 6 | 7 | type MiddlewareOptions = { 8 | extensions?: Array, 9 | maxSizeBytes?: number, 10 | maxFileSizeBytes?: number 11 | } 12 | 13 | const MW_OPTION_DEFAULTS: MiddlewareOptions = { 14 | extensions: [], 15 | maxSizeBytes: Number.MAX_SAFE_INTEGER, 16 | maxFileSizeBytes: Number.MAX_SAFE_INTEGER 17 | } 18 | 19 | function isFileField(fieldTuple: [string, string | ParsedFileField ]): fieldTuple is [string, ParsedFileField] { 20 | return typeof fieldTuple[1] !== 'string'; 21 | } 22 | 23 | function validateFormData(formData: ParsedFormData, options: Required) { 24 | const { maxFileSizeBytes, maxSizeBytes, extensions } = options 25 | const fileFields = Object.entries(formData).filter(isFileField) 26 | let totalBytes = 0; 27 | let errors: Record = {} 28 | for (const [_, file] of fileFields) { 29 | totalBytes += file.size; 30 | if (file.size > maxFileSizeBytes!) { 31 | if(!errors['size']) errors['size'] = {} 32 | errors['size'] = { 33 | [file.filename]: { 34 | size: file.size, 35 | allowed: maxFileSizeBytes, 36 | message: `Unsupported file upload size: ${file.size} bytes, for file: ${file.filename} (maximum: ${maxFileSizeBytes} bytes).` 37 | } 38 | } 39 | } 40 | 41 | if (extensions.length) { 42 | const fileExt = (file.filename || '').split(".").pop()! 43 | if(!extensions.includes(fileExt)) { 44 | if(!errors['ext']) errors['ext'] = {} 45 | errors['ext'] = { 46 | [file.filename]: { 47 | ext: fileExt, 48 | allowed: extensions, 49 | message: `Unsupported file extension: ${fileExt} (allowed: ${extensions}).` 50 | } 51 | } 52 | } 53 | } 54 | } 55 | if (totalBytes > maxSizeBytes!) { 56 | if(!errors['size']) errors['size'] = {} 57 | if(!errors['size']['total']) errors['size']['total'] = {} 58 | errors['size']['total'] = { 59 | size: totalBytes, 60 | allowed: maxSizeBytes, 61 | message: `Unspported total upload size: ${totalBytes} bytes (maximum: ${maxSizeBytes} bytes).` 62 | } 63 | } 64 | return errors 65 | } 66 | 67 | 68 | export const bodyParser: MiddlewareFunctionInitializer = (opts) => { 69 | const options = { ...MW_OPTION_DEFAULTS, ...opts } 70 | return async (ctx, next) => { 71 | if(ctx.method === 'post' || ctx.method === 'put' || ctx.method === 'patch') { 72 | const body = await parseBody(ctx.request!) 73 | if(isObject(body)) { 74 | // @ts-ignore 75 | const errors = validateFormData(body, options) 76 | if (Object.keys(errors).length) { 77 | throw new ZarfUnprocessableEntityError(errors); 78 | } 79 | } 80 | ctx.body = body 81 | } 82 | await next() 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/middlewares/mw-cors.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareFunctionInitializer } from '../core/middleware' 2 | 3 | const getOrigin = (reqOrigin: string, options: MiddlewareOptions) => { 4 | if(options.origins) { 5 | const { origins, credentials } = options 6 | if (origins.length > 0) { 7 | if (reqOrigin && origins.includes(reqOrigin)) { 8 | return reqOrigin 9 | } else { 10 | return origins[0] 11 | } 12 | } else { 13 | if (reqOrigin && credentials && origins[0] === '*') { 14 | return reqOrigin 15 | } 16 | return origins[0] 17 | } 18 | } else { 19 | return MW_OPTION_DEFAULTS.origins![0] 20 | } 21 | } 22 | 23 | type AllowedMethod = typeof MW_OPTION_METHOD_DEFAULTS[number] 24 | type HeaderVaryContent = "Origin" | "User-Agent" | "Accept-Encoding" | "Accept" | "Accept-Language" 25 | type MiddlewareOptions = { 26 | origins?: Array, 27 | credentials?: boolean, 28 | allowedHeaders?: Array, 29 | exposedHeaders?: Array, 30 | methods?: Array, 31 | vary?: HeaderVaryContent, 32 | maxAge?: number, 33 | preflightContinue?: boolean, 34 | cacheControl?: string 35 | } 36 | 37 | const MW_OPTION_METHOD_DEFAULTS = [ 'GET', 'PUT', 'POST', 'DELETE', 'PATCH', 'OPTIONS' ] as const 38 | const MW_OPTION_DEFAULTS: MiddlewareOptions = { 39 | origins: ['*'], 40 | credentials: false, 41 | allowedHeaders: [], 42 | exposedHeaders: [], 43 | methods: [ ...MW_OPTION_METHOD_DEFAULTS ], 44 | preflightContinue: false 45 | } 46 | 47 | const HEADERS = { 48 | ACAC: 'Access-Control-Allow-Credentials', 49 | ACAH: 'Access-Control-Allow-Headers', 50 | ACAM: 'Access-Control-Allow-Methods', 51 | ACAO: 'Access-Control-Allow-Origin', 52 | 53 | ACEH: 'Access-Control-Expose-Headers', 54 | ACRH: 'Access-Control-Request-Headers', 55 | ACRM: 'Access-Control-Request-Methods' 56 | } 57 | 58 | export const cors: MiddlewareFunctionInitializer = (opts) => { 59 | const options = { ...MW_OPTION_DEFAULTS, ...opts } 60 | return async (ctx, next: Function) => { 61 | const headers = ctx.response?.headers || new Map() 62 | 63 | if(headers.has(HEADERS.ACAC)) { 64 | options.credentials = headers.get(HEADERS.ACAC) === 'true' 65 | } 66 | if(options.credentials) ctx.setHeader(HEADERS.ACAC, String(options.credentials)) 67 | 68 | if(options.allowedHeaders && options.allowedHeaders.length && !headers.has(HEADERS.ACAH)) { 69 | ctx.setHeader(HEADERS.ACAH, options.allowedHeaders.join(', ')) 70 | } 71 | if(options.exposedHeaders && options.exposedHeaders.length && !headers.has(HEADERS.ACEH)) { 72 | ctx.setHeader(HEADERS.ACEH, options.exposedHeaders.join(', ')) 73 | } 74 | 75 | if(options.methods && !headers.has(HEADERS.ACAM)) { 76 | ctx.setHeader(HEADERS.ACAM, options.methods.join(',')) 77 | } 78 | 79 | if(options.origins?.length && !headers.has(HEADERS.ACAO)) { 80 | ctx.setHeader(HEADERS.ACAO, getOrigin(ctx.url.origin, options)) 81 | } 82 | 83 | if(headers.has(HEADERS.ACAO) && headers.get(HEADERS.ACAO) !== '*') { 84 | options.vary = 'Origin' 85 | } 86 | if(options.vary && !headers.has('Vary')){ 87 | ctx.setVary(options.vary) 88 | } 89 | 90 | if(options.maxAge && !headers.has('maxAge')) { 91 | ctx.setHeader('maxAge', String(options.maxAge)) 92 | } 93 | 94 | if(ctx.method === 'options') { 95 | 96 | if(options.cacheControl && !headers.has('Cache-Control')) { 97 | ctx.setHeader('Cache-Control', options.cacheControl) 98 | } 99 | 100 | if (options.preflightContinue) { 101 | await next() 102 | } else { 103 | ctx.setHeader('Content-Length', '0') 104 | return ctx.halt(200, '') 105 | } 106 | } else { 107 | await next() 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/middlewares/mw-request-id.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareFunctionInitializer } from '../core/middleware' 2 | 3 | type MiddlewareOptions = { 4 | header?: string, 5 | keygenFunc?: () => string, 6 | } 7 | 8 | const MW_OPTION_DEFAULTS: MiddlewareOptions = { 9 | header: 'X-Request-ID', 10 | keygenFunc: () => crypto.randomUUID() 11 | } 12 | 13 | const REQ_LOCALS_ID = 'request_id' 14 | export type RequestIdLocals = {[REQ_LOCALS_ID]?: string} 15 | 16 | export const requestId: MiddlewareFunctionInitializer = (opts) => { 17 | const { header, keygenFunc } = { ...MW_OPTION_DEFAULTS, ...opts } 18 | return async (ctx, next) => { 19 | if(header && keygenFunc && !ctx.request?.headers?.has(header)){ 20 | const reqId = keygenFunc() 21 | ctx.setHeader(header, reqId) 22 | ctx.locals['request_id'] = reqId 23 | } 24 | await next() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/middlewares/mw-uploads.ts: -------------------------------------------------------------------------------- 1 | import type { MiddlewareFunctionInitializer } from '../core/middleware' 2 | import { isFileField } from '../core/utils/parsers/form-data' 3 | 4 | import * as fs from 'node:fs/promises' 5 | import { constants } from 'node:fs' 6 | import { join, extname, basename } from 'path' 7 | 8 | type Slash = '/' 9 | type MiddlewareOptions = { 10 | path?: '.' | '..' | `${Slash}${string}` 11 | useTempDir?: boolean 12 | useOriginalFileName?: boolean 13 | useLocals?: boolean 14 | } 15 | 16 | const MW_UPLOAD_DIR = 'uploads' 17 | const MW_OPTION_DEFAULTS: MiddlewareOptions = { 18 | useTempDir: false, 19 | useOriginalFileName: false, 20 | useLocals: false 21 | } 22 | 23 | const REQ_LOCALS_ID = 'uploads' 24 | export type RequestIdLocals = {[REQ_LOCALS_ID]?: Record} 25 | 26 | function getRandomizedFileName(filename: string) { 27 | return `${Date.now()}-${crypto.randomUUID()}${extname(filename)}`; 28 | } 29 | 30 | function isValidPath(path: string) { 31 | const _path = path.trim() 32 | return _path === '.' || _path === '..' || basename(_path) 33 | } 34 | 35 | export const uploads: MiddlewareFunctionInitializer = (opts) => { 36 | const options = { ...MW_OPTION_DEFAULTS, ...opts } 37 | return async (ctx, next) => { 38 | // The entire middleware runs only when `body-parser` has run before. 39 | // `body-parser` middleware already makes sure that `ctx.body` is 40 | // populated only when the request is one of the `POST`ables 41 | if(ctx.body) { 42 | const uploadDir = options.path && isValidPath(options.path) ? join(options.path, MW_UPLOAD_DIR) : options.useTempDir && process.env.TMPDIR ? join(process.env.TMPDIR, MW_UPLOAD_DIR) : join(process.cwd(), MW_UPLOAD_DIR) 43 | 44 | try { 45 | await fs.access(uploadDir, constants.R_OK) 46 | } catch(e: any) { 47 | if(e?.code && e.code === 'ENOENT') { 48 | await fs.mkdir(uploadDir) 49 | } else { 50 | throw new Error('Upload middleware cannot proceed') 51 | } 52 | } 53 | const locals: Record = {} 54 | Object.entries(ctx.body).filter(isFileField).forEach(async([name, file]) => { 55 | if(file.filename) { 56 | const filename = options.useOriginalFileName ? file.filename : getRandomizedFileName(file.filename) 57 | const filepath = join(uploadDir, filename) 58 | if(options.useLocals) locals[name] = filepath 59 | await Bun.write(filepath, new Blob([ file.data ], { 60 | type: file.type 61 | })) 62 | } 63 | }) 64 | if(options.useLocals) ctx.locals[REQ_LOCALS_ID] = locals 65 | } else { 66 | console.debug(`Please configure body parser module to run before`) 67 | } 68 | await next() 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zarfjs/zarf/56bd2fafd61729b85a7b054654da1fba2ff62f3d/tests/.gitkeep -------------------------------------------------------------------------------- /tests/mime.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "bun:test"; 2 | import { getMimeType, getContentType, getMimeExt } from '../src/core/utils/mime' 3 | 4 | describe('mime', () => { 5 | describe('getMimeType', () => { 6 | it('.txt should return text/plain', () => { 7 | expect(getMimeType('.txt')).toBe('text/plain') 8 | }) 9 | it('txt should return text/plain', () => { 10 | expect(getMimeType('txt')).toBe('text/plain') 11 | }) 12 | it('file.txt should return text/plain', () => { 13 | expect(getMimeType('file.txt')).toBe('text/plain') 14 | }) 15 | it('.text should return text/plain', () => { 16 | expect(getMimeType('text')).toBe('text/plain') 17 | }) 18 | it('unknown string should return a falsy value', () => { 19 | expect(getMimeType('lorem')).toBe(false) 20 | }) 21 | it('unknown paths should return a falsy value', () => { 22 | expect(getMimeType('lorem/ipsum')).toBe(false) 23 | }) 24 | }) 25 | describe('getContentType', () => { 26 | it('.txt should return text/plain with correct charset', () => { 27 | expect(getContentType('txt')).toBe('text/plain; charset=utf-8') 28 | }) 29 | }) 30 | describe('getMimeExt', () => { 31 | it('text/plain should return txt', () => { 32 | expect(getMimeExt('text/plain')).toBe('txt') 33 | expect(getMimeExt('text/html')).toBe('html') 34 | expect(getMimeExt('application/json')).toBe('json') 35 | 36 | }) 37 | it('text/unknown should return false', () => { 38 | expect(getMimeExt('text/unknown')).toBe(false) 39 | }) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /tests/server.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "bun:test"; 2 | import { Zarf } from '../src/core/server' 3 | 4 | describe('server', () => { 5 | const app = new Zarf() 6 | 7 | app.get('/hello/:name', (ctx, params) => { 8 | return ctx.send(`${params.name}`) 9 | }) 10 | 11 | describe('routes', () => { 12 | it('should return a 200 on found routes', async () => { 13 | const res = await app.fetch('http://localhost/hello/john') 14 | expect(res?.status).toBe(200) 15 | }) 16 | 17 | it('should return a 404 on non-found routes', async () => { 18 | const res = await app.fetch('http://localhost/hello') 19 | expect(res?.status).toBe(404) 20 | expect(await res?.text()).toBe('No matching GET routes discovered for the path: /hello') 21 | }) 22 | 23 | it('should parse the params correctly', async () => { 24 | const res = await app.fetch('http://localhost/hello/john') 25 | expect(await res?.text()).toBe('john') 26 | }) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext"], 4 | "module": "esnext", 5 | "target": "esnext", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "paths": { 9 | "~/*": ["./src/*"] 10 | }, 11 | "types": ["bun-types"], 12 | "outDir": "dist/", 13 | }, 14 | "exclude": [ 15 | "node_modules", 16 | "dist/", 17 | "deno_dist/", 18 | "src/**/*.deno.ts", 19 | "src/**/*.deno.tsx", 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: { 5 | index: 'src/index.ts', 6 | 7 | 'utils/is/index': 'src/core/utils/is.ts', 8 | 'utils/mime/index': 'src/core/utils/mime.ts', 9 | 'utils/sanitize/index': 'src/core/utils/sanitize.ts', 10 | 11 | 'parsers/json/index': 'src/core/utils/parsers/json.ts', 12 | 'parsers/qs/index': 'src/core/utils/parsers/query-string.ts', 13 | 'parsers/body/index': 'src/core/utils/parsers/req-body.ts', 14 | 15 | 'mw/request-id/index': 'src/middlewares/mw-request-id.ts', 16 | 'mw/cors/index': 'src/middlewares/mw-cors.ts', 17 | 'mw/body-parser/index': 'src/middlewares/mw-body-parser.ts', 18 | 'mw/uploads/index': 'src/middlewares/mw-uploads.ts' 19 | }, 20 | format: ['cjs', 'esm'], 21 | dts: true, 22 | clean: true, 23 | treeshake: true 24 | }) 25 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { configDefaults, defineConfig } from 'vitest/config' 3 | 4 | export default defineConfig({ 5 | test: { 6 | exclude: [ ...configDefaults.exclude, './tests/**.test.ts' ] 7 | }, 8 | }) 9 | --------------------------------------------------------------------------------