├── .eslintignore ├── .eslintrc ├── .gitignore ├── README.md ├── examples ├── bun │ ├── index.js │ └── package.json └── cloudflare │ ├── index.js │ ├── package.json │ └── wrangler.toml ├── index.js ├── package.json ├── reply.js └── request.js /.eslintignore: -------------------------------------------------------------------------------- 1 | example -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | parser: '@babel/eslint-parser', 3 | parserOptions: { 4 | requireConfigFile: false, 5 | ecmaVersion: 2021, 6 | sourceType: 'module', 7 | }, 8 | extends: [ 9 | 'standard', 10 | ], 11 | rules: { 12 | 'semi': ['error', 'always'], 13 | 'comma-dangle': 'off', 14 | 'import/no-absolute-path': 'off', 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | node_modules 3 | dist 4 | bun.lockb 5 | .DS_Store 6 | .idea -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastify-edge 2 | 3 | An experimental **lightweight worker version** of [Fastify](https://fastify.io). 4 | 5 | Currently [**Cloudflare Workers**](https://workers.cloudflare.com/) and [**Bun**](https://bun.sh/) are supported. 6 | 7 | ## Install 8 | 9 | ```js 10 | npm i fastify-edge --save 11 | ```` 12 | 13 | ## Usage: Bun 14 | 15 | ```js 16 | import FastifyEdge from 'fastify-edge/bun' 17 | 18 | const app = FastifyEdge(); 19 | 20 | app.get('/', (_, reply) => { 21 | reply.send('Hello World') 22 | }) 23 | 24 | export default app; 25 | ``` 26 | 27 | See [`examples/bun`](https://github.com/galvez/fastify-edge/tree/main/examples/bun). 28 | 29 | ## Usage: Cloudflare Workers 30 | 31 | ```js 32 | import FastifyEdge from 'fastify-edge' 33 | 34 | const app = FastifyEdge() 35 | 36 | app.get('/', (_, reply) => { 37 | reply.send('Hello World') 38 | }) 39 | ``` 40 | 41 | See [`examples/cloudflare`](https://github.com/galvez/fastify-edge/tree/main/examples/cloudflare) with [`miniflare`](https://github.com/cloudflare/miniflare). 42 | 43 | ## Advanced Example 44 | 45 | ```js 46 | app.addHook('onSend', (req, reply, payload) => { 47 | if (req.url === '/') { 48 | return `${payload} World!` 49 | } 50 | }) 51 | 52 | app.get('/redirect', (_, reply) => { 53 | reply.redirect('/') 54 | }) 55 | 56 | app.get('/route-hook', { 57 | onRequest (_, reply) { 58 | reply.send('Content from onRequest hook') 59 | }, 60 | handler (_, reply) { 61 | reply.type('text/html') 62 | } 63 | }) 64 | ``` 65 | 66 | ## Supported APIs 67 | 68 | ### Server 69 | 70 | - `app.addHook(hook, function)` 71 | - `app.route(settings)` 72 | - `app.get(path, handlerOrSettings)` 73 | - `app.post(path, handlerOrSettings)` 74 | - `app.put(path, handlerOrSettings)` 75 | - `app.delete(path, handlerOrSettings)` 76 | - `app.options(path, handlerOrSettings)` 77 | 78 | ### Request 79 | 80 | 81 | 82 | 87 | 92 | 93 | 94 | 99 | 104 | 105 | 106 | 107 | 112 | 117 | 118 | 119 | 124 | 129 | 130 | 131 | 136 | 141 | 142 | 143 | 148 | 153 | 154 | 155 | 160 | 165 | 166 | 167 | 172 | 177 | 178 | 179 | 184 | 189 | 190 |
83 | 84 | `req.url` 85 | 86 | 88 | 89 | Returns the request URL path (`URL.pathname` + `URL.search`). 90 | 91 |
95 | 96 | `req.origin` 97 | 98 | 100 | 101 | Returns the request URL origin (e.g., `http://localhost:3000`). 102 | 103 |
108 | 109 | `req.hostname` 110 | 111 | 113 | 114 | Returns the request URL hostname (e.g., `localhost`). 115 | 116 |
120 | 121 | `req.protocol` 122 | 123 | 125 | 126 | Returns the request URL protocol (e.g., `http` or `https`). 127 | 128 |
132 | 133 | `req.query` 134 | 135 | 137 | 138 | Maps to the `fetch` request URL's `searchParams` object through a `Proxy`. 139 | 140 |
144 | 145 | `req.body` 146 | 147 | 149 | 150 | The consumed body following the parsing pattern from [this example](https://developers.cloudflare.com/workers/examples/read-post/). 151 | 152 |
156 | 157 | `req.params` 158 | 159 | 161 | 162 | The parsed route params from the internal Radix-tree router, **[radix3](https://github.com/unjs/radix3)**. 163 | 164 |
168 | 169 | `req.headers` 170 | 171 | 173 | 174 | Maps to the `fetch` request `headers` object through a `Proxy`. 175 | 176 |
180 | 181 | `req.raw` 182 | 183 | 185 | 186 | The raw `fetch` request object. 187 | 188 |
191 | 192 | 193 | ### Reply 194 | 195 | 196 | 197 | 202 | 207 | 208 | 209 | 214 | 219 | 220 | 221 | 226 | 231 | 232 | 233 | 238 | 243 | 244 | 245 | 250 | 255 | 256 | 257 | 262 | 267 | 268 | 269 | 274 | 279 | 280 | 281 | 287 | 293 | 294 | 295 | 300 | 305 | 306 | 307 | 312 | 321 | 322 |
198 | 199 | `reply.code(code)` 200 | 201 | 203 | 204 | Sets the `fetch` response `status` property. 205 | 206 |
210 | 211 | `reply.header(key, value)` 212 | 213 | 215 | 216 | Adds an individual header to the `fetch` response `headers` object. 217 | 218 |
222 | 223 | `reply.headers(object)` 224 | 225 | 227 | 228 | Adds multiple headers to the `fetch` response `headers` object. 229 | 230 |
234 | 235 | `reply.getHeader(key)` 236 | 237 | 239 | 240 | Retrieves an individual header from `fetch` response `headers` object. 241 | 242 |
246 | 247 | `reply.getHeaders()` 248 | 249 | 251 | 252 | Retrieves all headers from `fetch` response `headers` object. 253 | 254 |
258 | 259 | `reply.removeHeader(key)` 260 | 261 | 263 | 264 | Remove an individual header from `fetch` response `headers` object. 265 | 266 |
270 | 271 | `reply.hasHeader(header)` 272 | 273 | 275 | 276 | Asserts presence of an individual header in the `fetch` response `headers` object. 277 | 278 |
282 | 283 | `reply.redirect(code, dest)`
284 | `reply.redirect(dest)` 285 | 286 |
288 | 289 | Sets the `status` and redirect location for the `fetch` response object.
290 | Defaults to the HTTP **302 Found** response code. 291 | 292 |
296 | 297 | `reply.type(contentType)` 298 | 299 | 301 | 302 | Sets the `content-type` header for the `fetch` response object. 303 | 304 |
308 | 309 | `reply.send(data)` 310 | 311 | 313 | 314 | Sets the `body` for the `fetch` response object.
315 | 316 | Can be a **string**, an **object**, a **buffer** or a **stream**. 317 | 318 | Objects are automatically serialized as JSON. 319 | 320 |
323 | 324 | ## Supported hooks 325 | 326 | The original Fastify 327 | [`onRequest`](https://www.fastify.io/docs/latest/Reference/Hooks/#onrequest), 328 | [`onSend`](https://www.fastify.io/docs/latest/Reference/Hooks/#onsend) and 329 | [`onResponse`](https://www.fastify.io/docs/latest/Reference/Hooks/#onresponse) are supported. 330 | 331 | Diverging from Fastify, they're all treated as **async functions**. 332 | 333 | They can be set at the **global** and **route** levels. 334 | 335 | ## Limitations 336 | 337 | - No support for `preHandler`, `preParsing` and `preValdation` hooks. 338 | - No support for Fastify's plugin system (yet). 339 | - No support for Fastify's logging and validation facilities. 340 | - Still heavily experimental, more equivalent APIs coming soon. 341 | -------------------------------------------------------------------------------- /examples/bun/index.js: -------------------------------------------------------------------------------- 1 | import FastifyEdge from 'fastify-edge'; 2 | 3 | const app = FastifyEdge(); 4 | 5 | app.addHook('onSend', (req, reply, payload) => { 6 | if (req.url === '/') { 7 | return `${payload} World!`; 8 | } 9 | }); 10 | 11 | app.get('/', (_, reply) => { 12 | reply.send('Hello'); 13 | }); 14 | 15 | app.get('/redirect', (_, reply) => { 16 | reply.redirect('/'); 17 | }); 18 | 19 | app.get('/route-hook', { 20 | onRequest (_, reply) { 21 | reply.send('Content from onRequest hook'); 22 | }, 23 | handler (_, reply) { 24 | reply.type('text/html'); 25 | } 26 | }); 27 | 28 | export default app; 29 | -------------------------------------------------------------------------------- /examples/bun/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "type": "module", 4 | "description": "Fastify Edge Bun example", 5 | "scripts": { 6 | "dev": "bun index.js" 7 | }, 8 | "dependencies": { 9 | "fastify-edge": "^0.0.4" 10 | } 11 | } -------------------------------------------------------------------------------- /examples/cloudflare/index.js: -------------------------------------------------------------------------------- 1 | import FastifyEdge from 'fastify-edge'; 2 | 3 | const app = FastifyEdge(); 4 | 5 | app.addHook('onSend', (req, reply, payload) => { 6 | if (req.url === '/') { 7 | return `${payload} World!`; 8 | } 9 | }); 10 | 11 | app.get('/', (_, reply) => { 12 | reply.send('Hello'); 13 | }); 14 | 15 | app.get('/redirect', (_, reply) => { 16 | reply.redirect('/'); 17 | }); 18 | 19 | app.get('/route-hook', { 20 | onRequest (_, reply) { 21 | reply.send('Content from onRequest hook'); 22 | }, 23 | handler (_, reply) { 24 | reply.type('text/html'); 25 | } 26 | }); 27 | -------------------------------------------------------------------------------- /examples/cloudflare/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "type": "module", 4 | "description": "Fastify Edge example", 5 | "devDependencies": { 6 | "esbuild":"^0.14.38", 7 | "miniflare":"^2.4.0" 8 | }, 9 | "dependencies": { 10 | "fastify-edge": "^0.0.4" 11 | }, 12 | "main":"./dist/index.js", 13 | "scripts":{ 14 | "build":"esbuild --bundle --sourcemap --outdir=dist ./index.js", 15 | "dev":"miniflare --watch --debug" 16 | } 17 | } -------------------------------------------------------------------------------- /examples/cloudflare/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "fastify-edge-example" 2 | type = "javascript" 3 | 4 | account_id = "" 5 | workers_dev = true 6 | route = "" 7 | zone_id = "" 8 | compatibility_date = "2022-04-23" 9 | 10 | [build] 11 | command = "npm run build" 12 | 13 | [build.upload] 14 | format = "service-worker" -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* global addEventListener */ 2 | /* global Response */ 3 | 4 | import { createRouter } from 'radix3'; 5 | import FastifyEdgeRequest, { readBody } from './request.js'; 6 | import FastifyEdgeReply, { kBody, kResponse, kRedirect } from './reply.js'; 7 | 8 | const kHooks = Symbol('kHooks'); 9 | const handleRequest = Symbol('handleRequest'); 10 | const getRoute = Symbol('getRoute'); 11 | const runHooks = Symbol('runHooks'); 12 | const runOnSendHooks = Symbol('runOnSendHooks'); 13 | 14 | const kRouter = Symbol('kRrouter'); 15 | const sendResponse = Symbol('sendResponse'); 16 | 17 | export class FastifyEdge { 18 | [kHooks] = { 19 | onRequest: [], 20 | onSend: [], 21 | onResponse: [], 22 | }; 23 | 24 | [kRouter] = null; 25 | 26 | constructor () { 27 | this[kRouter] = createRouter(); 28 | this.setup(); 29 | } 30 | 31 | setup () { 32 | if (process.env.bun) { 33 | this.fetch = request => this[sendResponse](request); 34 | } else { 35 | addEventListener('fetch', this[handleRequest].bind(this)); 36 | } 37 | } 38 | 39 | [handleRequest] (event) { 40 | event.respondWith(this[sendResponse](event.request)); 41 | } 42 | 43 | async [sendResponse] (request) { 44 | const url = new URL(request.url); 45 | const route = this[kRouter].lookup(url.pathname); 46 | if (!route) { 47 | return new Response('Not found', { 48 | headers: { 'content-type': 'text/plain' }, 49 | status: 404, 50 | }); 51 | } 52 | const req = new FastifyEdgeRequest(request, url, route); 53 | const reply = new FastifyEdgeReply(req); 54 | await req[readBody](); 55 | await this[runHooks](this[kHooks].onRequest, req, reply); 56 | await this[runHooks](route.onRequest, req, reply); 57 | await route.handler(req, reply); 58 | await this[runOnSendHooks](this[kHooks].onSend, req, reply); 59 | await this[runOnSendHooks](route.onSend, req, reply); 60 | await this[runHooks](this[kHooks].onResponse, req, reply); 61 | await this[runHooks](route.onResponse, req, reply); 62 | if (reply[kRedirect]) { 63 | return Response.redirect(...reply[kRedirect]); 64 | } else { 65 | return new Response(reply[kBody], reply[kResponse]); 66 | } 67 | } 68 | 69 | addHook (hook, func) { 70 | this[kHooks][hook].push(func); 71 | } 72 | 73 | route (settings) { 74 | const route = this[getRoute](settings.method, settings.path, settings); 75 | this[kRouter].insert(route.path, route); 76 | } 77 | 78 | get (path, settings) { 79 | const route = this[getRoute]('get', path, settings); 80 | this[kRouter].insert(path, route); 81 | } 82 | 83 | post (path, settings) { 84 | const route = this[getRoute]('post', path, settings); 85 | this[kRouter].insert(path, route); 86 | } 87 | 88 | put (path, settings) { 89 | const route = this[getRoute]('put', path, settings); 90 | this[kRouter].insert(path, route); 91 | } 92 | 93 | delete (path, settings) { 94 | const route = this[getRoute]('delete', path, settings); 95 | this[kRouter].insert(path, route); 96 | } 97 | 98 | options (path, settings) { 99 | const route = this[getRoute]('options', path, settings); 100 | this[kRouter].insert(path, route); 101 | } 102 | 103 | async [runHooks] (run, ...args) { 104 | if (typeof run === 'function') { 105 | run = [run]; 106 | } 107 | if (Array.isArray(run)) { 108 | for (const hook of run) { 109 | await hook(...args); 110 | } 111 | } 112 | } 113 | 114 | async [runOnSendHooks] (run, req, reply) { 115 | let altered; 116 | let payload; 117 | if (typeof run === 'function') { 118 | run = [run]; 119 | } 120 | if (Array.isArray(run)) { 121 | for (const hook of run) { 122 | payload = reply[kBody]; 123 | // eslint-disable-next-line no-cond-assign 124 | if (altered = await hook(req, reply, payload) ?? false) { 125 | reply[kBody] = altered; 126 | } 127 | } 128 | } 129 | } 130 | 131 | [getRoute] (method, path, settings = {}) { 132 | const route = { method, path }; 133 | if (typeof settings === 'function') { 134 | route.handler = settings; 135 | } else { 136 | Object.assign(route, settings); 137 | } 138 | return route; 139 | } 140 | } 141 | 142 | export default (...args) => new FastifyEdge(...args); 143 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastify-edge", 3 | "version": "0.0.4", 4 | "scripts": { 5 | "lint": "eslint . --ext .js --fix" 6 | }, 7 | "exports": { 8 | ".": "./index.js" 9 | }, 10 | "type": "module", 11 | "main": "index.js", 12 | "files": [ 13 | "index.js", 14 | "bun.js", 15 | "reply.js", 16 | "request.js" 17 | ], 18 | "devDependencies": { 19 | "@babel/eslint-parser": "^7.16.0", 20 | "eslint": "^7.28.0", 21 | "eslint-config-standard": "^16.0.2", 22 | "eslint-plugin-import": "^2.22.1", 23 | "eslint-plugin-node": "^11.1.0", 24 | "eslint-plugin-promise": "^4.3.1", 25 | "eslint-plugin-react": "^7.24.0" 26 | }, 27 | "dependencies": { 28 | "radix3": "^0.1.1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /reply.js: -------------------------------------------------------------------------------- 1 | 2 | // https://www.fastify.io/docs/latest/Reference/Reply 3 | // - statusCode ✅ 4 | // - code(statusCode) ✅ 5 | // - header(key, value) ✅ 6 | // - headers(object) ✅ 7 | // - getHeader(key) ✅ 8 | // - getHeaders() ✅ 9 | // - removeHeader(key) ✅ 10 | // - hasHeader(key) ✅ 11 | // - trailer(key, function) ❌ 12 | // - hasTrailer(key) ❌ 13 | // - removeTrailer(key) ❌ 14 | // - redirect([code,] dest) ✅ 15 | // - callNotFound() ❌ 16 | // - getResponseTime() ❌ 17 | // - type(contentType) ✅ 18 | // - serializer(func) ❌ 19 | // - sent ❌ 20 | // - hijack() ❌ 21 | // - type (contentType) ✅ 22 | // - send (data) ✅ 23 | 24 | const kStatusCode = Symbol('kStatusCode'); 25 | const kHeaders = Symbol('kHeaders'); 26 | const kRequest = Symbol('kRequest'); 27 | 28 | const buildRedirectLocation = Symbol('buildRedirectLocation'); 29 | 30 | export const kRedirect = Symbol('kRedirect'); 31 | export const kBody = Symbol('kBody'); 32 | export const kResponse = Symbol('kResponse'); 33 | 34 | export default class FastifyEdgeReply { 35 | [kStatusCode] = 200 36 | 37 | get [kResponse] () { 38 | return { 39 | status: this[kStatusCode], 40 | headers: this[kHeaders], 41 | }; 42 | } 43 | 44 | constructor (req) { 45 | this[kRequest] = req; 46 | this[kHeaders] = {}; 47 | } 48 | 49 | get statusCode () { 50 | return this[kStatusCode]; 51 | } 52 | 53 | set statusCode (statusCode) { 54 | this[kStatusCode] = statusCode; 55 | } 56 | 57 | code (statusCode) { 58 | this[kStatusCode] = statusCode; 59 | } 60 | 61 | header (key, value) { 62 | this[kHeaders][key] = value; 63 | } 64 | 65 | headers (object) { 66 | Object.assign(this[kHeaders], object); 67 | } 68 | 69 | getHeader (key) { 70 | return this[kHeaders][key]; 71 | } 72 | 73 | getHeaders () { 74 | return this[kHeaders]; 75 | } 76 | 77 | removeHeader (key) { 78 | delete this[kHeaders][key]; 79 | } 80 | 81 | hasHeader (key) { 82 | return key in this[kHeaders]; 83 | } 84 | 85 | redirect (...args) { 86 | if (args.length === 1) { 87 | this[kRedirect] = [this[buildRedirectLocation](args[0]), 302]; 88 | } else { 89 | this[kRedirect] = [this[buildRedirectLocation](args[1]), args[0]]; 90 | } 91 | } 92 | 93 | type (contentType) { 94 | this[kHeaders]['content-type'] = contentType; 95 | } 96 | 97 | send (data) { 98 | if (typeof data === 'string') { 99 | if (!('content-type' in this[kHeaders])) { 100 | this[kHeaders]['content-type'] = 'text/plain; charset=utf-8'; 101 | } 102 | this[kBody] = data; 103 | } else if (typeof data === 'object') { 104 | this[kBody] = JSON.stringify(data, null, 2); 105 | } 106 | } 107 | 108 | [buildRedirectLocation] (location) { 109 | if (!location.startsWith('http')) { 110 | return `${this[kRequest].protocol}://${this[kRequest].origin}${location}`; 111 | } 112 | return location; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /request.js: -------------------------------------------------------------------------------- 1 | 2 | // query 3 | // body 4 | // params 5 | // headers 6 | // raw 7 | // req 8 | // server 9 | // id 10 | // log 11 | // ip 12 | // ips 13 | // hostname 14 | // protocol 15 | // method 16 | // url 17 | // routerMethod 18 | // routerPath 19 | // is404 20 | // connection 21 | // socket 22 | // context 23 | 24 | const kBody = Symbol('kBody'); 25 | 26 | export const readBody = Symbol('readBody'); 27 | 28 | export default class FastifyEdgeRequest { 29 | url = null 30 | query = null 31 | body = null 32 | params = null 33 | headers = null 34 | raw = null 35 | constructor (request, url, route) { 36 | this.url = `${url.pathname}${url.search}`; 37 | this.origin = url.origin; 38 | this.hostname = url.hostname; 39 | this.protocol = url.protocol.replace(':', ''); 40 | this.raw = request; 41 | this.query = new Proxy(url.searchParams, { 42 | get: (params, param) => params.get(param), 43 | }); 44 | this.params = route.params; 45 | this.headers = new Proxy(this.raw.headers, { 46 | get: (headers, header) => headers.get(header), 47 | }); 48 | } 49 | 50 | get body () { 51 | return this[kBody] || this.raw.body; 52 | } 53 | 54 | async [readBody] () { 55 | // Mostly adapted from https://developers.cloudflare.com/workers/examples/read-post/ 56 | const { headers } = this.raw; 57 | const contentType = headers.get('content-type') || ''; 58 | if (contentType.includes('application/json')) { 59 | this[kBody] = await this.raw.json(); 60 | } else if (contentType.includes('application/text')) { 61 | this[kBody] = this.raw.text(); 62 | } else if (contentType.includes('text/html')) { 63 | this[kBody] = this.raw.text(); 64 | } else if (contentType.includes('form')) { 65 | const formData = await this.raw.formData(); 66 | const body = {}; 67 | for (const entry of formData.entries()) { 68 | body[entry[0]] = entry[1]; 69 | } 70 | this[kBody] = body; 71 | } 72 | } 73 | } 74 | --------------------------------------------------------------------------------