blob: 2e74a55f776e505d8dafccb8f01ee4ac8b3bf46e [file] [log] [blame]
Leo Repp58b9f112021-11-22 11:57:47 +01001import fs from 'fs';
2import http from 'http';
3import https from 'https';
4import events from 'events';
5import {parse} from 'url';
6import Client from './client';
7import config from '../package.json';
8import anybody from 'body/any';
9import qs from 'qs';
10
11const debug = require('debug')('tinylr:server');
12
13const CONTENT_TYPE = 'content-type';
14const FORM_TYPE = 'application/x-www-form-urlencoded';
15
16function buildRootPath (prefix = '/') {
17 let rootUrl = prefix;
18
19 // Add trailing slash
20 if (prefix[prefix.length - 1] !== '/') {
21 rootUrl = `${rootUrl}/`;
22 }
23
24 // Add leading slash
25 if (prefix[0] !== '/') {
26 rootUrl = `/${rootUrl}`;
27 }
28
29 return rootUrl;
30}
31
32class Server extends events.EventEmitter {
33 constructor (options = {}) {
34 super();
35
36 this.options = options;
37
38 options.livereload = options.livereload || require.resolve('livereload-js/dist/livereload.js');
39
40 // todo: change falsy check to allow 0 for random port
41 options.port = parseInt(options.port || 35729, 10);
42
43 if (options.errorListener) {
44 this.errorListener = options.errorListener;
45 }
46
47 this.rootPath = buildRootPath(options.prefix);
48
49 this.clients = {};
50 this.configure(options.app);
51 this.routes(options.app);
52 }
53
54 routes () {
55 if (!this.options.dashboard) {
56 this.on(`GET ${this.rootPath}`, this.index.bind(this));
57 }
58
59 this.on(`GET ${this.rootPath}changed`, this.changed.bind(this));
60 this.on(`POST ${this.rootPath}changed`, this.changed.bind(this));
61 this.on(`POST ${this.rootPath}alert`, this.alert.bind(this));
62 this.on(`GET ${this.rootPath}livereload.js`, this.livereload.bind(this));
63 this.on(`GET ${this.rootPath}kill`, this.close.bind(this));
64 }
65
66 configure (app) {
67 debug('Configuring %s', app ? 'connect / express application' : 'HTTP server');
68
69 let handler = this.options.handler || this.handler;
70
71 if (!app) {
72 if ((this.options.key && this.options.cert) || this.options.pfx) {
73 this.server = https.createServer(this.options, handler.bind(this));
74 } else {
75 this.server = http.createServer(handler.bind(this));
76 }
77
78 this.server.on('upgrade', this.websocketify.bind(this));
79 this.server.on('error', this.error.bind(this));
80 return this;
81 }
82
83 this.app = app;
84 this.app.listen = (port, done) => {
85 done = done || function () {};
86 if (port !== this.options.port) {
87 debug('Warn: LiveReload port is not standard (%d). You are listening on %d', this.options.port, port);
88 debug('You\'ll need to rely on the LiveReload snippet');
89 debug('> http://feedback.livereload.com/knowledgebase/articles/86180-how-do-i-add-the-script-tag-manually-');
90 }
91
92 let srv = this.server = http.createServer(app);
93 srv.on('upgrade', this.websocketify.bind(this));
94 srv.on('error', this.error.bind(this));
95 srv.on('close', this.close.bind(this));
96 return srv.listen(port, done);
97 };
98
99 return this;
100 }
101
102 handler (req, res, next) {
103 let middleware = typeof next === 'function';
104 debug('LiveReload handler %s (middleware: %s)', req.url, middleware ? 'on' : 'off');
105
106 next = next || this.defaultHandler.bind(this, res);
107 req.headers[CONTENT_TYPE] = req.headers[CONTENT_TYPE] || FORM_TYPE;
108 return anybody(req, res, (err, body) => {
109 if (err) return next(err);
110 req.body = body;
111
112 if (!req.query) {
113 req.query = req.url.indexOf('?') !== -1
114 ? qs.parse(parse(req.url).query)
115 : {};
116 }
117
118 return this.handle(req, res, next);
119 });
120 }
121
122 index (req, res) {
123 res.setHeader('Content-Type', 'application/json');
124 res.write(JSON.stringify({
125 tinylr: 'Welcome',
126 version: config.version
127 }));
128
129 res.end();
130 }
131
132 handle (req, res, next) {
133 let url = parse(req.url);
134 debug('Request:', req.method, url.href);
135 let middleware = typeof next === 'function';
136
137 // do the routing
138 let route = req.method + ' ' + url.pathname;
139 let respond = this.emit(route, req, res);
140 if (respond) return;
141
142 if (middleware) return next();
143
144 // Only apply content-type on non middleware setup #70
145 return this.notFound(res);
146 }
147
148 defaultHandler (res, err) {
149 if (!err) return this.notFound(res);
150
151 this.error(err);
152 res.setHeader('Content-Type', 'text/plain');
153 res.statusCode = 500;
154 res.end('Error: ' + err.stack);
155 }
156
157 notFound (res) {
158 res.setHeader('Content-Type', 'application/json');
159 res.writeHead(404);
160 res.write(JSON.stringify({
161 error: 'not_found',
162 reason: 'no such route'
163 }));
164 res.end();
165 }
166
167 websocketify (req, socket, head) {
168 let client = new Client(req, socket, head, this.options);
169 this.clients[client.id] = client;
170
171 // handle socket error to prevent possible app crash, such as ECONNRESET
172 socket.on('error', (e) => {
173 // ignore frequent ECONNRESET error (seems inevitable when refresh)
174 if (e.code === 'ECONNRESET') return;
175 this.error(e);
176 });
177
178 client.once('info', (data) => {
179 debug('Create client %s (url: %s)', data.id, data.url);
180 this.emit('MSG /create', data.id, data.url);
181 });
182
183 client.once('end', () => {
184 debug('Destroy client %s (url: %s)', client.id, client.url);
185 this.emit('MSG /destroy', client.id, client.url);
186 delete this.clients[client.id];
187 });
188 }
189
190 listen (port, host, fn) {
191 port = port || this.options.port;
192
193 // Last used port for error display
194 this.port = port;
195
196 if (typeof host === 'function') {
197 fn = host;
198 host = undefined;
199 }
200
201 this.server.listen(port, host, fn);
202 }
203
204 close (req, res) {
205 Object.keys(this.clients).forEach(function (id) {
206 this.clients[id].close();
207 }, this);
208
209 if (this.server._handle) this.server.close(this.emit.bind(this, 'close'));
210
211 if (res) res.end();
212 }
213
214 error (e) {
215 if (this.errorListener) {
216 this.errorListener(e);
217 return;
218 }
219
220 console.error();
221 if (typeof e === 'undefined') {
222 console.error('... Uhoh. Got error %s ...', e);
223 } else {
224 console.error('... Uhoh. Got error %s ...', e.message);
225 console.error(e.stack);
226
227 if (e.code !== 'EADDRINUSE') return;
228 console.error();
229 console.error('You already have a server listening on %s', this.port);
230 console.error('You should stop it and try again.');
231 console.error();
232 }
233 }
234
235 // Routes
236
237 livereload (req, res) {
238 res.setHeader('Content-Type', 'application/javascript');
239 fs.createReadStream(this.options.livereload).pipe(res);
240 }
241
242 changed (req, res) {
243 let files = this.param('files', req);
244
245 debug('Changed event (Files: %s)', files.join(' '));
246 let clients = this.notifyClients(files);
247
248 if (!res) return;
249
250 res.setHeader('Content-Type', 'application/json');
251 res.write(JSON.stringify({
252 clients: clients,
253 files: files
254 }));
255
256 res.end();
257 }
258
259 alert (req, res) {
260 let message = this.param('message', req);
261
262 debug('Alert event (Message: %s)', message);
263 let clients = this.alertClients(message);
264
265 if (!res) return;
266
267 res.setHeader('Content-Type', 'application/json');
268 res.write(JSON.stringify({
269 clients: clients,
270 message: message
271 }));
272
273 res.end();
274 }
275
276 notifyClients (files) {
277 let clients = Object.keys(this.clients).map(function (id) {
278 let client = this.clients[id];
279 debug('Reloading client %s (url: %s)', client.id, client.url);
280 client.reload(files);
281 return {
282 id: client.id,
283 url: client.url
284 };
285 }, this);
286
287 return clients;
288 };
289
290 alertClients (message) {
291 let clients = Object.keys(this.clients).map(function (id) {
292 let client = this.clients[id];
293 debug('Alert client %s (url: %s)', client.id, client.url);
294 client.alert(message);
295 return {
296 id: client.id,
297 url: client.url
298 };
299 }, this);
300
301 return clients;
302 }
303
304 // Lookup param from body / params / query.
305 param (name, req) {
306 let param;
307 if (req.body && req.body[name]) param = req.body[name];
308 else if (req.params && req.params[name]) param = req.params[name];
309 else if (req.query && req.query[name]) param = req.query[name];
310
311 // normalize files array
312 if (name === 'files') {
313 param = Array.isArray(param) ? param
314 : typeof param === 'string' ? param.split(/[\s,]/)
315 : [];
316 }
317
318 return param;
319 }
320}
321
322export default Server;