Change var to let and use arrow functions everywhere
[jst_server.git] / ndserver.js
1 #!/usr/bin/env node
2
3 let assert = require('assert')
4 let BuildCache = require('build_cache')
5 let commander = require('commander')
6 let fs = require('fs')
7 let http = require('http')
8 let https = require('https')
9 let jstemplate = require('jstemplate')
10 let less = require('less/lib/less-node')
11 let querystring = require('querystring')
12 let util = require('util')
13 let url = require('url')
14 let zetjs = require('zetjs')
15
16 let readFileAsync = util.promisify(fs.readFile)
17 let statAsync = util.promisify(fs.stat)
18
19 commander.version('1.0.0').option(
20   '-c, --enable-caching',
21   'Enable caching'
22 ).option(
23   '-j, --ssl-cert [path]',
24   'Set SSL certificate [ssl/localhost_cert_bundle.pem]',
25   'ssl/localhost_cert_bundle.pem'
26 ).option(
27   '-k, --ssl-key [path]',
28   'Set SSL private key [ssl/localhost_key.pem]',
29   'ssl/localhost_key.pem'
30 ).option(
31   '-p, --http-port [port]',
32   'Set HTTP listen port, -1 disable [8080]',
33   8080
34 ).option(
35   '-q, --https-port [port]',
36   'Set HTTPS listen port, -1 disable [8443]',
37   8443
38 ).parse(process.argv)
39
40 let sites = JSON.parse(fs.readFileSync('config/sites.json'))
41 let mime_types = JSON.parse(fs.readFileSync('config/mime_types.json'))
42 let mime_type_default = 'application/octet-stream'
43 let mime_type_css = mime_types['css'] || mime_type_default
44 let mime_type_html = mime_types['html'] || mime_type_default
45 let build_cache_js = new BuildCache()
46 let build_cache_json = new BuildCache()
47 let build_cache_less = new BuildCache()
48 let build_cache_text = new BuildCache()
49 let build_cache_zet = new BuildCache()
50
51 let serve = (res, status, mime_type, data) => {
52   res.statusCode = status
53   // html files will be direct recipient of links/bookmarks so can't have
54   // a long lifetime, other files like css or images are often large files
55   // and won't change frequently (but we'll need cache busting eventually)
56   if (commander.enableCaching && mime_type !== mime_type_html)
57     res.setHeader('Cache-Control', 'max-age=3600')
58   res.setHeader('Content-Type', mime_type)
59   res.setHeader('Content-Length', data.length)
60   res.end(data)
61 }
62
63 let die = res => {
64   let body = '<html><body>Page not found</body></html>'
65   serve(res, 404, 'text/html; charset=utf-8', new Buffer(body, 'utf8'))
66 }
67
68 let redirect = (res, location) => {
69   res.statusCode = 301
70   res.setHeader('Location', location)
71   res.end('Redirecting to ' + location)
72 }
73
74 let app = async (req, res, protocol) => {
75   let site = req.headers.host || 'localhost'
76   let temp = site.indexOf(':')
77   let port_suffix = temp === -1 ? '' : site.substring(temp)
78   site = site.substring(0, site.length - port_suffix.length)
79   if (!sites.hasOwnProperty(site)) {
80     console.log('nonexistent site', site)
81     die(res)
82     return
83   }
84   temp = sites[site]
85   let site_root
86   if (temp.type === 'redirect') {
87     let site_domain = temp.domain
88     console.log('redirecting', site, 'to', site_domain)
89     redirect(res, protocol + '://' + site_domain + port_suffix + req.url)
90     return
91   }
92   else if (temp.type === 'site')
93     site_root = temp.root
94   else
95     assert(false)
96
97   // parse the pathname portion of url
98   // this is actually cheating since it's not a complete url
99   let parsed_url = url.parse(req.url, true)
100   let path = parsed_url.pathname.split('/')
101
102   // path must begin with /
103   if (path.length === 0 || path[0].length)
104     return die(res)
105
106   // path elements must be findable in the file system (thus can't be empty)
107   let dir_name = ''
108   let dir_name_is_pub = false
109   for (var i = 1; i < path.length - 1; ++i) {
110     dir_name += '/' + path[i]
111     if (path[i].length === 0 || path[i].charAt(0) === '.') {
112       console.log(site, 'bad path component', dir_name)
113       return die(res)
114     }
115     let stats
116     try {
117       stats = await statAsync(site_root + dir_name)
118     }
119     catch (err) {
120       if (err.code !== 'ENOENT')
121         throw err
122       if (!dir_name_is_pub) {
123         temp = dir_name + '.pub'
124         try {
125           stats = await statAsync(site_root + temp)
126           dir_name = temp
127           dir_name_is_pub = true
128         }
129         catch (err2) {
130           if (err2.code !== 'ENOENT')
131             throw err2
132           console.log(site, 'directory not found', dir_name)
133           return die(res)
134         }
135       }
136       if (!stats.isDirectory()) {
137         console.log(site, 'not directory', dir_name)
138         return die(res)
139       }
140     }
141   }
142
143   file_name = path[path.length - 1]
144   if (file_name === '') {
145     path[path.length - 1] = 'index.html'
146     path = path.join('/')
147     console.log(site, 'redirecting', parsed_url.pathname, 'to', path)
148     redirect(res, path + (parsed_url.search || ''))
149     return
150   }
151
152   temp = file_name.lastIndexOf('.')
153   let file_type = temp === -1 ? '' : file_name.substring(temp + 1)
154   let mime_type = mime_types[file_type] || mime_type_default
155
156   let page = dir_name + '/' + file_name, data
157   if (dir_name_is_pub) {
158     try {
159       data = await readFileAsync(site_root + page)
160       console.log(
161         site,
162         'serving',
163         page,
164         'length',
165         data.length,
166         'from filesystem'
167       )
168       serve(res, 200, mime_type, data)
169       return
170     }
171     catch (err) {
172       if (err.code !== 'ENOENT')
173         throw err
174     }
175   }
176   else {
177     temp = page + '.pub'
178     try {
179       data = await readFileAsync(site_root + temp)
180       console.log(
181         site,
182         'serving',
183         temp,
184         'length',
185         data.length,
186         'from filesystem'
187       )
188       serve(res, 200, mime_type, data)
189       return
190     }
191     catch (err) {
192       if (err.code !== 'ENOENT')
193         throw err
194     }
195
196     switch (file_type) {
197     case 'html':
198       temp = page + '.js'
199       try {
200         let buffers = []
201         let env = {
202           lang: 'en',
203           page: page,
204           query: parsed_url.query,
205           site: site,
206           site_root: site_root
207         }
208         let out = str => {buffers.push(Buffer.from(str))}
209         let req = async (str, type) => {
210           let key = (
211             str.length > 0 && str.charAt(0) === '/' ?
212             site_root :
213             site_root + dir_name + '/'
214           ) + str, result
215           switch (type) {
216           case undefined:
217           case 'js':
218             let render_func = await build_cache_js.get(key)
219             if (render_func === undefined) {
220               console.log(site, 'compiling', key)
221               render_func = await jstemplate(key)
222               build_cache_js.set(key, render_func)
223             }
224             result = await render_func(env, out, req)
225             break
226           case 'json':
227             result = await build_cache_json.get(key)
228             if (result === undefined) {
229               console.log(site, 'parsing', key)
230               result = JSON.parse(await readFileAsync(key))
231               build_cache_json.set(key, result)
232             }
233             break
234           case 'text':
235             result = await build_cache_text.get(key)
236             if (result === undefined) {
237               console.log(site, 'reading', key)
238               result = await readFileAsync(key, {encoding: 'utf-8'})
239               build_cache_text.set(key, result)
240             }
241             break
242           case 'zet':
243             result = await build_cache_zet.get(key)
244             if (result === undefined) {
245               console.log(site, 'opening', key)
246               result = new zetjs.Index(key)
247               build_cache_zet.set(
248                 key,
249                 result,
250                 [
251                   key + '.map.0',
252                   key + '.param.0',
253                   key + '.v.0',
254                   key + '.vocab.0'
255                 ]
256               )
257             }
258             break
259           default:
260             assert(false) 
261           }
262           return result
263         }
264         await req(temp)
265         data = Buffer.concat(buffers)
266         console.log(
267           site,
268           'serving',
269           temp,
270           'length',
271           data.length,
272           'from js render'
273         )
274         serve(res, 200, mime_type, data)
275         return
276       }
277       catch (err) {
278         if (err.code !== 'ENOENT') // note: err.code might be undefined
279           throw err
280       }
281       break
282
283     case 'css':
284       temp = page + '.less'
285       try {
286         let key = site_root + temp
287         let data = await build_cache_less.get(key)
288         if (data === undefined) {
289           console.log(site, 'compiling', key)
290           let result = await less.render(
291             await readFileAsync(site_root + temp, {encoding: 'utf-8'}),
292             {
293               //color: true,
294               //compress: false,
295               //depends: false,
296               filename: temp,
297               //globalVars: null,
298               //ieCompat: false,
299               //insecure: false,
300               //javascriptEnabled: false,
301               //lint: false,
302               //math: 0,
303               //modifyVars: null,
304               paths: [site_root + dir_name],
305               //plugins: [],
306               //reUsePluginManager: true,
307               //rewriteUrls: false,
308               rootpath: site_root//,
309               //strictImports: false,
310               //strictUnits: false,
311               //urlArgs: ''
312             }
313           )
314           data = new Buffer(result.css, 'utf-8')
315           build_cache_less.set(key, data, result.imports)
316         }
317         console.log(
318           site,
319           'serving',
320           temp,
321           'length',
322           data.length,
323           'from less render'
324         )
325         serve(res, 200, mime_type, data)
326         return
327       }
328       catch (err) {
329         if (err.code !== 'ENOENT') // note: err.code might be undefined
330           throw err
331       }
332       break
333     }
334   }
335
336   console.log(site, 'file not found', page)
337   return die(res)
338 }
339
340 let tryApp = (req, res, protocol) => {
341   app(req, res, protocol).catch(
342     err =>  {
343       console.log(err.stack || err.message)
344       let body =
345         '<html><body><pre>' +
346         (err.stack || err.message) +
347         '</pre></body></html>'
348       serve(res, 500, 'text/html; charset=utf-8', new Buffer(body, 'utf8'))
349     }
350   )
351   // note: the promise is forgotten about here, so each incoming request
352   // proceeds in an unsupervised fashion to eventual completion or error
353 }
354
355 if (commander.httpPort !== -1) {
356   http.createServer(
357     (req, res) => tryApp(req, res, 'http')
358   ).listen(commander.httpPort)
359   console.log('HTTP server listening on port', commander.httpPort)
360 }
361 if (commander.httpsPort !== -1) {
362   https.createServer(
363     {
364       'cert': fs.readFileSync(commander.sslCert),
365       'key': fs.readFileSync(commander.sslKey)
366     },
367     (req, res) => tryApp(req, res, 'https')
368   ).listen(commander.httpsPort)
369   console.log('HTTPS server listening on port', commander.httpsPort)
370 }