├── .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) => `${message}!` 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 | we ain't found shit 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 | `
6 | Name:
7 | Favorite Strain:
8 | Preferred Style:
13 | 14 |
`) 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 = `
4 | Name:
5 | Favorite Strain:
6 | Preferred Style:
11 | 12 |
` 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 | spliffy logo 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 |
4 | 5 | 6 |

7 |
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') 129 | res.end() 130 | }) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /example/www/streamWrite.test.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch') 2 | 3 | const expectedHtml = 'Particles' 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 | --------------------------------------------------------------------------------