@caboodle-tech/node-simple-server 1.3.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.eslintrc.json +35 -23
  2. package/COPYING.txt +7 -0
  3. package/LICENSE.txt +22 -0
  4. package/README.md +73 -40
  5. package/bin/nss.js +1039 -0
  6. package/changelogs/v1.md +7 -0
  7. package/changelogs/v2.md +28 -0
  8. package/examples/README.md +15 -0
  9. package/examples/controllers/website.js +60 -0
  10. package/examples/controllers/websocket.js +83 -0
  11. package/examples/run.js +17 -0
  12. package/examples/www-website/assets/fonts/roboto/LICENSE.txt +202 -0
  13. package/examples/www-website/assets/fonts/roboto/roboto-regular.ttf +0 -0
  14. package/examples/www-website/assets/fonts/roboto/roboto-regular.woff +0 -0
  15. package/examples/www-website/assets/fonts/roboto/roboto-regular.woff2 +0 -0
  16. package/examples/www-website/assets/imgs/logo.png +0 -0
  17. package/examples/www-website/css/main.css +96 -0
  18. package/examples/www-website/css/normalize.css +349 -0
  19. package/examples/www-website/index.html +47 -0
  20. package/examples/www-website/js/main.js +33 -0
  21. package/examples/www-websockets/assets/fonts/roboto/LICENSE.txt +202 -0
  22. package/examples/www-websockets/assets/fonts/roboto/roboto-regular.ttf +0 -0
  23. package/examples/www-websockets/assets/fonts/roboto/roboto-regular.woff +0 -0
  24. package/examples/www-websockets/assets/fonts/roboto/roboto-regular.woff2 +0 -0
  25. package/examples/www-websockets/assets/imgs/logo.png +0 -0
  26. package/examples/www-websockets/css/main.css +148 -0
  27. package/examples/www-websockets/css/normalize.css +349 -0
  28. package/examples/www-websockets/index.html +45 -0
  29. package/examples/www-websockets/js/main.js +63 -0
  30. package/{sources → handlers}/dir-listing.html +68 -5
  31. package/handlers/forbidden.html +43 -0
  32. package/{js → handlers/js}/content-types.js +3 -1
  33. package/{js → handlers/js}/http-status.js +2 -1
  34. package/handlers/live-reloading.html +209 -0
  35. package/handlers/not-found.html +43 -0
  36. package/package.json +13 -10
  37. package/LICENSE +0 -21
  38. package/server.js +0 -866
  39. package/sources/socket.html +0 -204
  40. /package/{sources → handlers}/favicon.ico +0 -0
package/bin/nss.js ADDED
@@ -0,0 +1,1039 @@
1
+ import Chokidar from 'chokidar';
2
+ import Fs from 'fs';
3
+ import Http from 'http';
4
+ import Os from 'os';
5
+ import Path from 'path';
6
+ import { WebSocketServer } from 'ws';
7
+ import { fileURLToPath } from 'url';
8
+
9
+ import ContentTypes from '../handlers/js/content-types.js';
10
+ import HTTPStatus from '../handlers/js/http-status.js';
11
+
12
+ // eslint-disable-next-line no-underscore-dangle
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ // eslint-disable-next-line no-underscore-dangle
15
+ const __dirname = Path.dirname(__filename);
16
+ const APP_ROOT = Path.join(__dirname, '../');
17
+
18
+ class NodeSimpleServer {
19
+
20
+ #handlers = {};
21
+
22
+ #OPS = {
23
+ callbacks: [],
24
+ contentType: 'text/html',
25
+ dirListing: false,
26
+ indexPage: 'index.html',
27
+ port: 5000,
28
+ root: Path.normalize(`${process.cwd()}${Path.sep}`),
29
+ running: false
30
+ };
31
+
32
+ #reload = ['.html', '.htm'];
33
+
34
+ #server = null;
35
+
36
+ #socket = null;
37
+
38
+ #sockets = {
39
+ routes: {},
40
+ map: {}
41
+ };
42
+
43
+ #VERSION = '2.0.0';
44
+
45
+ #watching = [];
46
+
47
+ /**
48
+ * Instantiate a new instance of Node Simple Server.
49
+ *
50
+ * @param {object} options
51
+ * @param {string} options.contentType The default Content-Type to report to the browser;
52
+ * defaults to text/html.
53
+ * @param {boolean} options.dirListing If a directory is requested should the directory listing
54
+ * page be shown; default false.
55
+ * @param {string} options.indexPage If a directory is requested consider this file to be the
56
+ * index page if it exits at that location; defaults to index.html
57
+ * @param {number} options.port The port number the HTTP and WebSocket server should listen on
58
+ * for requests; default 5000.
59
+ * @param {string} options.root The absolute path to the directory that should be considered the
60
+ * servers root directory.
61
+ */
62
+ constructor(options = {}) {
63
+ if (options.disableAutoRestart) {
64
+ this.#OPS.disableAutoRestart = true;
65
+ }
66
+ if (options.contentType) {
67
+ this.#OPS.contentType = options.contentType;
68
+ }
69
+ if (options.dirListing) {
70
+ this.#OPS.dirListing = options.dirListing;
71
+ }
72
+ if (options.port) {
73
+ this.#OPS.port = options.port;
74
+ }
75
+ if (options.root) {
76
+ this.#OPS.root = options.root;
77
+ if (this.#OPS.root[this.#OPS.root.length - 1] !== Path.sep) {
78
+ this.#OPS.root += Path.sep;
79
+ }
80
+ }
81
+ this.#loadHandlers();
82
+ }
83
+
84
+ /**
85
+ * Register a backend function to call when a frontend page messages in via websocket.
86
+ * This will allow you to have two way communications with a page as long as you registered
87
+ * a function on the frontend to capture and respond to websocket messages as well.
88
+ *
89
+ * @param {string} pattern A RegExp object to check the page URL's against or a string
90
+ * representing a regular expression to check the page URL's against.
91
+ * @param {function} callback The function to call if this page (url) messages.
92
+ * @return {boolean} True if the function was registered, false otherwise.
93
+ */
94
+ addWebsocketCallback(pattern, callback) {
95
+ const regex = this.makeRegex(pattern);
96
+ if (regex !== null && typeof callback === 'function') {
97
+ this.#OPS.callbacks.push([regex, callback]);
98
+ return true;
99
+ }
100
+ return false;
101
+ }
102
+
103
+ /**
104
+ * Get an array of all the IP addresses you can reach this server at either from
105
+ * the machine itself or on the LAN.
106
+ *
107
+ * @return {Array} An array of loop back ip addresses and LAN addresses to this server.
108
+ */
109
+ getAddresses(port) {
110
+ const locals = this.#getLocalAddresses();
111
+ const addresses = [
112
+ `http://localhost:${port}`,
113
+ `http://127.0.0.1:${port}`
114
+ ];
115
+ Object.keys(locals).forEach((key) => {
116
+ addresses.push(`http://${locals[key]}:${port}`);
117
+ });
118
+ return addresses;
119
+ }
120
+
121
+ /**
122
+ * Get the contents of a directory for displaying in the directory listing page.
123
+ *
124
+ * @param {string} location The directory to search.
125
+ * @return {object} The HTML entries for all directories [directories] and files [files] found.
126
+ */
127
+ #getDirList(location) {
128
+ // Get all directories and files at this location.
129
+ const files = [];
130
+ const dirs = [];
131
+ Fs.readdirSync(location).forEach((item) => {
132
+ if (Fs.lstatSync(Path.join(location, item)).isDirectory()) {
133
+ dirs.push(item);
134
+ } else {
135
+ files.push(item);
136
+ }
137
+ });
138
+ files.sort((a, b) => a.localeCompare(b));
139
+ dirs.sort((a, b) => a.localeCompare(b));
140
+ // Build the innerHTML for the directory and files unordered list.
141
+ let fileHtml = '';
142
+ let dirHtml = '';
143
+ // Replace files in the directory listing template.
144
+ files.forEach((file) => {
145
+ fileHtml += `<li><a href="[b]/${file}">${file}</a></li>`;
146
+ });
147
+ // Add the go back link (parent directory) for nested directories.
148
+ if (location.replace(this.#OPS.root, '').length > 0) {
149
+ dirHtml += '<li><a href="[b]/../">../</a></li>';
150
+ }
151
+ // Replace directories in the directory listing template.
152
+ dirs.forEach((dir) => {
153
+ dirHtml += `<li><a href="[b]/${dir}">${dir}</a></li>`;
154
+ });
155
+ return {
156
+ directories: dirHtml,
157
+ files: fileHtml
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Attempt to locate the default index page for the specified directory.
163
+ *
164
+ * @param {string} location The absolute path to a dir the user is trying to
165
+ * access on the frontend.
166
+ * @return {string} The correct path to this directories default index or an empty path if
167
+ * none was found.
168
+ */
169
+ #getIndexForLocation(location) {
170
+ let index = '';
171
+ for (let i = 0; i < this.#reload.length; i++) {
172
+ const tmpIndex = `index${this.#reload[i]}`;
173
+ if (Fs.existsSync(Path.normalize(Path.join(this.#OPS.root, location, tmpIndex)))) {
174
+ index = tmpIndex;
175
+ break;
176
+ }
177
+ }
178
+ return index;
179
+ }
180
+
181
+ /**
182
+ * Convert a timestamp or date into a browser compliant header date.
183
+ *
184
+ * @param {string} date Any timestamp string; usually the last modified date
185
+ * of a file.
186
+ * @return {string} The data converted to the format browsers expect to see
187
+ * in the content headers.
188
+ */
189
+ getLastModified(date) {
190
+ // Use the current time if no time was passed in.
191
+ let timestamp = new Date();
192
+ if (date) {
193
+ timestamp = new Date(Date.parse(date));
194
+ }
195
+ // Build and return the timestamp.
196
+ const dayAry = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
197
+ const monthAry = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
198
+ const dayStr = dayAry[timestamp.getUTCDay()];
199
+ const monStr = monthAry[timestamp.getUTCMonth()];
200
+ let dayNum = timestamp.getUTCDate();
201
+ let hour = timestamp.getUTCHours();
202
+ let minute = timestamp.getUTCMinutes();
203
+ let second = timestamp.getUTCSeconds();
204
+ if (dayNum.length < 10) {
205
+ dayNum = `0${dayNum}`;
206
+ }
207
+ if (hour.length < 10) {
208
+ hour = `0${hour}`;
209
+ }
210
+ if (minute.length < 10) {
211
+ minute = `0${minute}`;
212
+ }
213
+ if (second.length < 10) {
214
+ second = `0${second}`;
215
+ }
216
+ return `${dayStr}, ${dayNum} ${monStr} ${timestamp.getUTCFullYear()} ${hour}:${minute}:${second} GMT`;
217
+ }
218
+
219
+ /**
220
+ * Get all IP addresses that are considered external on this machine; the server will
221
+ * attempt to listen to the requested port on these addresses as well, allowing you to
222
+ * view the site from other devices on the same network.
223
+ *
224
+ * {@link https://github.com/nisaacson/interface-addresses | Original Source}.
225
+ *
226
+ * @author Noah Isaacson
227
+ * @return {object} An object of interface names [key] and their IPv4 IP addresses [value].
228
+ */
229
+ #getLocalAddresses() {
230
+ const addresses = {};
231
+ const interfaces = Os.networkInterfaces();
232
+ Object.keys(interfaces).filter((key) => {
233
+ const items = interfaces[key];
234
+ return items.forEach((item) => {
235
+ const { family } = item;
236
+ if (family !== 'IPv4') {
237
+ return false;
238
+ }
239
+ const { internal } = item;
240
+ if (internal) {
241
+ return false;
242
+ }
243
+ const { address } = item;
244
+ addresses[key] = address;
245
+ return true;
246
+ });
247
+ });
248
+ return addresses;
249
+ }
250
+
251
+ /**
252
+ * Create the HTTP headers object for the specified file if any.
253
+ *
254
+ * @param {object} settings Additional settings to set on the header object.
255
+ * @param {string} settings.charset The charset to use for this content; defaults to utf-8.
256
+ * @param {string} settings.contentType The content type of this request; defaults to this.#OPS.contentType.
257
+ * @param {string} settings.file The systems absolute path to the file being requested by the server.
258
+ * @param {string} settings.location The location (URL) to redirect to; must set for all 302 responses.
259
+ * @return {object} The HTTP header object.
260
+ */
261
+ #getHeaders(settings = {}) {
262
+ // Create the header object and set the Content-Type.
263
+ const headers = {};
264
+ const contentType = settings?.contentType || this.#OPS.contentType;
265
+ const charset = settings?.charset || 'UTF-8';
266
+ headers['Content-Type'] = `${contentType}; charset=${charset}`;
267
+ // Standard headers that should always be set for NSS.
268
+ let mtime = new Date().toUTCString();
269
+ if (settings?.file) {
270
+ mtime = Fs.statSync(settings.file).mtime;
271
+ }
272
+ const nssHeaders = {
273
+ 'Cache-Control': 'public, max-age=0',
274
+ 'Last-Modified': this.getLastModified(mtime),
275
+ // eslint-disable-next-line quote-props
276
+ 'Vary': 'Origin',
277
+ 'X-Powered-By': `Node Simple Server (NSS) ${this.#VERSION}`
278
+ };
279
+ // If this is a redirect what is the proper location?
280
+ if (settings?.location) {
281
+ headers.Location = settings.location;
282
+ }
283
+ // Combine the headers and return the header object.
284
+ return Object.assign(headers, nssHeaders);
285
+ }
286
+
287
+ /**
288
+ * Returns an array of watcher objects showing you which directories and files are
289
+ * actively being watched for changes.
290
+ *
291
+ * @return {Array} An array of watcher objects; 1 object per call to watch().
292
+ */
293
+ getWatched() {
294
+ const watched = [];
295
+ this.#watching.forEach((watcher) => {
296
+ watched.push(watcher.getWatched());
297
+ });
298
+ return watched;
299
+ }
300
+
301
+ /**
302
+ * Loads all the handler files that we use to respond to various requests
303
+ * like directory listing, page not found, access denied, and so on.
304
+ */
305
+ #loadHandlers() {
306
+ // eslint-disable-next-line max-len, no-template-curly-in-string
307
+ const internalError = '<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>500 Internal Server Error</title><style>body,html{margin:0;padding:0}body{padding:15px}</style></head><body><h1>500 Internal Server Error</h1><p>Could not locate source file.</p><hr><p><i>Node Simple Server (NSS) {{version}} Server <script type="text/javascript">document.write(`${document.location.protocol}//${document.location.hostname}`);</script> Port <script type="text/javascript">document.write(document.location.port)</script></i></p>{{liveReloading}}</body></html>';
308
+
309
+ const dirListingSrc = Path.join(APP_ROOT, 'handlers', 'dir-listing.html');
310
+ const forbiddenSrc = Path.join(APP_ROOT, 'handlers', 'forbidden.html');
311
+ const liveReloadingSrc = Path.join(APP_ROOT, 'handlers', 'live-reloading.html');
312
+ const notFoundSrc = Path.join(APP_ROOT, 'handlers', 'not-found.html');
313
+
314
+ let dirListingContent = '';
315
+ try {
316
+ dirListingContent = Fs.readFileSync(dirListingSrc, { encoding: 'utf-8', flag: 'r' });
317
+ } catch (_) { dirListingContent = internalError; }
318
+
319
+ let forbiddenContent = '';
320
+ try {
321
+ forbiddenContent = Fs.readFileSync(forbiddenSrc, { encoding: 'utf-8', flag: 'r' });
322
+ } catch (_) { forbiddenContent = internalError; }
323
+
324
+ let liveReloadingContent = '';
325
+ try {
326
+ liveReloadingContent = Fs.readFileSync(liveReloadingSrc, { encoding: 'utf-8', flag: 'r' });
327
+ } catch (_) { liveReloadingContent = '<!-- 500 Internal Server Error -->'; }
328
+
329
+ let notFoundContent = '';
330
+ try {
331
+ notFoundContent = Fs.readFileSync(notFoundSrc, { encoding: 'utf-8', flag: 'r' });
332
+ } catch (_) { notFoundContent = internalError; }
333
+
334
+ dirListingContent = dirListingContent.replace('{{version}}', this.#VERSION);
335
+ dirListingContent = dirListingContent.replace('{{live_reload}}', liveReloadingContent);
336
+ forbiddenContent = forbiddenContent.replace('{{version}}', this.#VERSION);
337
+ forbiddenContent = forbiddenContent.replace('{{live_reload}}', liveReloadingContent);
338
+ notFoundContent = notFoundContent.replace('{{version}}', this.#VERSION);
339
+ notFoundContent = notFoundContent.replace('{{live_reload}}', liveReloadingContent);
340
+
341
+ this.#handlers = {
342
+ dirListing: dirListingContent,
343
+ forbidden: forbiddenContent,
344
+ liveReloading: liveReloadingContent,
345
+ notFound: notFoundContent
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Converts a regular expression (regex) string into an actual RegExp object.
351
+ *
352
+ * @param {string} pattern A string of text or a regex expressed as a string; don't forget to
353
+ * escape characters that should be interpreted literally.
354
+ * @return {RegExp|null} A RegExp object if the string could be converted, null otherwise.
355
+ */
356
+ makeRegex(pattern) {
357
+ try {
358
+ if (/\[|\]|\(|\)|\{|\}|\*|\$|\^/.test(pattern)) {
359
+ return new RegExp(pattern);
360
+ }
361
+ if (pattern[0] === '/' && pattern[pattern.length - 1] === '/') {
362
+ // eslint-disable-next-line no-param-reassign
363
+ pattern = pattern.substr(1, pattern.length - 2);
364
+ }
365
+ return new RegExp(`^${pattern}$`);
366
+ } catch (e) {
367
+ return null;
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Message a frontend page via the WebSocket connection if the page is currently connected.
373
+ *
374
+ * @param {string} pattern A RegExp object to check the page URL's against, a string
375
+ * representing a regular expression to check the page URL's against,
376
+ * or a font-end pages ID.
377
+ * @param {*} message The message you would like to send. Any standard datatype may be provided
378
+ * because the messages is converted to the NSS standard of:
379
+ * { data: message, type: typeof message }
380
+ * @return {boolean} True if the page is connected and the message was sent, false otherwise.
381
+ */
382
+ message(pattern, message) {
383
+ const original = pattern.toString();
384
+ const regex = this.makeRegex(pattern);
385
+ let result = false;
386
+ // See if we can instantly message the specified socket.
387
+ if (this.#sockets.map[`S${pattern}`]) {
388
+ this.#sockets.map[`S${pattern}`].send(message);
389
+ return true;
390
+ }
391
+ // Attempt to find the requested page and message it.
392
+ const keys = Object.keys(this.#sockets.routes);
393
+ for (let i = 0; i < keys.length; i++) {
394
+ if (regex != null) {
395
+ if (regex.test(keys[i])) {
396
+ this.#sockets.routes[keys[i]].forEach((socket) => {
397
+ socket.send(message);
398
+ });
399
+ result = true;
400
+ }
401
+ } else if (original === keys[i]) {
402
+ this.#sockets.routes[keys[i]].forEach((socket) => {
403
+ socket.send(message);
404
+ });
405
+ result = true;
406
+ }
407
+ }
408
+ return result;
409
+ }
410
+
411
+ /**
412
+ * Send the reload message to all connected pages.
413
+ */
414
+ reloadAllPages() {
415
+ // Send the reload message to all connections.
416
+ const keys = Object.keys(this.#sockets.map);
417
+ keys.forEach((key) => {
418
+ this.#sockets.map[key].send('reload');
419
+ });
420
+ }
421
+
422
+ /**
423
+ * Reload a single page or single group of font-end pages matching a specified pattern.
424
+ *
425
+ * @param {RegExp|String} pattern A RegExp object to check the page URL's against, a string
426
+ * representing a regular expression to check the page URL's
427
+ * against, or a string representing the pages front-end ID.
428
+ * @return {null} Used only as a short circuit.
429
+ */
430
+ reloadSinglePage(pattern) {
431
+ const original = pattern.toString();
432
+ if (this.whatIs(pattern) !== 'regexp') {
433
+ // eslint-disable-next-line no-param-reassign
434
+ pattern = this.makeRegex(pattern);
435
+ }
436
+ // See if the pattern is a page id first.
437
+ if (this.#sockets.map[original]) {
438
+ this.#sockets.map[original].send('reload');
439
+ return;
440
+ }
441
+ // See if the pattern matches a specific URL and reload all those pages.
442
+ const keys = Object.keys(this.#sockets.routes);
443
+ if (pattern != null) {
444
+ for (let i = 0; i < keys.length; i++) {
445
+ if (pattern.test(keys[i])) {
446
+ this.#sockets.routes[keys[i]].forEach((socket) => {
447
+ socket.send('reload');
448
+ });
449
+ return;
450
+ }
451
+ }
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Send the refreshCSS message to all connected pages; this reloads only the CSS
457
+ * and not the whole page.
458
+ */
459
+ reloadAllStyles() {
460
+ // Send the refreshCSS message to all connections.
461
+ const keys = Object.keys(this.#sockets.map);
462
+ keys.forEach((key) => {
463
+ this.#sockets.map[key].send('refreshCSS');
464
+ });
465
+ }
466
+
467
+ /**
468
+ * Reload the stylesheets for a single page or single group of pages matching a
469
+ * specified pattern.
470
+ *
471
+ * @param {RegExp|String} pattern A RegExp object to check the page URL's against, a string
472
+ * representing a regular expression to check the page URL's
473
+ * against, or a string representing the pages front-end ID.
474
+ * @return {null} Used only as a short circuit.
475
+ */
476
+ reloadSingleStyles(pattern) {
477
+ const original = pattern.toString();
478
+ if (this.whatIs(pattern) !== 'regexp') {
479
+ // eslint-disable-next-line no-param-reassign
480
+ pattern = this.makeRegex(pattern) || original;
481
+ }
482
+ // See if the pattern is a page id first.
483
+ if (this.#sockets.map[original]) {
484
+ this.#sockets.map[original].send('refreshCSS');
485
+ return;
486
+ }
487
+ // See if the pattern matches a specific URL and reload all those pages.
488
+ const keys = Object.keys(this.#sockets.routes);
489
+ if (pattern != null) {
490
+ for (let i = 0; i < keys.length; i++) {
491
+ if (pattern.test(keys[i])) {
492
+ this.#sockets.routes[keys[i]].forEach((socket) => {
493
+ socket.send('refreshCSS');
494
+ });
495
+ return;
496
+ }
497
+ }
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Unregister a backend function that was set with addWebsocketCallback.
503
+ *
504
+ * @param {string} pattern The same regular expression (regex) object or string that was
505
+ * used when the callback function was first registered.
506
+ * @param {function} callback The function that was originally registered as the callback.
507
+ * @return {boolean} True is the function was unregistered, false otherwise.
508
+ */
509
+ removeWebsocketCallback(pattern, callback) {
510
+ // Make sure the pattern is not null.
511
+ let oldRegex = this.makeRegex(pattern);
512
+ if (!oldRegex) {
513
+ return false;
514
+ }
515
+ // Convert the regex to a string otherwise comparing will never work.
516
+ oldRegex = oldRegex.toString();
517
+ // Remove the pattern and callback from the registered callbacks if they exits.
518
+ for (let i = 0; i < this.#OPS.callbacks.length; i++) {
519
+ const regex = this.#OPS.callbacks[i][0].toString();
520
+ const func = this.#OPS.callbacks[i][1];
521
+ if (regex === oldRegex && func === callback) {
522
+ this.#OPS.callbacks.splice(i, 1);
523
+ return true;
524
+ }
525
+ }
526
+ return false;
527
+ }
528
+
529
+ /**
530
+ * The server listener for NSS. All HTTP requests are handled here.
531
+ *
532
+ * @param {object} request The HTTP request object.
533
+ * @param {object} resp The HTTP response object waiting for communication back.
534
+ * @return {void} Used only as a short circuit.
535
+ */
536
+ #serverListener(request, resp) {
537
+
538
+ let requestUrl = request.url.replace(this.#OPS.root, '').replace(/(\.{1,2}[\\/])/g, '');
539
+ let systemPath = Path.normalize(Path.join(this.#OPS.root, requestUrl.split(/[?#]/)[0]));
540
+ let filename = Path.basename(systemPath);
541
+ let ext = Path.extname(filename);
542
+
543
+ if (filename === 'websocket.ws') {
544
+ /*
545
+ * ERROR: This should never trigger! If it does that means the server has an issue.
546
+ * We can try and send back a 100 to save the socket connection but we might be
547
+ * toast, http.createServer is having issues on this machine/ network.
548
+ */
549
+ resp.writeHead(HTTPStatus.continue, this.#getHeaders({ contentType: 'text/plain' }));
550
+ return;
551
+ }
552
+
553
+ if (requestUrl === '/favicon.ico' && filename === 'favicon.ico') {
554
+ let iconPath = Path.join(this.#OPS.root, 'favicon.ico'); // Users favicon.
555
+ // If the user does not have a favicon try to default to NSS's favicon.
556
+ if (!Fs.existsSync(iconPath)) {
557
+ iconPath = Path.normalize(`${APP_ROOT}/sources/favicon.ico`);
558
+ }
559
+ // If NSS's favicon is also missing don't use a favicon; hides the 404.
560
+ if (!Fs.existsSync(iconPath)) {
561
+ resp.writeHead(HTTPStatus.noContent, this.#getHeaders({ contentType: ContentTypes[''] }));
562
+ resp.end();
563
+ return;
564
+ }
565
+ // We found a valid file, use it as the sites favicon.
566
+ const ico = Fs.readFileSync(iconPath, { encoding: 'binary', flag: 'r' });
567
+ resp.writeHead(HTTPStatus.found, this.#getHeaders({
568
+ contentType: ContentTypes['.ico'],
569
+ file: iconPath
570
+ }));
571
+ resp.write(ico, 'binary');
572
+ resp.end();
573
+ return;
574
+ }
575
+
576
+ // Attempt to locate the index it is missing or we are at the root of a directory.
577
+ if (requestUrl === '' || requestUrl === '/' || !ext) {
578
+ // Determine the correct home page by checking the allowed extensions:
579
+ const index = this.#getIndexForLocation(requestUrl);
580
+ // No home page found:
581
+ if (!index) {
582
+ // Show the directory listing if it is allowed.
583
+ this.#showDirListing(resp, systemPath);
584
+ return;
585
+ }
586
+ // If we found an index make sure the requested path ends with a path separator.
587
+ if (requestUrl[requestUrl.length - 1] !== '/') {
588
+ requestUrl += '/';
589
+ // This means the system path needs it added as well.
590
+ systemPath += Path.sep;
591
+ }
592
+ // Home page was located, update variables and load it in a minute.
593
+ requestUrl += index;
594
+ systemPath += index;
595
+ filename = index;
596
+ ext = Path.extname(index);
597
+ }
598
+
599
+ // Did the user specifically request a dir?
600
+ if (!ext) {
601
+ if (requestUrl.slice(-1) === '/') {
602
+ requestUrl = requestUrl.substring(0, requestUrl.length - 1);
603
+ }
604
+ if (systemPath.slice(-1) === Path.sep) {
605
+ systemPath = systemPath.substring(0, systemPath.length - 1);
606
+ }
607
+ this.#showDirListing(resp, systemPath);
608
+ return;
609
+ }
610
+
611
+ // Attempt to access the requested file.
612
+ try {
613
+ Fs.accessSync(systemPath, Fs.constants.R_OK);
614
+ } catch (err) {
615
+ if (err.message.includes('no such')) {
616
+ this.#showNotFound(resp);
617
+ return;
618
+ }
619
+ // If this forbidden is called it means there are file permission errors.
620
+ this.#showForbidden(resp);
621
+ return;
622
+ }
623
+
624
+ // Get the file.
625
+ const file = Fs.readFileSync(systemPath, { encoding: 'binary' });
626
+
627
+ // Output the file to the browser.
628
+ resp.writeHead(HTTPStatus.ok, this.#getHeaders({
629
+ contentType: ContentTypes[ext] || this.#OPS.contentType,
630
+ file: systemPath
631
+ }));
632
+
633
+ // If needed inject NSS's WebSocket at the end of the page.
634
+ if (this.#reload.includes(ext)) {
635
+ let html = file.toString();
636
+ const last = html.lastIndexOf('</body>');
637
+ if (last && last > 0) {
638
+ const start = html.substring(0, last);
639
+ const end = html.substring(last);
640
+ html = start + this.#handlers.liveReloading + end;
641
+ resp.write(html, 'utf-8');
642
+ } else {
643
+ resp.write(file, 'binary');
644
+ }
645
+ } else {
646
+ resp.write(file, 'binary');
647
+ }
648
+ resp.end();
649
+ }
650
+
651
+ /**
652
+ * If enabled displays the directory listing page to the user.
653
+ *
654
+ * @param {ServerResponse} resp The response object for a request being made.
655
+ * @param {string} systemPath The directory that the user is trying to view.
656
+ * @return {void} Used only as a short circuit.
657
+ */
658
+ #showDirListing(resp, systemPath) {
659
+ if (!Fs.existsSync(systemPath)) {
660
+ this.#showNotFound(resp);
661
+ return;
662
+ }
663
+
664
+ if (!this.#OPS.dirListing || !Fs.lstatSync(systemPath).isDirectory()) {
665
+ this.#showForbidden(resp);
666
+ return;
667
+ }
668
+
669
+ const { directories, files } = this.#getDirList(systemPath);
670
+ let html = this.#handlers.dirListing;
671
+ html = html.replace('{{files}}', files);
672
+ html = html.replace('{{directories}}', directories);
673
+ resp.writeHead(HTTPStatus.ok, this.#getHeaders({ contentType: 'text/html' }));
674
+ resp.write(html, 'utf8');
675
+ resp.end();
676
+ }
677
+
678
+ /**
679
+ * Displays the 403 forbidden page to the user.
680
+ *
681
+ * @param {ServerResponse} resp The response object for a request being made.
682
+ */
683
+ #showForbidden(resp) {
684
+ resp.writeHead(HTTPStatus.forbidden, this.#getHeaders({ contentType: 'text/html' }));
685
+ resp.write(this.#handlers.forbidden);
686
+ resp.end();
687
+ }
688
+
689
+ /**
690
+ * Displays the 404 page not found to the user.
691
+ *
692
+ * @param {ServerResponse} resp The response object for a request being made.
693
+ */
694
+ #showNotFound(resp) {
695
+ resp.writeHead(HTTPStatus.notFound, this.#getHeaders({ contentType: 'text/html' }));
696
+ resp.write(this.#handlers.notFound);
697
+ resp.end();
698
+ }
699
+
700
+ /**
701
+ * The WebSocket listener for NSS. All WebSocket requests are handled here.
702
+ *
703
+ * @param {object} socket The WebSocket object for this connection.
704
+ * @param {object} request The incoming initial connection.
705
+ */
706
+ #socketListener(socket, request) {
707
+
708
+ // Strip the page ID and /ws tag off the url to get the actual url.
709
+ let cleanURL = request.url.substr(1, request.url.indexOf('/ws?id=') - 1);
710
+ if (!cleanURL) {
711
+ cleanURL = this.#OPS.indexPage;
712
+ }
713
+
714
+ // Do not assuming this is a directory listing, check for an index first.
715
+ if (!Path.extname(cleanURL)) {
716
+ const index = this.#getIndexForLocation(cleanURL);
717
+ if (index) {
718
+ if (cleanURL[cleanURL.length - 1] !== '/') {
719
+ cleanURL += '/';
720
+ }
721
+ cleanURL += index;
722
+ }
723
+ }
724
+
725
+ // Record the unique page ID directly on the socket object.
726
+ const pageId = request.url.substr(request.url.indexOf('?id=')).replace('?id=', '');
727
+ // eslint-disable-next-line no-param-reassign
728
+ socket.nssUid = pageId;
729
+
730
+ // Overwrite the default send method to NSS's standard.
731
+ const originalSend = socket.send.bind(socket);
732
+ // eslint-disable-next-line no-param-reassign
733
+ socket.send = (message) => {
734
+ originalSend(JSON.stringify({
735
+ message,
736
+ type: this.whatIs(message)
737
+ }));
738
+ };
739
+
740
+ // Record new socket connections.
741
+ if (this.#sockets.routes[cleanURL]) {
742
+ this.#sockets.routes[cleanURL].push(socket); // This page has opened multiple times.
743
+ } else {
744
+ this.#sockets.routes[cleanURL] = [socket]; // First time we've seen this page.
745
+ }
746
+ this.#sockets.map[`S${pageId}`] = socket;
747
+
748
+ // If auto restart is supposed to be disabled tell the page now.
749
+ if (this.#OPS.disableAutoRestart && this.#OPS.disableAutoRestart === true) {
750
+ socket.send('disableAutoRestart');
751
+ }
752
+
753
+ // Handle future incoming WebSocket messages from this page.
754
+ socket.on('message', (message) => {
755
+ // NSS messages have a standard format
756
+ const msgObj = JSON.parse(message.toString());
757
+ // See if the message belongs to a callback and send it there.
758
+ for (let i = 0; i < this.#OPS.callbacks.length; i++) {
759
+ const regex = this.#OPS.callbacks[i][0];
760
+ const callback = this.#OPS.callbacks[i][1];
761
+ if (regex.test(cleanURL)) {
762
+ callback(msgObj, pageId);
763
+ return;
764
+ }
765
+ }
766
+ // No one is listening for this message.
767
+ console.log(`Unanswered WebSocket message from ${cleanURL}: ${message.toString()}`);
768
+ });
769
+
770
+ // When a connection closes remove it from CONNECTIONS.
771
+ socket.on('close', () => {
772
+ // Remove this page from our list of active routes.
773
+ const connections = this.#sockets.routes[cleanURL];
774
+ for (let i = 0; i < connections.length; i++) {
775
+ if (connections[i].nssUid === pageId) {
776
+ connections.splice(i, 1);
777
+ delete this.#sockets.map[pageId];
778
+ break;
779
+ }
780
+ }
781
+ });
782
+
783
+ }
784
+
785
+ /**
786
+ * Attempt to start the HTTP server and WebSocket listener.
787
+ *
788
+ * @param {int|null} port Allows force overriding of port number; you should usually not use
789
+ * this, it's meant to be used internally to NSS.
790
+ * @param {function} [callback] Optional function to call when the server successfully starts
791
+ * (true) or gives up on trying to start (false);
792
+ * @return {void} Used only as a short circuit.
793
+ */
794
+ start(port, callback) {
795
+
796
+ // Port is usually internal to NSS so check if a user placed the callback first.
797
+ if (port && typeof port === 'function') {
798
+ // eslint-disable-next-line no-param-reassign
799
+ callback = port;
800
+ // eslint-disable-next-line no-param-reassign
801
+ port = null;
802
+ }
803
+
804
+ // Make sure we have a proper callback function or null the variable.
805
+ if (callback && typeof callback !== 'function') {
806
+ // eslint-disable-next-line no-param-reassign
807
+ callback = null;
808
+ }
809
+
810
+ // Don't start an already running server.
811
+ if (this.#OPS.running) {
812
+ console.log('Server is already running.');
813
+ // Notify the callback.
814
+ if (callback) {
815
+ callback(true);
816
+ }
817
+ return;
818
+ }
819
+
820
+ // Create the HTTP server.
821
+ this.#server = Http.createServer(this.#serverListener.bind(this));
822
+ // Capture connection upgrade requests so we don't break WebSocket connections.
823
+ this.#server.on('upgrade', (request, socket) => {
824
+ /*
825
+ * Node's http server is capable of handling websocket but you have to manually
826
+ * handle a lot of work like the handshakes. We use WebSocketServer to avoid
827
+ * having to do all that extra work. See the following if you want to handle
828
+ * websocket without the WebSocket module:
829
+ * https://medium.com/hackernoon/implementing-a-websocket-server-with-node-js-d9b78ec5ffa8
830
+ *
831
+ * this.#server.upgrade will clash with this.#socket.connection by running first. Currently
832
+ * this.#server.upgrade is not used but in case we do in the future ignore all websocket
833
+ * requests so they get passed on to the this.#socket.connection listener.
834
+ */
835
+ if (request.headers.upgrade === 'websocket') {
836
+ // eslint-disable-next-line no-useless-return
837
+ return;
838
+ }
839
+ });
840
+ // Capture server errors and respond as needed.
841
+ this.#server.on('error', (error) => {
842
+ // The port we tried to use is taken, increment and try to start again.
843
+ if (error.code === 'EADDRINUSE') {
844
+ if (port) {
845
+ // Stop trying new ports after 100 attempts.
846
+ if (this.#OPS.port - port > 100) {
847
+ console.log(`FATAL ERROR: Could not find an available port number in the range of ${this.#OPS.port}–${this.#OPS.port + 100}.`);
848
+ // Notify the callback.
849
+ if (callback) {
850
+ callback(false);
851
+ }
852
+ return;
853
+ }
854
+ this.start(port + 1, callback);
855
+ } else {
856
+ this.start(this.#OPS.port + 1, callback);
857
+ }
858
+ return;
859
+ }
860
+ console.log('Server error', error);
861
+ });
862
+
863
+ // Attempt to start the server now.
864
+ // eslint-disable-next-line no-param-reassign
865
+ port = port || this.#OPS.port;
866
+ this.#server.listen(port, () => {
867
+ // Server started successfully without error.
868
+ this.#OPS.running = true;
869
+
870
+ // Start the WebSocket Server on the same port.
871
+ this.#socket = new WebSocketServer({ server: this.#server });
872
+ this.#socket.on('connection', this.#socketListener.bind(this));
873
+
874
+ // Warn the user if we had to change port numbers.
875
+ if (port && (port !== this.#OPS.port)) {
876
+ console.log(`Port ${this.#OPS.port} was in use, switched to using ${port}.\n`);
877
+ }
878
+
879
+ // Record port in use.
880
+ this.#OPS.portInUse = port;
881
+
882
+ // Log the ip addresses being watched.
883
+ console.log('Node Simple Server live @:');
884
+ const addresses = this.getAddresses(port);
885
+ addresses.forEach((address) => {
886
+ console.log(` ${address}`);
887
+ });
888
+ console.log('');
889
+
890
+ // Notify the callback.
891
+ if (callback) {
892
+ callback(true);
893
+ }
894
+ });
895
+ }
896
+
897
+ /**
898
+ * Stop the HTTP server and WebSocket listener gracefully.
899
+ *
900
+ * @param {function} [callback] Optional function to call when the server successfully
901
+ * stops (true).
902
+ */
903
+ stop(callback) {
904
+ if (this.#OPS.running) {
905
+ // If any back-end files are being watched for changes stop monitoring them.
906
+ this.watchEnd();
907
+ // Close all socket connections; these would force the server to stay up.
908
+ const keys = Object.keys(this.#sockets);
909
+ keys.forEach((key) => {
910
+ this.#sockets.routes[key].forEach((socket) => {
911
+ socket.send('close');
912
+ socket.close();
913
+ });
914
+ });
915
+ // Now gracefully close SERVER and SOCKET.
916
+ this.#server.close();
917
+ this.#socket.close();
918
+ // Reset NSS.
919
+ this.#server = null;
920
+ this.#socket = null;
921
+ this.#OPS.running = false;
922
+ console.log('Server has been stopped.');
923
+ }
924
+ // Notify the callback.
925
+ if (callback) {
926
+ callback(true);
927
+ }
928
+ }
929
+
930
+ /**
931
+ * Stop watching directories or files for changes; previously registered with watch().
932
+ *
933
+ * Warning: If you watched a directory for changes watch() will auto watch all contents
934
+ * of that directory recursively. You will need a different paths argument to truly
935
+ * remove all files, it may be easier to call watchEnd() and then restart the watch()
936
+ * you still need.
937
+ *
938
+ * @param {String|Array} paths Files, directories, or glob patterns for tracking. Takes an
939
+ * array of strings or just one string.
940
+ */
941
+ unwatch(paths) {
942
+ // Convert paths to array if it's not already.
943
+ if (this.whatIs(paths) === 'string') {
944
+ // eslint-disable-next-line no-param-reassign
945
+ paths = [paths];
946
+ }
947
+ // Search all watchers and remove any paths that match.
948
+ this.#watching.forEach((watcher) => {
949
+ watcher.unwatch(paths);
950
+ });
951
+ }
952
+
953
+ /**
954
+ * Start watching a file, files, directory, or directories for changes and then callback to
955
+ * functions that can/ will respond to these changes.
956
+ *
957
+ * @param {string|array} paths Files, directories, or glob patterns for tracking. Takes an
958
+ * array of strings or just one string.
959
+ * @param {object} options A special configuration object that includes chokidar options and NSS options.
960
+ * There are to many options to list here, see NSS's README for more
961
+ * information and remember `options.events` is required!
962
+ * @return {boolean} True if it appears everything worked, false if something was missing or
963
+ * an error was thrown.
964
+ */
965
+ watch(paths, options) {
966
+ // Insure we have an options object.
967
+ if (!options || !options.events) {
968
+ return false;
969
+ }
970
+ /*
971
+ * For security and a better user experience set the watchers current working
972
+ * directory to NSS's root if the setting is missing.
973
+ */
974
+ if (!options.cwd) {
975
+ // eslint-disable-next-line no-param-reassign
976
+ options.cwd = this.#OPS.root;
977
+ }
978
+ // Convert paths to array if it's not already.
979
+ if (this.whatIs(paths) === 'string') {
980
+ // eslint-disable-next-line no-param-reassign
981
+ paths = [paths];
982
+ }
983
+ try {
984
+ // Start watching the path(s).
985
+ const watcher = Chokidar.watch(paths, options);
986
+ this.#watching.push(watcher);
987
+ // Hookup requested listeners; they are case sensitive so type them right in your code!
988
+ const safe = ['all', 'add', 'addDir', 'change', 'unlink', 'unlinkDir', 'ready', 'raw', 'error'];
989
+ Object.keys(options.events).forEach((key) => {
990
+ if (safe.includes(key)) {
991
+ /**
992
+ * NON-STANDARD ALTERATION
993
+ *
994
+ * Chokidar provides paths in the correct OS format but NSS will change
995
+ * all backslashes (\) into forward slashes (/).
996
+ */
997
+ watcher.on(key, (evt, path) => {
998
+ // Capture the call and alter the path before passing it on.
999
+ const altPath = path.replace(/\\/g, '/');
1000
+ // Since we're messing with the path already grab the extension for the user.
1001
+ const ext = Path.extname(altPath);
1002
+ options.events[key](evt, altPath, ext.replace('.', ''));
1003
+ });
1004
+ }
1005
+ });
1006
+ } catch (error) {
1007
+ console.log(error);
1008
+ return false;
1009
+ }
1010
+ return true;
1011
+ }
1012
+
1013
+ /**
1014
+ * Stop watching registered file, files, directory, or directories for changes.
1015
+ */
1016
+ watchEnd() {
1017
+ this.#watching.forEach((watcher) => {
1018
+ watcher.close();
1019
+ });
1020
+ this.#watching.splice(0, this.#watching.length);
1021
+ }
1022
+
1023
+ /**
1024
+ * The fastest way to get the actual type of anything in JavaScript.
1025
+ *
1026
+ * {@link https://jsbench.me/ruks9jljcu/2 | See benchmarks}.
1027
+ *
1028
+ * @param {*} unknown Anything you wish to check the type of.
1029
+ * @return {string|undefined} The type in lowercase of the unknown value passed in or undefined.
1030
+ */
1031
+ whatIs(unknown) {
1032
+ try {
1033
+ return ({}).toString.call(unknown).match(/\s([^\]]+)/)[1].toLowerCase();
1034
+ } catch (e) { return undefined; }
1035
+ }
1036
+
1037
+ }
1038
+
1039
+ export default NodeSimpleServer;