├── .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 |
83 |
84 | `req.url`
85 |
86 | |
87 |
88 |
89 | Returns the request URL path (`URL.pathname` + `URL.search`).
90 |
91 | |
92 |
93 |
94 |
95 |
96 | `req.origin`
97 |
98 | |
99 |
100 |
101 | Returns the request URL origin (e.g., `http://localhost:3000`).
102 |
103 | |
104 |
105 |
106 |
107 |
108 |
109 | `req.hostname`
110 |
111 | |
112 |
113 |
114 | Returns the request URL hostname (e.g., `localhost`).
115 |
116 | |
117 |
118 |
119 |
120 |
121 | `req.protocol`
122 |
123 | |
124 |
125 |
126 | Returns the request URL protocol (e.g., `http` or `https`).
127 |
128 | |
129 |
130 |
131 |
132 |
133 | `req.query`
134 |
135 | |
136 |
137 |
138 | Maps to the `fetch` request URL's `searchParams` object through a `Proxy`.
139 |
140 | |
141 |
142 |
143 |
144 |
145 | `req.body`
146 |
147 | |
148 |
149 |
150 | The consumed body following the parsing pattern from [this example](https://developers.cloudflare.com/workers/examples/read-post/).
151 |
152 | |
153 |
154 |
155 |
156 |
157 | `req.params`
158 |
159 | |
160 |
161 |
162 | The parsed route params from the internal Radix-tree router, **[radix3](https://github.com/unjs/radix3)**.
163 |
164 | |
165 |
166 |
167 |
168 |
169 | `req.headers`
170 |
171 | |
172 |
173 |
174 | Maps to the `fetch` request `headers` object through a `Proxy`.
175 |
176 | |
177 |
178 |
179 |
180 |
181 | `req.raw`
182 |
183 | |
184 |
185 |
186 | The raw `fetch` request object.
187 |
188 | |
189 |
190 |
191 |
192 |
193 | ### Reply
194 |
195 |
196 |
197 |
198 |
199 | `reply.code(code)`
200 |
201 | |
202 |
203 |
204 | Sets the `fetch` response `status` property.
205 |
206 | |
207 |
208 |
209 |
210 |
211 | `reply.header(key, value)`
212 |
213 | |
214 |
215 |
216 | Adds an individual header to the `fetch` response `headers` object.
217 |
218 | |
219 |
220 |
221 |
222 |
223 | `reply.headers(object)`
224 |
225 | |
226 |
227 |
228 | Adds multiple headers to the `fetch` response `headers` object.
229 |
230 | |
231 |
232 |
233 |
234 |
235 | `reply.getHeader(key)`
236 |
237 | |
238 |
239 |
240 | Retrieves an individual header from `fetch` response `headers` object.
241 |
242 | |
243 |
244 |
245 |
246 |
247 | `reply.getHeaders()`
248 |
249 | |
250 |
251 |
252 | Retrieves all headers from `fetch` response `headers` object.
253 |
254 | |
255 |
256 |
257 |
258 |
259 | `reply.removeHeader(key)`
260 |
261 | |
262 |
263 |
264 | Remove an individual header from `fetch` response `headers` object.
265 |
266 | |
267 |
268 |
269 |
270 |
271 | `reply.hasHeader(header)`
272 |
273 | |
274 |
275 |
276 | Asserts presence of an individual header in the `fetch` response `headers` object.
277 |
278 | |
279 |
280 |
281 |
282 |
283 | `reply.redirect(code, dest)`
284 | `reply.redirect(dest)`
285 |
286 | |
287 |
288 |
289 | Sets the `status` and redirect location for the `fetch` response object.
290 | Defaults to the HTTP **302 Found** response code.
291 |
292 | |
293 |
294 |
295 |
296 |
297 | `reply.type(contentType)`
298 |
299 | |
300 |
301 |
302 | Sets the `content-type` header for the `fetch` response object.
303 |
304 | |
305 |
306 |
307 |
308 |
309 | `reply.send(data)`
310 |
311 | |
312 |
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 | |
321 |
322 |
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 |
--------------------------------------------------------------------------------