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