├── .gitignore
├── LICENSE.txt
├── README.md
├── docs
├── 404.html
├── 404.js
├── GitHub-Mark-64px.png
├── GitHub_Logo.png
├── config.js
├── constants.js
├── contentSection.js
├── fnelements.mjs
├── fnroute.mjs
├── fntags.mjs
├── header.js
├── home.js
├── index.html
├── middleware.js
├── prismCode.js
├── routes.js
├── routing.js
├── spliffy_logo_text_small.png
├── staticFiles.js
└── streaming.js
├── example
├── config.mjs
├── hello
│ ├── serveHello.cjs
│ └── www
│ │ └── index.rt.cjs
├── serve.mjs
├── serverImages
│ ├── .gitignore
│ ├── cat_eating_pancake.jpg
│ └── uploads
│ │ └── .gitkeep.jpg
├── templates
│ └── marquee.cjs
└── www
│ ├── $nested
│ └── $path
│ │ └── $params
│ │ ├── $here.rt.cjs
│ │ └── nestedPathParams.test.cjs
│ ├── $strainName
│ ├── info.rt.cjs
│ └── strainName.test.cjs
│ ├── 404.html
│ ├── big_hubble_pic.test.js
│ ├── big_hubble_pic.tif
│ ├── cookie.rt.cjs
│ ├── cookie.test.js
│ ├── errors.rt.mjs
│ ├── errors.test.js
│ ├── form.rt.cjs
│ ├── form.test.js
│ ├── home.rt.cjs
│ ├── ignoreMe.rt.cjs
│ ├── ignoreMe.test.js
│ ├── ignoreThisDir
│ ├── andThisFile.html
│ ├── andThisRoute.rt.cjs
│ └── ignoreThisDir.test.js
│ ├── images
│ ├── $foo.rt.cjs
│ ├── combTheDesert.gif
│ ├── images.test.js
│ └── logo.spliff
│ ├── index.css
│ ├── index.html
│ ├── index.test.js
│ ├── middleware
│ ├── index.rt.cjs
│ ├── middleware.mw.cjs
│ ├── middleware.test.js
│ ├── secrets
│ │ ├── denied
│ │ │ ├── auth.mw.cjs
│ │ │ ├── index.rt.cjs
│ │ │ ├── middlewareSecretsDenied.test.js
│ │ │ └── treasures
│ │ │ │ ├── middlewareSecretsDeniedTreasures.test.js
│ │ │ │ └── mysecrets.txt
│ │ ├── middlewareSecrets.test.js
│ │ └── mine.rt.cjs
│ └── stuff
│ │ ├── errors
│ │ ├── mad
│ │ │ ├── bad.mw.cjs
│ │ │ ├── index.rt.cjs
│ │ │ └── middlewareStuffErrorsMad.test.js
│ │ ├── middlewareStuffErrors.test.js
│ │ ├── noMoreErrors.mw.cjs
│ │ └── oops.rt.cjs
│ │ ├── index.rt.cjs
│ │ ├── middlewareStuff.test.js
│ │ └── put_only_middleware.mw.cjs
│ ├── module.rt.mjs
│ ├── module.test.js
│ ├── redirect.rt.mjs
│ ├── redirect.test.js
│ ├── roundtrip
│ ├── cheech.html
│ ├── cheechFun.js
│ ├── chong.rt.cjs
│ └── roundtrip.test.js
│ ├── some.js
│ ├── some.test.js
│ ├── strains+.rt.cjs
│ ├── strains.test.js
│ ├── streamReadable.rt.cjs
│ ├── streamReadable.test.js
│ ├── streamRequest.rt.cjs
│ ├── streamRequest.test.js
│ ├── streamWritable.rt.cjs
│ ├── streamWritable.test.js
│ ├── streamWrite.rt.cjs
│ ├── streamWrite.test.js
│ ├── testIgnore
│ ├── cantLoadThis.rt.cjs
│ ├── index.rt.cjs
│ └── testIgnore.test.js
│ ├── upload.js
│ └── websocket
│ ├── index.html
│ ├── ws.js
│ └── ws.rt.js
├── jest.config.cjs
├── jestGlobalSetup.cjs
├── jestGlobalTeardown.cjs
├── package-lock.json
├── package.json
├── spliffy_logo_text_small.png
├── src
├── content-types.mjs
├── content.mjs
├── decorator.mjs
├── errors.mjs
├── handler.mjs
├── httpStatusCodes.mjs
├── index.mjs
├── log.mjs
├── middleware.mjs
├── nodeModuleHandler.mjs
├── routes.mjs
├── server.mjs
├── serverConfig.mjs
├── start.mjs
├── staticHandler.mjs
└── url.mjs
└── testServer.cjs
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .idea
3 | *.iml
4 | .DS_Store
5 | npm-debug.log*
6 | yarn-debug.log*
7 | yarn-error.log*
8 | build
9 | example/**/*.key
10 | example/**/*.cert
11 | coverage
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Robert Kempton
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.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Spliffy
2 |
3 | > directory based routing with js request handlers and static file serving
4 |
5 | ## Getting started
6 | Create a directories for your app
7 |
8 | `mkdir -p ~/app/www`
9 |
10 | Install spliffy
11 |
12 | `cd ~/app && npm install spliffy`
13 |
14 | Create a handler for the desired route name
15 |
16 | `vi ~/app/www/spliffy.js`
17 | ```js
18 | module.exports = {
19 | GET: () => ({hello: "spliffy"})
20 | }
21 |
22 | ```
23 | Create the start script, ```vi ~/app/serve.js```
24 | ```js
25 | require('spliffy')({routeDir: __dirname+ '/www'})
26 | ```
27 |
28 | because the routeDir is ~/app/www, the filename `spliffy.js` creates the path `/spliffy`
29 |
30 | The object passed to spliffy is the config. See the [Config](#Config) section for more information.
31 |
32 | routeDir is the only required property and should be an absolute path.
33 |
34 | `10420` is the default port for http, and can be changed by setting the port in the config
35 |
36 | start the server
37 | `node ~/app/serve.js`
38 |
39 | Go to `localhost:10420/spliffy`
40 |
41 | # [Documentation](https://srfnstack.github.io/spliffy/)
42 |
43 | #### [Examples](https://github.com/narcolepticsnowman/spliffy/tree/master/example)
44 |
--------------------------------------------------------------------------------
/docs/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Not Found
6 |
9 |
10 |
11 |
12 | redirecting...
13 |
14 |
--------------------------------------------------------------------------------
/docs/404.js:
--------------------------------------------------------------------------------
1 | import { div, h3, img } from './fnelements.mjs'
2 |
3 | export default div(
4 | h3('404 Page not found'),
5 | div(img({ src: 'http://placekitten.com/500/500' }))
6 | )
7 |
--------------------------------------------------------------------------------
/docs/GitHub-Mark-64px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SRFNStack/spliffy/79a7b886ea5441dc0f4c601cb81c1a5dbc8f2ce5/docs/GitHub-Mark-64px.png
--------------------------------------------------------------------------------
/docs/GitHub_Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SRFNStack/spliffy/79a7b886ea5441dc0f4c601cb81c1a5dbc8f2ce5/docs/GitHub_Logo.png
--------------------------------------------------------------------------------
/docs/config.js:
--------------------------------------------------------------------------------
1 | import { a, div, li, p, strong, ul } from './fnelements.mjs'
2 | import prismCode from './prismCode.js'
3 |
4 | export default div(
5 | p('These are all of the settings available with example values. You can include just the properties you want to change or all of them.'),
6 | prismCode(`{
7 | port: 10420,
8 | httpsPort: 14420,
9 | httpsKeyFile: "/opt/certs/server.key",
10 | httpsCertFile: "/opt/certs/server.cert",
11 | routeDir: path.join(moduleDirname(import.meta.url), 'www'),
12 | logLevel: 'INFO',
13 | logAccess: true,
14 | logger: require('bunyan').createLogger({name: 'spliffy'}),
15 | routePrefix: "api",
16 | defaultRoute: "/app.js",
17 | notFoundRoute: "/404",
18 | acceptsDefault: "*/*",
19 | defaultContentType: "*/*",
20 | parseCookie: true,
21 | ignoreFilesMatching: ['iHateThisFile.sux'],
22 | allowTestFileRoutes: true,
23 | resolveWithoutExtension: ['.js'],
24 | errorTransformer: ( e, refId ) => e,
25 | contentHandlers: {
26 | 'application/json': {
27 | deserialize: requestBody => JSON.parse(requestBody),
28 | serialize: responseBody => JSON.stringify(responseBody)
29 | }
30 | },
31 | staticContentTypes: {
32 | '.foo': 'application/foo'
33 | },
34 | staticCacheControl: "max-age=86400",
35 | extendIncomingMessage: false,
36 | writeDateHeader: false,
37 | autoOptions: false
38 | }
39 | `, null, '100%'
40 | ),
41 | ul(
42 | li(strong('port'), ': The port for the http server to listen on'),
43 | li(strong('httpsPort'), ': The port to listen on for https'),
44 | li(strong('httpsKeyFile'), ': (Optional for manual config) The path to the key file to use for https'),
45 | li(strong('httpsCertFile'), ': (Optional for manual config) The path to the certificate file to use for https'),
46 | li(strong('routeDir'), ': The directory the routes are contained in, should be an absolute path'),
47 | li(strong('logLevel'),
48 | ': The level at which to log. One of ["ERROR","WARN","INFO","DEBUG"].',
49 | ' Default "INFO". You can use const {log} = require("spliffy") in your handlers'
50 | ),
51 | li(strong('logAccess'), ': Whether to log access to the server or not. Default false.'),
52 | li(strong('logger'), ': A custom logger impl, logLevel and logAccess are ignored if this is provided.'),
53 | li(strong('routePrefix'),
54 | ': A prefix that will be included at the beginning of the path for every request. For example, a request to /foo becomes /routePrefix/foo'),
55 | li(strong('defaultRoute'),
56 | ': The default route to return when the path is not found. Responds with a 200 status. Takes precedence over notFoundRoute. Used for single page apps.'),
57 | li(strong('notFoundRoute'),
58 | ': The route to use for the not found page. Not used if defaultRoute is set. Responds with a 404 status code.'),
59 | li(strong('acceptsDefault'),
60 | ': The default mime type to use when accepting a request body. e({m},/) will convert objects from json by default'),
61 | li(strong('defaultContentType'),
62 | ': The default mime type to use when writing content to a response. will convert objects to json by default '),
63 | li(strong('parseCookie'),
64 | ': Whether to parse cookies on the request, false by default'),
65 | li(strong('ignoreFilesMatching'),
66 | ': A list of file name patterns to ignore when searching for routes. Files ending in .test.js are always ignored unless allowTestRoutes is set to true.'),
67 | li(strong('allowTestFileRoutes'),
68 | ': Allow files ending with .test.js to be considered as routes.'),
69 | li(strong('resolveWithoutExtension'),
70 | ': Add extensions to this list to allow resolving files without their extension. For example, setting [\'.js\'] would cause /foo.js to also be routable as /foo'),
71 | li(strong('errorTransformer'),
72 | ': A function to transform errors to a more user friendly error. A refId is passed as the second argument to help correlate error messages.'),
73 | li(strong('contentHandlers'),
74 | ': Content negotiation handlers keyed by the media type they handle. Media types must be all lower case.',
75 | ul(
76 | li(strong('deserialize'), ': A method to convert the request body to an object'),
77 | li(strong('serialize'), ': A method to convert the response body to a string')
78 | )
79 | ),
80 | li(strong('staticContentTypes'),
81 | ': Custom file extension to content-type mappings. These overwrite default mappings from: ',
82 | a({ href: 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Complete_list_of_MIME_types' },
83 | 'List Of Mime Types')
84 | ),
85 | li(strong('staticCacheControl'), ': Custom value for the Cache-Control header of static files'),
86 | li(strong('decodePathParameters'),
87 | ': run decodeURIComponent(param.replace(/+/g,"%20")) on each path parameter value. false by default.'),
88 | li(strong('staticMode'),
89 | ': if true, the server will only serve static content and will not execute js request handlers. '),
90 | li(strong('decodeQueryParameters'),
91 | ': run decodeURIComponent(param.replace(/+/g,"%20")) on each query parameter key and value. This is disabled by default. The recommended way to send data is via json in a request body.'),
92 | li(strong('cacheStatic'), ': cache static files in memory to increase performance. false by default.'),
93 | li(strong('extendIncomingMessage'), ': Apply the prototype of IncomingMessage to enable middleware that pollutes the prototype (like passportjs), default false.'),
94 | li(strong('writeDateHeader'), ': write a Date header with the server time with ISO format, default false.'),
95 | li(strong('autoOptions'), ': automatically generate options routes for every end point if not provided, default false.')
96 | )
97 | )
98 |
--------------------------------------------------------------------------------
/docs/constants.js:
--------------------------------------------------------------------------------
1 | export const primaryColor = '#058a2b'
2 | export const secondaryColor = '#001d05'
3 |
--------------------------------------------------------------------------------
/docs/contentSection.js:
--------------------------------------------------------------------------------
1 | import { goTo, pathState } from './fntags.mjs'
2 | import { h3, hr, p, section, span } from './fnelements.mjs'
3 |
4 | export default (title, ...content) => section(
5 | h3({ id: title }, title,
6 | span({
7 | style: 'cursor: pointer',
8 | title: title,
9 | onclick: (e) => {
10 | goTo(`${pathState.info.currentRoute}#${encodeURIComponent(title)}`)
11 | }
12 | },
13 | ' \uD83D\uDD17'
14 | )),
15 | ...content.map(c => typeof c === 'string' ? p(c) : c),
16 | hr()
17 | )
18 |
--------------------------------------------------------------------------------
/docs/fnelements.mjs:
--------------------------------------------------------------------------------
1 | import { h, styled } from './fntags.mjs'
2 |
3 | /**
4 | * @type {function(...[*]=): HTMLAnchorElement}
5 | */
6 | export const a = (...children) => h('a', ...children)
7 |
8 | /**
9 | * @type {function(...[*]=): ( ...children) => h}
10 | */
11 | export const abbr = (...children) => h('abbr', ...children)
12 |
13 | /**
14 | * @type {function(...[*]=): ( ...children) => h}
15 | */
16 | export const acronym = (...children) => h('acronym', ...children)
17 |
18 | /**
19 | * @type {function(...[*]=): ( ...children) => h}
20 | */
21 | export const address = (...children) => h('address', ...children)
22 |
23 | /**
24 | * @type {function(...[*]=): HTMLAreaElement}
25 | */
26 | export const area = (...children) => h('area', ...children)
27 |
28 | /**
29 | * @type {function(...[*]=): ( ...children) => h}
30 | */
31 | export const article = (...children) => h('article', ...children)
32 |
33 | /**
34 | * @type {function(...[*]=): ( ...children) => h}
35 | */
36 | export const aside = (...children) => h('aside', ...children)
37 |
38 | /**
39 | * @type {function(...[*]=): HTMLAudioElement}
40 | */
41 | export const audio = (...children) => h('audio', ...children)
42 |
43 | /**
44 | * @type {function(...[*]=): ( ...children) => h}
45 | */
46 | export const b = (...children) => h('b', ...children)
47 |
48 | /**
49 | * @type {function(...[*]=): HTMLBaseElement}
50 | */
51 | export const base = (...children) => h('base', ...children)
52 |
53 | /**
54 | * @type {function(...[*]=): ( ...children) => h}
55 | */
56 | export const bdi = (...children) => h('bdi', ...children)
57 |
58 | /**
59 | * @type {function(...[*]=): ( ...children) => h}
60 | */
61 | export const bdo = (...children) => h('bdo', ...children)
62 |
63 | /**
64 | * @type {function(...[*]=): ( ...children) => h}
65 | */
66 | export const big = (...children) => h('big', ...children)
67 |
68 | /**
69 | * @type {function(...[*]=): HTMLQuoteElement}
70 | */
71 | export const blockquote = (...children) => h('blockquote', ...children)
72 |
73 | /**
74 | * @type {function(...[*]=): HTMLBRElement}
75 | */
76 | export const br = (...children) => h('br', ...children)
77 |
78 | /**
79 | * @type {function(...[*]=): HTMLButtonElement}
80 | */
81 | export const button = (...children) => h('button', ...children)
82 |
83 | /**
84 | * @type {function(...[*]=): HTMLCanvasElement}
85 | */
86 | export const canvas = (...children) => h('canvas', ...children)
87 |
88 | /**
89 | * @type {function(...[*]=): HTMLTableCaptionElement}
90 | */
91 | export const caption = (...children) => h('caption', ...children)
92 |
93 | /**
94 | * @type {function(...[*]=): ( ...children) => h}
95 | */
96 | export const cite = (...children) => h('cite', ...children)
97 |
98 | /**
99 | * @type {function(...[*]=): ( ...children) => h}
100 | */
101 | export const code = (...children) => h('code', ...children)
102 |
103 | /**
104 | * @type {function(...[*]=): HTMLTableColElement}
105 | */
106 | export const col = (...children) => h('col', ...children)
107 |
108 | /**
109 | * @type {function(...[*]=): HTMLTableColElement}
110 | */
111 | export const colgroup = (...children) => h('colgroup', ...children)
112 |
113 | /**
114 | * @type {function(...[*]=): HTMLDataElement}
115 | */
116 | export const data = (...children) => h('data', ...children)
117 |
118 | /**
119 | * @type {function(...[*]=): HTMLDataListElement}
120 | */
121 | export const datalist = (...children) => h('datalist', ...children)
122 |
123 | /**
124 | * @type {function(...[*]=): ( ...children) => h}
125 | */
126 | export const dd = (...children) => h('dd', ...children)
127 |
128 | /**
129 | * @type {function(...[*]=): HTMLModElement}
130 | */
131 | export const del = (...children) => h('del', ...children)
132 |
133 | /**
134 | * @type {function(...[*]=): HTMLDetailsElement}
135 | */
136 | export const details = (...children) => h('details', ...children)
137 |
138 | /**
139 | * @type {function(...[*]=): ( ...children) => h}
140 | */
141 | export const dfn = (...children) => h('dfn', ...children)
142 |
143 | /**
144 | * @type {function(...[*]=): HTMLDialogElement}
145 | */
146 | export const dialog = (...children) => h('dialog', ...children)
147 |
148 | /**
149 | * @type {function(...[*]=): HTMLDivElement}
150 | */
151 | export const div = (...children) => h('div', ...children)
152 |
153 | /**
154 | * @type {function(...[*]=): HTMLDListElement}
155 | */
156 | export const dl = (...children) => h('dl', ...children)
157 |
158 | /**
159 | * @type {function(...[*]=): ( ...children) => h}
160 | */
161 | export const dt = (...children) => h('dt', ...children)
162 |
163 | /**
164 | * @type {function(...[*]=): ( ...children) => h}
165 | */
166 | export const em = (...children) => h('em', ...children)
167 |
168 | /**
169 | * @type {function(...[*]=): HTMLEmbedElement}
170 | */
171 | export const embed = (...children) => h('embed', ...children)
172 |
173 | /**
174 | * @type {function(...[*]=): HTMLFieldSetElement}
175 | */
176 | export const fieldset = (...children) => h('fieldset', ...children)
177 |
178 | /**
179 | * @type {function(...[*]=): ( ...children) => h}
180 | */
181 | export const figcaption = (...children) => h('figcaption', ...children)
182 |
183 | /**
184 | * @type {function(...[*]=): ( ...children) => h}
185 | */
186 | export const figure = (...children) => h('figure', ...children)
187 |
188 | /**
189 | * @type {function(...[*]=): HTMLDivElement}
190 | */
191 | export const flexCol = (...children) => styled(
192 | {
193 | display: 'flex',
194 | 'flex-direction': 'column'
195 | },
196 | 'div',
197 | children
198 | )
199 |
200 | /**
201 | * @type {function(...[*]=): HTMLDivElement}
202 | */
203 | export const flexCenteredCol = (...children) => styled(
204 | {
205 | display: 'flex',
206 | 'flex-direction': 'column',
207 | 'align-items': 'center'
208 | },
209 | 'div',
210 | children
211 | )
212 |
213 | /**
214 | * @type {function(...[*]=): HTMLDivElement}
215 | */
216 | export const flexRow = (...children) => styled(
217 | {
218 | display: 'flex',
219 | 'flex-direction': 'row'
220 | },
221 | 'div',
222 | children
223 | )
224 |
225 | /**
226 | * @type {function(...[*]=): HTMLDivElement}
227 | */
228 | export const flexCenteredRow = (...children) => styled(
229 | {
230 | display: 'flex',
231 | 'flex-direction': 'row',
232 | 'align-items': 'center'
233 | },
234 | 'div',
235 | children
236 | )
237 |
238 | /**
239 | * @type {function(...[*]=): ( ...children) => h}
240 | */
241 | export const footer = (...children) => h('footer', ...children)
242 |
243 | /**
244 | * @type {function(...[*]=): HTMLFormElement}
245 | */
246 | export const form = (...children) => h('form', ...children)
247 |
248 | /**
249 | * @type {function(...[*]=): HTMLFrameElement}
250 | */
251 | export const frame = (...children) => h('frame', ...children)
252 |
253 | /**
254 | * @type {function(...[*]=): HTMLFrameSetElement}
255 | */
256 | export const frameset = (...children) => h('frameset', ...children)
257 |
258 | /**
259 | * @type {function(...[*]=): HTMLHeadingElement}
260 | */
261 | export const h1 = (...children) => h('h1', ...children)
262 |
263 | /**
264 | * @type {function(...[*]=): HTMLHeadingElement}
265 | */
266 | export const h2 = (...children) => h('h2', ...children)
267 |
268 | /**
269 | * @type {function(...[*]=): HTMLHeadingElement}
270 | */
271 | export const h3 = (...children) => h('h3', ...children)
272 |
273 | /**
274 | * @type {function(...[*]=): HTMLHeadingElement}
275 | */
276 | export const h4 = (...children) => h('h4', ...children)
277 |
278 | /**
279 | * @type {function(...[*]=): HTMLHeadingElement}
280 | */
281 | export const h5 = (...children) => h('h5', ...children)
282 |
283 | /**
284 | * @type {function(...[*]=): HTMLHeadingElement}
285 | */
286 | export const h6 = (...children) => h('h6', ...children)
287 |
288 | /**
289 | * @type {function(...[*]=): ( ...children) => h}
290 | */
291 | export const header = (...children) => h('header', ...children)
292 |
293 | /**
294 | * @type {function(...[*]=): HTMLHRElement}
295 | */
296 | export const hr = (...children) => h('hr', ...children)
297 |
298 | /**
299 | * @type {function(...[*]=): ( ...children) => h}
300 | */
301 | export const i = (...children) => h('i', ...children)
302 |
303 | /**
304 | * @type {function(...[*]=): HTMLIFrameElement}
305 | */
306 | export const iframe = (...children) => h('iframe', ...children)
307 |
308 | /**
309 | * @type {function(...[*]=): HTMLImageElement}
310 | */
311 | export const img = (...children) => h('img', ...children)
312 |
313 | /**
314 | * @type {function(...[*]=): HTMLInputElement}
315 | */
316 | export const input = (...children) => h('input', ...children)
317 |
318 | /**
319 | * @type {function(...[*]=): HTMLModElement}
320 | */
321 | export const ins = (...children) => h('ins', ...children)
322 |
323 | /**
324 | * @type {function(...[*]=): ( ...children) => h}
325 | */
326 | export const kbd = (...children) => h('kbd', ...children)
327 |
328 | /**
329 | * @type {function(...[*]=): HTMLLabelElement}
330 | */
331 | export const label = (...children) => h('label', ...children)
332 |
333 | /**
334 | * @type {function(...[*]=): HTMLLegendElement}
335 | */
336 | export const legend = (...children) => h('legend', ...children)
337 |
338 | /**
339 | * @type {function(...[*]=): HTMLLIElement}
340 | */
341 | export const li = (...children) => h('li', ...children)
342 |
343 | /**
344 | * @type {function(...[*]=): HTMLLinkElement}
345 | */
346 | export const link = (...children) => h('link', ...children)
347 |
348 | /**
349 | * @type {function(...[*]=): ( ...children) => h}
350 | */
351 | export const main = (...children) => h('main', ...children)
352 |
353 | /**
354 | * @type {function(...[*]=): HTMLMapElement}
355 | */
356 | export const map = (...children) => h('map', ...children)
357 |
358 | /**
359 | * @type {function(...[*]=): ( ...children) => h}
360 | */
361 | export const mark = (...children) => h('mark', ...children)
362 |
363 | /**
364 | * The best html element for every occasion.
365 | * @type {function(...[*]=): ( ...children) => h}
366 | */
367 | export const marquee = (...children) => h('marquee', ...children)
368 |
369 | /**
370 | * @type {function(...[*]=): HTMLMenuElement}
371 | */
372 | export const menu = (...children) => h('menu', ...children)
373 |
374 | /**
375 | * @type {function(...[*]=): HTMLMetaElement}
376 | */
377 | export const meta = (...children) => h('meta', ...children)
378 |
379 | /**
380 | * @type {function(...[*]=): HTMLMeterElement}
381 | */
382 | export const meter = (...children) => h('meter', ...children)
383 |
384 | /**
385 | * @type {function(...[*]=): ( ...children) => h}
386 | */
387 | export const nav = (...children) => h('nav', ...children)
388 |
389 | /**
390 | * @type {function(...[*]=): ( ...children) => h}
391 | */
392 | export const noframes = (...children) => h('noframes', ...children)
393 |
394 | /**
395 | * @type {function(...[*]=): ( ...children) => h}
396 | */
397 | export const noscript = (...children) => h('noscript', ...children)
398 |
399 | /**
400 | * @type {function(...[*]=): HTMLObjectElement}
401 | */
402 | export const object = (...children) => h('object', ...children)
403 |
404 | /**
405 | * @type {function(...[*]=): HTMLOListElement}
406 | */
407 | export const ol = (...children) => h('ol', ...children)
408 |
409 | /**
410 | * @type {function(...[*]=): HTMLOptGroupElement}
411 | */
412 | export const optgroup = (...children) => h('optgroup', ...children)
413 |
414 | /**
415 | * @type {function(...[*]=): HTMLOptionElement}
416 | */
417 | export const option = (...children) => h('option', ...children)
418 |
419 | /**
420 | * @type {function(...[*]=): HTMLOutputElement}
421 | */
422 | export const output = (...children) => h('output', ...children)
423 |
424 | /**
425 | * @type {function(...[*]=): HTMLParagraphElement}
426 | */
427 | export const p = (...children) => h('p', ...children)
428 |
429 | /**
430 | * @type {function(...[*]=): HTMLParamElement}
431 | */
432 | export const param = (...children) => h('param', ...children)
433 |
434 | /**
435 | * @type {function(...[*]=): HTMLPictureElement}
436 | */
437 | export const picture = (...children) => h('picture', ...children)
438 |
439 | /**
440 | * @type {function(...[*]=): HTMLPreElement}
441 | */
442 | export const pre = (...children) => h('pre', ...children)
443 |
444 | /**
445 | * @type {function(...[*]=): HTMLProgressElement}
446 | */
447 | export const progress = (...children) => h('progress', ...children)
448 |
449 | /**
450 | * @type {function(...[*]=): HTMLQuoteElement}
451 | */
452 | export const q = (...children) => h('q', ...children)
453 |
454 | /**
455 | * @type {function(...[*]=): ( ...children) => h}
456 | */
457 | export const rp = (...children) => h('rp', ...children)
458 |
459 | /**
460 | * @type {function(...[*]=): ( ...children) => h}
461 | */
462 | export const rt = (...children) => h('rt', ...children)
463 |
464 | /**
465 | * @type {function(...[*]=): ( ...children) => h}
466 | */
467 | export const ruby = (...children) => h('ruby', ...children)
468 |
469 | /**
470 | * @type {function(...[*]=): ( ...children) => h}
471 | */
472 | export const samp = (...children) => h('samp', ...children)
473 |
474 | /**
475 | * @type {function(...[*]=): HTMLScriptElement}
476 | */
477 | export const script = (...children) => h('script', ...children)
478 |
479 | /**
480 | * @type {function(...[*]=): ( ...children) => h}
481 | */
482 | export const section = (...children) => h('section', ...children)
483 |
484 | /**
485 | * @type {function(...[*]=): HTMLSelectElement}
486 | */
487 | export const select = (...children) => h('select', ...children)
488 |
489 | /**
490 | * @type {function(...[*]=): ( ...children) => h}
491 | */
492 | export const small = (...children) => h('small', ...children)
493 |
494 | /**
495 | * @type {function(...[*]=): HTMLSourceElement}
496 | */
497 | export const source = (...children) => h('source', ...children)
498 |
499 | /**
500 | * @type {function(...[*]=): HTMLSpanElement}
501 | */
502 | export const span = (...children) => h('span', ...children)
503 |
504 | /**
505 | * @type {function(...[*]=): ( ...children) => h}
506 | */
507 | export const strong = (...children) => h('strong', ...children)
508 |
509 | /**
510 | * @type {function(...[*]=): HTMLStyleElement}
511 | */
512 | export const style = (...children) => h('style', ...children)
513 |
514 | /**
515 | * @type {function(...[*]=): ( ...children) => h}
516 | */
517 | export const sub = (...children) => h('sub', ...children)
518 |
519 | /**
520 | * @type {function(...[*]=): ( ...children) => h}
521 | */
522 | export const summary = (...children) => h('summary', ...children)
523 |
524 | /**
525 | * @type {function(...[*]=): ( ...children) => h}
526 | */
527 | export const sup = (...children) => h('sup', ...children)
528 |
529 | /**
530 | * @type {function(...[*]=): ( ...children) => h}
531 | */
532 | export const svg = (...children) => h('ns=http://www.w3.org/2000/svg|svg', ...children)
533 |
534 | /**
535 | * @type {function(...[*]=): HTMLTableElement}
536 | */
537 | export const table = (...children) => h('table', ...children)
538 |
539 | /**
540 | * @type {function(...[*]=): HTMLTableSectionElement}
541 | */
542 | export const tbody = (...children) => h('tbody', ...children)
543 |
544 | /**
545 | * @type {function(...[*]=): HTMLTableDataCellElement}
546 | */
547 | export const td = (...children) => h('td', ...children)
548 |
549 | /**
550 | * @type {function(...[*]=): HTMLTemplateElement}
551 | */
552 | export const template = (...children) => h('template', ...children)
553 |
554 | /**
555 | * @type {function(...[*]=): HTMLTextAreaElement}
556 | */
557 | export const textarea = (...children) => h('textarea', ...children)
558 |
559 | /**
560 | * @type {function(...[*]=): HTMLTableSectionElement}
561 | */
562 | export const tfoot = (...children) => h('tfoot', ...children)
563 |
564 | /**
565 | * @type {function(...[*]=): HTMLTableHeaderCellElement}
566 | */
567 | export const th = (...children) => h('th', ...children)
568 |
569 | /**
570 | * @type {function(...[*]=): HTMLTableSectionElement}
571 | */
572 | export const thead = (...children) => h('thead', ...children)
573 |
574 | /**
575 | * @type {function(...[*]=): HTMLTimeElement}
576 | */
577 | export const time = (...children) => h('time', ...children)
578 |
579 | /**
580 | * @type {function(...[*]=): HTMLTitleElement}
581 | */
582 | export const title = (...children) => h('title', ...children)
583 |
584 | /**
585 | * @type {function(...[*]=): HTMLTableRowElement}
586 | */
587 | export const tr = (...children) => h('tr', ...children)
588 |
589 | /**
590 | * @type {function(...[*]=): HTMLTrackElement}
591 | */
592 | export const track = (...children) => h('track', ...children)
593 |
594 | /**
595 | * @type {function(...[*]=): ( ...children) => h}
596 | */
597 | export const tt = (...children) => h('tt', ...children)
598 |
599 | /**
600 | * @type {function(...[*]=): HTMLUListElement}
601 | */
602 | export const ul = (...children) => h('ul', ...children)
603 |
604 | /**
605 | * @type {function(...[*]=): ( ...children) => h}
606 | */
607 | export const use = (...children) => h('ns=http://www.w3.org/2000/svg|use', ...children)
608 |
609 | /**
610 | * @type {function(...[*]=): ( ...children) => h}
611 | */
612 | export const _var = (...children) => h('var', ...children)
613 |
614 | /**
615 | * @type {function(...[*]=): HTMLVideoElement}
616 | */
617 | export const video = (...children) => h('video', ...children)
618 |
619 | /**
620 | * @type {function(...[*]=): ( ...children) => h}
621 | */
622 | export const wbr = (...children) => h('wbr', ...children)
623 |
--------------------------------------------------------------------------------
/docs/fnroute.mjs:
--------------------------------------------------------------------------------
1 | import { fnstate, getAttrs, h, isAttrs, renderNode } from './fntags.mjs'
2 |
3 | /**
4 | * An element that is displayed only if the the current route starts with elements path attribute.
5 | *
6 | * For example,
7 | * route({path: "/proc"},
8 | * div(
9 | * "proc",
10 | * div({path: "/cpuinfo"},
11 | * "cpuinfo"
12 | * )
13 | * )
14 | * )
15 | *
16 | * You can override this behavior by setting the attribute, absolute to any value
17 | *
18 | * route({path: "/usr"},
19 | * div(
20 | * "proc",
21 | * div({path: "/cpuinfo", absolute: true},
22 | * "cpuinfo"
23 | * )
24 | * )
25 | * )
26 | *
27 | * @param children The attributes and children of this element.
28 | * @returns HTMLDivElement
29 | */
30 | export const route = (...children) => {
31 | const attrs = getAttrs(children)
32 | children = children.filter(c => !isAttrs(c))
33 | const routeEl = h('div', attrs)
34 | const display = routeEl.style.display
35 | const path = routeEl.getAttribute('path')
36 | if (!path) {
37 | throw new Error('route must have a string path attribute')
38 | }
39 | routeEl.updateRoute = () => {
40 | while (routeEl.firstChild) {
41 | routeEl.removeChild(routeEl.firstChild)
42 | }
43 | // this forces a re-render on route change
44 | routeEl.append(...children.map(c => renderNode(typeof c === 'function' ? c() : c)))
45 | routeEl.style.display = display
46 | }
47 | return routeEl
48 | }
49 |
50 | /**
51 | * An element that only renders the first route that matches and updates when the route is changed
52 | * The primary purpose of this element is to provide catchall routes for not found pages and path variables
53 | * @param children
54 | */
55 | export const routeSwitch = (...children) => {
56 | const sw = h('div', getAttrs(children))
57 |
58 | return pathState.bindAs(
59 | () => {
60 | while (sw.firstChild) {
61 | sw.removeChild(sw.firstChild)
62 | }
63 | for (const child of children) {
64 | const path = child.getAttribute('path')
65 | if (path) {
66 | const shouldDisplay = shouldDisplayRoute(path, !!child.absolute || child.getAttribute('absolute') === 'true')
67 | if (shouldDisplay) {
68 | updatePathParameters()
69 | child.updateRoute(true)
70 | sw.append(child)
71 | return sw
72 | }
73 | }
74 | }
75 | }
76 | )
77 | }
78 |
79 | function stripParameterValues (currentRoute) {
80 | return removeTrailingSlash(currentRoute.substr(1)).split('/').reduce((res, part) => {
81 | const paramStart = part.indexOf(':')
82 | let value = part
83 | if (paramStart > -1) {
84 | value = part.substr(0, paramStart)
85 | }
86 | return `${res}/${value}`
87 | }, '')
88 | }
89 |
90 | const moduleCache = {}
91 |
92 | export const modRouter = ({ routePath, attrs, onerror, frame, sendRawPath, formatPath }) => {
93 | const container = h('div', attrs || {})
94 | if (!routePath) {
95 | throw new Error('You must provide a root url for modRouter. Routes in the ui will be looked up relative to this url.')
96 | }
97 | const loadRoute = (newPathState) => {
98 | let path = newPathState.currentRoute
99 | if (!sendRawPath) {
100 | path = stripParameterValues(newPathState.currentRoute)
101 | }
102 | if (typeof formatPath === 'function') {
103 | path = formatPath(path)
104 | }
105 | const filePath = path ? routePath + ensureOnlyLeadingSlash(path) : routePath
106 |
107 | const p = moduleCache[filePath] ? Promise.resolve(moduleCache[filePath]) : import(filePath).then(m => { moduleCache[filePath] = m })
108 |
109 | p.then(module => {
110 | const route = module.default
111 | if (route) {
112 | while (container.firstChild) {
113 | container.removeChild(container.firstChild)
114 | }
115 | let node = renderNode(route)
116 | if (typeof frame === 'function') {
117 | node = renderNode(frame(node, module))
118 | }
119 | if (node) {
120 | container.append(node)
121 | }
122 | }
123 | })
124 | .catch(err => {
125 | while (container.firstChild) {
126 | container.removeChild(container.firstChild)
127 | }
128 | if (typeof onerror === 'function') {
129 | err = onerror(err, newPathState)
130 | if (err) {
131 | container.append(err)
132 | }
133 | } else {
134 | console.error('Failed to load route: ', err)
135 | container.append('Failed to load route.')
136 | }
137 | })
138 | }
139 | listenFor(afterRouteChange, loadRoute)
140 | updatePathParameters()
141 | loadRoute(pathState())
142 | return container
143 | }
144 |
145 | function updatePathParameters () {
146 | const path = pathState().currentRoute
147 | const pathParts = path.split('/')
148 |
149 | const parameters = {
150 | idx: []
151 | }
152 | for (let i = 0; i < pathParts.length; i++) {
153 | const part = pathParts[i]
154 | const paramStart = part.indexOf(':')
155 | if (paramStart > -1) {
156 | const paramName = part.substr(0, paramStart)
157 | const paramValue = part.substr(paramStart + 1)
158 | parameters.idx.push(paramValue)
159 | if (paramName) {
160 | parameters[paramName] = paramValue
161 | }
162 | }
163 | }
164 | pathParameters(parameters)
165 | }
166 |
167 | /**
168 | * A link element that is a link to another route in this single page app
169 | * @param children The attributes of the anchor element and any children
170 | */
171 | export const fnlink = (...children) => {
172 | let context = null
173 | if (children[0] && children[0].context) {
174 | context = children[0].context
175 | }
176 | const a = h('a', ...children)
177 |
178 | const to = a.getAttribute('to')
179 | if (!to) {
180 | throw new Error('fnlink must have a "to" string attribute').stack
181 | }
182 | a.addEventListener('click', (e) => {
183 | e.preventDefault()
184 | e.stopPropagation()
185 | goTo(to, context)
186 | })
187 | a.setAttribute(
188 | 'href',
189 | makePath(to)
190 | )
191 | return a
192 | }
193 |
194 | /**
195 | * A function to navigate to the specified route
196 | * @param route The route to navigate to
197 | * @param context Data related to the route change
198 | * @param replace Whether to replace the state or push it. pushState is used by default.
199 | * @param silent Prevent route change events from being emitted for this route change
200 | */
201 | export const goTo = (route, context, replace = false, silent = false) => {
202 | const newPath = window.location.origin + makePath(route)
203 |
204 | const patch = {
205 | currentRoute: route.split(/[#?]/)[0],
206 | context
207 | }
208 |
209 | const oldPathState = pathState()
210 | const newPathState = Object.assign({}, oldPathState, patch)
211 | if (!silent) {
212 | try {
213 | emit(beforeRouteChange, newPathState, oldPathState)
214 | } catch (e) {
215 | console.log('Path change cancelled', e)
216 | return
217 | }
218 | }
219 | if (replace) {
220 | history.replaceState({}, route, newPath)
221 | } else {
222 | history.pushState({}, route, newPath)
223 | }
224 |
225 | setTimeout(() => {
226 | pathState.assign({
227 | currentRoute: route.split(/[#?]/)[0],
228 | context
229 | })
230 | updatePathParameters()
231 | if (!silent) {
232 | emit(afterRouteChange, newPathState, oldPathState)
233 | }
234 | if (newPath.indexOf('#') > -1) {
235 | const el = document.getElementById(decodeURIComponent(newPath.split('#')[1]))
236 | el && el.scrollIntoView()
237 | } else {
238 | window.scrollTo(0, 0)
239 | }
240 | if (!silent) {
241 | emit(routeChangeComplete, newPathState, oldPathState)
242 | }
243 | })
244 | }
245 |
246 | const ensureOnlyLeadingSlash = (part) => removeTrailingSlash(part.startsWith('/') ? part : '/' + part)
247 |
248 | const removeTrailingSlash = part => part.endsWith('/') && part.length > 1 ? part.slice(0, -1) : part
249 |
250 | export const pathParameters = fnstate({})
251 |
252 | export const pathState = fnstate(
253 | {
254 | rootPath: ensureOnlyLeadingSlash(window.location.pathname),
255 | currentRoute: ensureOnlyLeadingSlash(window.location.pathname),
256 | context: null
257 | })
258 |
259 | export const beforeRouteChange = 'beforeRouteChange'
260 | export const afterRouteChange = 'afterRouteChange'
261 | export const routeChangeComplete = 'routeChangeComplete'
262 | const eventListeners = {
263 | [beforeRouteChange]: [],
264 | [afterRouteChange]: [],
265 | [routeChangeComplete]: []
266 | }
267 |
268 | const emit = (event, newPathState, oldPathState) => {
269 | for (const fn of eventListeners[event]) fn(newPathState, oldPathState)
270 | }
271 |
272 | /**
273 | * Listen for routing events
274 | * @param event a string event to listen for
275 | * @param handler A function that will be called when the event occurs.
276 | * The function receives the new and old pathState objects, in that order.
277 | * @return {function()} a function to stop listening with the passed handler.
278 | */
279 | export const listenFor = (event, handler) => {
280 | if (!eventListeners[event]) {
281 | throw new Error(`Invalid event. Must be one of ${Object.keys(eventListeners)}`)
282 | }
283 | eventListeners[event].push(handler)
284 | return () => {
285 | const i = eventListeners[event].indexOf(handler)
286 | if (i > -1) {
287 | return eventListeners[event].splice(i, 1)
288 | }
289 | }
290 | }
291 |
292 | /**
293 | * Set the root path of the app. This is necessary to make deep linking work in cases where the same html file is served from all paths.
294 | */
295 | export const setRootPath = (rootPath) =>
296 | pathState.assign({
297 | rootPath: ensureOnlyLeadingSlash(rootPath),
298 | currentRoute: ensureOnlyLeadingSlash(window.location.pathname.replace(new RegExp('^' + rootPath), '')) || '/'
299 | })
300 |
301 | window.addEventListener(
302 | 'popstate',
303 | () => {
304 | const oldPathState = pathState()
305 | const patch = {
306 | currentRoute: ensureOnlyLeadingSlash(window.location.pathname.replace(new RegExp('^' + pathState().rootPath), '')) || '/'
307 | }
308 | const newPathState = Object.assign({}, oldPathState, patch)
309 | try {
310 | emit(beforeRouteChange, newPathState, oldPathState)
311 | } catch (e) {
312 | console.trace('Path change cancelled', e)
313 | goTo(oldPathState.currentRoute, oldPathState.context, true, true)
314 | return
315 | }
316 | pathState.assign(patch)
317 | updatePathParameters()
318 | emit(afterRouteChange, newPathState, oldPathState)
319 | emit(routeChangeComplete, newPathState, oldPathState)
320 | }
321 | )
322 |
323 | const makePath = path => (pathState().rootPath === '/' ? '' : pathState().rootPath) + ensureOnlyLeadingSlash(path)
324 |
325 | const shouldDisplayRoute = (route, isAbsolute) => {
326 | const path = makePath(route)
327 | const currPath = window.location.pathname
328 | if (isAbsolute) {
329 | return currPath === path || currPath === (path + '/') || currPath.match((path).replace(/\/\$[^/]+(\/?)/g, '/[^/]+$1') + '$')
330 | } else {
331 | const pattern = path.replace(/\/\$[^/]+(\/|$)/, '/[^/]+$1').replace(/^(.*)\/([^/]*)$/, '$1/?$2([/?#]|$)')
332 | return !!currPath.match(pattern)
333 | }
334 | }
335 |
--------------------------------------------------------------------------------
/docs/fntags.mjs:
--------------------------------------------------------------------------------
1 | /**
2 | * A function to create dom elements with the given attributes and children.
3 | * If an argument is a non-node object it is considered an attributes object, attributes are combined with Object.assign in the order received.
4 | * All standard html attributes can be passed, as well as any other property.
5 | * Strings are added as attributes via setAttribute, functions are added as event listeners, other types are set as properties.
6 | *
7 | * The rest of the arguments will be considered children of this element and appended to it in the same order as passed.
8 | *
9 | * @param tag html tag to use when created the element
10 | * @param children optional attrs and children for the element
11 | * @returns HTMLElement an html element
12 | *
13 |
14 | */
15 | export const h = (tag, ...children) => {
16 | let element
17 | if (tag.startsWith('ns=')) {
18 | element = document.createElementNS(...(tag.slice(3).split('|')))
19 | } else {
20 | element = document.createElement(tag)
21 | }
22 |
23 | if (isAttrs(children[0])) {
24 | const attrs = children.shift()
25 | for (const a in attrs) {
26 | let attr = attrs[a]
27 | if (typeof attr === 'function' && attr.isBoundAttribute) {
28 | attr.init(a, element)
29 | attr = attr()
30 | }
31 | setAttribute(a, attr, element)
32 | }
33 | }
34 | for (const child of children) {
35 | if (Array.isArray(child)) {
36 | for (const c of child) {
37 | element.append(renderNode(c))
38 | }
39 | } else {
40 | element.append(renderNode(child))
41 | }
42 | }
43 | return element
44 | }
45 |
46 | /**
47 | * Create a state object that can be bound to.
48 | * @param initialValue The initial state
49 | * @param mapKey A map function to extract a key from an element in the array. Receives the array value to extract the key from.
50 | * @returns function A function that can be used to get and set the state.
51 | * When getting the state, you get the actual reference to the underlying value. If you perform modifications to the object, be sure to set the value
52 | * when you're done or the changes won't be reflected correctly.
53 | *
54 | * SideNote: this _could_ be implemented such that it returned a clone, however that would add a great deal of overhead, and a lot of code. Thus, the decision
55 | * was made that it's up to the caller to ensure that the fnstate is called whenever there are modifications.
56 | */
57 | export const fnstate = (initialValue, mapKey) => {
58 | const ctx = {
59 | currentValue: initialValue,
60 | observers: [],
61 | bindContexts: [],
62 | selectObservers: {},
63 | nextId: 0,
64 | mapKey,
65 | state (newState) {
66 | if (arguments.length === 0 || (arguments.length === 1 && arguments[0] === ctx.state)) {
67 | return ctx.currentValue
68 | } else {
69 | ctx.currentValue = newState
70 | for (const observer of ctx.observers) {
71 | observer.fn(newState)
72 | }
73 | }
74 | return newState
75 | }
76 | }
77 |
78 | /**
79 | * Bind the values of this state to the given element.
80 | * Values are items/elements of an array.
81 | * If the current value is not an array, this will behave the same as bindAs.
82 | *
83 | * @param parent The parent to bind the children to.
84 | * @param element The element to bind to. If not a function, an update function must be passed
85 | * @param update If passed this will be executed directly when the state of any value changes with no other intervention
86 | */
87 | ctx.state.bindChildren = (parent, element, update) => doBindChildren(ctx, parent, element, update)
88 |
89 | /**
90 | * Bind this state to the given element
91 | *
92 | * @param element The element to bind to. If not a function, an update function must be passed
93 | * @param update If passed this will be executed directly when the state changes with no other intervention
94 | * @returns {(HTMLDivElement|Text)[]|HTMLDivElement|Text}
95 | */
96 | ctx.state.bindAs = (element, update) => doBindAs(ctx, element, update)
97 |
98 | /**
99 | * Bind this state as it's value
100 | *
101 | * @returns {(HTMLDivElement|Text)[]|HTMLDivElement|Text}
102 | */
103 | ctx.state.bindSelf = () => doBindAs(ctx, ctx.state)
104 |
105 | /**
106 | * Bind attribute values to state changes
107 | * @param attribute A function that returns an attribute value
108 | * @returns {function(): *} A function that calls the passed function, with some extra metadata
109 | */
110 | ctx.state.bindAttr = (attribute) => doBindAttr(ctx.state, attribute)
111 |
112 | /**
113 | * Bind style values to state changes
114 | * @param style A function that returns a style's value
115 | * @returns {function(): *} A function that calls the passed function, with some extra metadata
116 | */
117 | ctx.state.bindStyle = (style) => doBindStyle(ctx.state, style)
118 |
119 | /**
120 | * Bind select and deselect to an element
121 | * @param element The element to bind to. If not a function, an update function must be passed
122 | * @param update If passed this will be executed directly when the state changes with no other intervention
123 | */
124 | ctx.state.bindSelect = (element, update) => doBindSelect(ctx, element, update)
125 |
126 | /**
127 | * Bind select and deselect to an attribute
128 | * @param attribute A function that returns an attribute value
129 | * @returns {function(): *} A function that calls the passed function, with some extra metadata
130 | */
131 | ctx.state.bindSelectAttr = (attribute) => doBindSelectAttr(ctx, attribute)
132 |
133 | /**
134 | * Mark the element with the given key as selected. This causes the bound select functions to be executed.
135 | */
136 | ctx.state.select = (key) => doSelect(ctx, key)
137 |
138 | /**
139 | * Get the currently selected key
140 | * @returns {*}
141 | */
142 | ctx.state.selected = () => ctx.selected
143 |
144 | ctx.state.isFnState = true
145 |
146 | /**
147 | * Perform an Object.assign on the current state using the provided update
148 | */
149 | ctx.state.assign = (update) => ctx.state(Object.assign(ctx.currentValue, update))
150 |
151 | /**
152 | * Get a value at the given property path, an error is thrown if the value is not an object
153 | *
154 | * This returns a reference to the real current value. If you perform any modifications to the object, be sure to call setPath after you're done or the changes
155 | * will not be reflected correctly.
156 | */
157 | ctx.state.getPath = (path) => {
158 | if (typeof path !== 'string') {
159 | throw new Error('Invalid path').stack
160 | }
161 | if (typeof ctx.currentValue !== 'object') {
162 | throw new Error('Value is not an object').stack
163 | }
164 | return path
165 | .split('.')
166 | .reduce(
167 | (curr, part) => {
168 | if (part in curr) {
169 | return curr[part]
170 | } else {
171 | return undefined
172 | }
173 | },
174 | ctx.currentValue
175 | )
176 | }
177 |
178 | /**
179 | * Set a value at the given property path
180 | * @param path The JSON path of the value to set
181 | * @param value The value to set the path to
182 | * @param fillWithObjects Whether to non object values with new empty objects.
183 | */
184 | ctx.state.setPath = (path, value, fillWithObjects = false) => {
185 | const s = path.split('.')
186 | const parent = s
187 | .slice(0, -1)
188 | .reduce(
189 | (current, part) => {
190 | if (fillWithObjects && typeof current[part] !== 'object') {
191 | current[part] = {}
192 | }
193 | return current[part]
194 | },
195 | ctx.currentValue
196 | )
197 |
198 | if (parent && typeof parent === 'object') {
199 | parent[s.slice(-1)] = value
200 | ctx.state(ctx.currentValue)
201 | } else {
202 | throw new Error(`No object at path ${path}`).stack
203 | }
204 | }
205 |
206 | /**
207 | * Register a callback that will be executed whenever the state is changed
208 | * @return a function to stop the subscription
209 | */
210 | ctx.state.subscribe = (callback) => doSubscribe(ctx, ctx.observers, callback)
211 |
212 | /**
213 | * Remove all of the observers and optionally reset the value to it's initial value
214 | */
215 | ctx.state.reset = (reInit) => doReset(ctx, reInit, initialValue)
216 |
217 | return ctx.state
218 | }
219 |
220 | function doSubscribe (ctx, list, listener) {
221 | const id = ctx.nextId++
222 | list.push({ id, fn: listener })
223 | return () => {
224 | list.splice(list.findIndex(l => l.id === id), 1)
225 | list = null
226 | }
227 | }
228 |
229 | const subscribeSelect = (ctx, callback) => {
230 | const parentCtx = ctx.state.parentCtx
231 | const key = keyMapper(parentCtx.mapKey, ctx.currentValue)
232 | if (parentCtx.selectObservers[key] === undefined) {
233 | parentCtx.selectObservers[key] = []
234 | }
235 | parentCtx.selectObservers[key].push(callback)
236 | }
237 |
238 | const doBindSelectAttr = function (ctx, attribute) {
239 | const boundAttr = createBoundAttr(attribute)
240 | boundAttr.init = (attrName, element) =>
241 | subscribeSelect(ctx, () => setAttribute(attrName, attribute(), element))
242 | return boundAttr
243 | }
244 |
245 | function createBoundAttr (attr) {
246 | if (typeof attr !== 'function') {
247 | throw new Error('You must pass a function to bindAttr').stack
248 | }
249 | const boundAttr = () => attr()
250 | boundAttr.isBoundAttribute = true
251 | return boundAttr
252 | }
253 |
254 | function doBindAttr (state, attribute) {
255 | const boundAttr = createBoundAttr(attribute)
256 | boundAttr.init = (attrName, element) => state.subscribe(() => setAttribute(attrName, attribute(), element))
257 | return boundAttr
258 | }
259 |
260 | function doBindStyle (state, style) {
261 | if (typeof style !== 'function') {
262 | throw new Error('You must pass a function to bindStyle').stack
263 | }
264 | const boundStyle = () => style()
265 | boundStyle.isBoundStyle = true
266 | boundStyle.init = (styleName, element) => state.subscribe(() => { element.style[styleName] = style() })
267 | return boundStyle
268 | }
269 |
270 | function doReset (ctx, reInit, initialValue) {
271 | ctx.observers = []
272 | ctx.selectObservers = {}
273 | if (reInit) {
274 | ctx.currentValue = initialValue
275 | }
276 | }
277 |
278 | function doSelect (ctx, key) {
279 | const currentSelected = ctx.selected
280 | ctx.selected = key
281 | if (ctx.selectObservers[currentSelected] !== undefined) {
282 | for (const obs of ctx.selectObservers[currentSelected]) obs()
283 | }
284 | if (ctx.selectObservers[ctx.selected] !== undefined) {
285 | for (const obs of ctx.selectObservers[ctx.selected]) obs()
286 | }
287 | }
288 |
289 | function doBindChildren (ctx, parent, element, update) {
290 | parent = renderNode(parent)
291 | if (parent === undefined) {
292 | throw new Error('You must provide a parent element to bind the children to. aka Need Bukkit.').stack
293 | }
294 | if (typeof element !== 'function' && typeof update !== 'function') {
295 | throw new Error('You must pass an update function when passing a non function element').stack
296 | }
297 | if (typeof ctx.mapKey !== 'function') {
298 | console.warn('Using value index as key, may not work correctly when moving items...')
299 | ctx.mapKey = (o, i) => i
300 | }
301 |
302 | if (!Array.isArray(ctx.currentValue)) {
303 | return ctx.state.bindAs(element, update)
304 | }
305 | ctx.currentValue = ctx.currentValue.map(v => v.isFnState ? v : fnstate(v))
306 | ctx.bindContexts.push({ element, update, parent })
307 | ctx.state.subscribe(() => {
308 | if (!Array.isArray(ctx.currentValue)) {
309 | console.warn('A state used with bindChildren was updated to a non array value. This will be converted to an array of 1 and the state will be updated.')
310 | new Promise((resolve) => {
311 | ctx.state([ctx.currentValue])
312 | resolve()
313 | }).catch(e => {
314 | console.error('Failed to update element: ')
315 | console.dir(element)
316 | const err = new Error('Failed to update element')
317 | err.stack += '\nCaused by: ' + e.stack
318 | throw e
319 | })
320 | } else {
321 | reconcile(ctx)
322 | }
323 | })
324 | reconcile(ctx)
325 | return parent
326 | }
327 |
328 | const doBind = function (ctx, element, update, handleUpdate, handleReplace) {
329 | if (typeof element !== 'function' && typeof update !== 'function') {
330 | throw new Error('You must pass an update function when passing a non function element').stack
331 | }
332 | if (typeof update === 'function') {
333 | const boundElement = renderNode(evaluateElement(element, ctx.currentValue))
334 | handleUpdate(boundElement)
335 | return boundElement
336 | } else {
337 | const elCtx = { current: renderNode(evaluateElement(element, ctx.currentValue)) }
338 | handleReplace(elCtx)
339 | return () => elCtx.current
340 | }
341 | }
342 |
343 | const updateReplacer = (ctx, element, elCtx) => () => {
344 | let rendered = renderNode(evaluateElement(element, ctx.currentValue))
345 | if (rendered !== undefined) {
346 | if (elCtx.current.key !== undefined) {
347 | rendered.current.key = elCtx.current.key
348 | }
349 | if (ctx.parentCtx) {
350 | for (const bindContext of ctx.parentCtx.bindContexts) {
351 | bindContext.boundElementByKey[elCtx.current.key] = rendered
352 | }
353 | }
354 | // Perform this action on the next event loop to give the parent a chance to render
355 | new Promise((resolve) => {
356 | elCtx.current.replaceWith(rendered)
357 | elCtx.current = rendered
358 | rendered = null
359 | resolve()
360 | }).catch(e => {
361 | console.error('Failed to replace element with new element')
362 | console.dir(elCtx, rendered)
363 | const err = new Error('Failed to replace element with new element')
364 | err.stack += '\nCaused by: ' + e.stack
365 | throw e
366 | })
367 | }
368 | }
369 |
370 | const doBindSelect = (ctx, element, update) =>
371 | doBind(ctx, element, update,
372 | boundElement =>
373 | subscribeSelect(ctx, () => update(boundElement)),
374 | (elCtx) =>
375 | subscribeSelect(
376 | ctx,
377 | updateReplacer(ctx, element, elCtx)
378 | )
379 | )
380 |
381 | const doBindAs = (ctx, element, update) =>
382 | doBind(ctx, element, update,
383 | boundElement => {
384 | ctx.state.subscribe(() => update(boundElement))
385 | },
386 | (elCtx) =>
387 | ctx.state.subscribe(updateReplacer(ctx, element, elCtx))
388 | )
389 |
390 | /**
391 | * Reconcile the state of the current array value with the state of the bound elements
392 | */
393 | function reconcile (ctx) {
394 | for (const bindContext of ctx.bindContexts) {
395 | if (bindContext.boundElementByKey === undefined) {
396 | bindContext.boundElementByKey = {}
397 | }
398 | arrangeElements(ctx, bindContext)
399 | }
400 | }
401 |
402 | function keyMapper (mapKey, value) {
403 | if (typeof value !== 'object') {
404 | return value
405 | } else if (typeof mapKey !== 'function') {
406 | return 0
407 | } else {
408 | return mapKey(value)
409 | }
410 | }
411 |
412 | function arrangeElements (ctx, bindContext) {
413 | if (ctx.currentValue.length === 0) {
414 | bindContext.parent.textContent = ''
415 | bindContext.boundElementByKey = {}
416 | ctx.selectObservers = {}
417 | return
418 | }
419 |
420 | const keys = {}
421 | const keysArr = []
422 | for (const i in ctx.currentValue) {
423 | let valueState = ctx.currentValue[i]
424 | if (valueState === null || valueState === undefined || !valueState.isFnState) {
425 | valueState = ctx.currentValue[i] = fnstate(valueState)
426 | }
427 | const key = keyMapper(ctx.mapKey, valueState())
428 | if (keys[key]) {
429 | throw new Error('Duplicate keys in a bound array are not allowed.').stack
430 | }
431 | keys[key] = i
432 | keysArr[i] = key
433 | }
434 |
435 | let prev = null
436 | const parent = bindContext.parent
437 |
438 | for (let i = ctx.currentValue.length - 1; i >= 0; i--) {
439 | const key = keysArr[i]
440 | const valueState = ctx.currentValue[i]
441 | let current = bindContext.boundElementByKey[key]
442 | let isNew = false
443 | // ensure the parent state is always set and can be accessed by the child states to lsiten to the selection change and such
444 | if (valueState.parentCtx === undefined) {
445 | valueState.parentCtx = ctx
446 | }
447 | if (current === undefined) {
448 | isNew = true
449 | current = bindContext.boundElementByKey[key] = renderNode(evaluateElement(bindContext.element, valueState))
450 | current.key = key
451 | }
452 | // place the element in the parent
453 | if (prev == null) {
454 | if (!parent.lastChild || parent.lastChild.key !== current.key) {
455 | parent.append(current)
456 | }
457 | } else {
458 | if (prev.previousSibling === null) {
459 | // insertAdjacentElement is faster, but some nodes don't have it (lookin' at you text)
460 | if (prev.insertAdjacentElement !== undefined && current.insertAdjacentElement !== undefined) {
461 | prev.insertAdjacentElement('beforeBegin', current)
462 | } else {
463 | parent.insertBefore(current, prev)
464 | }
465 | } else if (prev.previousSibling.key !== current.key) {
466 | // the previous was deleted all together, so we will delete it and replace the element
467 | if (keys[prev.previousSibling.key] === undefined) {
468 | delete bindContext.boundElementByKey[prev.previousSibling.key]
469 | if (ctx.selectObservers[prev.previousSibling.key] !== undefined && current.insertAdjacentElement !== undefined) {
470 | delete ctx.selectObservers[prev.previousSibling.key]
471 | }
472 | prev.previousSibling.replaceWith(current)
473 | } else if (isNew) {
474 | // insertAdjacentElement is faster, but some nodes don't have it (lookin' at you text)
475 | if (prev.insertAdjacentElement !== undefined) {
476 | prev.insertAdjacentElement('beforeBegin', current)
477 | } else {
478 | parent.insertBefore(current, prev)
479 | }
480 | } else {
481 | // if it's an existing key, replace the current object with the correct object
482 | prev.previousSibling.replaceWith(current)
483 | }
484 | }
485 | }
486 | prev = current
487 | }
488 |
489 | // catch any strays
490 | for (const key in bindContext.boundElementByKey) {
491 | if (keys[key] === undefined) {
492 | bindContext.boundElementByKey[key].remove()
493 | delete bindContext.boundElementByKey[key]
494 | if (ctx.selectObservers[key] !== undefined) {
495 | delete ctx.selectObservers[key]
496 | }
497 | }
498 | }
499 | }
500 |
501 | const evaluateElement = (element, value) => {
502 | if (element.isFnState) {
503 | return element()
504 | } else {
505 | return typeof element === 'function' ? element(value) : element
506 | }
507 | }
508 |
509 | /**
510 | * Convert non objects (objects are assumed to be nodes) to text nodes and allow promises to resolve to nodes
511 | */
512 | export const renderNode = (node) => {
513 | if (node && typeof node === 'object' && node.then === undefined) {
514 | return node
515 | } else if (node && typeof node === 'object' && typeof node.then === 'function') {
516 | const temp = marker()
517 | node.then(el => temp.replaceWith(renderNode(el))).catch(e => console.error('Caught failed node promise.', e))
518 | return temp
519 | } else if (typeof node === 'function') {
520 | return renderNode(node())
521 | } else {
522 | return document.createTextNode(node + '')
523 | }
524 | }
525 |
526 | const booleanAttributes = {
527 | allowfullscreen: true,
528 | allowpaymentrequest: true,
529 | async: true,
530 | autofocus: true,
531 | autoplay: true,
532 | checked: true,
533 | controls: true,
534 | default: true,
535 | disabled: true,
536 | formnovalidate: true,
537 | hidden: true,
538 | ismap: true,
539 | itemscope: true,
540 | loop: true,
541 | multiple: true,
542 | muted: true,
543 | nomodule: true,
544 | novalidate: true,
545 | open: true,
546 | playsinline: true,
547 | readonly: true,
548 | required: true,
549 | reversed: true,
550 | selected: true,
551 | truespeed: true
552 | }
553 |
554 | const setAttribute = function (attrName, attr, element) {
555 | if (attrName === 'value') {
556 | element.setAttribute('value', attr)
557 | // html5 nodes like range don't update unless the value property on the object is set
558 | element.value = attr
559 | } else if (booleanAttributes[attrName]) {
560 | element[attrName] = !!attr
561 | } else if (attrName === 'style' && typeof attr === 'object') {
562 | for (const style in attr) {
563 | if (typeof attr[style] === 'function' && attr[style].isBoundStyle) {
564 | attr[style].init(style, element)
565 | attr[style] = attr[style]()
566 | }
567 | element.style[style] = attr[style] && attr[style].toString()
568 | }
569 | } else if (typeof attr === 'function' && attrName.startsWith('on')) {
570 | element.addEventListener(attrName.substring(2), attr)
571 | } else {
572 | if (attrName.startsWith('ns=')) {
573 | element.setAttributeNS(...(attrName.slice(3).split('|')), attr)
574 | } else {
575 | element.setAttribute(attrName, attr)
576 | }
577 | }
578 | }
579 |
580 | export const isAttrs = (val) => val !== null && typeof val === 'object' && val.nodeType === undefined && !Array.isArray(val) && typeof val.then !== 'function'
581 | /**
582 | * helper to get the attr object
583 | */
584 | export const getAttrs = (children) => Array.isArray(children) && isAttrs(children[0]) ? children[0] : {}
585 |
586 | /**
587 | * A hidden div node to mark your place in the dom
588 | * @returns {HTMLDivElement}
589 | */
590 | const marker = (attrs) => h('div', Object.assign(attrs || {}, { style: 'display:none' }))
591 |
592 | /**
593 | * A function to create an element with a pre-defined style.
594 | * For example, the flex* elements in fnelements.
595 | *
596 | * @param style
597 | * @param tag
598 | * @param children
599 | * @return {*}
600 | */
601 | export const styled = (style, tag, children) => {
602 | const firstChild = children[0]
603 | if (isAttrs(firstChild)) {
604 | children[0].style = Object.assign(style, firstChild.style)
605 | } else {
606 | children.unshift({ style })
607 | }
608 | return h(tag, ...children)
609 | }
610 |
--------------------------------------------------------------------------------
/docs/header.js:
--------------------------------------------------------------------------------
1 | import { blockquote, div, h1, header, img, nav, p } from './fnelements.mjs'
2 | import { primaryColor } from './constants.js'
3 | import { routeNavItems } from './routes.js'
4 | import { goTo } from './fnroute.mjs'
5 |
6 | export default header({ class: 'container text-center' },
7 | div({
8 | class: 'flex-center',
9 | style: 'flex-wrap: wrap; padding-bottom: 10px; cursor: pointer',
10 | onclick: () => goTo('/')
11 | },
12 | h1(
13 | img({
14 | src: 'spliffy_logo_text_small.png',
15 | alt: 'Spliffy',
16 | title: 'Spliffy Logo',
17 | style: 'height: 125px'
18 | })),
19 | blockquote(
20 | p('directory based routing with js request handlers and static file serving')
21 | )
22 | ),
23 | nav({ class: 'flex-center', style: 'border-bottom: solid 1px darkgray; background-color: ' + primaryColor },
24 | div({ class: 'flex-center noselect', style: 'flex-wrap: wrap;' }, ...routeNavItems()))
25 | )
26 |
--------------------------------------------------------------------------------
/docs/home.js:
--------------------------------------------------------------------------------
1 | import { a, b, div, h2, h3, h4, hr, li, p, pre, strong, ul } from './fnelements.mjs'
2 | import prismCode from './prismCode.js'
3 | import { fnlink } from './fnroute.mjs'
4 |
5 | export default div({ class: 'flex-center', style: 'flex-direction: column; font-size: 16px;' },
6 | h2({ id: 'getting-started' }, 'Getting started'),
7 | p('Create a directory for your app'),
8 | p(prismCode('mkdir -p ~/app/www')),
9 | p('Install spliffy'),
10 | p(prismCode('cd ~/app && npm install @srfnstack/spliffy')),
11 | p('Create a handler for the desired route name (a regular js file with the suffix .rt.js) '),
12 | p(prismCode('vi ~/app/www/spliffy.rt.js')),
13 | prismCode(`
14 | module.exports = {
15 | GET:() => ({hello: "spliffy"})
16 | }`),
17 | p('Create the start script, ', prismCode('vi ~/app/serve.js')),
18 | pre(prismCode(`
19 | require('spliffy')({
20 | routeDir: __dirname+ '/www'
21 | })`)),
22 | p('The spliffy.rt.js file in ~/app/www creates the route',
23 | prismCode('/spliffy')),
24 | p('See ', fnlink({ to: '/config' }, 'Config'), ' for a complete set of options.'),
25 | p('routeDir is the only required property and should be an absolute path.'),
26 | p('10420 is the default port for http, and can be changed by setting the port in the config'),
27 | p('start the server', prismCode('node ~/app/serve.js')),
28 | p('Go to ', prismCode('localhost:10420/spliffy')),
29 |
30 | h3({ id: '-examples-https-github-com-narcolepticsnowman-spliffy-tree-master-example-' },
31 | a({ href: 'https://github.com/narcolepticsnowman/spliffy/tree/master/example' }, 'Examples')),
32 | h3({ id: 'js-request-handler' }, 'JS Request Handler'),
33 | pre(prismCode(`module.exports = {
34 | GET: ({url, bodyPromise, headers, req, res}) => {
35 | body: "hello Mr. Marley"
36 | }
37 | }`
38 | )),
39 | p('The exported properties are all caps request methods, any request method is allowed.'),
40 | p('Files named index.rt.js can be created to handle the route of the name of the folder just like in apache.'),
41 | h3({ id: 'handler-arguments' }, 'Handler arguments:'),
42 | ul(
43 | li(strong('url'), ': An object containing path and parameter information about the url',
44 | ul(
45 | li(strong('path'), ': The path of the current request'),
46 | li(strong('query'),
47 | ': An object containing the query parameters. Not decoded by default. This can be configured by setting the decodeQueryParameters to true.'),
48 | li(strong('param'),
49 | ': a function to retrieve a path parameter by name. Not decoded by default. This can be configured by setting the decodePathParameters to true.')
50 | )
51 | ),
52 | li(strong('bodyPromise'), ': A promise that resolves to the request body'),
53 | li(strong('headers'), ': The request headers'),
54 | li(strong('req'), ': A µWebSockets.js request adapted to function like an express request'),
55 | li(strong('res'), ': A µWebSockets.js response adapted to function like an express response')
56 | ),
57 |
58 | h4('Set Cookie'),
59 | p('To set a cookie, use res.setCookie().'),
60 | p('Arguments are passed verbatim to ',
61 | a({ href: 'https://www.npmjs.com/package/cookie#cookieserializename-value-options' },
62 | 'cookie.serialize')
63 | ),
64 | hr(),
65 |
66 | h3({ id: 'handler-return' }, 'What to return from the handler'),
67 | p('The handler can return any kind of data and it will be serialized if there is a serializer for the specified content-type.' +
68 | ' The default, application/json, is used by default when returning an object.'),
69 | p('If the returned value is Falsey, a 200 OK is returned.'),
70 | p('If the returned value is a promise, it will be resolved and handled as usual.'),
71 | p('To set the statusCode, headers, etc, you must either ',
72 | b('return'), 'or ', b('throw'),
73 | ' an object with a body property for the body and optionally one or more of the following properties'),
74 | prismCode(`{
75 | headers: {
76 | "cache-control": 'no-cache'
77 | },
78 | body: {
79 | some: 'object'
80 | },
81 | statusCode: 420,
82 | statusMessage: "Enhance Your Calm"
83 | }`
84 | )
85 | )
86 |
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Spliffy Docs
6 |
7 |
8 |
9 |
11 |
13 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
106 |
107 |
108 |
109 |
--------------------------------------------------------------------------------
/docs/middleware.js:
--------------------------------------------------------------------------------
1 | import { a, div, hr, p, h3, code } from './fnelements.mjs'
2 | import prismCode from './prismCode.js'
3 | export default div(
4 | p(
5 | 'Spliffy supports express.js style middleware and works with some existing express.js middleware. '
6 | ),
7 | p(
8 | a({ href: 'https://www.passportjs.org/' }, 'Passport'), ' and ', a({ href: 'https://github.com/helmetjs/helmet' }, 'Helmet'), ' are supported and their use is encouraged.'
9 | ),
10 | p(
11 | 'Middleware is a function that receives three arguments: ', code('request, response, next. ')
12 | ),
13 | p(
14 | 'The request and response may be modified by middleware and do a lot of fun tricks. '
15 | ),
16 | p(
17 | 'The next function, when called with no arguments, will continue to either the next middleware in the chain, or to the route handler if there is one.'
18 | ),
19 | p(
20 | 'If anything is passed to next(), it\'s considered an error and the request is ended.'
21 | ),
22 | p('To handle errors in middleware, you can add middleware functions that take 4 parameters. ' +
23 | 'The err that was passed to next or thrown is prepended to the usual arguments: ', code('err, request, response, next')),
24 | p(
25 | 'You must either call next() or response.end() in your middleware or the server will hang indefinitely and the client will ultimately timeout.'
26 | ),
27 | p(
28 | 'Middleware can either be added to the application config, exported as a variable on a controller, or a ',
29 | 'file with the extension `.mw.js` can be placed in a route directory and it will be applied to all routes in that folder and all sub-folders.'
30 | ),
31 |
32 | p(
33 | 'The middleware property can either be an array that applies to all methods, or it can be an object that ' +
34 | 'applies only to the specified methods. There is a special ', code({ style: 'font-size: large;' }, 'ALL'),
35 | ' method that can be used to apply middleware to all methods and still provide other middleware for specific methods.'
36 | ),
37 | hr(),
38 | h3('Root config example'),
39 | prismCode(
40 | `
41 | require('spliffy')( {
42 | routeDir: __dirname + '/www',
43 | middleware: [(req, res, next)=>{
44 | console.log("Look at me! I'm in the middle!")
45 | next()
46 | }]
47 | } )
48 | `
49 | ),
50 | h3('Route example'),
51 | prismCode(
52 | `
53 | module.exports = {
54 | middleware: [ ( req, res, next ) => {
55 | if( req.user
56 | && Array.isArray(req.user.roles)
57 | && req.user.roles.indexOf( 'admin' ) > -1
58 | ) {
59 | next()
60 | return
61 | }
62 | res.statusCode = 403
63 | res.statusMessage = 'Forbidden'
64 | res.end()
65 | }],
66 | POST: async ( { req: { user }, url: { param } } ) =>
67 | await doAdminStuff(user, param('adminStuffId'))
68 | }
69 | `
70 | ),
71 | h3('.mw.js file example'),
72 | p('Create a file named ', code({ style: 'font-size: large;' }, 'middleware.mw.js'),
73 | ' (middleware can be anything you want, i.e. ', code({ style: 'font-size: large;' }, 'requiresAuth.mw.js'), '). ' +
74 | 'All middleware files will be applied in no specific order to all routes in the same directory and all routes in sub-directories.'
75 | ),
76 | prismCode(
77 | `
78 | module.exports = {
79 | middleware: {
80 | ALL: [
81 | (req, res, next)=>{
82 | console.log("This middleware applies to everything under " + __dirname)
83 | next()
84 | },
85 | (err, req, res, next)=>{
86 | console.log("Handling error thrown in " + __dirname)
87 | next()
88 | }
89 | ],
90 | PUT: [(req, res, next)=>{
91 | console.log("Put to route inside " + __dirname)
92 | next()
93 | }]
94 | }
95 | }
96 | `
97 | )
98 | )
99 |
--------------------------------------------------------------------------------
/docs/prismCode.js:
--------------------------------------------------------------------------------
1 | import { code, div, pre } from './fnelements.mjs'
2 |
3 | export default (sourceCode, width = '100%') => {
4 | const src = pre({
5 | class: 'language-js',
6 | style: 'font-size: 14px; width: 100%; box-sizing: border-box; box-shadow: 0px 0px 3px 0px rgba(0,0,0,0.75);'
7 | }, code(sourceCode.trim()))
8 |
9 | Prism.highlightElement(src)
10 |
11 | return div({ style: `margin: auto; display: flex; flex-direction: column; padding-bottom: 15px;width: ${width}; max-width: 94vw;` },
12 | src
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/docs/routes.js:
--------------------------------------------------------------------------------
1 | import home from './home.js'
2 | import routing from './routing.js'
3 |
4 | import { fnlink, pathState, route } from './fnroute.mjs'
5 | import streaming from './streaming.js'
6 | import staticFiles from './staticFiles.js'
7 | import config from './config.js'
8 | import middleware from './middleware.js'
9 |
10 | const routes = [
11 | { url: '/', component: home, absolute: true },
12 | { url: '/routing', linkText: 'Routing', component: routing },
13 | { url: '/static', linkText: 'Static Files', component: staticFiles },
14 | { url: '/middleware', linkText: 'Middleware', component: middleware },
15 | { url: '/streaming', linkText: 'Streaming', component: streaming },
16 | { url: '/config', linkText: 'Config', component: config },
17 | // {url: "/reference", linkText: 'Reference', component: reference},
18 | { url: '.*', component: home }
19 | ]
20 |
21 | export const routeElements = () => routes.map((r) => route({ path: r.url, absolute: !!r.absolute }, r.component))
22 | export const routeNavItems = () =>
23 | routes
24 | .filter(r => r.linkText)
25 | .map(
26 | (r) =>
27 | fnlink({
28 | to: r.url,
29 | style: {
30 | cursor: 'pointer',
31 | padding: '12px',
32 | 'font-weight': 400,
33 | 'font-size': '18px',
34 | 'text-decoration': 'none',
35 | color: pathState.bindStyle(() => pathState().currentRoute.startsWith(r.url) ? 'limegreen' : 'inherit')
36 | }
37 | },
38 | r.linkText
39 | )
40 | )
41 |
--------------------------------------------------------------------------------
/docs/routing.js:
--------------------------------------------------------------------------------
1 | import { div, h3, li, p, strong, ul } from './fnelements.mjs'
2 |
3 | export default div(
4 | p('Routes are based entirely on their directory structure much like they are in apache.'),
5 | p('Example dir:'),
6 | ul(
7 | li('www',
8 | ul(
9 | li('strains',
10 | ul(
11 | li('gorillaGlue.rt.js'),
12 | li('blueDream.rt.js'),
13 | li('indica',
14 | ul(
15 | li('index.rt.js')
16 | )
17 | ),
18 | li('sativa',
19 | ul(
20 | li('index.rt.js'),
21 | li('smokeit.rt.js')
22 | )
23 | ),
24 | li('index.rt.js')
25 | )
26 | )
27 | )
28 | )
29 | ),
30 | p('This would create the following route mappings:'),
31 | ul(
32 | li('/strains/ > /www/strains/index.js'),
33 | li('/strains/gorillaGlue > /www/strains/gorillaGlue.rt.js'),
34 | li('/strains/blueDream > /www/strains/blueDream.rt.js'),
35 | li('/strains/indica/ > /www/strains/indica/index.rt.js'),
36 | li('/strains/sativa/ > /www/strains/sativa/index.rt.js'),
37 | li('/strains/sativa/smokeit > /www/strains/sativa/smokeit.rt.js')
38 | ),
39 |
40 | h3({ id: 'path-variables' }, 'Path variables'),
41 | p('You can include path variables by prefixing the folder or file name with a $'),
42 | p('Example dir:'),
43 | ul(
44 | li('www',
45 | ul(
46 | li('strains',
47 | ul(
48 | li('$strainName',
49 | ul(
50 | li('info')
51 | )
52 | )
53 | )
54 | )
55 | )
56 | )
57 | ),
58 | p('would handle:'),
59 | ul(
60 | li('/www/strains/gorillaGlue/info'),
61 | li('/www/strains/blueDream/info')
62 | ),
63 | p('The path parameters are available via the ', strong('param'), ' object on the first argument passed to the handler, pass in the name to get the value'),
64 | p('The variable will be the folder or file name excluding the $, i.e. $strainName -> { strainName: "gorillaGlue"}'),
65 | p('**You can only have on variable file/folder within any given folder. This is because it would be ambiguous which one to use and thus the result couldn\'t be defined. '),
66 |
67 | h3({ id: 'catchall-path' }, 'Catchall path'),
68 | p('You can make a handler handle all requests that start with the given path by appending a + to the file or folder name.'),
69 | p('Example dir:'),
70 | ul(
71 | li('www',
72 | ul(
73 | li('strains+.rt.js')
74 | )
75 | )
76 | ),
77 | p('would handle:'),
78 | ul(
79 | li('/www/strains/gorillaGlue/info/something/more/stuff'),
80 | li('/www/strains/blueDream/dankness/allOfIt')
81 | )
82 | // TODO update docs for middleware
83 |
84 | )
85 |
--------------------------------------------------------------------------------
/docs/spliffy_logo_text_small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SRFNStack/spliffy/79a7b886ea5441dc0f4c601cb81c1a5dbc8f2ce5/docs/spliffy_logo_text_small.png
--------------------------------------------------------------------------------
/docs/staticFiles.js:
--------------------------------------------------------------------------------
1 | import { a, div, p } from './fnelements.mjs'
2 | import { fnlink } from './fnroute.mjs'
3 |
4 | export default div(p('Any non-js files will be served verbatim from disk.'),
5 | p('Any file prefixed with "index." (i.e. index.html, index.txt, index.png) will be served as the default file in the directory they are in.'),
6 | p('The extension determines the content-type of the file for the known types listed at ',
7 | a({ href: 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Complete_list_of_MIME_types' },
8 | 'mozilla.org')
9 | ),
10 | p('You can also add custom extension to content-type mappings by setting the staticContentTypes property on the ', fnlink({ to: '/config' }, 'config'), '.'),
11 | p('GET is the only supported request method for static files, all other request methods will result in a 405 Method Not Allowed.'),
12 | p('ETags will be generated on startup and will be recalculated if the file content changes. The cache-control max-age is set to 10min by default for static files.'),
13 | p('You can configure this with the staticCacheControl property of the ',
14 | fnlink({ to: '/config' }, 'config'),
15 | '.'),
16 | p('You can cache files in memory by setting cacheStatic to true on the config.')
17 | )
18 |
--------------------------------------------------------------------------------
/docs/streaming.js:
--------------------------------------------------------------------------------
1 | import { h3, div, p, code } from './fnelements.mjs'
2 | import prismCode from './prismCode.js'
3 |
4 | export default div(
5 | h3('Streaming Request'),
6 | p(
7 | 'By default the entire request is read into memory. In order to use a readable instead, set the property ',
8 | code('streamRequestBody'), ' to true.'
9 | ),
10 | p(
11 | 'This can be set at the route level to apply to all methods'
12 | ),
13 | prismCode(`
14 | module.exports = {
15 | streamRequestBody: true,
16 | POST: async ( { url: { query: { filename = 'foo.dat' } }, body } ) => promisifiedPipeline(
17 | body,
18 | fs.createWriteStream( path.join( os.homedir(), filename ) )
19 | )
20 | }
21 | `),
22 | p('And at the handler level by setting the handler as an object'),
23 | prismCode(`
24 | module.exports = {
25 | POST: {
26 | streamRequestBody: true,
27 | handler: async ( { url: { query: { filename = 'foo.dat' } }, body } ) => promisifiedPipeline(
28 | body,
29 | fs.createWriteStream( path.join( os.homedir(), filename ) )
30 | )
31 | }
32 | }
33 | `),
34 | h3('Streaming Response'),
35 | p(
36 | 'Instead of returning the body as a whole from the handler, you can stream the response.'
37 | ),
38 | p(
39 | 'Headers are sent before the first write, and cannot be modified after.'
40 | ),
41 | p(
42 | 'The best way is to use ', code('res.asWritable()'), ' to get a stream.Writable in conjunction with pipeline.'
43 | ),
44 | prismCode(`
45 | GET: async ({res}) => promisifiedPipeline(
46 | fs.createReadStream(catEatingPancakePath),
47 | res.getWritable()
48 | )
49 | `),
50 | p('You could also write directly to the response. '),
51 | p('If using this method, you must set ', code('res.streaming=true'), ' before returning from the handler to ensure proper handling. '),
52 | p('You must also ensure that res.end() is called when you\'re done or the request will hang indefinitely and will' +
53 | ' eventually run your process out of memory as more requests are handled.'
54 | ),
55 | prismCode(`
56 | GET: async ({res}) => {
57 | res.headers['Content-Type'] = 'text/html'
58 | res.streaming = true
59 | res.write('')
60 | writeBody(res).finally(()=>{
61 | res.write('')
62 | res.end()
63 | })
64 | }
65 | `)
66 | )
67 |
--------------------------------------------------------------------------------
/example/config.mjs:
--------------------------------------------------------------------------------
1 | import helmet from 'helmet'
2 | import { moduleDirname } from '../src/index.mjs'
3 | import path from 'path'
4 |
5 | const __dirname = moduleDirname(import.meta.url)
6 |
7 | export default () =>
8 | ({
9 | routeDir: path.join(__dirname, 'www'),
10 | port: 11420,
11 | staticContentTypes: {
12 | '.spliff': 'image/png'
13 | },
14 | logAccess: true,
15 | ignoreFilesMatching: ['^ignore', 'cantLoadThis'],
16 | decodeQueryParameters: true,
17 | middleware: [
18 | (req, res, next) => {
19 | res.headers['app-mw-applied'] = true
20 | console.log('Look at me! I\'m in the middle!')
21 | next()
22 | },
23 | helmet()
24 | ],
25 | nodeModuleRoutes: {
26 | nodeModulesPath: path.resolve(__dirname, '../node_modules'),
27 | files: [
28 | 'cookie/index.js',
29 | {
30 | modulePath: 'etag/index.js',
31 | urlPath: '/etag.js'
32 | }
33 | ]
34 | },
35 | printRoutes: true,
36 | logLevel: 'DEBUG',
37 | notFoundRoute: '/404.html',
38 | resolveWithoutExtension: '.js',
39 | cacheStatic: false,
40 | parseCookie: true,
41 | autoOptions: true,
42 | serveRoutesWithSlash: true
43 | })
44 |
--------------------------------------------------------------------------------
/example/hello/serveHello.cjs:
--------------------------------------------------------------------------------
1 | require('../../src/index')(
2 | {
3 | routeDir: require('path').join(__dirname, '/www'),
4 | port: 11420
5 | }
6 | )
7 |
--------------------------------------------------------------------------------
/example/hello/www/index.rt.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | GET: () => 'hello'
3 | }
4 |
--------------------------------------------------------------------------------
/example/serve.mjs:
--------------------------------------------------------------------------------
1 | import spliffy from '../src/index.mjs'
2 | import config from './config.mjs'
3 | spliffy(config())
4 |
--------------------------------------------------------------------------------
/example/serverImages/.gitignore:
--------------------------------------------------------------------------------
1 | **
2 | !cat_eating_pancake.jpg
3 | !uploads/.gitkeep.jpg
--------------------------------------------------------------------------------
/example/serverImages/cat_eating_pancake.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SRFNStack/spliffy/79a7b886ea5441dc0f4c601cb81c1a5dbc8f2ce5/example/serverImages/cat_eating_pancake.jpg
--------------------------------------------------------------------------------
/example/serverImages/uploads/.gitkeep.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SRFNStack/spliffy/79a7b886ea5441dc0f4c601cb81c1a5dbc8f2ce5/example/serverImages/uploads/.gitkeep.jpg
--------------------------------------------------------------------------------
/example/templates/marquee.cjs:
--------------------------------------------------------------------------------
1 | module.exports = (message, scrollamount = 30) => ``
2 |
--------------------------------------------------------------------------------
/example/www/$nested/$path/$params/$here.rt.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | GET: ({ url: { param } }) => `${param('nested')} ${param('path')} ${param('params')} ${param('here')}, never gonna let you down`
3 | }
4 |
--------------------------------------------------------------------------------
/example/www/$nested/$path/$params/nestedPathParams.test.cjs:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 |
3 | describe('nest path params', () => {
4 | it('should call the right route and pass params', async () => {
5 | const res = await fetch('http://localhost:11420/never/gonna/give%20you/up')
6 | const body = await res.text()
7 | expect(res.status).toEqual(200)
8 | expect(res.headers.get('content-type')).toEqual('text/plain')
9 | expect(body).toBe('never gonna give you up, never gonna let you down')
10 | })
11 | })
12 |
--------------------------------------------------------------------------------
/example/www/$strainName/info.rt.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | GET: ({ url }) => url.param('strainName') + ' is dank'
3 | }
4 |
--------------------------------------------------------------------------------
/example/www/$strainName/strainName.test.cjs:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 |
3 | describe('path param dir', () => {
4 | it('should get the $strainName end point correctly', async () => {
5 | const res = await fetch('http://localhost:11420/gorrila%20glue/info')
6 | const body = await res.text()
7 | expect(res.status).toEqual(200)
8 | expect(res.headers.get('content-type')).toEqual('text/plain')
9 | expect(body).toBe('gorrila glue is dank')
10 | })
11 | })
12 |
--------------------------------------------------------------------------------
/example/www/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Not Found
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/example/www/big_hubble_pic.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 | const fs = require('fs')
3 | const path = require('path')
4 | const crypto = require('crypto')
5 | const expectedFile = fs.readFileSync(path.join(__dirname, 'big_hubble_pic.tif'))
6 | const expectedMd5 = '3a62c497a720978e97c22d42ca34706a'
7 |
8 | function md5 (buffer) {
9 | const hash = crypto.createHash('md5')
10 | hash.setEncoding('hex')
11 | hash.write(buffer)
12 | hash.end()
13 | return hash.read()
14 | }
15 |
16 | describe('large static file', () => {
17 | it('should load the entire contents correctly', async () => {
18 | const res = await fetch('http://localhost:11420/big_hubble_pic.tif')
19 | const body = await res.buffer()
20 | expect(res.headers.get('content-type')).toEqual('image/tiff')
21 | expect(body.equals(expectedFile)).toBe(true)
22 | expect(md5(body)).toEqual(expectedMd5)
23 | })
24 | })
25 |
--------------------------------------------------------------------------------
/example/www/big_hubble_pic.tif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SRFNStack/spliffy/79a7b886ea5441dc0f4c601cb81c1a5dbc8f2ce5/example/www/big_hubble_pic.tif
--------------------------------------------------------------------------------
/example/www/cookie.rt.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | GET: ({ req, res }) => {
3 | res.setCookie('Coooooooookie', 'crisps', {
4 | httpOnly: true,
5 | sameSite: 'lax',
6 | maxAge: 60 * 60 * 24 * 7 // 1 week
7 | })
8 | res.setCookie('isCookieMonster', 'true')
9 | return {
10 | ...req.cookies
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/example/www/cookie.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 |
3 | describe('cookies!', () => {
4 | it('loves cookies', async () => {
5 | const res = await fetch('http://localhost:11420/cookie', {
6 | headers: {
7 | cookie: 'username=cookiezzz; giveme=cookies;'
8 | }
9 | })
10 | const body = await res.json()
11 | expect(res.headers.get('set-cookie')).toEqual('Coooooooookie=crisps; Max-Age=604800; HttpOnly; SameSite=Lax, isCookieMonster=true')
12 | expect(body).toEqual({
13 | username: 'cookiezzz',
14 | giveme: 'cookies'
15 | })
16 | })
17 | })
18 |
--------------------------------------------------------------------------------
/example/www/errors.rt.mjs:
--------------------------------------------------------------------------------
1 | import {
2 | BadRequestError,
3 | EnhanceYourCalmError,
4 | InternalServerError,
5 | ServiceUnavailableError
6 | } from '../../src/errors.mjs'
7 |
8 | export default {
9 | GET: ({ url: { query } }) => {
10 | switch (query.statusCode) {
11 | case '400': throw new BadRequestError({ errors: ['big bad request'] })
12 | case '420': throw new EnhanceYourCalmError({ errors: ['chill out dude'] })
13 | case '503': throw new ServiceUnavailableError({ errors: ['not home right now'] })
14 | default: throw new InternalServerError({ errors: ['broke af'] })
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/example/www/errors.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 |
3 | const expectations = [
4 | {
5 | statusCode: '400',
6 | statusMessage: 'Bad Request',
7 | body: { errors: ['big bad request'] }
8 | },
9 | {
10 | statusCode: '420',
11 | statusMessage: 'Enhance Your Calm',
12 | body: { errors: ['chill out dude'] }
13 | },
14 | {
15 | statusCode: '503',
16 | statusMessage: 'Service Unavailable',
17 | body: { errors: ['not home right now'] }
18 | },
19 | {
20 | statusCode: '500',
21 | statusMessage: 'Internal Server Error',
22 | body: { errors: ['broke af'] }
23 | }
24 | ]
25 |
26 | describe('errors!', () => {
27 | it('sets the status code, status message, and body from the error', async () => {
28 | for (const expectation of expectations) {
29 | const res = await fetch(`http://localhost:11420/errors?statusCode=${expectation.statusCode}`)
30 | const body = await res.json()
31 | expect(res.status).toBe(parseInt(expectation.statusCode))
32 | expect(res.statusText).toBe(expectation.statusMessage)
33 | expect(body).toEqual(expectation.body)
34 | }
35 | })
36 | })
37 |
--------------------------------------------------------------------------------
/example/www/form.rt.cjs:
--------------------------------------------------------------------------------
1 |
2 | const htmlDoc = body => `${body}`
3 |
4 | const form = htmlDoc(
5 | ``)
15 |
16 | module.exports = {
17 | GET: () => ({
18 | headers: {
19 | 'Content-Type': 'text/html'
20 | },
21 | body: form
22 | }),
23 | POST: async ({ bodyPromise }) => {
24 | const { name, favStrain, prefStyle } = await bodyPromise
25 | return {
26 | headers: {
27 | 'Content-Type': 'text/html'
28 | },
29 | body: htmlDoc(`Hello ${name}, have some ${prefStyle} of ${favStrain}`)
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/example/www/form.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 |
3 | const expectedForm = ``
13 |
14 | describe('form test', () => {
15 | it('Loads the html from /form', async () => {
16 | const res = await fetch('http://localhost:11420/form')
17 | const form = await res.text()
18 | expect(res.status).toBe(200)
19 | expect(form).toEqual(expectedForm)
20 | })
21 |
22 | it('Form consumes url encoded data and returns expected html', async () => {
23 | const res = await fetch('http://localhost:11420/form', {
24 | method: 'POST',
25 | body: 'name=Jerry&favStrain=Bruce%20Banner&prefStyle=bud',
26 | headers: {
27 | 'content-type': 'application/x-www-form-urlencoded'
28 | }
29 | })
30 | const formResponse = await res.text()
31 | expect(res.status).toBe(200)
32 | expect(formResponse).toEqual('Hello Jerry, have some bud of Bruce Banner')
33 | })
34 | })
35 |
--------------------------------------------------------------------------------
/example/www/home.rt.cjs:
--------------------------------------------------------------------------------
1 | const marquee = require('../templates/marquee.cjs')
2 |
3 | module.exports = {
4 | GET: () => ({
5 | headers: {
6 | 'content-type': 'text/html'
7 | },
8 | body: `
9 |
10 |
11 | ${marquee('shenanigans', 10) || ''}
12 |
13 |
14 | `
15 | })
16 | }
17 |
--------------------------------------------------------------------------------
/example/www/ignoreMe.rt.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | GET: () => "can't get this"
3 | }
4 |
--------------------------------------------------------------------------------
/example/www/ignoreMe.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 |
3 | describe('test ignoreFilesMatching ^ignore pattern with file', () => {
4 | it('Should ignore ignoreMe.rt.js', async () => {
5 | const res = await fetch('http://localhost:11420/ignoreMe')
6 | expect(res.status).toEqual(404)
7 | })
8 | })
9 |
--------------------------------------------------------------------------------
/example/www/ignoreThisDir/andThisFile.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | this is ignores
4 |
5 |
--------------------------------------------------------------------------------
/example/www/ignoreThisDir/andThisRoute.rt.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | GET: () => "can't get this"
3 | }
4 |
--------------------------------------------------------------------------------
/example/www/ignoreThisDir/ignoreThisDir.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 |
3 | describe('test ignoreThisDir ignoreFilesMatching ^ignore', () => {
4 | it('Should ignore both files in ignoreThisDir', async () => {
5 | const res = await fetch('http://localhost:11420/ignoreThisDir/andThisFile.html')
6 | expect(res.status).toEqual(404)
7 | const res2 = await fetch('http://localhost:11420/ignoreThisDir/andThisRoute.rt.js')
8 | expect(res2.status).toEqual(404)
9 | })
10 | })
11 |
--------------------------------------------------------------------------------
/example/www/images/$foo.rt.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | GET: ({ url }) => `got parameter ${url.param('foo')}`
3 | }
4 |
--------------------------------------------------------------------------------
/example/www/images/combTheDesert.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SRFNStack/spliffy/79a7b886ea5441dc0f4c601cb81c1a5dbc8f2ce5/example/www/images/combTheDesert.gif
--------------------------------------------------------------------------------
/example/www/images/images.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 | const fs = require('fs')
3 | const path = require('path')
4 | const expectedFile = fs.readFileSync(path.join(__dirname, 'logo.spliff'))
5 |
6 | describe('custom mime type', () => {
7 | it('should return the right mime type for the .spliff extension', async () => {
8 | const res = await fetch('http://localhost:11420/images/logo.spliff')
9 | const body = await res.buffer()
10 | expect(res.status).toEqual(200)
11 | expect(res.headers.get('content-type')).toEqual('image/png')
12 | expect(body.equals(expectedFile)).toBe(true)
13 | })
14 | it('should get the $foo end point correctly', async () => {
15 | const res = await fetch('http://localhost:11420/images/taco.jaco')
16 | const body = await res.text()
17 | expect(res.status).toEqual(200)
18 | expect(body).toBe('got parameter taco.jaco')
19 | })
20 | })
21 |
--------------------------------------------------------------------------------
/example/www/images/logo.spliff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SRFNStack/spliffy/79a7b886ea5441dc0f4c601cb81c1a5dbc8f2ce5/example/www/images/logo.spliff
--------------------------------------------------------------------------------
/example/www/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | display: flex;
3 | flex-direction: column;
4 | width: 100%;
5 | height: 100%;
6 | justify-content: center;
7 | align-items: center;
8 | }
--------------------------------------------------------------------------------
/example/www/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Spliffy Example
5 |
6 |
7 |
8 |
9 |
10 |
11 | Shizzle for the webizzle
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/example/www/index.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 | const fs = require('fs')
3 | const path = require('path')
4 |
5 | const expectedFile = fs.readFileSync(path.join(__dirname, '/index.html')).toString('utf-8')
6 | const expected404 = fs.readFileSync(path.join(__dirname, '/404.html')).toString('utf-8')
7 | describe('static files, folder index index.html', () => {
8 | it('Should return the right content from get root', async () => {
9 | const res = await fetch('http://localhost:11420')
10 | const body = await res.text()
11 | expect(res.status).toEqual(200)
12 | expect(body).toEqual(expectedFile)
13 | })
14 | it('Should return options for the root', async () => {
15 | const res = await fetch('http://localhost:11420', { method: 'OPTIONS' })
16 | expect(res.status).toEqual(204)
17 | expect(res.headers.get('allow')).toEqual('GET')
18 | })
19 | it('Should return the right content from get root with slash', async () => {
20 | const res = await fetch('http://localhost:11420/')
21 | const body = await res.text()
22 | expect(res.status).toEqual(200)
23 | expect(body).toEqual(expectedFile)
24 | })
25 | it('Should return options on the slash end point', async () => {
26 | const res = await fetch('http://localhost:11420/', { method: 'OPTIONS' })
27 | expect(res.status).toEqual(204)
28 | expect(res.headers.get('allow')).toEqual('GET')
29 | })
30 | it('Should resolve the index file explicitly', async () => {
31 | const res = await fetch('http://localhost:11420/index.html')
32 | const body = await res.text()
33 | expect(res.status).toEqual(200)
34 | expect(body).toEqual(expectedFile)
35 | })
36 | it('Should return options for index.html', async () => {
37 | const res = await fetch('http://localhost:11420/index.html', { method: 'OPTIONS' })
38 | expect(res.status).toEqual(204)
39 | expect(res.headers.get('allow')).toEqual('GET')
40 | })
41 | it('Should resolve the index file without .html', async () => {
42 | const res = await fetch('http://localhost:11420/index')
43 | const body = await res.text()
44 | expect(res.status).toEqual(200)
45 | expect(body).toEqual(expectedFile)
46 | })
47 | it('Should return options for /index', async () => {
48 | const res = await fetch('http://localhost:11420/index', { method: 'OPTIONS' })
49 | expect(res.status).toEqual(204)
50 | expect(res.headers.get('allow')).toEqual('GET')
51 | })
52 | it('Should not make this test file into a route', async () => {
53 | const res = await fetch('http://localhost:11420/laksjdflkasldkfj')
54 | const body = await res.text()
55 | expect(res.status).toEqual(404)
56 | expect(body).toBe(expected404)
57 | })
58 | })
59 |
--------------------------------------------------------------------------------
/example/www/middleware/index.rt.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | GET: () => "this isn't middleware"
3 | }
4 |
--------------------------------------------------------------------------------
/example/www/middleware/middleware.mw.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | middleware: [(req, res, next) => {
3 | console.log('This middleware applies to everything under /middleware')
4 | res.headers['middleware-was-here'] = true
5 | next()
6 | }]
7 | }
8 |
--------------------------------------------------------------------------------
/example/www/middleware/middleware.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 |
3 | describe('middleware files', () => {
4 | it('applies middleware to routes at the same level', async () => {
5 | const res = await fetch('http://localhost:11420/middleware')
6 | expect(res.headers.get('middleware-was-here')).toEqual('true')
7 | expect(res.headers.get('app-mw-applied')).toEqual('true')
8 | })
9 | it('applies middleware to routes in sub folders', async () => {
10 | const res = await fetch('http://localhost:11420/middleware/stuff')
11 | expect(res.headers.get('middleware-was-here')).toEqual('true')
12 | const res2 = await fetch('http://localhost:11420/middleware/stuff/errors/oops')
13 | expect(res2.headers.get('middleware-was-here')).toEqual('true')
14 | })
15 | })
16 |
--------------------------------------------------------------------------------
/example/www/middleware/secrets/denied/auth.mw.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | middleware: {
3 | GET: [(req, res, next) => {
4 | if (req.spliffyUrl.query.isAuthorized) {
5 | console.log('They said they were authorized...')
6 | next()
7 | } else {
8 | console.log('Everyone is unauthorized!')
9 | res.send({ statusCode: 401, statusMessage: 'Get Outta Here!' })
10 | next()
11 | }
12 | }]
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/example/www/middleware/secrets/denied/index.rt.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | GET: ({ res }) => {
3 | res.headers['route-was-hit'] = true
4 | return "Can't get here"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/example/www/middleware/secrets/denied/middlewareSecretsDenied.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 |
3 | describe('middleware ends request', () => {
4 | it('does not call route if middleware ends request', async () => {
5 | const res = await fetch('http://localhost:11420/middleware/secrets/denied')
6 | expect(res.status).toEqual(401)
7 | expect(res.headers.get('route-was-hit')).toEqual(null)
8 | })
9 | it('calls route if param is present', async () => {
10 | const res = await fetch('http://localhost:11420/middleware/secrets/denied?isAuthorized=true')
11 | expect(res.status).toEqual(200)
12 | expect(res.headers.get('route-was-hit')).toEqual('true')
13 | })
14 | })
15 |
--------------------------------------------------------------------------------
/example/www/middleware/secrets/denied/treasures/middlewareSecretsDeniedTreasures.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 |
3 | describe('parent middleware ends request', () => {
4 | it('does not call route if middleware ends request', async () => {
5 | const res = await fetch('http://localhost:11420/middleware/secrets/denied/treasures/mysecrets.txt')
6 | expect(res.status).toEqual(401)
7 | expect(res.headers.get('route-was-hit')).toEqual(null)
8 | })
9 | it('calls route if param is present', async () => {
10 | const res = await fetch('http://localhost:11420/middleware/secrets/denied/treasures/mysecrets.txt?isAuthorized=true')
11 | const body = await res.text()
12 | expect(res.status).toEqual(200)
13 | expect(res.headers.get('content-type')).toEqual('text/plain')
14 | expect(body).toEqual('I like cake')
15 | })
16 | })
17 |
--------------------------------------------------------------------------------
/example/www/middleware/secrets/denied/treasures/mysecrets.txt:
--------------------------------------------------------------------------------
1 | I like cake
--------------------------------------------------------------------------------
/example/www/middleware/secrets/middlewareSecrets.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 |
3 | describe('route middleware', () => {
4 | it('applies route middleware', async () => {
5 | const res = await fetch('http://localhost:11420/middleware/secrets/mine')
6 | expect(res.status).toEqual(200)
7 | expect(res.headers.get('app-mw-applied')).toEqual('true')
8 | expect(res.headers.get('middleware-was-here')).toEqual('true')
9 | expect(res.headers.get('route-mw-applied')).toEqual('true')
10 | })
11 | })
12 |
--------------------------------------------------------------------------------
/example/www/middleware/secrets/mine.rt.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | middleware: {
3 | GET: [(req, res, next) => {
4 | res.headers['route-mw-applied'] = true
5 | console.log('got my secrets')
6 | next()
7 | }]
8 | },
9 | POST: ({ body }) => 'Got post for ' + JSON.stringify(body),
10 | PUT: ({ body }) => 'Got put for ' + JSON.stringify(body),
11 | GET: ({ body }) => 'Got get for ' + JSON.stringify(body),
12 | PATCH: ({ body }) => 'Got patch for ' + JSON.stringify(body),
13 | DELETE: ({ body }) => 'Got delete for ' + JSON.stringify(body)
14 | }
15 |
--------------------------------------------------------------------------------
/example/www/middleware/stuff/errors/mad/bad.mw.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | middleware: [(req, res, next) => {
3 | res.headers['broke-mw-applied'] = true
4 | throw new Error('Made to fail')
5 | }]
6 | }
7 |
--------------------------------------------------------------------------------
/example/www/middleware/stuff/errors/mad/index.rt.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | GET: () => 'stuff'
3 | }
4 |
--------------------------------------------------------------------------------
/example/www/middleware/stuff/errors/mad/middlewareStuffErrorsMad.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 |
3 | describe('middleware error', () => {
4 | it('should pass errors thrown by middleware to error middleware', async () => {
5 | const res = await fetch('http://localhost:11420/middleware/stuff/errors/mad')
6 | expect(res.headers.get('error-mw-applied')).toEqual('true')
7 | expect(res.headers.get('app-mw-applied')).toEqual('true')
8 | expect(res.headers.get('middleware-was-here')).toEqual('true')
9 | expect(res.headers.get('broke-mw-applied')).toEqual('true')
10 | expect(res.status).toEqual(200)
11 | })
12 | })
13 |
--------------------------------------------------------------------------------
/example/www/middleware/stuff/errors/middlewareStuffErrors.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 | describe('error middleware', () => {
3 | it('applies error middleware and allows recovering from exceptions', async () => {
4 | const res = await fetch('http://localhost:11420/middleware/stuff/errors/oops')
5 | expect(res.headers.get('error-mw-applied')).toEqual('true')
6 | expect(res.headers.get('app-mw-applied')).toEqual('true')
7 | expect(res.headers.get('middleware-was-here')).toEqual('true')
8 | expect(res.status).toEqual(200)
9 | })
10 | })
11 |
--------------------------------------------------------------------------------
/example/www/middleware/stuff/errors/noMoreErrors.mw.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | middleware: [(err, req, res, next) => {
3 | res.statusCode = 200
4 | res.statusMessage = 'Everything is fine here'
5 | res.headers['error-mw-applied'] = true
6 | res.end(err.message)
7 | next()
8 | }]
9 | }
10 |
--------------------------------------------------------------------------------
/example/www/middleware/stuff/errors/oops.rt.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | GET () { throw new Error('Successfully Failed') }
3 | }
4 |
--------------------------------------------------------------------------------
/example/www/middleware/stuff/index.rt.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | POST: ({ body }) => 'index post for ' + JSON.stringify(body),
3 | PUT: ({ body }) => 'index put for ' + JSON.stringify(body),
4 | GET: ({ body }) => 'index get for ' + JSON.stringify(body)
5 | }
6 |
--------------------------------------------------------------------------------
/example/www/middleware/stuff/middlewareStuff.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 |
3 | describe('middleware method application', () => {
4 | it('applies all middleware and middleware for specific methods', async () => {
5 | const res = await fetch('http://localhost:11420/middleware/stuff', {
6 | method: 'PUT'
7 | })
8 | expect(res.headers.get('all-mw-applied')).toEqual('true')
9 | expect(res.headers.get('put-mw-applied')).toEqual('true')
10 | expect(res.headers.get('app-mw-applied')).toEqual('true')
11 | })
12 | it('doesn\'t apply middleware it shouldn\'t', async () => {
13 | const res = await fetch('http://localhost:11420/middleware/stuff')
14 | expect(res.headers.get('all-mw-applied')).toEqual('true')
15 | expect(res.headers.get('put-mw-applied')).toEqual(null)
16 | const res2 = await fetch('http://localhost:11420/middleware/stuff', {
17 | method: 'POST'
18 | })
19 | expect(res2.headers.get('all-mw-applied')).toEqual('true')
20 | expect(res2.headers.get('put-mw-applied')).toEqual(null)
21 | })
22 | })
23 |
--------------------------------------------------------------------------------
/example/www/middleware/stuff/put_only_middleware.mw.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | middleware: {
3 | ALL: [(req, res, next) => {
4 | console.log('This middleware applies to everything under /middleware/stuff')
5 | res.headers['all-mw-applied'] = true
6 | next()
7 | }],
8 | PUT: [(req, res, next) => {
9 | console.log('Put to /middleware/stuff')
10 | res.headers['put-mw-applied'] = true
11 | next()
12 | }]
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/example/www/module.rt.mjs:
--------------------------------------------------------------------------------
1 | export default {
2 | GET: () => 'GET module',
3 | POST: () => 'POST module',
4 | PATCH: () => 'PATCH module',
5 | DELETE: () => 'DELETE module',
6 | PUT: () => 'PUT module'
7 | }
8 |
--------------------------------------------------------------------------------
/example/www/module.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 |
3 | describe('es6 module route, module.rt.mjs', () => {
4 | it('Should make a route and allow the exported methods', async () => {
5 | for (const method of ['GET', 'POST', 'PATCH', 'DELETE', 'PUT']) {
6 | const res = await fetch('http://localhost:11420/module', { method })
7 | expect(res.status).toEqual(200)
8 | const body = await res.text()
9 | expect(body).toEqual(`${method} module`)
10 | }
11 | })
12 | })
13 |
--------------------------------------------------------------------------------
/example/www/redirect.rt.mjs:
--------------------------------------------------------------------------------
1 | import { redirect } from '../../src/index.mjs'
2 |
3 | export default {
4 | GET: redirect('/')
5 | }
6 |
--------------------------------------------------------------------------------
/example/www/redirect.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 |
3 | describe('redirect route, redirect.rt.js', () => {
4 | it('Should respond with a 301 and correct location header', async () => {
5 | const res = await fetch('http://localhost:11420/redirect', { redirect: 'manual' })
6 | expect(res.status).toEqual(301)
7 | expect(res.headers.get('location')).toEqual('http://localhost:11420/')
8 | })
9 | })
10 |
--------------------------------------------------------------------------------
/example/www/roundtrip/cheech.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/example/www/roundtrip/cheechFun.js:
--------------------------------------------------------------------------------
1 | document.getElementById('form').addEventListener('submit', (e) => {
2 | e.preventDefault()
3 | fetch(
4 | 'chong?q=some+string%20with%27codes%27',
5 | {
6 | method: 'POST',
7 | headers: {
8 | 'content-type': 'application/json'
9 | },
10 | body: JSON.stringify({ requestMessage: document.getElementById('cheech').value })
11 | }
12 | ).then(r => r.json())
13 | .then(data => { document.getElementById('chong').innerText = data.responseMessage })
14 | })
15 |
--------------------------------------------------------------------------------
/example/www/roundtrip/chong.rt.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | POST: async ({ url: { query }, bodyPromise }) => {
3 | const body = await bodyPromise
4 | if (typeof body !== 'object') {
5 | throw new Error('request body not parsed')
6 | }
7 | return { ...body, responseMessage: "I think we're parked man", query: query }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/example/www/roundtrip/roundtrip.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 | const fs = require('fs')
3 | const path = require('path')
4 |
5 | const expectedCheech = fs.readFileSync(path.join(__dirname, 'cheech.html')).toString('utf-8')
6 | const expectedCheechFun = fs.readFileSync(path.join(__dirname, 'cheechFun.js')).toString('utf-8')
7 | describe('roundtrip test', () => {
8 | it('Loads the html from /roundTrip/cheech', async () => {
9 | const res = await fetch('http://localhost:11420/roundtrip/cheech')
10 | const cheech = await res.text()
11 | expect(res.status).toBe(200)
12 | expect(res.headers.get('transfer-encoding')).toBe('chunked')
13 | expect(cheech).toEqual(expectedCheech)
14 | })
15 |
16 | it('Loads the js from /roundTrip/cheechFun', async () => {
17 | const res = await fetch('http://localhost:11420/roundtrip/cheechFun')
18 | const cheechFun = await res.text()
19 | expect(res.status).toBe(200)
20 | expect(res.headers.get('transfer-encoding')).toBe('chunked')
21 | expect(cheechFun).toEqual(expectedCheechFun)
22 | })
23 |
24 | it('chong puffs and passes', async () => {
25 | const input = {
26 | puff: {
27 | puff: {
28 | pass: true
29 | }
30 | }
31 | }
32 | const res = await fetch(`http://localhost:11420/roundtrip/chong?q=${encodeURIComponent('Hey man, am I driving ok?')}`, {
33 | method: 'POST',
34 | body: JSON.stringify(input),
35 | headers: {
36 | 'content-type': 'application/json'
37 | }
38 | })
39 | const chong = await res.json()
40 | expect(res.status).toBe(200)
41 | expect(chong).toEqual({
42 | ...input,
43 | query: {
44 | q: 'Hey man, am I driving ok?'
45 | },
46 | responseMessage: "I think we're parked man"
47 | })
48 | })
49 | })
50 |
--------------------------------------------------------------------------------
/example/www/some.js:
--------------------------------------------------------------------------------
1 | setTimeout(() => { document.getElementById('main').innerText = 'Have a nice day!' }, 1000)
2 |
--------------------------------------------------------------------------------
/example/www/some.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 | const fs = require('fs')
3 | const path = require('path')
4 |
5 | const expectedFile = fs.readFileSync(path.join(__dirname, 'some.js')).toString('utf-8')
6 | const expected404 = fs.readFileSync(path.join(__dirname, '404.html')).toString('utf-8')
7 | describe('static js file, some.js', () => {
8 | it('Should return the right content from get', async () => {
9 | const res = await fetch('http://localhost:11420/some.js')
10 | const body = await res.text()
11 | expect(res.status).toEqual(200)
12 | expect(body).toEqual(expectedFile)
13 | })
14 | it('resolves without .js extension', async () => {
15 | const res = await fetch('http://localhost:11420/some')
16 | const body = await res.text()
17 | expect(res.status).toEqual(200)
18 | expect(body).toEqual(expectedFile)
19 | })
20 | it('Should not make this test file into a route', async () => {
21 | const res = await fetch(`http://localhost:11420/${__filename}`)
22 | const body = await res.text()
23 | expect(res.status).toEqual(404)
24 | expect(body).toBe(expected404)
25 | })
26 | })
27 |
--------------------------------------------------------------------------------
/example/www/strains+.rt.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | GET: ({ url }) => ({ body: url, headers: { 'Content-type': 'potato' } })
3 | }
4 |
--------------------------------------------------------------------------------
/example/www/strains.test.js:
--------------------------------------------------------------------------------
1 |
2 | const fetch = require('node-fetch')
3 |
4 | describe('catch all route, strains+.rt.js', () => {
5 | it('Should match strains without anything extra', async () => {
6 | const res = await fetch('http://localhost:11420/strains')
7 | const body = await res.json()
8 | expect(res.status).toEqual(200)
9 | expect(body).toEqual({
10 | path: '/strains',
11 | query: {}
12 | })
13 | expect(res.headers.get('content-type')).toEqual('potato')
14 | })
15 |
16 | it('Should parse query params', async () => {
17 | const res = await fetch('http://localhost:11420/strains/?foo=bar%20baz&baz=foo&baz=bar&baz=baz')
18 | const body = await res.json()
19 | expect(res.status).toEqual(200)
20 | expect(body).toEqual({
21 | path: '/strains/',
22 | query: {
23 | foo: 'bar baz',
24 | baz: ['foo', 'bar', 'baz']
25 | }
26 | })
27 | expect(res.headers.get('content-type')).toEqual('potato')
28 | })
29 |
30 | it('Should respond to additional path segment', async () => {
31 | const res = await fetch('http://localhost:11420/strains/gorillaGlue/?isGood=true')
32 | const body = await res.json()
33 | expect(res.status).toEqual(200)
34 | expect(body).toEqual({
35 | path: '/strains/gorillaGlue/',
36 | query: {
37 | isGood: 'true'
38 | }
39 | })
40 | expect(res.headers.get('content-type')).toEqual('potato')
41 | })
42 |
43 | it('Should respond to path with lots of segments', async () => {
44 | const res = await fetch('http://localhost:11420/strains/foo/bar/baz/')
45 | const body = await res.json()
46 | expect(res.status).toEqual(200)
47 | expect(body).toEqual({
48 | path: '/strains/foo/bar/baz/',
49 | query: {}
50 | })
51 | expect(res.headers.get('content-type')).toEqual('potato')
52 | })
53 | })
54 |
--------------------------------------------------------------------------------
/example/www/streamReadable.rt.cjs:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 | const catEatingPancakePath = require('path').join(__dirname, '../serverImages/cat_eating_pancake.jpg')
3 |
4 | module.exports = {
5 | GET: async () => ({
6 | headers: {
7 | 'Content-Type': 'img/jpeg',
8 | 'Content-Disposition': 'inline; filename="cat_eating_pancake.jpg'
9 | },
10 | body: fs.createReadStream(catEatingPancakePath)
11 | })
12 | }
13 |
--------------------------------------------------------------------------------
/example/www/streamReadable.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 | const fs = require('fs')
3 | const path = require('path')
4 |
5 | const expectedFile = fs.readFileSync(path.resolve(__dirname, '../serverImages/cat_eating_pancake.jpg'))
6 |
7 | describe('test stream readable body', () => {
8 | it('Should return the entire contents of the file', async () => {
9 | const res = await fetch('http://localhost:11420/streamReadable')
10 | const body = await res.buffer()
11 | expect(res.status).toEqual(200)
12 | expect(body).toEqual(expectedFile)
13 | expect(res.headers.get('content-type')).toEqual('img/jpeg')
14 | expect(res.headers.get('transfer-encoding')).toEqual('chunked')
15 | })
16 | })
17 |
--------------------------------------------------------------------------------
/example/www/streamRequest.rt.cjs:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 | const path = require('path')
3 | const { promisify } = require('util')
4 | const pipeline = promisify(require('stream').pipeline)
5 | const uploadsPath = path.join(__dirname, '../serverImages/uploads')
6 |
7 | module.exports = {
8 | GET: () => ({
9 | headers: {
10 | 'Content-Type': 'text/html'
11 | },
12 | body: `
13 |
14 |
15 | An example of streaming a request body in spliffy
16 |
17 |
18 |
19 |
20 |
21 |
22 | `
23 | }),
24 | POST: {
25 | streamRequestBody: true,
26 | handler: async ({ url: { query: { filename = 'foo.dat' } }, bodyPromise }) => pipeline(
27 | await bodyPromise,
28 | fs.createWriteStream(path.join(uploadsPath, filename))
29 | )
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/example/www/streamRequest.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 | const fs = require('fs')
3 | const path = require('path')
4 | const crypto = require('crypto')
5 |
6 | const expectedFile = fs.readFileSync(path.join(__dirname, 'big_hubble_pic.tif'))
7 | const expectedMd5 = '3a62c497a720978e97c22d42ca34706a'
8 | const uploadedFilepath = path.join(__dirname, '../serverImages/uploads/hubble.tif')
9 |
10 | async function md5 (buffer) {
11 | const hash = crypto.createHash('md5')
12 | hash.setEncoding('hex')
13 | hash.write(buffer)
14 | hash.end()
15 | return hash.read()
16 | }
17 |
18 | afterEach(() => {
19 | fs.unlinkSync(path.resolve(uploadedFilepath))
20 | })
21 | describe('streaming request body', () => {
22 | it('receives and stores the whole file correctly', async () => {
23 | const res = await fetch('http://localhost:11420/streamRequest?filename=hubble.tif', {
24 | method: 'POST',
25 | body: expectedFile
26 | })
27 | expect(res.status).toBe(200)
28 | const uploadedFile = fs.readFileSync(uploadedFilepath)
29 | // jest toEqual is slow on this big file
30 | expect(uploadedFile.equals(expectedFile)).toEqual(true)
31 | const uploadedMd5 = await md5(uploadedFile)
32 | expect(uploadedMd5).toEqual(expectedMd5)
33 | })
34 | })
35 |
--------------------------------------------------------------------------------
/example/www/streamWritable.rt.cjs:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 | const { promisify } = require('util')
3 | const pipeline = promisify(require('stream').pipeline)
4 | const catEatingPancakePath = require('path').join(__dirname, '../serverImages/cat_eating_pancake.jpg')
5 |
6 | module.exports = {
7 | GET: async ({ res }) => {
8 | res.assignHeaders({
9 | 'Content-Type': 'img/jpeg',
10 | 'Content-Disposition': 'inline; filename="cat_eating_pancake.jpg"'
11 | })
12 | return pipeline(
13 | fs.createReadStream(catEatingPancakePath),
14 | res.getWritable()
15 | )
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/example/www/streamWritable.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 | const fs = require('fs')
3 | const path = require('path')
4 |
5 | const expectedFile = fs.readFileSync(path.resolve(__dirname, '../serverImages/cat_eating_pancake.jpg'))
6 |
7 | describe('test stream writable response', () => {
8 | it('Should return the entire contents of the file', async () => {
9 | const res = await fetch('http://localhost:11420/streamWritable')
10 | const body = await res.buffer()
11 | expect(res.status).toEqual(200)
12 | expect(body).toEqual(expectedFile)
13 | expect(res.headers.get('content-type')).toEqual('img/jpeg')
14 | expect(res.headers.get('transfer-encoding')).toEqual('chunked')
15 | expect(res.headers.get('content-disposition')).toEqual('inline; filename="cat_eating_pancake.jpg"')
16 | })
17 | })
18 |
--------------------------------------------------------------------------------
/example/www/streamWrite.rt.cjs:
--------------------------------------------------------------------------------
1 | const particles = [
2 | {
3 | name: 'baryon'
4 | },
5 | {
6 | name: 'bottom quark'
7 | },
8 | {
9 | name: 'chargino'
10 | },
11 | {
12 | name: 'charm quark'
13 | },
14 | {
15 | name: 'down quark'
16 | },
17 | {
18 | name: 'electron'
19 | },
20 | {
21 | name: 'electron neutrino'
22 | },
23 | {
24 | name: 'fermion'
25 | },
26 | {
27 | name: 'gluino'
28 | },
29 | {
30 | name: 'gluon'
31 | },
32 | {
33 | name: 'gravitino'
34 | },
35 | {
36 | name: 'graviton'
37 | },
38 | {
39 | name: 'boson'
40 | },
41 | {
42 | name: 'higgs boson'
43 | },
44 | {
45 | name: 'higgsino'
46 | },
47 | {
48 | name: 'neutralino'
49 | },
50 | {
51 | name: 'neutron'
52 | },
53 | {
54 | name: 'meson'
55 | },
56 | {
57 | name: 'muon'
58 | },
59 | {
60 | name: 'muon nuetrino'
61 | },
62 | {
63 | name: 'positron'
64 | },
65 | {
66 | name: 'photino'
67 | },
68 | {
69 | name: 'photon'
70 | },
71 | {
72 | name: 'proton'
73 | },
74 | {
75 | name: 'sleptons'
76 | },
77 | {
78 | name: 'sneutrino'
79 | },
80 | {
81 | name: 'strange quark'
82 | },
83 | {
84 | name: 'squark'
85 | },
86 | {
87 | name: 'tau'
88 | },
89 | {
90 | name: 'tau neutrino'
91 | },
92 | {
93 | name: 'top quark'
94 | },
95 | {
96 | name: 'up quark'
97 | },
98 | {
99 | name: 'w boson'
100 | },
101 | {
102 | name: 'wino'
103 | },
104 | {
105 | name: 'z boson'
106 | },
107 | {
108 | name: 'zino'
109 | }
110 | ]
111 |
112 | const writeParticles = async res => {
113 | let p = Promise.resolve()
114 | for (const particle of particles) {
115 | p = writeParticle(res, particle)
116 | }
117 | return p
118 | }
119 |
120 | const writeParticle = async (res, particle) => res.write(`${particle.name}`)
121 |
122 | module.exports = {
123 | GET: async ({ res }) => {
124 | res.headers['Content-Type'] = 'text/html'
125 | res.streaming = true
126 | res.write('Particles')
127 | writeParticles(res).finally(() => {
128 | res.write('
')
129 | res.end()
130 | })
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/example/www/streamWrite.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 |
3 | const expectedHtml = 'Particles' +
4 | '- baryon
' +
5 | '- bottom quark
' +
6 | '- chargino
' +
7 | '- charm quark
' +
8 | '- down quark
' +
9 | '- electron
' +
10 | '- electron neutrino
' +
11 | '- fermion
' +
12 | '- gluino
' +
13 | '- gluon
' +
14 | '- gravitino
' +
15 | '- graviton
' +
16 | '- boson
' +
17 | '- higgs boson
' +
18 | '- higgsino
' +
19 | '- neutralino
' +
20 | '- neutron
' +
21 | '- meson
' +
22 | '- muon
' +
23 | '- muon nuetrino
' +
24 | '- positron
' +
25 | '- photino
' +
26 | '- photon
' +
27 | '- proton
' +
28 | '- sleptons
' +
29 | '- sneutrino
' +
30 | '- strange quark
' +
31 | '- squark
' +
32 | '- tau
' +
33 | '- tau neutrino
' +
34 | '- top quark
' +
35 | '- up quark
' +
36 | '- w boson
' +
37 | '- wino
' +
38 | '- z boson
' +
39 | '- zino
' +
40 | '
'
41 |
42 | describe('test write to response', () => {
43 | it('Should return all of the written chunks', async () => {
44 | const res = await fetch('http://localhost:11420/streamWrite')
45 | const body = await res.text()
46 | expect(res.status).toEqual(200)
47 | expect(body).toEqual(expectedHtml)
48 | expect(res.headers.get('transfer-encoding')).toEqual('chunked')
49 | expect(res.headers.get('content-type')).toEqual('text/html')
50 | })
51 | })
52 |
--------------------------------------------------------------------------------
/example/www/testIgnore/cantLoadThis.rt.cjs:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SRFNStack/spliffy/79a7b886ea5441dc0f4c601cb81c1a5dbc8f2ce5/example/www/testIgnore/cantLoadThis.rt.cjs
--------------------------------------------------------------------------------
/example/www/testIgnore/index.rt.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | GET: () => 'This is ok though'
3 | }
4 |
--------------------------------------------------------------------------------
/example/www/testIgnore/testIgnore.test.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch')
2 |
3 | describe('test ignoreFilesMatching cantLoadThis pattern with file', () => {
4 | it('Should ignore cantLoadThis.rt.js', async () => {
5 | const res = await fetch('http://localhost:11420/testIgnore/cantLoadThis')
6 | expect(res.status).toEqual(404)
7 | })
8 |
9 | it('Should load testIgnore index', async () => {
10 | const res = await fetch('http://localhost:11420/testIgnore')
11 | const body = await res.text()
12 | expect(res.status).toEqual(200)
13 | expect(body).toBe('This is ok though')
14 | })
15 | })
16 |
--------------------------------------------------------------------------------
/example/www/upload.js:
--------------------------------------------------------------------------------
1 | const fileInput = document.getElementById('file')
2 | document.getElementById('butty').onclick = e => {
3 | e.preventDefault()
4 | fetch('?filename=' + fileInput.files[0].name, { method: 'POST', body: fileInput.files[0] })
5 | .then(() => alert('Upload Successful'))
6 | .catch(console.error)
7 | return false
8 | }
9 |
--------------------------------------------------------------------------------
/example/www/websocket/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/example/www/websocket/ws.js:
--------------------------------------------------------------------------------
1 | const ws = new WebSocket('/websocket/ws')
2 | ws.onmessage = (event) => {
3 | const div = document.createElement('div')
4 | div.innerText = event.data
5 | document.body.append(div)
6 | }
7 |
--------------------------------------------------------------------------------
/example/www/websocket/ws.rt.js:
--------------------------------------------------------------------------------
1 | const allSockets = []
2 |
3 | setInterval(() => {
4 | for (const ws of allSockets) {
5 | ws.send(new Date().toISOString())
6 | }
7 | }, 3000)
8 |
9 | export default {
10 | WEBSOCKET: {
11 | open: (ws) => {
12 | allSockets.push(ws)
13 | },
14 | close: (ws) => {
15 | const index = allSockets.indexOf(ws)
16 | if (index !== -1) {
17 | allSockets.splice(index, 1)
18 | }
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/jest.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | globalSetup: './jestGlobalSetup.cjs',
3 | globalTeardown: './jestGlobalTeardown.cjs'
4 | }
5 |
--------------------------------------------------------------------------------
/jestGlobalSetup.cjs:
--------------------------------------------------------------------------------
1 | const { start } = require('./testServer.cjs')
2 |
3 | module.exports = start
4 |
--------------------------------------------------------------------------------
/jestGlobalTeardown.cjs:
--------------------------------------------------------------------------------
1 | const { stop } = require('./testServer.cjs')
2 |
3 | module.exports = stop
4 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@srfnstack/spliffy",
3 | "version": "1.2.5",
4 | "author": "snowbldr",
5 | "private": false,
6 | "homepage": "https://github.com/SRFNStack/spliffy",
7 | "license": "MIT",
8 | "type": "module",
9 | "files": [
10 | "src/*",
11 | "LICENSE.txt",
12 | "README.md"
13 | ],
14 | "main": "src/index.mjs",
15 | "repository": {
16 | "type": "git",
17 | "url": "git@github.com:SRFNStack/spliffy.git"
18 | },
19 | "scripts": {
20 | "test": "npm run lint:fix && jest",
21 | "lint": "standard --env jest src ./*js && standard --env jest --env browser --global Prism docs example",
22 | "lint:fix": "standard --env jest --fix src ./*js && standard --env jest --env browser --global Prism --fix docs example"
23 | },
24 | "keywords": [
25 | "node",
26 | "http",
27 | "server",
28 | "web",
29 | "framework",
30 | "rest"
31 | ],
32 | "dependencies": {
33 | "cookie": "^1.0.2",
34 | "etag": "^1.8.1",
35 | "uuid": "^8.3.2",
36 | "uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.51.0"
37 | },
38 | "devDependencies": {
39 | "helmet": "^4.6.0",
40 | "jest": "^27.3.1",
41 | "node-fetch": "^2.6.7",
42 | "standard": "^16.0.4"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/spliffy_logo_text_small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SRFNStack/spliffy/79a7b886ea5441dc0f4c601cb81c1a5dbc8f2ce5/spliffy_logo_text_small.png
--------------------------------------------------------------------------------
/src/content-types.mjs:
--------------------------------------------------------------------------------
1 | export default {
2 | '.aac': 'audio/aac',
3 | '.abw': 'application/x-abiword',
4 | '.arc': 'application/x-freearc',
5 | '.avi': 'video/x-msvideo',
6 | '.azw': 'application/vnd.amazon.ebook',
7 | '.bin': 'application/octet-stream',
8 | '.bmp': 'image/bmp',
9 | '.bz': 'application/x-bzip',
10 | '.bz2': 'application/x-bzip2',
11 | '.csh': 'application/x-csh',
12 | '.css': 'text/css',
13 | '.csv': 'text/csv',
14 | '.doc': 'application/msword',
15 | '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
16 | '.eot': 'application/vnd.ms-fontobject',
17 | '.epub': 'application/epub+zip',
18 | '.gif': 'image/gif',
19 | '.htm': 'text/html',
20 | '.html': 'text/html',
21 | '.ico': 'image/vnd.microsoft.icon',
22 | '.ics': 'text/calendar',
23 | '.jar': 'application/java-archive',
24 | '.jpeg': 'image/jpeg',
25 | '.jpg': 'image/jpeg',
26 | '.js': 'text/javascript',
27 | '.json': 'application/json',
28 | '.jsonld': 'application/ld+json',
29 | '.map': 'application/json',
30 | '.mid': 'audio/midi',
31 | '.midi': 'audio/x-midi',
32 | '.mjs': 'text/javascript',
33 | '.mp3': 'audio/mpeg',
34 | '.mpeg': 'video/mpeg',
35 | '.mpkg': 'application/vnd.apple.installer+xml',
36 | '.odp': 'application/vnd.oasis.opendocument.presentation',
37 | '.ods': 'application/vnd.oasis.opendocument.spreadsheet',
38 | '.odt': 'application/vnd.oasis.opendocument.text',
39 | '.oga': 'audio/ogg',
40 | '.ogv': 'video/ogg',
41 | '.ogg': 'application/ogg',
42 | '.ogx': 'application/ogg',
43 | '.otf': 'font/otf',
44 | '.png': 'image/png',
45 | '.pdf': 'application/pdf',
46 | '.ppt': 'application/vnd.ms-powerpoint',
47 | '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
48 | '.rar': 'application/x-rar-compressed',
49 | '.rtf': 'application/rtf',
50 | '.sh': 'application/x-sh',
51 | '.svg': 'image/svg+xml',
52 | '.swf': 'application/x-shockwave-flash',
53 | '.tar': 'application/x-tar',
54 | '.tif': 'image/tiff',
55 | '.tiff': 'font/ttf',
56 | '.ts': 'video/mp2t',
57 | '.ttf': 'font/ttf ',
58 | '.txt': 'text/plain',
59 | '.vsd': 'application/vnd.visio',
60 | '.wav': 'audio/wav',
61 | '.weba': 'audio/webm',
62 | '.webm': 'video/webm',
63 | '.webp': 'image/webp',
64 | '.woff': 'font/woff',
65 | '.woff2': 'font/woff2',
66 | '.xhtml': 'application/xhtml+xml',
67 | '.xls': 'application/vnd.ms-excel',
68 | '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
69 | '.xml': 'application/xml',
70 | '.xul': 'application/vnd.mozilla.xul+xml',
71 | '.zip': 'application/zip',
72 | '.3gp': 'video/3gpp',
73 | '.3g2': 'video/3gpp2',
74 | '.7z': 'application/x-7z-compressed ',
75 | default: 'application/octet-stream'
76 | }
77 |
--------------------------------------------------------------------------------
/src/content.mjs:
--------------------------------------------------------------------------------
1 | import contentTypes from './content-types.mjs'
2 | import { parseQuery } from './url.mjs'
3 |
4 | const defaultHandler = {
5 | deserialize: o => {
6 | if (!o) return o
7 | try {
8 | return JSON.parse(o && o.toString())
9 | } catch (e) {
10 | return o
11 | }
12 | },
13 | serialize: o => {
14 | if (!o) return { data: o }
15 | if (typeof o === 'string') {
16 | return {
17 | contentType: 'text/plain',
18 | data: o
19 | }
20 | }
21 | if (o instanceof Buffer) {
22 | return {
23 | contentType: 'application/octet-stream',
24 | data: o
25 | }
26 | }
27 | return {
28 | contentType: 'application/json',
29 | data: JSON.stringify(o)
30 | }
31 | }
32 | }
33 |
34 | const toFormData = (key, value) => {
35 | if (Array.isArray(value)) {
36 | return value.map(toFormData).flat()
37 | } else if (typeof value === 'object') {
38 | return Object.keys(value).map(k => toFormData(`${key}.${k}`, value[k])).flat()
39 | } else {
40 | return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`
41 | }
42 | }
43 |
44 | const contentHandlers = {
45 | 'application/json': {
46 | deserialize: s => s && JSON.parse(s && s.toString()),
47 | serialize: o => o && JSON.stringify(o)
48 | },
49 | 'text/plain': {
50 | deserialize: s => s && s.toString(),
51 | serialize: o => o && o.toString()
52 | },
53 | 'application/octet-stream': defaultHandler,
54 | 'application/x-www-form-urlencoded': {
55 | deserialize: s => s && parseQuery(s.toString(), true),
56 | serialize: o => o && Object.keys(o).map(toFormData).flat().join('&')
57 | },
58 | '*/*': defaultHandler
59 | }
60 |
61 | function getHandler (contentType, acceptsDefault) {
62 | if (!contentType) return contentHandlers[acceptsDefault]
63 | // content-type is singular https://greenbytes.de/tech/webdav/rfc2616.html#rfc.section.14.17
64 | let handler = contentHandlers[contentType]
65 | if (!handler && contentType.indexOf(';') > -1) {
66 | handler = contentHandlers[contentType.split(';')[0].trim()]
67 | }
68 | if (handler && typeof handler) {
69 | if (typeof handler.serialize !== 'function') {
70 | throw new Error(`Content handlers must provide a serialize function. ${handler}`)
71 | }
72 | if (typeof handler.deserialize !== 'function') {
73 | throw new Error(`Content handlers must provide a deserialize function. ${handler}`)
74 | }
75 | return handler
76 | }
77 | return contentHandlers[acceptsDefault]
78 | }
79 |
80 | export function getContentTypeByExtension (name, staticContentTypes) {
81 | const extension = name.indexOf('.') > -1 ? name.slice(name.lastIndexOf('.')).toLowerCase() : 'default'
82 | const contentType = staticContentTypes?.[extension] || null
83 |
84 | return contentType || contentTypes[extension]
85 | }
86 |
87 | export function serializeBody (content, contentType, acceptsDefault) {
88 | return getHandler(contentType && contentType.toLowerCase(), acceptsDefault).serialize(content)
89 | }
90 |
91 | export function deserializeBody (content, contentType, acceptsDefault) {
92 | return getHandler(contentType && contentType.toLowerCase(), acceptsDefault).deserialize(content)
93 | }
94 |
95 | export function initContentHandlers (handlers) {
96 | return Object.assign({}, contentHandlers, handlers)
97 | }
98 |
--------------------------------------------------------------------------------
/src/decorator.mjs:
--------------------------------------------------------------------------------
1 | import cookie from 'cookie'
2 | import http from 'http'
3 | import { parseQuery } from './url.mjs'
4 | import log from './log.mjs'
5 | import { v4 as uuid } from 'uuid'
6 | import stream from 'stream'
7 | import httpStatusCodes, { defaultStatusMessages } from './httpStatusCodes.mjs'
8 |
9 | const { Writable } = stream
10 |
11 | const addressArrayBufferToString = addrBuf => String.fromCharCode.apply(null, new Int8Array(addrBuf))
12 | const excludedMessageProps = {
13 | setTimeout: true,
14 | _read: true,
15 | destroy: true,
16 | _addHeaderLines: true,
17 | _addHeaderLine: true,
18 | _dump: true,
19 | __proto__: true
20 | }
21 |
22 | const normalizeHeader = header => header.toLowerCase()
23 |
24 | const reqProtoProps = () => Object.keys(http.IncomingMessage.prototype).filter(p => !excludedMessageProps[p])
25 |
26 | export const setCookie = (res) => function () {
27 | return res.setHeader('Set-Cookie', [...(res.getHeader('Set-Cookie') || []), cookie.serialize(...arguments)])
28 | }
29 |
30 | export function decorateRequest (uwsReq, pathParameters, res, {
31 | decodeQueryParameters,
32 | decodePathParameters,
33 | parseCookie,
34 | extendIncomingMessage
35 | } = {}) {
36 | // uwsReq can't be used in async functions because it gets de-allocated when the handler function returns
37 | const req = {}
38 | if (extendIncomingMessage) {
39 | // frameworks like passport like to modify the message prototype
40 | // Setting the prototype of req is not desirable because the entire api of IncomingMessage is not supported
41 | for (const p of reqProtoProps()) {
42 | if (!req[p]) req[p] = http.IncomingMessage.prototype[p]
43 | }
44 | }
45 | const query = uwsReq.getQuery()
46 | req.path = uwsReq.getUrl()
47 | req.url = `${req.path}${query ? '?' + query : ''}`
48 | const paramToIndex = pathParameters.reduce((acc, cur, i) => {
49 | acc[cur] = i
50 | return acc
51 | }, {})
52 | req.spliffyUrl = {
53 | path: req.path,
54 | query: (query && parseQuery(query, decodeQueryParameters)) || {},
55 | param: name => uwsReq.getParameter(paramToIndex[name])
56 | }
57 | req.query = req.spliffyUrl.query
58 | req.headers = {}
59 | uwsReq.forEach((header, value) => { req.headers[header] = value })
60 | req.method = uwsReq.getMethod().toUpperCase()
61 | req.remoteAddress = addressArrayBufferToString(res.getRemoteAddressAsText())
62 | req.proxiedRemoteAddress = addressArrayBufferToString(res.getProxiedRemoteAddressAsText())
63 | req.get = header => req.headers[header]
64 | if (parseCookie) {
65 | req.cookies = (req.headers.cookie && cookie.parse(req.headers.cookie)) || {}
66 | }
67 | return req
68 | }
69 |
70 | function toArrayBuffer (buffer) {
71 | return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
72 | }
73 |
74 | export function decorateResponse (res, req, finalizeResponse, errorTransformer, endError, { acceptsDefault }) {
75 | res.onAborted(() => {
76 | res.ended = true
77 | res.writableEnded = true
78 | res.finalized = true
79 | log.error(`Request to ${req.url} was aborted`)
80 | })
81 | res.acceptsDefault = acceptsDefault
82 | res.headers = {}
83 | res.headersSent = false
84 | res.setHeader = (header, value) => {
85 | res.headers[normalizeHeader(header)] = value
86 | }
87 | res.removeHeader = header => {
88 | delete res.headers[normalizeHeader(header)]
89 | }
90 | res.flushHeaders = () => {
91 | if (res.headersSent) return
92 | if (!res.statusCode) res.statusCode = httpStatusCodes.OK
93 | if (!res.statusMessage) res.statusMessage = defaultStatusMessages[res.statusCode]
94 | res.headersSent = true
95 | res.writeStatus(`${res.statusCode} ${res.statusMessage}`)
96 | if (typeof res.onFlushHeaders === 'function') {
97 | res.onFlushHeaders(res)
98 | }
99 | for (const header of Object.keys(res.headers)) {
100 | if (Array.isArray(res.headers[header])) {
101 | for (const multiple of res.headers[header]) {
102 | res.writeHeader(header, multiple.toString())
103 | }
104 | } else {
105 | res.writeHeader(header, res.headers[header].toString())
106 | }
107 | }
108 | }
109 | res.writeHead = (status, headers) => {
110 | res.statusCode = status
111 | res.assignHeaders(headers)
112 | }
113 | res.assignHeaders = headers => {
114 | for (const header of Object.keys(headers)) {
115 | res.headers[normalizeHeader(header)] = headers[header]
116 | }
117 | }
118 | res.getHeader = header => {
119 | return res.headers[normalizeHeader(header)]
120 | }
121 | res.status = (code) => {
122 | res.statusCode = code
123 | return this
124 | }
125 |
126 | res.uwsWrite = res.write
127 | res.write = (chunk, encoding, cb) => {
128 | try {
129 | res.streaming = true
130 | res.flushHeaders()
131 | let data
132 | if (chunk instanceof Buffer) {
133 | data = toArrayBuffer(chunk)
134 | } else if (typeof chunk === 'string') {
135 | data = toArrayBuffer(Buffer.from(chunk, encoding || 'utf8'))
136 | } else {
137 | data = toArrayBuffer(Buffer.from(JSON.stringify(chunk), encoding || 'utf8'))
138 | }
139 | const result = res.uwsWrite(data)
140 | if (typeof cb === 'function') {
141 | cb()
142 | }
143 | return result
144 | } catch (e) {
145 | if (typeof cb === 'function') {
146 | cb(e)
147 | } else {
148 | throw e
149 | }
150 | }
151 | }
152 | let outStream
153 | res.getWritable = () => {
154 | if (!outStream) {
155 | res.streaming = true
156 | outStream = new Writable({
157 | write: res.write
158 | })
159 | .on('finish', res.end)
160 | .on('end', res.end)
161 | .on('error', e => {
162 | try {
163 | outStream.destroy()
164 | } finally {
165 | endError(res, e, uuid(), errorTransformer)
166 | }
167 | })
168 | }
169 | return outStream
170 | }
171 |
172 | const uwsEnd = res.end
173 | res.ended = false
174 | res.end = body => {
175 | if (res.ended) {
176 | return
177 | }
178 | // provide writableEnded like node does, with slightly different behavior
179 | if (!res.writableEnded) {
180 | res.flushHeaders()
181 | uwsEnd.call(res, body)
182 | res.writableEnded = true
183 | res.ended = true
184 | }
185 | if (typeof res.onEnd === 'function') {
186 | res.onEnd()
187 | }
188 | }
189 |
190 | res.redirect = function (code, location) {
191 | if (arguments.length === 1) {
192 | location = code
193 | code = httpStatusCodes.MOVED_PERMANENTLY
194 | }
195 | return finalizeResponse(req, res, {
196 | statusCode: code,
197 | headers: {
198 | location: location
199 | }
200 | })
201 | }
202 | res.send = (body) => {
203 | finalizeResponse(req, res, body)
204 | }
205 | res.json = res.send
206 | res.setCookie = setCookie(res)
207 | res.cookie = res.setCookie
208 | return res
209 | }
210 |
--------------------------------------------------------------------------------
/src/errors.mjs:
--------------------------------------------------------------------------------
1 | export class HttpError extends Error {
2 | constructor (statusCode, statusMessage, body) {
3 | super()
4 | this.statusCode = statusCode
5 | this.statusMessage = statusMessage
6 | this.body = body
7 | }
8 | }
9 |
10 | export class BadRequestError extends HttpError {
11 | constructor (body) {
12 | super(400, 'Bad Request', body)
13 | }
14 | }
15 |
16 | export class UnauthorizedError extends HttpError {
17 | constructor (body) {
18 | super(401, 'Unauthorized', body)
19 | }
20 | }
21 |
22 | export class PaymentRequiredError extends HttpError {
23 | constructor (body) {
24 | super(402, 'Payment Required', body)
25 | }
26 | }
27 |
28 | export class ForbiddenError extends HttpError {
29 | constructor (body) {
30 | super(403, 'Forbidden', body)
31 | }
32 | }
33 |
34 | export class NotFoundError extends HttpError {
35 | constructor (body) {
36 | super(404, 'Not Found', body)
37 | }
38 | }
39 |
40 | export class MethodNotAllowedError extends HttpError {
41 | constructor (body) {
42 | super(405, 'Method Not Allowed', body)
43 | }
44 | }
45 |
46 | export class NotAcceptableError extends HttpError {
47 | constructor (body) {
48 | super(406, 'Not Acceptable', body)
49 | }
50 | }
51 |
52 | export class ProxyAuthenticationRequiredError extends HttpError {
53 | constructor (body) {
54 | super(407, 'Proxy Authentication Required', body)
55 | }
56 | }
57 |
58 | export class RequestTimeoutError extends HttpError {
59 | constructor (body) {
60 | super(408, 'Request Timeout', body)
61 | }
62 | }
63 |
64 | export class ConflictError extends HttpError {
65 | constructor (body) {
66 | super(409, 'Conflict', body)
67 | }
68 | }
69 |
70 | export class GoneError extends HttpError {
71 | constructor (body) {
72 | super(410, 'Gone', body)
73 | }
74 | }
75 |
76 | export class LengthRequiredError extends HttpError {
77 | constructor (body) {
78 | super(411, 'Length Required', body)
79 | }
80 | }
81 |
82 | export class PreconditionFailedError extends HttpError {
83 | constructor (body) {
84 | super(412, 'Precondition Failed', body)
85 | }
86 | }
87 |
88 | export class PayloadTooLargeError extends HttpError {
89 | constructor (body) {
90 | super(413, 'Payload Too Large', body)
91 | }
92 | }
93 |
94 | export class URITooLongError extends HttpError {
95 | constructor (body) {
96 | super(414, 'URI Too Long', body)
97 | }
98 | }
99 |
100 | export class UnsupportedMediaTypeError extends HttpError {
101 | constructor (body) {
102 | super(415, 'Unsupported Media Type', body)
103 | }
104 | }
105 |
106 | export class RangeNotSatisfiableError extends HttpError {
107 | constructor (body) {
108 | super(416, 'Bad Request', body)
109 | }
110 | }
111 |
112 | export class ExpectationFailedError extends HttpError {
113 | constructor (body) {
114 | super(417, 'Expectation Failed', body)
115 | }
116 | }
117 |
118 | export class ImATeapotError extends HttpError {
119 | constructor (body) {
120 | super(418, 'I can\'t brew coffee', body)
121 | }
122 | }
123 |
124 | export class EnhanceYourCalmError extends HttpError {
125 | constructor (body) {
126 | super(420, 'Enhance Your Calm', body)
127 | }
128 | }
129 |
130 | export class MisdirectedRequestError extends HttpError {
131 | constructor (body) {
132 | super(421, 'Misdirected Request', body)
133 | }
134 | }
135 |
136 | export class UnprocessableEntityError extends HttpError {
137 | constructor (body) {
138 | super(422, 'Unprocessable Entity', body)
139 | }
140 | }
141 |
142 | export class LockedError extends HttpError {
143 | constructor (body) {
144 | super(423, 'Locked', body)
145 | }
146 | }
147 |
148 | export class FailedDependencyError extends HttpError {
149 | constructor (body) {
150 | super(424, 'Failed Dependency', body)
151 | }
152 | }
153 |
154 | export class TooEarlyError extends HttpError {
155 | constructor (body) {
156 | super(425, 'Too Early', body)
157 | }
158 | }
159 |
160 | export class UpgradeRequiredError extends HttpError {
161 | constructor (body) {
162 | super(426, 'Upgrade Required', body)
163 | }
164 | }
165 |
166 | export class PreconditionRequiredError extends HttpError {
167 | constructor (body) {
168 | super(428, 'Precondition Required', body)
169 | }
170 | }
171 |
172 | export class TooManyRequestsError extends HttpError {
173 | constructor (body) {
174 | super(429, 'Too Many Requests', body)
175 | }
176 | }
177 |
178 | export class RequestHeaderFieldsTooLargeError extends HttpError {
179 | constructor (body) {
180 | super(431, 'Request Header Fields Too Large', body)
181 | }
182 | }
183 |
184 | export class UnavailableForLegalReasonsError extends HttpError {
185 | constructor (body) {
186 | super(451, 'Unavailable For Legal Reasons', body)
187 | }
188 | }
189 |
190 | export class InternalServerError extends HttpError {
191 | constructor (body) {
192 | super(500, 'Internal Server Error', body)
193 | }
194 | }
195 |
196 | export class NotImplementedError extends HttpError {
197 | constructor (body) {
198 | super(501, 'Not Implemented', body)
199 | }
200 | }
201 |
202 | export class BadGatewayError extends HttpError {
203 | constructor (body) {
204 | super(502, 'Bad Gateway', body)
205 | }
206 | }
207 |
208 | export class ServiceUnavailableError extends HttpError {
209 | constructor (body) {
210 | super(503, 'Service Unavailable', body)
211 | }
212 | }
213 |
214 | export class GatewayTimeoutError extends HttpError {
215 | constructor (body) {
216 | super(504, 'Gateway Timeout', body)
217 | }
218 | }
219 |
220 | export class HTTPVersionNotSupportedError extends HttpError {
221 | constructor (body) {
222 | super(505, 'HTTP Version Not Supported', body)
223 | }
224 | }
225 |
226 | export class VariantAlsoNegotiatesError extends HttpError {
227 | constructor (body) {
228 | super(506, 'Variant Also Negotiates', body)
229 | }
230 | }
231 |
232 | export class InsufficientStorageError extends HttpError {
233 | constructor (body) {
234 | super(507, 'Insufficient Storage', body)
235 | }
236 | }
237 |
238 | export class LoopDetectedError extends HttpError {
239 | constructor (body) {
240 | super(508, 'Loop Detected', body)
241 | }
242 | }
243 |
244 | export class NotExtendedError extends HttpError {
245 | constructor (body) {
246 | super(510, 'Not Extended', body)
247 | }
248 | }
249 |
250 | export class NetworkAuthenticationRequiredError extends HttpError {
251 | constructor (body) {
252 | super(511, 'Network Authentication Required', body)
253 | }
254 | }
255 |
--------------------------------------------------------------------------------
/src/handler.mjs:
--------------------------------------------------------------------------------
1 | import log from './log.mjs'
2 | import { deserializeBody, serializeBody } from './content.mjs'
3 | import { invokeMiddleware } from './middleware.mjs'
4 | import { decorateResponse, decorateRequest } from './decorator.mjs'
5 | import { v4 as uuid } from 'uuid'
6 | import stream from 'stream'
7 | const { Readable } = stream
8 |
9 | /**
10 | * Execute the handler
11 | * @param url The url being requested
12 | * @param res The uws response object
13 | * @param req The uws request object
14 | * @param bodyPromise The request body promise
15 | * @param handler The handler function for the route
16 | * @param middleware The middleware that applies to this request
17 | * @param errorTransformer An errorTransformer to convert error objects into response data
18 | */
19 | const executeHandler = async (url, res, req, bodyPromise, handler, middleware, errorTransformer) => {
20 | try {
21 | bodyPromise = bodyPromise.then(bodyContent => {
22 | if (bodyContent instanceof Readable) return bodyContent
23 | if (res.writableEnded) return
24 | return deserializeBody(bodyContent, req.headers['content-type'], res.acceptsDefault)
25 | })
26 | } catch (e) {
27 | log.error('Failed to parse request.', e)
28 | end(res, 400, handler.statusCodeOverride)
29 | return
30 | }
31 |
32 | try {
33 | const handled = await handler({ url, bodyPromise, headers: req.headers, req, res })
34 | finalizeResponse(req, res, handled, handler.statusCodeOverride)
35 | } catch (e) {
36 | const refId = uuid()
37 | await executeMiddleware(middleware, req, res, errorTransformer, refId, e)
38 | endError(res, e, refId, errorTransformer)
39 | }
40 | }
41 |
42 | const endError = (res, e, refId, errorTransformer) => {
43 | if (e.body && typeof e.body !== 'string') {
44 | e.body = JSON.stringify(e.body)
45 | }
46 | if (typeof errorTransformer === 'function') {
47 | e = errorTransformer(e, refId)
48 | }
49 | res.headers['x-ref-id'] = refId
50 | const status = e.statusCode || 500
51 | if (status === 500) {
52 | log.error(e)
53 | }
54 | end(res, status, null, e.body || '')
55 | }
56 |
57 | const end = (res, defaultStatusCode, statusCodeOverride, body) => {
58 | // status set directly on res wins
59 | res.statusCode = statusCodeOverride || res.statusCode || defaultStatusCode
60 | if (body instanceof Readable || res.streaming) {
61 | res.streaming = true
62 | if (body instanceof Readable) {
63 | pipeResponse(res, body)
64 | }
65 | // handler is responsible for ending the response if they are streaming
66 | } else {
67 | res.end(doSerializeBody(body, res) || '')
68 | }
69 | }
70 |
71 | const ipv6CompressRegex = /\b:?(?:0+:?){2,}/g
72 |
73 | const compressIpv6 = ip => ip && ip.includes(':') ? ip.replaceAll(ipv6CompressRegex, '::') : ip
74 |
75 | const writeAccess = function (req, res) {
76 | const start = new Date().getTime()
77 | return () => {
78 | log.access(compressIpv6(req.remoteAddress), compressIpv6(res.proxiedRemoteAddress) || '', res.statusCode, req.method, req.url, new Date().getTime() - start + 'ms')
79 | }
80 | }
81 |
82 | const finalizeResponse = (req, res, handled, statusCodeOverride) => {
83 | if (!res.finalized) {
84 | if (!handled) {
85 | // if no error was thrown, assume everything is fine. Otherwise each handler must return truthy which is un-necessary for methods that don't need to return anything
86 | end(res, 200, statusCodeOverride)
87 | } else {
88 | // if the returned object has known fields, treat it as a response object instead of the body
89 | if (handled.body || handled.statusMessage || handled.statusCode || handled.headers) {
90 | if (handled.headers) {
91 | res.assignHeaders(handled.headers)
92 | }
93 | res.statusMessage = handled.statusMessage || res.statusMessage
94 | end(res, handled.statusCode || 200, statusCodeOverride, handled.body)
95 | } else {
96 | end(res, 200, statusCodeOverride, handled)
97 | }
98 | }
99 | res.finalized = true
100 | }
101 | }
102 |
103 | const pipeResponse = (res, readStream, errorTransformer) => {
104 | readStream.on('data', res.write)
105 | .on('end', res.end)
106 | .on('error', e => {
107 | try {
108 | readStream.destroy()
109 | } finally {
110 | endError(res, e, uuid(), errorTransformer)
111 | }
112 | })
113 | }
114 |
115 | const doSerializeBody = (body, res) => {
116 | if (!body || typeof body === 'string' || body instanceof Readable) {
117 | return body
118 | }
119 | const contentType = res.getHeader('content-type')
120 | const serialized = serializeBody(body, contentType, res.acceptsDefault)
121 |
122 | if (serialized?.contentType && !contentType) {
123 | res.headers['content-type'] = serialized.contentType
124 | }
125 | return serialized?.data || ''
126 | }
127 |
128 | async function executeMiddleware (middleware, req, res, errorTransformer, refId, e) {
129 | if (!middleware) return
130 |
131 | let applicableMiddleware = middleware[req.method]
132 | if (middleware.ALL) {
133 | if (applicableMiddleware) applicableMiddleware = middleware.ALL.concat(applicableMiddleware)
134 | else applicableMiddleware = middleware.ALL
135 | }
136 |
137 | if (!applicableMiddleware || applicableMiddleware.length === 0) {
138 | return
139 | }
140 | if (e) {
141 | await invokeMiddleware(applicableMiddleware.filter(mw => mw.length === 4), req, res, e)
142 | } else {
143 | await invokeMiddleware(applicableMiddleware.filter(mw => mw.length === 3), req, res)
144 | }
145 | }
146 |
147 | const handleRequest = async (req, res, handler, middleware, errorTransformer) => {
148 | try {
149 | let reqBody
150 | if (!handler.streamRequestBody) {
151 | let buffer
152 | reqBody = new Promise(
153 | resolve =>
154 | res.onData(async (data, isLast) => {
155 | if (isLast) {
156 | buffer = data.byteLength > 0 ? Buffer.concat([buffer, Buffer.from(data)].filter(b => b)) : buffer
157 | resolve(buffer)
158 | }
159 | buffer = Buffer.concat([buffer, Buffer.from(data)].filter(b => b))
160 | })
161 | )
162 | } else {
163 | const readable = new Readable({
164 | read: () => {
165 | }
166 | })
167 | res.onData(async (data, isLast) => {
168 | if (data.byteLength === 0 && !isLast) return
169 | // data must be copied so it isn't lost
170 | readable.push(Buffer.concat([Buffer.from(data)]))
171 | if (isLast) {
172 | readable.push(null)
173 | }
174 | })
175 | reqBody = Promise.resolve(readable)
176 | }
177 | await executeMiddleware(middleware, req, res, errorTransformer)
178 | if (!res.writableEnded && !res.ended) {
179 | await executeHandler(req.spliffyUrl, res, req, reqBody, handler, middleware, errorTransformer)
180 | }
181 | } catch (e) {
182 | const refId = uuid()
183 | await executeMiddleware(middleware, req, res, errorTransformer, refId, e)
184 | if (!res.writableEnded) { endError(res, e, refId, errorTransformer) }
185 | }
186 | }
187 |
188 | export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD', 'CONNECT', 'TRACE', 'WEBSOCKET']
189 |
190 | let currentDate = new Date().toISOString()
191 | setInterval(() => { currentDate = new Date().toISOString() }, 1000)
192 |
193 | export const createHandler = (handler, middleware, pathParameters, config) => function (res, req) {
194 | try {
195 | res.cork(() => {
196 | req = decorateRequest(req, pathParameters, res, config)
197 | res = decorateResponse(res, req, finalizeResponse, config.errorTransformer, endError, config)
198 |
199 | if (config.logAccess) {
200 | res.onEnd = writeAccess(req, res)
201 | }
202 |
203 | if (config.writeDateHeader) {
204 | res.headers.date = currentDate
205 | }
206 |
207 | handleRequest(req, res, handler, middleware, config.errorTransformer)
208 | .catch(e => {
209 | log.error('Failed handling request', e)
210 | res.statusCode = 500
211 | res.end()
212 | })
213 | })
214 | } catch (e) {
215 | log.error('Failed handling request', e)
216 | res.statusCode = 500
217 | res.end()
218 | }
219 | }
220 |
221 | export const createNotFoundHandler = config => {
222 | const handler = config.defaultRouteHandler || config.notFoundRouteHandler
223 | const params = handler?.pathParameters || []
224 | return (res, req) => {
225 | try {
226 | req = decorateRequest(req, params, res, config)
227 | res = decorateResponse(res, req, finalizeResponse, config.errorTransformer, endError, config)
228 | if (config.logAccess) {
229 | res.onEnd = writeAccess(req, res)
230 | }
231 | if (handler && typeof handler === 'object') {
232 | if (handler.handlers && typeof handler.handlers[req.method] === 'function') {
233 | if ('statusCodeOverride' in handler) {
234 | handler.handlers[req.method].statusCodeOverride = handler.statusCodeOverride
235 | }
236 | handleRequest(req, res,
237 | handler.handlers[req.method],
238 | handler.middleware,
239 | config.errorTransformer
240 | ).catch((e) => {
241 | log.error('Unexpected exception during request handling', e)
242 | res.statusCode = 500
243 | })
244 | } else {
245 | res.statusCode = 404
246 | res.end()
247 | }
248 | } else {
249 | res.statusCode = 404
250 | res.end()
251 | }
252 | } catch (e) {
253 | log.error('Failed handling request', e)
254 | }
255 | }
256 | }
257 |
--------------------------------------------------------------------------------
/src/httpStatusCodes.mjs:
--------------------------------------------------------------------------------
1 | export const defaultStatusMessages = {
2 | 200: 'OK',
3 | 201: 'Created',
4 | 202: 'Accepted',
5 | 203: 'Non-Authoritative Information',
6 | 204: 'No Content',
7 | 205: 'Reset Content',
8 | 206: 'Partial Content',
9 | 300: 'Multiple Choice',
10 | 301: 'Moved Permanently',
11 | 302: 'Found',
12 | 303: 'See Other',
13 | 304: 'Not Modified',
14 | 307: 'Temporary Redirect',
15 | 308: 'Permanent Redirect',
16 | 400: 'Bad Request',
17 | 401: 'Unauthorized',
18 | 402: 'Payment Required',
19 | 403: 'Forbidden',
20 | 404: 'Not Found',
21 | 405: 'Method Not Allowed',
22 | 406: 'Not Acceptable',
23 | 407: 'Proxy Authentication Required',
24 | 408: 'Request Timeout',
25 | 409: 'Conflict',
26 | 410: 'Gone',
27 | 411: 'Length Required',
28 | 412: 'Precondition Failed',
29 | 413: 'Payload Too Large',
30 | 414: 'URI Too Long',
31 | 415: 'Unsupported Media Type',
32 | 416: 'Range Not Satisfiable',
33 | 417: 'Expectation Failed',
34 | 418: 'I\'m a teapot',
35 | 420: 'Enhance Your Calm',
36 | 425: 'Too Early',
37 | 426: 'Upgrade Required',
38 | 428: 'Precondition Required',
39 | 429: 'Too Many Requests',
40 | 431: 'Request Header Fields Too Large',
41 | 451: 'Unavailable For Legal Reasons',
42 | 500: 'Internal Server Error',
43 | 501: 'Not Implemented',
44 | 502: 'Bad Gateway',
45 | 503: 'Service Unavailable',
46 | 504: 'Gateway Timeout',
47 | 505: 'Http Version Not Supported',
48 | 506: 'Variant Also Negotiates',
49 | 510: 'Not Extended',
50 | 511: 'Network Authentication Required'
51 | }
52 |
53 | export default {
54 | OK: 200,
55 | CREATED: 201,
56 | ACCEPTED: 202,
57 | NON_AUTHORITATIVE_INFORMATION: 203,
58 | NO_CONTENT: 204,
59 | RESET_CONTENT: 205,
60 | PARTIAL_CONTENT: 206,
61 | MULTIPLE_CHOICE: 300,
62 | MOVED_PERMANENTLY: 301,
63 | FOUND: 302,
64 | SEE_OTHER: 303,
65 | NOT_MODIFIED: 304,
66 | TEMPORARY_REDIRECT: 307,
67 | PERMANENT_REDIRECT: 308,
68 | BAD_REQUEST: 400,
69 | UNAUTHORIZED: 401,
70 | PAYMENT_REQUIRED: 402,
71 | FORBIDDEN: 403,
72 | NOT_FOUND: 404,
73 | METHOD_NOT_ALLOWED: 405,
74 | NOT_ACCEPTABLE: 406,
75 | PROXY_AUTHENTICATION_REQUIRED: 407,
76 | REQUEST_TIMEOUT: 408,
77 | CONFLICT: 409,
78 | GONE: 410,
79 | LENGTH_REQUIRED: 411,
80 | PRECONDITION_FAILED: 412,
81 | PAYLOAD_TOO_LARGE: 413,
82 | URI_TOO_LONG: 414,
83 | UNSUPPORTED_MEDIA_TYPE: 415,
84 | RANGE_NOT_SATISFIABLE: 416,
85 | EXPECTATION_FAILED: 417,
86 | IM_A_TEAPOT: 418,
87 | ENHANCE_YOUR_CALM: 420,
88 | TOO_EARLY: 425,
89 | UPGRADE_REQUIRED: 426,
90 | PRECONDITION_REQUIRED: 428,
91 | TOO_MANY_REQUESTS: 429,
92 | REQUEST_HEADER_FIELDS_TOO_LARGE: 431,
93 | UNAVAILABLE_FOR_LEGAL_REASONS: 451,
94 | INTERNAL_SERVER_ERROR: 500,
95 | NOT_IMPLEMENTED: 501,
96 | BAD_GATEWAY: 502,
97 | SERVICE_UNAVAILABLE: 503,
98 | GATEWAY_TIMEOUT: 504,
99 | HTTP_VERSION_NOT_SUPPORTED: 505,
100 | VARIANT_ALSO_NEGOTIATES: 506,
101 | NOT_EXTENDED: 510,
102 | NETWORK_AUTHENTICATION_REQUIRED: 511
103 | }
104 |
--------------------------------------------------------------------------------
/src/index.mjs:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import { fileURLToPath } from 'url'
3 |
4 | /**
5 | * A helper for creating a redirect handler
6 | * @param location The location to redirect to
7 | * @param permanent Whether this is a permanent redirect or not
8 | */
9 | export const redirect = (location, permanent = true) => () => ({
10 | statusCode: permanent ? 301 : 302,
11 | headers: {
12 | location: location
13 | }
14 | })
15 |
16 | export { default as log } from './log.mjs'
17 | export { parseQuery, setMultiValueKey } from './url.mjs'
18 |
19 | /**
20 | * Startup function for the spliffy server
21 | * @param config See https://github.com/narcolepticsnowman/spliffy#config
22 | * @returns {Promise} Either the https server if https is configured or the http server
23 | */
24 | export { default } from './start.mjs'
25 |
26 | /**
27 | * Get the dirname for the given meta url
28 | * @param metaUrl The import.meta.url value to get the dirname from
29 | * @return {string} The full path to the directory the module is in
30 | */
31 | export const moduleDirname = metaUrl => path.dirname(fileURLToPath(metaUrl))
32 |
--------------------------------------------------------------------------------
/src/log.mjs:
--------------------------------------------------------------------------------
1 | import util from 'util'
2 |
3 | const inspect = util.inspect
4 | const levelOrder = { TRACE: 10, DEBUG: 20, INFO: 30, ACCESS: 30, 'GOOD NEWS EVERYONE!': 30, WARN: 40, ERROR: 50, FATAL: 60, NONE: 100 }
5 | let logLevel = levelOrder.INFO
6 |
7 | const ifLevelEnabled = (fn, level, args) => {
8 | const configLevel = levelOrder[logLevel] || levelOrder.INFO
9 | if (levelOrder[level] >= configLevel) {
10 | fn(`[${new Date().toISOString()}] [${level}] ${args.map(a => typeof a === 'string' ? a : inspect(a, { depth: null })).join(' ')}`)
11 | }
12 | }
13 |
14 | const callLog = (level, logImplFn, defaultFn, args) => {
15 | if (logImpl && typeof logImpl[logImplFn] === 'function') {
16 | logImpl[logImplFn](...args)
17 | } else {
18 | ifLevelEnabled(defaultFn, level, args)
19 | }
20 | }
21 |
22 | let logImpl = null
23 |
24 | export default {
25 | setLogLevel (level) {
26 | level = level.toUpperCase()
27 | if (!(level in levelOrder)) {
28 | throw new Error(`Invalid level: ${level}`)
29 | }
30 | logLevel = level
31 | },
32 | setLogger (logger) {
33 | logImpl = logger
34 | },
35 | trace () {
36 | callLog('TRACE', 'trace', console.trace, [...arguments])
37 | },
38 | debug () {
39 | callLog('DEBUG', 'debug', console.debug, [...arguments])
40 | },
41 | info () {
42 | callLog('INFO', 'info', console.info, [...arguments])
43 | },
44 | gne () {
45 | callLog('GOOD NEWS EVERYONE!', 'info', console.info, [...arguments])
46 | },
47 | access () {
48 | callLog('ACCESS', 'info', console.info, [...arguments])
49 | },
50 | warn () {
51 | callLog('WARN', 'warn', console.warn, [...arguments])
52 | },
53 | error () {
54 | callLog('ERROR', 'error', console.error, [...arguments].map(arg => arg.stack ? arg.stack : arg))
55 | },
56 | fatal () {
57 | callLog('ERROR', 'error', console.error, [...arguments].map(arg => arg.stack ? arg.stack : arg))
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/middleware.mjs:
--------------------------------------------------------------------------------
1 | import log from './log.mjs'
2 | /**
3 | * middleware is stored as an object where the properties are request methods the middleware applies to
4 | * if a middleware applies to all methods, the property ALL is used
5 | * example:
6 | * {
7 | * GET: [(req,res,next)=>console.log('ice cream man')]
8 | * POST: [(req,res,next)=>console.log('gelato')]
9 | * ALL: [(req,res,next)=>console.log('bruce banner')]
10 | * }
11 | */
12 | export const mergeMiddleware = (incoming, existing) => {
13 | const mergeInto = cloneMiddleware(existing)
14 |
15 | validateMiddleware(incoming)
16 | if (Array.isArray(incoming)) {
17 | mergeInto.ALL = (existing.ALL || []).concat(incoming)
18 | } else if (typeof incoming === 'object') {
19 | for (const method in incoming) {
20 | const upMethod = method.toUpperCase()
21 | mergeInto[upMethod] = (mergeInto[method] || []).concat(incoming[upMethod] || [])
22 | }
23 | }
24 | return mergeInto
25 | }
26 |
27 | export const cloneMiddleware = (middleware) => {
28 | const clone = { ...middleware }
29 | for (const method in middleware) {
30 | clone[method] = [...(middleware[method] || [])]
31 | }
32 | return clone
33 | }
34 |
35 | /**
36 | * Ensure the given middleware is valid
37 | * @param middleware
38 | */
39 | export const validateMiddleware = (middleware) => {
40 | if (!Array.isArray(middleware) && typeof middleware === 'object') {
41 | for (const method in middleware) {
42 | // ensure methods are always available as uppercase
43 | const upMethod = method.toUpperCase()
44 | middleware[upMethod] = middleware[method]
45 | validateMiddlewareArray(middleware[upMethod])
46 | }
47 | } else {
48 | validateMiddlewareArray(middleware)
49 | }
50 | }
51 |
52 | export const validateMiddlewareArray = (arr) => {
53 | if (!arr) return
54 | if (!Array.isArray(arr)) {
55 | throw new Error('middleware must be an array of functions')
56 | }
57 | for (const f of arr) {
58 | if (typeof f !== 'function') {
59 | throw new Error('Each element in the array of middleware must be a function')
60 | }
61 | }
62 | }
63 |
64 | export async function invokeMiddleware (middleware, req, res, reqErr) {
65 | await new Promise((resolve, reject) => {
66 | let current = -1
67 | const next = (err) => {
68 | if (err) reject(err)
69 | if (res.writableEnded) {
70 | resolve()
71 | return
72 | }
73 | current++
74 | if (current === middleware.length) {
75 | resolve()
76 | } else {
77 | try {
78 | if (reqErr) middleware[current](reqErr, req, res, next)
79 | else middleware[current](req, res, next)
80 | } catch (e) {
81 | log.error('Middleware threw exception', e)
82 | reject(e)
83 | }
84 | }
85 | }
86 |
87 | next()
88 | })
89 | }
90 |
--------------------------------------------------------------------------------
/src/nodeModuleHandler.mjs:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import path from 'path'
3 | import { mergeMiddleware } from './middleware.mjs'
4 | import { createStaticHandler } from './staticHandler.mjs'
5 | import { getContentTypeByExtension } from './content.mjs'
6 |
7 | const stripLeadingSlash = p => p.startsWith('/') ? p.substr(1) : p
8 |
9 | /**
10 | This helper will add all the configured node_module files to the given routes.
11 | The configured node moduleRoutes must be explicit files, no pattern matching is supported.
12 | Generating the list of files using pattern matching yourself is highly discouraged.
13 | It is much safer to explicitly list every file you wish to be served so you don't inadvertently serve additional files.
14 |
15 | The default method is to read and serve directly from the node_modules directory without copying.
16 | if method is set to "copy", files are copied from node_modules to their final location within the routes dir folder.
17 |
18 | The primary benefit of using this in copy mode is that the files will be automatically updated when the package version
19 | is updated, and it improves IDE integration by making the file really available after first run.
20 |
21 | This could be destructive if not configured correctly, hence the default of read only
22 | */
23 | export function getNodeModuleRoutes (config) {
24 | const nodeModuleRoutes = config.nodeModuleRoutes
25 | const routes = []
26 | if (nodeModuleRoutes && typeof nodeModuleRoutes === 'object') {
27 | const nodeModulesDir = nodeModuleRoutes.nodeModulesPath ? path.resolve(nodeModuleRoutes.nodeModulesPath) : path.resolve(config.routeDir, '..', 'node_modules')
28 | if (!fs.existsSync(nodeModulesDir)) {
29 | throw new Error(`Unable to find node_modules dir at ${nodeModulesDir}`)
30 | }
31 | const prefix = stripLeadingSlash(nodeModuleRoutes.routePrefix || 'lib/ext')
32 | if (!Array.isArray(nodeModuleRoutes.files)) {
33 | nodeModuleRoutes.files = [nodeModuleRoutes.files]
34 | }
35 | for (const file of nodeModuleRoutes.files) {
36 | let filePath, urlPath
37 | if (file && typeof file === 'object') {
38 | filePath = path.join(nodeModulesDir, file.modulePath)
39 | urlPath = `/${prefix}/${stripLeadingSlash(file.urlPath || file.modulePath)}`
40 | } else if (file && typeof file === 'string') {
41 | filePath = path.join(nodeModulesDir, file)
42 | urlPath = `/${prefix}/${stripLeadingSlash(file)}`
43 | } else {
44 | throw new Error('Invalid node_module file: ' + file)
45 | }
46 |
47 | if (fs.existsSync(filePath)) {
48 | if (nodeModuleRoutes.method === 'copy') {
49 | const dest = path.join(config.routeDir, urlPath)
50 | fs.mkdirSync(path.dirname(dest), { recursive: true })
51 | fs.copyFileSync(filePath, dest)
52 | } else {
53 | const parts = urlPath.split('/')
54 | const lastPart = parts.pop()
55 | const mw = {}
56 | mergeMiddleware(config.middleware, mw)
57 | mergeMiddleware(nodeModuleRoutes.middleware || {}, mw)
58 | routes.push({
59 | pathParameters: [],
60 | urlPath,
61 | filePath,
62 | handlers: createStaticHandler(
63 | filePath, getContentTypeByExtension(lastPart, config.staticContentTypes),
64 | config.cacheStatic, config.staticCacheControl
65 | ),
66 | middleware: mw
67 | })
68 | }
69 | } else {
70 | console.warn(`The specified node_modules file: ${file} does not exist and will not be served.`)
71 | }
72 | }
73 | }
74 | return routes
75 | }
76 |
--------------------------------------------------------------------------------
/src/routes.mjs:
--------------------------------------------------------------------------------
1 | import { validateMiddleware, mergeMiddleware } from './middleware.mjs'
2 | import { createStaticHandler } from './staticHandler.mjs'
3 | import { getContentTypeByExtension } from './content.mjs'
4 | import fs from 'fs'
5 | import path from 'path'
6 | import { HTTP_METHODS } from './handler.mjs'
7 | import util from 'util'
8 |
9 | const { promisify } = util
10 |
11 | const readdir = promisify(fs.readdir)
12 |
13 | const isVariable = part => part.startsWith('$')
14 | const getVariableName = part => part.substr(1)
15 | const getPathPart = name => {
16 | if (name === 'index') {
17 | return ''
18 | }
19 | if (name.startsWith('$')) {
20 | return `:${name.substr(1)}`
21 | } else if (name.endsWith('+')) {
22 | return `${name.substr(0, name.length - 1)}/*`
23 | } else {
24 | return name
25 | }
26 | }
27 | const filterTestFiles = config => f => (!f.name.endsWith('.test.js') && !f.name.endsWith('.test.mjs')) || config.allowTestFileRoutes
28 | const filterIgnoredFiles = config => f => !config.ignoreFilesMatching.filter(p => p).find(pattern => f.name.match(pattern))
29 | const ignoreHandlerFields = { middleware: true, streamRequestBody: true }
30 |
31 | const isRouteFile = name => name.endsWith('.rt.js') || name.endsWith('.rt.mjs') || name.endsWith('.rt.cjs')
32 | const isMiddlewareFile = name => name.endsWith('.mw.js') || name.endsWith('.mw.mjs') || name.endsWith('.mw.cjs')
33 |
34 | const doFindRoutes = async (config, currentFile, filePath, urlPath, pathParameters, inheritedMiddleware) => {
35 | const routes = []
36 | const name = currentFile.name
37 | if (currentFile.isDirectory()) {
38 | routes.push(...(await findRoutesInDir(name, filePath, urlPath, inheritedMiddleware, pathParameters, config)))
39 | } else if (!config.staticMode && isRouteFile(name)) {
40 | routes.push(await buildJSHandlerRoute(name, filePath, urlPath, inheritedMiddleware, pathParameters))
41 | } else {
42 | routes.push(...buildStaticRoutes(name, filePath, urlPath, inheritedMiddleware, pathParameters, config))
43 | }
44 | return routes
45 | }
46 |
47 | const wrapSyntaxError = (e, path) => {
48 | // Hack to workaround https://github.com/nodejs/modules/issues/471
49 | if (e instanceof SyntaxError) {
50 | const newError = new SyntaxError(`${e.message}. In file: ${path}`)
51 | newError.stack += `\nCaused By: ${e.stack}`
52 | throw newError
53 | }
54 | throw e
55 | }
56 |
57 | const importModules = async (config, dirPath, files) => Promise.all(
58 | files
59 | .filter(filterTestFiles(config))
60 | .filter(filterIgnoredFiles(config))
61 | .map(f => path.join(dirPath, f.name))
62 | .map(mwPath => import(`file://${mwPath}`)
63 | .then(module => ({ module, mwPath }))
64 | .catch(e => wrapSyntaxError(e, mwPath))
65 | ))
66 |
67 | const findRoutesInDir = async (name, filePath, urlPath, inheritedMiddleware, pathParameters, config) => {
68 | if (isVariable(name)) {
69 | pathParameters = pathParameters.concat(getVariableName(name))
70 | }
71 | const files = await readdir(filePath, { withFileTypes: true })
72 |
73 | const middlewareModules = await importModules(config, filePath, files.filter(f => isMiddlewareFile(f.name)))
74 | const dirMiddleware = middlewareModules.map(({ module, mwPath }) => {
75 | const middleware = module.middleware || module.default?.middleware
76 | if (!middleware) {
77 | throw new Error(`${mwPath} must export a middleware property or have a middleware property on the default export`)
78 | }
79 | try {
80 | validateMiddleware(middleware)
81 | } catch (e) {
82 | throw new Error('Failed to load middleware in file ' + mwPath + '\n' + e.message + '\n' + e.stack)
83 | }
84 | return middleware
85 | })
86 | .reduce((result, incoming) => mergeMiddleware(incoming, result), inheritedMiddleware)
87 |
88 | return Promise.all(files
89 | .filter(f => !isMiddlewareFile(f.name))
90 | .filter(filterTestFiles(config))
91 | .filter(filterIgnoredFiles(config))
92 | .map(
93 | (f) => doFindRoutes(
94 | config,
95 | f,
96 | path.join(filePath, f.name),
97 | urlPath + '/' + getPathPart(name),
98 | pathParameters,
99 | dirMiddleware
100 | )
101 | ))
102 | .then(routes => routes.flat())
103 | }
104 |
105 | const buildJSHandlerRoute = async (name, filePath, urlPath, inheritedMiddleware, pathParameters) => {
106 | if (name.endsWith('.mjs') || name.endsWith('.cjs')) {
107 | name = name.substr(0, name.length - '.rt.mjs'.length)
108 | } else {
109 | name = name.substr(0, name.length - '.rt.js'.length)
110 | }
111 | if (isVariable(name)) {
112 | pathParameters = pathParameters.concat(getVariableName(name))
113 | }
114 | const route = {
115 | pathParameters,
116 | urlPath: `${urlPath}/${getPathPart(name)}`,
117 | filePath,
118 | handlers: {}
119 | }
120 | let module
121 | try {
122 | module = await import(`file://${filePath}`)
123 | } catch (e) {
124 | wrapSyntaxError(e, filePath)
125 | }
126 | const handlers = module.default
127 | try {
128 | route.middleware = mergeMiddleware(handlers.middleware || [], inheritedMiddleware)
129 | } catch (e) {
130 | const err = new Error(`Failed to load middleware for route: ${filePath}`)
131 | err.stack += `\nCaused By: ${e.stack}`
132 | throw err
133 | }
134 | for (const method of Object.keys(handlers).filter(k => !ignoreHandlerFields[k])) {
135 | if (HTTP_METHODS.indexOf(method) === -1) {
136 | throw new Error(`Method: ${method} in file ${filePath} is not a valid http method. It must be one of: ${HTTP_METHODS}. Method names must be all uppercase.`)
137 | }
138 | const loadedHandler = handlers[method]
139 | let handler = loadedHandler
140 | if (typeof loadedHandler.handler === 'function') {
141 | handler = loadedHandler.handler
142 | }
143 | if (typeof handler !== 'function' && method !== 'WEBSOCKET') {
144 | throw new Error(`Request method ${method} in file ${filePath} must be a function. Got: ${typeof handlers[method]}`)
145 | } else if (method === 'WEBSOCKET' && typeof handler !== 'object') {
146 | throw new Error(`Websocket in file ${filePath} must be an object. Got: ${typeof handlers[method]}`)
147 | }
148 | if (!('streamRequestBody' in loadedHandler)) {
149 | handler.streamRequestBody = handlers.streamRequestBody
150 | } else {
151 | handler.streamRequestBody = loadedHandler.streamRequestBody
152 | }
153 | route.handlers[method] = handler
154 | }
155 | return route
156 | }
157 |
158 | const buildStaticRoutes = (name, filePath, urlPath, inheritedMiddleware, pathParameters, config) => {
159 | const routes = []
160 | if (isVariable(name)) {
161 | pathParameters = pathParameters.concat(getVariableName(name))
162 | }
163 | const contentType = getContentTypeByExtension(name, config.staticContentTypes)
164 | const route = {
165 | pathParameters,
166 | urlPath: `${urlPath}/${getPathPart(name)}`,
167 | filePath,
168 | handlers: createStaticHandler(filePath, contentType, config.cacheStatic, config.staticCacheControl),
169 | middleware: inheritedMiddleware
170 | }
171 |
172 | routes.push(route)
173 |
174 | for (const ext of config.resolveWithoutExtension) {
175 | if (name.endsWith(ext)) {
176 | const strippedName = name.substr(0, name.length - ext.length)
177 | // in the index case we need to add both the stripped and an empty path so it will resolve the parent
178 | if (strippedName === 'index') {
179 | const noExtRoute = Object.assign({}, route)
180 | noExtRoute.urlPath = `${urlPath}/${strippedName}`
181 | routes.push(noExtRoute)
182 | }
183 | const noExtRoute = Object.assign({}, route)
184 | noExtRoute.urlPath = `${urlPath}/${getPathPart(strippedName)}`
185 | routes.push(noExtRoute)
186 | }
187 | }
188 | return routes
189 | }
190 |
191 | export async function findRoutes (config) {
192 | const fullRouteDir = path.resolve(config.routeDir)
193 | if (!fs.existsSync(fullRouteDir)) {
194 | throw new Error(`can't find route directory: ${fullRouteDir}`)
195 | }
196 | const appMiddleware = mergeMiddleware(config.middleware || [], {})
197 | const files = await readdir(fullRouteDir, { withFileTypes: true })
198 | return Promise.all(files
199 | .filter(filterTestFiles(config))
200 | .filter(filterIgnoredFiles(config))
201 | .map(
202 | f => doFindRoutes(config, f, path.join(fullRouteDir, f.name), '', [], appMiddleware)
203 | ))
204 | .then(routes => routes.flat())
205 | }
206 |
--------------------------------------------------------------------------------
/src/server.mjs:
--------------------------------------------------------------------------------
1 | import log from './log.mjs'
2 | import { getNodeModuleRoutes } from './nodeModuleHandler.mjs'
3 | import uws from 'uWebSockets.js'
4 | import { createHandler, createNotFoundHandler } from './handler.mjs'
5 | import { findRoutes } from './routes.mjs'
6 | import path from 'path'
7 | import fs from 'fs'
8 |
9 | const state = {
10 | routes: {},
11 | initialized: false
12 | }
13 | const appMethods = {
14 | GET: 'get',
15 | POST: 'post',
16 | PUT: 'put',
17 | PATCH: 'patch',
18 | DELETE: 'del',
19 | OPTIONS: 'options',
20 | HEAD: 'head',
21 | CONNECT: 'connect',
22 | TRACE: 'trace',
23 | WEBSOCKET: 'ws'
24 | }
25 | const optionsHandler = (config, middleware, methods) => {
26 | return createHandler(() => ({
27 | headers: {
28 | allow: methods
29 | },
30 | statusCode: 204
31 | }),
32 | middleware,
33 | [],
34 | config
35 | )
36 | }
37 |
38 | const startHttpRedirect = (host, port) => {
39 | // redirect http to https
40 | uws.App().any('/*',
41 | (req, res) => {
42 | try {
43 | res.writeHead(301, { Location: `https://${req.headers.get('host')}:${port}${req.url}` })
44 | res.end()
45 | } catch (e) {
46 | log.error(`Failed to handle http request on port ${port}`, req.url, e)
47 | }
48 | }
49 | ).listen(host || '0.0.0.0', port, (token) => {
50 | if (token) {
51 | log.gne(`Http redirect server initialized at ${new Date().toISOString()} and listening on port ${port}`)
52 | } else {
53 | throw new Error(`Failed to start server on port ${port}`)
54 | }
55 | })
56 | }
57 |
58 | const getHttpsApp = (key, cert) => {
59 | const keyPath = path.resolve(key)
60 | const certPath = path.resolve(cert)
61 | if (!fs.existsSync(keyPath)) throw new Error(`Can't find https key file: ${keyPath}`)
62 | if (!fs.existsSync(certPath)) throw new Error(`Can't find https cert file: ${keyPath}`)
63 | return uws.App({
64 | key_file_name: keyPath,
65 | cert_file_name: certPath
66 | })
67 | }
68 |
69 | export async function startServer (config) {
70 | if (!state.initialized) {
71 | state.initialized = true
72 | const routes = [...getNodeModuleRoutes(config), ...(await findRoutes(config))]
73 | let app, port
74 | if (config.httpsKeyFile) {
75 | app = getHttpsApp(config.secure)
76 | port = config.secure.port || 14420
77 | startHttpRedirect(config.host, config.port || 10420)
78 | } else {
79 | app = uws.App()
80 | port = config.port || 10420
81 | }
82 |
83 | for (const route of routes) {
84 | if (config.printRoutes) {
85 | log.info('Configured Route: ', route)
86 | }
87 | const routePattern = `^${route.urlPath.replace(/:[^/]+/g, '[^/]+').replace(/\*/g, '.*')}$`
88 | if (config.notFoundRoute && config.notFoundRoute.match(routePattern)) {
89 | config.notFoundRouteHandler = route
90 | route.statusCodeOverride = 404
91 | }
92 | if (config.defaultRoute && config.defaultRoute.match(routePattern)) {
93 | config.defaultRouteHandler = route
94 | }
95 | let hadSlash = false
96 | if (route.urlPath.endsWith('/') && route.urlPath.length > 1) {
97 | hadSlash = true
98 | route.urlPath = route.urlPath.substring(0, route.urlPath.length - 1)
99 | }
100 | for (const method in route.handlers) {
101 | let theHandler = null
102 | if (method === 'WEBSOCKET') {
103 | theHandler = route.handlers[method]
104 | } else {
105 | theHandler = createHandler(route.handlers[method], route.middleware, route.pathParameters, config)
106 | }
107 | app[appMethods[method]](route.urlPath, theHandler)
108 | if (hadSlash && config.serveRoutesWithSlash) {
109 | app[appMethods[method]](route.urlPath + '/', theHandler)
110 | }
111 | if (route.urlPath.endsWith('/*')) {
112 | app[appMethods[method]](route.urlPath.substr(0, route.urlPath.length - 2), theHandler)
113 | }
114 | }
115 | if (config.autoOptions && !route.handlers.OPTIONS) {
116 | const theHandler = optionsHandler(config, route.middleware, Object.keys(route.handlers).join(', '))
117 | app.options(route.urlPath, theHandler)
118 | if (hadSlash && config.serveRoutesWithSlash) {
119 | app.options(route.urlPath + '/', theHandler)
120 | }
121 | }
122 | }
123 |
124 | if (config.notFoundRoute && !config.notFoundRouteHandler) {
125 | log.warn('No route matched not found route: ' + config.notFoundRoute)
126 | }
127 | if (config.defaultRoute && !config.defaultRouteHandler) {
128 | log.warn('No route matched default route: ' + config.notFoundRoute)
129 | }
130 |
131 | app.any('/*', createNotFoundHandler(config))
132 | app.listen(config.host || '::', config.port, (token) => {
133 | if (token) {
134 | log.gne(`Server initialized at ${new Date().toISOString()} and listening on port ${port}`)
135 | } else {
136 | throw new Error(`Failed to start server on port ${port}`)
137 | }
138 | })
139 | return app
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/src/serverConfig.mjs:
--------------------------------------------------------------------------------
1 | import { initContentHandlers } from './content.mjs'
2 | import { validateMiddleware } from './middleware.mjs'
3 | import log from './log.mjs'
4 |
5 | const defaultHeaders = {
6 | acceptsDefault: '*/*',
7 | defaultContentType: '*/*'
8 | }
9 | // this is mainly for performance reasons
10 | const nonsense = [
11 | 'I\'m toasted',
12 | 'that hurt',
13 | 'your interwebs!',
14 | 'I see a light...',
15 | 'totally zooted',
16 | 'misplaced my bits',
17 | 'maybe reboot?',
18 | 'what was I doing again?',
19 | 'my cabbages!!!',
20 | 'Leeerrroooyyy Jeeenkins',
21 | 'at least I have chicken'
22 | ]
23 |
24 | export const randomNonsense = () => `[OH NO, ${nonsense[Math.floor(Math.random() * nonsense.length)]}]`
25 |
26 | export async function initConfig (userConfig) {
27 | const config = Object.assign({}, userConfig)
28 |
29 | if (!('decodePathParameters' in config)) {
30 | config.decodePathParameters = false
31 | }
32 |
33 | if (!('decodeQueryParams' in config)) {
34 | config.decodeQueryParams = false
35 | }
36 |
37 | if (!('extendIncomingMessage' in config)) {
38 | config.extendIncomingMessage = false
39 | }
40 |
41 | if (!('parseCookie' in config)) {
42 | config.parseCookie = false
43 | }
44 |
45 | if (!('writeDateHeader' in config)) {
46 | config.writeDateHeader = false
47 | }
48 |
49 | config.acceptsDefault = config.acceptsDefault || defaultHeaders.acceptsDefault
50 | config.defaultContentType = config.defaultContentType || defaultHeaders.defaultContentType
51 |
52 | config.contentHandlers = initContentHandlers(config.contentHandlers || {})
53 | config.resolveWithoutExtension = config.resolveWithoutExtension || []
54 | if (!Array.isArray(config.resolveWithoutExtension)) {
55 | config.resolveWithoutExtension = [config.resolveWithoutExtension]
56 | }
57 |
58 | if (config.resolveWithoutExtension.indexOf('.htm') === -1) {
59 | config.resolveWithoutExtension.push('.htm')
60 | }
61 | if (config.resolveWithoutExtension.indexOf('.html') === -1) {
62 | config.resolveWithoutExtension.push('.html')
63 | }
64 |
65 | if (config.middleware) {
66 | validateMiddleware(config.middleware)
67 | }
68 |
69 | if (!('logAccess' in config)) {
70 | config.logAccess = false
71 | }
72 | if ('logLevel' in config) {
73 | log.setLogLevel(config.logLevel)
74 | }
75 | if (!('ignoreFilesMatching' in config)) {
76 | config.ignoreFilesMatching = []
77 | } else if (!Array.isArray(config.ignoreFilesMatching)) {
78 | config.ignoreFilesMatching = [config.ignoreFilesMatching]
79 | }
80 | if (!('allowTestFileRoutes' in config)) {
81 | config.allowTestFileRoutes = false
82 | }
83 | config.port = config.port || 10420
84 | if (!config.httpPort) {
85 | config.httpPort = config.port - 1
86 | }
87 |
88 | if (!('autoOptions' in config)) {
89 | config.autoOptions = false
90 | }
91 |
92 | if (config.logger) {
93 | log.setLogger(config.logger)
94 | }
95 |
96 | if ((config.httpsKeyFile && !config.httpsCertFile) || (!config.httpsKeyFile && config.httpsCertFile)) {
97 | throw new Error('You must provide both httpsKeyFile and httpsCertFile')
98 | }
99 | return config
100 | }
101 |
--------------------------------------------------------------------------------
/src/start.mjs:
--------------------------------------------------------------------------------
1 | import { initConfig, randomNonsense } from './serverConfig.mjs'
2 | import log from './log.mjs'
3 | import { startServer } from './server.mjs'
4 |
5 | export default async function (config) {
6 | if (!config || !config.routeDir) {
7 | throw new Error('You must supply a config object with at least a routeDir property. routeDir should be a full path.')
8 | }
9 | process
10 | .on('unhandledRejection', (reason, p) => {
11 | log.error(randomNonsense(), reason, 'Unhandled Rejection at Promise', p)
12 | })
13 | .on('uncaughtException', (err, origin) => {
14 | log.error(randomNonsense(), `Caught unhandled exception: ${err}\n` +
15 | `Exception origin: ${origin}`)
16 | })
17 |
18 | log.gne('Starting Spliffy!')
19 | const configWithDefaults = await initConfig(config)
20 | return startServer(configWithDefaults).catch(e => {
21 | log.error(randomNonsense(), 'Exception during startup:', e)
22 | // Spliffy threw an exception, or a route handler failed to load.
23 | process.exit(420)
24 | })
25 | }
26 |
--------------------------------------------------------------------------------
/src/staticHandler.mjs:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import etag from 'etag'
3 |
4 | const readFile = async (fullPath) => await new Promise(
5 | (resolve, reject) =>
6 | fs.readFile(fullPath, (err, data) => {
7 | if (err) reject(err)
8 | else resolve(data)
9 | }
10 | )
11 | )
12 |
13 | const writeHeaders = (req, res, tag, stat, contentType, staticCacheControl) => {
14 | if (req.headers['if-none-match'] === tag) {
15 | res.statusCode = 304
16 | return
17 | }
18 | res.writeHead(200, {
19 | 'Content-Type': contentType,
20 | // content-length should not be included because transfer-encoding is chunked
21 | // see https://datatracker.ietf.org/doc/html/rfc2616#section-4.4 sub section 3.
22 | // Not all clients are compliant (node-fetch) and throw instead of ignoring the header as specified
23 | 'Cache-Control': staticCacheControl || 'max-age=600',
24 | ETag: tag
25 | })
26 | }
27 |
28 | const readStat = async path => new Promise((resolve, reject) =>
29 | fs.stat(path, (err, stats) =>
30 | err ? reject(err) : resolve(stats)
31 | ))
32 |
33 | export function createStaticHandler (fullPath, contentType, cacheStatic, staticCacheControl) {
34 | const cache = {}
35 | return {
36 | GET: async ({ req, res }) => {
37 | if (cacheStatic) {
38 | if (!cache.exists || !cache.stat) {
39 | cache.exists = fs.existsSync(fullPath)
40 | if (cache.exists) {
41 | cache.stat = await readStat(fullPath)
42 | cache.content = await readFile(fullPath)
43 | cache.etag = etag(cache.content)
44 | }
45 | }
46 | if (!cache.exists) {
47 | return {
48 | statusCode: 404
49 | }
50 | }
51 | writeHeaders(req, res, cache.etag, cache.stat, contentType, staticCacheControl)
52 | if (res.statusCode === 304) {
53 | return
54 | }
55 | return cache.content
56 | } else {
57 | if (!fs.existsSync(fullPath)) {
58 | return {
59 | statusCode: 404
60 | }
61 | }
62 | const stat = await readStat(fullPath)
63 | writeHeaders(req, res, etag(stat), stat, contentType, staticCacheControl)
64 | if (res.statusCode === 304) {
65 | return
66 | }
67 | if (stat.size === 0) {
68 | return ''
69 | }
70 | return fs.createReadStream(fullPath)
71 | }
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/url.mjs:
--------------------------------------------------------------------------------
1 | export function parseQuery (query, decodeQueryParams) {
2 | const parsed = {}
3 | if (query) {
4 | if (decodeQueryParams) {
5 | query = decodeURIComponent(query.replace(/\+/g, '%20'))
6 | }
7 | for (const param of query.split('&')) {
8 | const eq = param.indexOf('=')
9 | setMultiValueKey(parsed, param.substr(0, eq), param.substr(eq + 1))
10 | }
11 | }
12 | return parsed
13 | }
14 |
15 | export function setMultiValueKey (obj, key, value) {
16 | if (key in obj) {
17 | if (!Array.isArray(obj[key])) {
18 | obj[key] = [obj[key]]
19 | }
20 | obj[key].push(value)
21 | } else {
22 | obj[key] = value
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/testServer.cjs:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const childProcess = require('child_process')
3 | let server
4 |
5 | module.exports = {
6 | start: async () => new Promise((resolve, reject) => {
7 | console.log('Starting spliffy server')
8 | const timeout = 5_000
9 | const rejectTimeout = setTimeout(() => {
10 | reject(new Error(`Server was not initialized within ${timeout}ms`))
11 | }, timeout)
12 | server = childProcess.spawn('node', [path.resolve(__dirname, 'example', 'serve.mjs')])
13 | server.on('error', err => {
14 | console.log('got error from server', err)
15 | clearTimeout(rejectTimeout)
16 | reject(err)
17 | })
18 | server.on('exit', (code) => {
19 | clearTimeout(rejectTimeout)
20 | if (code === 0) {
21 | resolve()
22 | } else {
23 | reject(new Error('Server exited with status: ' + code))
24 | }
25 | })
26 | server.stdout.setEncoding('utf-8')
27 | server.stdout.on('data', data => {
28 | console.log(data)
29 | if (data.match('Server initialized')) {
30 | clearTimeout(rejectTimeout)
31 | // give it a little extra time to initialize
32 | setTimeout(resolve, 250)
33 | }
34 | })
35 | server.stderr.setEncoding('utf-8')
36 | server.stderr.on('data', console.error)
37 | }),
38 | stop: async () => server.kill()
39 | }
40 |
--------------------------------------------------------------------------------