2 * @file HTTP(S) Router that treats the first directory in a URL's path as
10 const dlog
= (msg
) => console
.log(msg
)
18 /** Regular expression for valid routes
19 * @prop {Object.RegEx} validRoutes - matches valid route names
21 validRoutes
: /[a-zA-Z][a-zA-Z0-9\-_]*/,
23 /** HTML to distribute initially during bootstrapping process
24 * @prop {string} bootStrapp - raw HTML distributed to all clients
25 * @prop {string} bootStrappJS - raw JS distributed to all clients
31 * @prop {Object.Map} routes - all the routes!
35 /** Parameters set on bootup (startHttpServer)
36 * @prop {string[2]} skelPage - html document split in twain for JS injection
37 * @prop {string} clientJS - jerverscripps to inject in skelPage for clients
38 * @prop {string} hostJS - jerverscripps for the hosts
39 * @prop {string} httpdRoot - a normalized path for http-servable files
40 * @prop {string} bindJail - jail bindings to this path
49 * @summary Start main HTTP server
50 * @desc starts up an HTTP or HTTPS server used for routing
51 * @arg {Object} conf - object containing configuration properties
52 * @prop {number|string} conf.port - local system port to bind to
53 * @prop {string} conf.skelFile - location of the skeleton HTML page
54 * @prop {string} conf.clientJS - client JS file
55 * @prop {string} conf.hostJS - host JS file
56 * @prop {string} [conf.httpdRoot] - root path of http-accessible files, if
57 * undefined no files are accessible
58 * @prop {Object} [conf.tls] - if present, startHttpServer will use tls
59 * @prop {string} [conf.tls.certFile] - tls certificate file
60 * @prop {string} [conf.tls.keyFile] - tls public key file
62 startHttpServer: function (conf
) {
64 throw new Error('httpd already running')
65 if (conf
.tls
== undefined)
66 this.httpd
= require('http').createServer((req
, res
) =>
67 this.httpdListener(req
, res
))
68 else if (!('keyFile' in conf
.tls
) || !('certFile' in conf
.tls
))
69 throw new Error('HTTPS requires a valid key and cert')
71 this.syncReads([conf
.tls
.keyFile
, conf
.tls
.certFile
]).then((results
) => {
72 Object
.defineProperty(this, 'httpsOpts', {
74 key
: results
[conf
.tls
.keyFile
],
75 cert
: results
[conf
.tls
.certFile
]
79 require('https').createServer(this.httpsOpts
, (request
,response
) =>
80 this.httpdListener(request
,response
))
84 this.httpdRoot
= require('path').normalize(conf
.httpdRoot
)
85 while (this.httpdRoot
[this.httpdRoot
.length
- 1] == require('path').sep
)
86 this.httpdRoot
= this.httpdRoot
.slice(0,-1)
88 this.syncReads([conf
.skelFile
, conf
.clientJS
, conf
.hostJS
])
90 this.skelPage
= results
[conf
.skelFile
].split('<!--STRAPP_SRC-->')
91 this.clientJS
= results
[conf
.clientJS
]
92 this.hostJS
= results
[conf
.hostJS
]
97 console
.log(`HTTP${(conf.tls == undefined) ? '' : 'S'} ` +
98 `Server Started on port ${conf.port}${this.httpdRoot ?
99 `, serving files
from ${this.httpdRoot}
`:''}`)
103 * @summary Create a binding for the server
104 * @desc makes a new route which is bound to a file or a path. routes
105 * bound to files always serve that file, regardless of any
106 * additional path parameters provided by the URI
107 * @arg {string} routeName - the route to create
108 * @arg {string} path - the path to the file or directory to bind
110 createBind: function (routeName
, path
) {
111 dlog(`Binding ${routeName} to ${path}`)
112 if (routeName
in this.routes
)
113 throw new Error(`route ${routeName} already exists`)
114 path
= require('path').normalize(path
)
116 && path
.indexOf(`${this.bindJail}/`) !== 0
117 && this.bindJail
!= path
)
118 throw new Error(`${routeName}:${path} jailed to ${this.bindJail}`)
119 if (require('fs').existsSync(path
)) {
120 this.routes
[routeName
] = {
123 dir
: require('fs').lstatSync(path
).isDirectory()
128 throw new Error(`${path} not found, ${routeName} not bound`)
133 * @desc listens for http client requests, authorization for hosts, message
134 * relaying with strapp API over HTTP
135 * @arg {http.ClientRequest} request
136 * @arg {http.ServerResponse} response
138 httpdListener: function (request
,response
) {
139 dlog(`Received request ${request.method} ${request.url}`)
141 /* No strapp type: serve bootstrapp.html and bootstrapp.js only */
143 if (request
.method
!== 'GET')
144 response
.writeHead(405)
145 else if (/^\/bootstrapp\.js[^/]*/
.test(request
.url
)) {
146 response
.writeHead(200, { 'Content-Type': 'application/javascript' })
147 response
.write(this.bootStrappJS
)
150 response
.writeHead(200, { 'Content-Type': 'text/html' })
151 response
.write(this.bootStrapp
)
157 const uri
= request
.url
.slice(1).split('?')
158 const routeName
= uri
[0].split('/')[0]
160 /* Process strapp types that make sense without a route */
161 if (routeName
=== '') {
163 case 'route-list': //GET
164 this.serveRouteList()
166 case 'register-account': //PUT
169 dlog(`x-strapp-type: ${type} not valid without a route`)
170 response
.writeHead(400)
175 /* Handle invalid routenames (serve files if httpdRoot is defined) */
176 else if (!this.validRoutes
.test(routeName
)) {
177 if (this.httpdRoot
) {
178 let realPath
= require('path').join(this.httpdRoot
, uri
[0])
179 if (realPath
== this.httpdRoot
)
180 realPath
+= '/index.html'
181 if (realPath
.indexOf(`${this.httpdRoot}/`) == 0) {
182 const stat_cb
= (err
, stat
) => {
185 response
.writeHead(404)
188 else if (stat
.isDirectory()) {
189 realPath
+= '/index.html'
190 require('fs').lstat(realPath
, stat_cb
)
192 else if (stat
.isFile())
193 this.serveFile(response
, realPath
)
195 response
.writeHead(403)
199 require('fs').lstat(realPath
, stat_cb
)
202 dlog(`Erroneous file location ${realPath} from ${request.url}`)
204 response
.writeHead(400)
208 /* Route does not exist */
209 else if (!(routeName
in this.routes
)) {
210 response
.writeHead(404)
215 const route
= this.routes
[routeName
]
216 const authData
= request
.headers
['Authorization']
217 switch (request
.method
) {
218 /* Public Requests */
219 case 'POST': //forward message to route
220 this.forward(route
, authData
, type
, request
.headers
['x-strapp-data'])
222 route
.socket
.send(JSON
.stringify(
224 sdp
: request
.headers
['x-sdp'],
226 responseID
: route
.nextResponseID(response
)
231 /* Authorization Required */
232 case 'make-socket': //CONNECT
233 this.authRouteOwner(route
, authData
)
234 .then(() => this.servePort(route
, response
))
235 .catch(() => this.serveHead(response
, 400))
237 case 'app-list': //TRACE
238 this.authRouteOwner(route
, authData
)
239 .then(() => this.serveAppList(response
))
240 .catch(() => this.serveHead(response
, 400))
243 const app
= request
.headers
['x-strapp-app']
244 if (app
&& app
!== '' && app
in this.appList
) {
245 this.authRouteOwner(route
, authData
)
246 .then(() => this.serveApp(request
.headers
['x-strapp-app'], response
))
247 .catch(() => this.serveHead(response
, 400))
250 this.serveHead(response
, 404)
253 this.serveHead(response
, 400)
254 dlog(`Unrecognized x-strapp-type: ${type}`)
258 let htArgv
= request
.url
.slice(1).split('?')
259 // const routeName = htArgv[0].split('/')[0]
261 /* At the root (no route selected) */
262 if (routeName
=== '') {
264 this.serveSplash(response
)
266 dlog(`${type} request at null route`)
267 response
.writeHead(400)
273 /* TODO: A new client account is registered */
274 if (type
=== 'register-account') {
275 dlog("Not implemented")
276 response
.writeHead(501)
281 let route
= this.routes
[routeName
]
283 /* TODO: A host, who needs authed, is requesting a route registration */
284 if (type
=== 'register-route') {
285 dlog("Not implemented")
286 response
.writeHead(501)
290 /* TODO: Register a route to a host account */
291 if (type
=== 'register-route-answer') {
292 this.routes
[routeName
] = true
293 require('get-port')()
295 this.createHost(routeName
, htArgv
, port
, request
, response
)
298 delete this.routes
[routeName
]
302 /* Route exists, but is bound to a directory */
303 if (route
&& route
.bind
) {
304 htArgv
[0] = htArgv
[0].replace(`${routeName}`,'')
305 if (htArgv
[0][0] === '/')
306 htArgv
[0] = htArgv
[0].slice(1)
307 this.serveBind(response
, route
.bind
, htArgv
)
311 /* Route may or may not be registered, and may or may not be online */
315 this.serveSplash(response
, routeName
)
316 else if (route
.online
)
317 route
.socket
.send(JSON
.stringify(
320 responseID
: route
.nextResponseID(response
),
324 response
.writeHead(503)
329 if (route
&& route
.online
)
330 route
.socket
.send(JSON
.stringify(
332 sdp
: request
.headers
['x-sdp'],
334 responseID
: route
.nextResponseID(response
)
337 response
.writeHead(503)
341 case 'ice-candidate':
342 if (route
&& route
.online
)
343 route
.socket
.send(JSON
.stringify(
345 ice
: request
.headers
['x-ice'],
347 responseID
: route
.nextResponseID(response
)
350 response
.writeHead(503)
354 case 'account-create' :
355 dlog("Not implemented")
356 response
.writeHead(501)
362 response
.writeHead(404)
363 else if (pubKey
!= route
.pubKey
)
364 response
.writeHead(401)
365 else if (route
.pendingSecret
) {
366 response
.writeHead(409)
367 if (route
.timeout
=== undefined)
368 route
.timeout
= setTimeout(() => {
369 delete route
.pendingSecret
374 response
.writeHead(200, { 'Content-Type': 'application/json' })
375 route
.pendingSecret
= this.nextSecret()
376 response
.write(JSON
.stringify(
378 secret
: this.encrypt(route
.pendingSecret
, route
.pubKey
),
385 case 'host-login-answer':
386 const answer
= request
.headers
['x-strapp-answer']
387 if (!route
|| pubKey
!= route
.pubKey
|| !route
.pendingSecret
) {
388 response
.writeHead(400)
391 else if (answer
&& this.decrypt(answer
) === route
.pendingSecret
) {
392 route
.socket
.close(1,'host reconnected')
394 this.serveSocket(response
, route
)
395 delete route
.pendingSecret
398 response
.writeHead(401)
402 case 'route-connect':
405 dlog(`Unrecognized x-strapp-type: ${type}`)
411 * @summary Serves a binding to a client
412 * @desc Resolves a binding and serves a response to the client
413 * @arg {http.ServerResponse} response - the response to use
414 * @arg {Object} bind - the binding to serve the client
415 * @arg {string[]} argv - path and arguments for the bind
417 serveBind: function (response
, bind
, argv
) {
418 dlog(`Serving binding ${bind.path}/${argv[0]}`)
421 argv
[0] = 'index.html'
422 this.serveFile(response
, require('path').join(bind
.path
, argv
[0]))
425 this.serveFile(response
, bind
.path
)
429 * @summary Serve a route to an http client
430 * @desc routes may be bound to the filesystem, or to an outgoing host
431 * @arg {http.ClientRequest} request - request from the client
432 * @arg {http.ServerResponse} response - response object to use
433 * @arg {Object} route - route associated with client request
435 serveClient: function (request
, response
, route
) {
436 const type
= request
.headers
['x-strapp-type']
437 const pubKey
= request
.headers
['x-strapp-pubkey']
438 dlog(`Client ${type || 'HT GET'} request routed to ${route.name}`)
442 response
.writeHead(200, { 'Content-Type': 'text/html' })
443 response
.write(`${this.skelPage[0]}${this.clientJS}${this.skelPage[1]}`)
446 case 'ice-candidate-request':
447 case 'ice-candidate-submission':
448 case 'client-sdp-offer':
451 let data
= request
.headers
['x-strapp-offer']
452 route
.pendingResponses
.addResponse(pubKey
, response
)
453 dlog(`${route.origin}=>\n${pubKey}\n${type}`)
454 dlog(JSON
.parse(data
))
455 route
.socket
.send(`${pubKey} ${type} ${data}`)
458 response
.writeHead(401)
463 response
.writeHead(400)
469 * @summary Create a new route for a host
470 * @desc makes a new route for the given route name
471 * @arg {string} routeName - name of the new route
472 * @arg {string[]} argv - Origin address from the request that made this
473 * route (for security verification on the socket)
474 * @arg {number|string} port - the port to listen on for websocket
475 * @arg {http.ClientRequest} request - host's request
476 * @arg {http.ServerResponse} response - responder
478 createHost: function (routeName
, argv
, port
, request
, response
) {
479 const origin
= (request
.headers
['x-forwarded-for'] ||
480 request
.connection
.remoteAddress
)
481 dlog(`New ${this.httpsOpts?'TLS ':''}route ${routeName}:${port}=>${origin}`)
482 const httpd
= this.httpsOpts
483 ? require('https').createServer(this.httpsOpts
)
484 : require('http').createServer()
486 pendingResponses
: new Map([]),
495 route
.httpd
.listen(port
)
496 route
.wsd
= new (require('ws').Server
)({ server
: httpd
})
497 .on('connection', (socket
) => {
498 route
.socket
= socket
499 socket
.on('message', (msg
) =>
500 this.hostMessage(msg
,route
))
502 route
.pendingResponses
.addResponse = function (key
, response_p
) {
503 let responses
= this.get(key
) || []
504 responses
.push(response_p
)
505 this.set(key
, responses
)
507 this.routes
[routeName
] = route
508 this.serveHost(response
, route
, argv
)
512 * @summary Serve a route to an authorized http host
513 * @desc services host application to the client, establishing a socket
514 * @arg {http.ServerResponse} response - response object to use
515 * @arg {Object} route - the route that belongs to this host
516 * @arg {string[]} argv - vector of arguments sent to the host
518 serveHost: function (response
, route
, argv
) {
519 dlog(`Serving host ${route.origin}`)
520 response
.writeHead(200, { 'Content-Type': 'text/html' })
521 response
.write(`${this.skelPage[0]}` +
522 `\tconst _strapp_port = ${route.port}\n` +
523 `\tconst _strapp_protocol = ` +
524 `'${this.httpsOpts ? 'wss' : 'ws'}'\n` +
525 `${this.hostJS}\n${this.skelPage[1]}`)
530 * @summary handle host message
531 * @desc receives a message from a host, handles the command (first character),
532 * and responds to either the host or the client, or both. Commands
533 * are whitespace separated strings.
535 * Forward Payload to Client)
536 * < clientKey payload [header]
537 * Route 'payload' to the client identified by 'clientKey'.
538 * The optional 'header' argument is a stringified JSON object,
539 * which will be written to the HTTP response header
540 * In case of multiple requests from a single client, the
541 * oldest request will be serviced on arrival of message
542 * Translate SDP and Forward to Client)
543 * ^ clientKey sdp [header]
544 * Route the JSON object 'sdp' to the client, after translating
545 * for interop between browsers using planB or Unified. Other
546 * than the interop step, this is identical to the '<' command
548 * ! errorMessage errorCode [offendingMessage]
549 * Notify host that an error has occured, providing a message
550 * and error code. 'offendingMessage', if present, is the
551 * message received from the remote that triggered the error.
552 * @arg {string} message - raw string from the host
553 * @arg {Object} route - the route over
555 hostMessage: function (message
, route
) {
556 let argv
= message
.split(' ')
557 const command
= argv
[0][0]
559 dlog(`Received host message from ${route.name}: ${command}`)
562 if (argv
.length
< 2) {
563 dlog(`Malformed '${command}' command from ${route.origin}`)
564 route
.socket
.send(`! "Insufficient arguments" 0 ${message}`)
567 argv
[1] = JSON
.parse(argv
[1])
569 argv
[1] = JSON
.stringify(argv
[1])
570 //TODO: argv[1] = encryptForClient(argv[0], argv[1])
571 /* Fallthrough to '<' behavior after translating argv[1] */
573 const response
= route
.pendingResponses
.get(argv
[0]).shift()
575 route
.socket
.send(`! "No pending responses for client ${argv[0]}" 0 `
577 else if (argv
.length
=== 2 || argv
.length
=== 3) {
578 const header
= argv
.length
=== 3 ? JSON
.parse(argv
[2]) : {}
579 if (!('Content-Type' in header
))
580 header
['Content-Type'] = 'application/octet-stream'
581 response
.writeHead(200, header
)
582 response
.write(argv
[1])
586 route
.socket
.send(`! "Insufficient arguments" 0 ${message}`)
589 if (argv
.length
=== 3)
590 argv
[0] += `\nIn message: ${argv[2]}`
591 console
.log(`Error[${route.origin}|${argv[1]}]:${argv[0]}`)
594 route
.socket
.send(`! "Unknown command '${command}'" 0 ${message}`)
595 dlog(`Host ${route.origin} send unknown command: ${message}`)
601 * @summary Serve a file to an http client after a request
602 * @desc reads files from the system to be distributed to clients, and
603 * buffers recently accessed files
604 * @arg {http.ServerResponse} response - the response object to use
605 * @arg {string} filePath - relative location of the file
607 serveFile: function (response
, filePath
, rootPath
) {
608 //TODO: Make a buffer to hold recently used files, and only read if we
609 // have to (don't forget to preserve mimetype)
610 require('fs').readFile(filePath
, { encoding
: 'utf8' }, (err
, data
) => {
611 if (err
|| data
=== undefined)
612 response
.writeHead(404)
614 response
.writeHead(200, {
615 'Content-Type': require('mime').lookup(filePath
)
624 * @summary Synchronize Reading Multiple Files
625 * @desc reads an array of files into an object, whose keys are the
626 * input filenames, and values are the data read
627 * @arg {string[]} files - array of file names to read
628 * @arg {Object} [readOpts] - options to pass to fs.readFile()
630 syncReads
: (files
, readOpts
) => new Promise((resolve
,reject
) => {
631 dlog(`syncing reads from ${files}`)
634 const read_cb
= (fileName
) => (err
, data
) => {
638 results
[fileName
] = data
639 if (++count
=== files
.length
)
642 if (readOpts
== undefined)
643 readOpts
= { encoding
: 'utf8' }
644 files
.forEach((file
) =>
645 require('fs').readFile(file
, readOpts
, read_cb(file
)))
649 module
.exports
= exports