Fix let-bug in Resources.unref() caught by ES6 (another was fixed previously)
[jst_server.git] / Server.mjs
1 /*
2  * Copyright (C) 2018-2022 Nick Downing <nick@ndcode.org>
3  * SPDX-License-Identifier: MIT
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
7  * deal in the Software without restriction, including without limitation the
8  * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
9  * sell 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
13  * all 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
20  * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
21  * IN THE SOFTWARE.
22  */
23
24 import JSTCache from '@ndcode/jst_cache'
25 import Listener from './Listener.mjs'
26 import Problem from './Problem.mjs'
27 import Resources from './Resources.mjs'
28 import Site from './Site.mjs'
29 import assert from 'assert'
30 import jst_server from './index.mjs'
31 import url from 'url'
32
33 class Server {
34   constructor(resources, options/*, prev_server*/) {
35     this.resources = resources
36     this.options = Object.assign(
37       {
38         caching: false,
39         listeners: [
40           {
41             protocol: 'http:'
42           },
43           {
44             protocol: 'https:'
45           }
46         ],
47         hosts: {
48           'localhost': {
49             type: 'site',
50             root: 'site'
51           },
52           'localhost.localdomain': {
53             type: 'redirect',
54             host: 'localhost'
55           }
56         }
57       },
58       options || {}
59     )
60
61     this.jst_cache = undefined
62     this.site_roots = undefined
63     this.listeners = undefined
64     this.request_func =
65       (request, response, listener) =>
66       /*await*/ this.respond(request, response, listener.options.protocol)
67       // returned Promise will be ignored
68   }
69
70   async start() {
71     assert(this.jst_cache === undefined)
72     this.jst_cache = await this.resources.ref(
73       'jst_cache:.',
74       async () => new JSTCache('.', {_jst_server: jst_server}, true)
75     )
76
77     assert(this.site_roots === undefined)
78     this.site_roots = {}
79     for (let i in this.options.hosts) {
80       let host
81       if (
82         Object.prototype.hasOwnProperty.call(this.options.hosts, i) &&
83         (host = this.options.hosts[i]).type === 'site' &&
84         !Object.prototype.hasOwnProperty.call(this.site_roots, host.root)
85       )
86         this.site_roots[host.root] = await this.resources.ref(
87           `site_root:${host.root}`,
88           async () => (
89             {
90               jst_cache: await this.resources.ref(
91                 `jst_cache:${host.root}`,
92                 async () =>
93                   new JSTCache(host.root, {_jst_server: jst_server}, true)
94               ),
95               root: host.root,
96               site: undefined
97             }
98           ),
99           async site_root => {
100             await this.resources.unref(`jst_cache:${site_root.root}`)
101             if (site_root.site !== undefined)
102               await site_root.site.stop()
103           }
104         )
105     }
106
107     assert(this.listeners === undefined)
108     this.listeners = []
109     for (let i = 0; i < this.options.listeners.length; ++i) {
110       let options
111       switch (this.options.listeners[i].protocol) {
112       case 'http:':
113         options = Object.assign(
114           {
115             protocol: 'https:',
116             address: '0.0.0.0',
117             port: 8080
118           },
119           this.options.listeners[i]
120         )
121         break
122       case 'https:':
123         options = Object.assign(
124           {
125             protocol: 'https:',
126             address: '0.0.0.0',
127             port: 8443,
128             ssl_cert: '_ssl/localhost_cert_bundle.pem',
129             ssl_key: '_ssl/localhost_key.pem'
130           },
131           this.options.listeners[i]
132         )
133         break
134       default:
135         assert(false)
136       }
137       let listener = await this.resources.ref(
138         `listener:${JSON.stringify(options)}`,
139         async () => new Listener(undefined, options, true),
140         listener => /*await*/ listener.stop()
141       )
142       this.listeners.push(listener)
143     }
144   }
145
146   async stop() {
147     assert(this.jst_cache !== undefined)
148     await this.resources.unref('jst_cache:.')
149
150     assert(this.site_roots !== undefined)
151     for (let i in this.site_roots)
152       if (Object.prototype.hasOwnProperty.call(this.site_roots, i))
153         await this.resources.unref(`site_root:${i}`)
154
155     assert(this.listeners !== undefined)
156     for (let i = 0; i < this.listeners.length; ++i)
157       await this.resources.unref(
158         `listener:${JSON.stringify(this.listeners[i].options)}`
159       )
160   }
161
162   async kick() {
163     assert(this.jst_cache !== undefined)
164     this.jst_cache.kick()
165
166     assert(this.site_roots !== undefined)
167     for (let i in this.site_roots)
168       if (Object.prototype.hasOwnProperty.call(this.site_roots, i)) {
169         let site_root = this.site_roots[i]
170         let config = await site_root.jst_cache.get(
171           `${i}/_config/site.jst`,
172           true
173         )
174         if (config !== undefined) {
175           let prev_site = site_root.site
176           let new_site = await config(this.resources, i, prev_site)
177           await new_site.start() // exception here cancels site change
178           site_root.site = new_site
179           if (prev_site !== undefined)
180             await prev_site.stop() // exception here doesn't cancel change
181         }
182         await site_root.site.kick()
183       }
184
185     assert(this.listeners !== undefined)
186     for (let i = 0; i < this.listeners.length; ++i) {
187       this.listeners[i].request_func = this.request_func
188       await this.listeners[i].start()
189     }
190   }
191
192   serve_internal(response, status, mime_type, data) {
193     response.statusCode = status
194     // no real need to cache errors and hostname redirects
195     // (pathname redirects on the other hand will be cached, see Site.js)
196     response.setHeader('Content-Type', mime_type)
197     response.setHeader('Content-Length', data.length)
198     response.end(data)
199   }
200
201   redirect(response, location, message) {
202     console.log(message)
203     response.statusCode = 301
204     response.setHeader('Location', location)
205     this.serve_internal(
206       response,
207       301,
208       'text/html; charset=utf-8',
209       Buffer.from(
210         `<html>
211   <head>
212     <meta http-equiv="content-type" content="text/html;charset=utf-8">
213     <title>301 Moved Permanently</title>
214   </head>
215   <body style="font-family: sans-serif, serif, monospace; font-size: 20px;">
216     <h2>301 Moved Permanently</h2>
217     The document has moved <a href="${location}">here</a>.
218   </body>
219 </html>
220 `,
221         'utf-8'
222       )
223     )
224   }
225
226   async respond(request, response, protocol) {
227     let env = {request, response, server: this}
228     try {
229       env.parsed_url = url.parse(
230         protocol + '//' + (request.headers.host || 'localhost') + request.url,
231         true
232       )
233
234       if (
235         !Object.prototype.hasOwnProperty.call(
236           this.options.hosts,
237           env.parsed_url.hostname
238         )
239       )
240         throw new Problem(
241           'Not found',
242           `Site "${env.parsed_url.hostname}" not found.`,
243           404
244         )
245
246       let host = this.options.hosts[env.parsed_url.hostname]
247       switch (host.type) {
248       case 'redirect':
249         let new_host = host.host
250         if (env.parsed_url.port !== null)
251           new_host += ':' + env.parsed_url.port
252         this.redirect(
253           response,
254           `${env.parsed_url.protocol}//${new_host}${request.url}`,
255           `redirecting ${env.parsed_url.host} to ${new_host}`
256         )
257         break
258       case 'site':
259         await this.site_roots[host.root].site.respond(env)
260         break
261       default:
262         assert(false)
263       }
264     }
265     catch (error) {
266       let problem = Problem.from(error)
267       console.log(
268         env.parsed_url === undefined ?
269           `${problem.status} ${problem.title}` :
270           `${env.parsed_url.host} ${problem.status} ${problem.title}`
271       )
272       console.log(`  ${problem.detail}`)
273
274       // for API endpoints, serve the JSON-encoded Problem object directly
275       if (env.mime_type === 'application/json; charset=utf-8')
276         this.serve_internal(
277           response,
278           problem.status,
279           'application/problem+json; charset=utf-8',
280           Buffer.from(
281             JSON.stringify(
282               {
283                 title: problem.title,
284                 detail: problem.detail,
285                 status: problem.status
286               },
287               null,
288               2
289             ) + '\n',
290             'utf-8'
291           )
292         )
293       else
294         this.serve_internal(
295           response,
296           problem.status,
297           'text/html; charset=utf-8',
298           Buffer.from(
299             `<html>
300   <head>
301     <meta http-equiv="content-type" content="text/html; charset=utf-8">
302     <title>${problem.status} ${problem.title}</title>
303   </head>
304   <body style="font-family: sans-serif, serif, monospace; font-size: 20px;">
305     <h2>${problem.status} ${problem.title}</h2>
306     ${problem.detail.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('\n', '<br>')}
307   </body>
308 </html>
309 `,
310             'utf-8'
311           )
312         )
313     }
314   }
315 }
316
317 export default Server