Server-class is functional master
authorJimmy Christensen <dusted@dusted.dk>
Sun, 29 Jan 2017 22:05:22 +0000 (23:05 +0100)
committerJimmy Christensen <dusted@dusted.dk>
Sun, 29 Jan 2017 22:05:54 +0000 (23:05 +0100)
README.md
client.js
common.js
example-server.js
package.json
server.js

index 53f9289..f9840ee 100644 (file)
--- a/README.md
+++ b/README.md
@@ -22,6 +22,14 @@ client.get('gopher://gopher.floodgap.com/0/gopher/relevance.txt', (err, reply)=>
 
 # API
 
+### Class: Server
+
+```bash
+npm install
+./node_modules/jsdoc/jsdoc.js server.js common.js
+firefox out/index.html
+```
+
 ### Class: Client([{timeout: 5000, parseDir: true}])
 If timeout is set false, no timeout is used.<br>
 If parseDir is set false, the directory in the result will be a buffer with the raw text from the server instead of being an Array of GopherResource.
index dee0350..1edba71 100644 (file)
--- a/client.js
+++ b/client.js
@@ -111,7 +111,6 @@ class GopherClient {
                                                switch(l[0]) {
                                                        case 'i':
                                                        case '3':
-                                       //              dir.push( { type: l[0], txt: l.substring(1).replace(/\t.+$/,'') });
                                                        dir.push( new GopherResource( '-', '1', '', l[0], l.substring(1).replace(/\t.+$/,'') ) );  
                                                        break;
                                                        default:
index 59cb419..a0df3e9 100644 (file)
--- a/common.js
+++ b/common.js
@@ -1,11 +1,25 @@
+/*jslint node: true */
+/*jshint esversion: 6 */
+
 'use strict';
 
 const path = require('path');
 
 const GopherURIPattern='^(gopher:\\/\\/)?(.+?(?=:|\\/|$))(:\\d+?(?=$|\\/))?(\\/(\\d|g|I|h|t|M)?)?([^#]+?(?=\\?|$|#))?(\\?.+?(?=$|#))?(#.+)?';
 
+/** @class */
 class GopherResource {
-       constructor( host, port, selector, type, name, query, itemNum ) {
+
+       /**
+        * @param {string} host|url - Hostname or url, if url, do not provide other arguments
+        * @param {string} port - Port for the entry
+        * @param {string} selector - Selector for the entry
+        * @param {string} type - The type of entry (for example 'i')
+        * @param {string} name - The name to show to the user in a gopher-map
+        * @param {string} query - Search string to send to server (not expressed in gopher-map)
+        * @description Describes a gopher-resource (a menu-item)
+        */
+       constructor( host, port, selector, type, name, query ) {
                if(host && !port) {
                        var regEx = new RegExp(GopherURIPattern);
                        var matches = regEx.exec(decodeURI(host));
@@ -26,7 +40,6 @@ class GopherResource {
                        this.selector=selector;
                        this.name=name;
                        this.query=(query)?query:false;
-                       this.itemNum;
                } else {
                        throw new Error('Invalid arguments to constructor.');
                }
@@ -47,6 +60,10 @@ class GopherResource {
 
        }
 
+       /**
+        * @method
+        * @description Return a goper-map formatted string representation of this resource
+        */
        toDirectoryEntity() {
                return this.type+this.name+'\t'+this.selector+'\t'+this.host+'\t'+this.port+'\r\n';
        }
@@ -56,6 +73,28 @@ class GopherResource {
        }
 }
 
+/**
+ * @method
+ * @memberof GopherResource
+ * @param {string} txt - Message for this info-item
+ * @description Create a new GopherResource with info text, suitable for menus
+ * @returns {GopherResource}
+ */
+GopherResource.info = (txt)=>{
+       return new GopherResource('i', 1, '-', 'i', txt)
+};
+
+/**
+ * @method
+ * @memberof GopherResource
+ * @param {string} txt - Message for this error-item
+ * @description Create a new GopherResource with error text, suitable for menus
+ * @returns {GopherResource}
+ */
+GopherResource.error = (txt)=>{
+       return new GopherResource('e', 1, '-', '3', txt)
+};
+
 const GopherType = {
        info: 'i',
        text: '0',
index b5a4a99..7de415a 100644 (file)
@@ -1,58 +1,54 @@
+/*jslint node: true */
+/*jshint esversion: 6 */
+
 const Server = require('./server');
 const Common = require('./common');
-const Type = Common.Type;
+const Resource = Common.Resource;
+
 
 const server = new Server(7000, 'localhost');
 
-server.listen(7000);
-
-const menu = [
-    ['i', 'This is a test server',' ', 'h',1],
-    ['1', 'DusteD.dk', '/', 'dusted.dk', 70],
-    ['1', 'Submenu', '/sub', 'localhost', 7000],
-];
-
-server.addMenu('',menu);
-
-
-// TODO: This is not how it work yet.
-// The idea is, that if host/port is left out,
-// The ones used in the constructor will be inserted where relevant.
-// If an entry has "file" then that file will be served.
-// If an entry has "scan" then an index will be scanned from that.
-
-const nicerMenu = [
-    {
-        type: Type.info,
-        name: 'A test server indeed'
-    },
-    {
-        type: Type.directory,
-        name: 'Local path',
-        path: '/something'
-    },
-    {
-        url: 'gopher://gopher.floodgap.com/0/gopher/relevance.txt#Why is gopher still relevant today'
-    },
-    {
-        type: Type.text,
-        name: 'Why is gopher still relevant today',
-        host: 'gopher.floodgap.com',
-        path: '/gopher/relevance.txt',
-        port: 70
-    },
-    {
-        type: Type.text,
-        name: 'Server source',
-        path: '/server.js', /* This is optional, if left out, it is the hash of the filename */ 
-        file: __dirname + 'server.js'
-    },
-    {
-        type: Type.directory,
-        name: 'Something dynamic',
-        path: '/stuffs',
-        scan: '/somewhere/over/the/rainbow'
+server.listen(() => {
+    console.log('Server ready');
+});
+
+const preLog = (req, rep)=>{
+    console.log('Incoming connection (' + req.serial + ') from ' + rep.socket.remoteAddress + ' requesting: "' + req.selector + '"');
+    if(!req.handler) {
+        console.log('NOTE: No handler found for selector, default error-message is sent to client.');
     }
-];
+};
 
-server.addMenu('/sub',nicerMenu);
\ No newline at end of file
+const postLog = (req, rep)=>{
+    console.log('Ended connection (' + req.serial + ') bytes sent: ' + rep.socket.bytesWritten);
+};
+
+const myHandler = (request, rep)=>{
+    var remoteAddress = rep.socket.remoteAddress;
+    var now = new Date();
+
+    rep.menuItem( {name: 'Why hello there '+remoteAddress, type:'i'});
+    rep.menuItem( {name: 'Lovely that you dropped by '+now, type:'i'});
+    if(request.query) {
+        rep.menuItem( {name: 'You wanted to search "'+request.query+'".', type:'i'});
+    }
+    rep.menuItem( {name: 'Back to index', type:'1'});
+
+    rep.end();
+};
+
+server.addPreHandler(preLog);
+server.addPostHandler(postLog);
+
+server.addMenu('')
+    .addEntry({name: '+--------------------------+', type:'i'})
+    .addEntry({name: '| A gopher-server in node! |', type:'i'})
+    .addEntry({name: '+--------------------------+', type:'i'})
+    .addEntry({name: ' ', type:'i'})
+    .addEntry({name: 'Dynamically generated menu!', type: '1', selector:'/userHandler'})
+    .addEntry({name: 'Send query', type: '7', selector:'/userHandler'})
+    .addEntry({name: 'Directory Index', type: '1', selector:'/dirIndex'})
+    .addEntry({name: 'External Site', type: '1', host: 'dusted.dk', port: 70});
+
+server.addHandler('/userHandler', myHandler);
+server.addDir('/dirIndex', './');
index 4c59b66..36a1ec1 100644 (file)
@@ -1,6 +1,6 @@
 {
   "name": "gopher-lib",
-  "version": "0.1.2",
+  "version": "0.2.0",
   "description": "Client, server and utility library for the Gopher Internet protocol.",
   "main": "lib.js",
   "scripts": {
   ],
   "author": "DusteD",
   "license": "WTFPL",
-  "repository" : {
-    "type" : "git",
-    "url" : "https://github.com/DusteDdk/gopher-lib.git"
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/DusteDdk/gopher-lib.git"
+  },
+  "devDependencies": {
+    "jsdoc": "3.4.3",
+    "jshint": "2.9.4"
   }
 }
index e6dd3d6..4505510 100644 (file)
--- a/server.js
+++ b/server.js
@@ -1,17 +1,36 @@
+/*jslint node: true */
+/*jshint esversion: 6 */
+
 'use strict';
 
 const net = require('net');
 const fs = require('fs');
+const path = require('path');
 
 const Common = require('./common.js');
+const GopherResource = Common.Resource;
+
 
+/** @class */
 class Reply {
-       constructor(socket) {
+       constructor(hostname, port, socket, endCallback) {
+               /**
+                * @property {net.Socket} socket - The node network socket, contains interesting information. (Voids warranty)
+                */
                this.socket = socket;
-               this.isBin = false;
+
+               this.hostname=hostname;
+               this.port=port;
+
                this.queue = 0;
+               this.endCallback = endCallback;
+               this.wroteMenu = false;
        }
 
+       /**
+        * @param {string} - data
+        * @description Send data to client. Connection kept open!
+        */
        write(data) {
                if (!this.socket.write(data)) {
                        this.queue++;
@@ -22,23 +41,20 @@ class Reply {
                }
        }
 
-
-       file(fn, bin) {
+       /**
+        * @param {string} filname
+        * @description Send file from disk, close connection when complete.
+        */
+       file(fn) {
                var socket = this.socket;
-               this.isBin = bin || false;
 
-               var socket = this.socket;
                var self = this;
-
-
                var readStream = fs.createReadStream(fn);
 
                socket.on('drain', () => {
-                       resume++;
                        readStream.resume();
                });
 
-
                readStream.on('data', (data) => {
                        // Todo: detect start-of-line, check if first character is '.', prepend another '.' if so.
                        if (!socket.write(data)) {
@@ -57,121 +73,414 @@ class Reply {
                });
        }
 
-
-       menuItem(type, txt, path, host, port) {
-               path = path || '';
-               host = host || 'h';
-               port = port || 1;
-               this.write(type + txt + '\t' + path + '\t' + host + '\t' + port + '\r\n');
+       /**
+        * @param {GopherMapEntry} item
+        * @description Send one Gopher menu-entry to the client. Connection kept open!
+        */
+       menuItem(item) {
+               this.wroteMenu = true;
+               if (item.url) {
+                       item.host = item.url;
+               } else if (item.type != 'i' && item.type != '3') {
+                       item.host = item.host || this.hostname;
+                       item.port = item.port || this.port;
+               } else {
+                       item.host = 'h';
+                       item.port = '1';
+               }
+               item.selector = item.selector || '';
+               this.write(new GopherResource(item.host, item.port, item.selector, item.type, item.name).toDirectoryEntity());
        }
 
+       /**
+        * @param {string} message
+        * @description Send an error item to client. Connection kept open!
+        */
        menuErr(txt) {
-               this.menuItem('3', txt, ' ', 'e', '1');
+               this.menuItem(GopherResource.error(txt));
        }
 
+       /**
+        * @param {string} message
+        * @description Send an info item to client. Connection kept open!
+        */
        menuInfo(txt) {
-               this.menuItem('i', txt, ' ', 'i', '1');
+               this.menuItem(GopherResource.info(txt));
        }
 
+       /**
+        * @description Close connection. Note: This waits for data to be sent to client before the connection is closed.
+        */
        end() {
                var socket = this.socket;
+               var self = this;
 
-               if (!this.isBin) {
-                       this.write('.\r\n');
+               if (this.wroteMenu) {
+                       socket.write('.');
                }
 
                if (this.queue || socket.bufferSize) {
                        socket.on('drain', () => {
                                socket.end();
+                               self.endCallback();
                        });
                } else {
                        socket.end();
+                       self.endCallback();
+               }
+       }
+}
+
+
+/** @class */
+class GopherMenu {
+       constructor() {
+               this.entries = [];
+       }
+
+       send(reply) {
+               for (var entry of this.entries) {
+                       reply.menuItem(entry);
+               }
+               reply.end();
+       }
+
+       /**
+        * @param {fileName} fileName - Name of file containing JSON array of GopherMapEntry
+        * @param {bool} [silent=false] - Don't throw error if adding the file fails
+        * @description Populate the menu from a file, see {@link GopherMapEntry}
+        * 
+        */
+       fromFile(fileName, silent) {
+
+               try {
+                       var fileObj = JSON.parse(fs.readFileSync(fileName));
+                       if (fileObj) {
+                               fileObj.forEach((itemObj) => {
+                                       this.addEntry(itemObj);
+                               });
+                       }
+               } catch (e) {
+                       if (!silent) {
+                               throw e;
+                       }
                }
        }
+
+       /**
+        * @param {GopherMapEntry} item - Item to add to menu
+        * @description Add a menu-item to the menu, fills out host, port if empty.
+        * @returns {GopherMenu} - Returns same instance for easy chaining.
+        */
+       addEntry(item) {
+               /**
+                * @typedef {Object} GopherMapEntry
+                * @description An object from which a GopherResource can be constructed
+                * @property {string} [type='3'] - The type of resource that this is
+                * @property {string} [url] - A gopher url, all other parameters are ignored.
+                * @property {string} [name='No Name] - Name of this entry
+                * @property {string} [selector=''] - Selector
+                * @property {string} [host=(Servers hostname)] - If empty, autofilled if type != 'i' or '3'
+                * @property {string} [port=(Servers port)] - If empty, autofilled if type != 'i' or '3'
+                * 
+                */
+
+               this.entries.push(item);
+               return this;
+       }
+
 }
 
+
+/** @class */
 class GopherServer {
 
-       constructor(externalPort, externalName) {
+       /**
+        * @param {integer} port - The port on which to listen
+        * @param {string} hostname - The hostname that should be reported in menus
+        * @description Create a new GopherServer class, it will not listen before you call listen().
+        * Note that the server supports listening on a different port than it reports to clients.
+        * */
+       constructor(port, hostname) {
                this.handlers = new Map();
                this.handlers.set('err', this._errHandler);
                this.server = null;
-               this.externalPort = externalPort;
-               this.externalName = externalName;
+               this.port = port;
+               this.hostname = hostname;
+               this.preHandlers = [];
+               this.postHandlers = [];
+               this.numRequests = 1;
        }
 
        _errHandler(request, reply) {
-               reply.menuErr('Path not found: "' + request.path + '"');
+               reply.menuErr('Selector not found: "' + request.selector + '"');
                reply.end();
        }
 
-       addHandler(path, handler) {
-               this.handlers.set(path, handler);
+       /**
+        * @param {GopherServer~handlerCallback} preHandler
+        * @description Add a handler that is called on any selector (found or not), this should generally not send data or modify the socket.
+        */
+       addPreHandler(handler) {
+               this.preHandlers.push(handler);
+       }
+
+       /**
+        * @param {GopherServer~handlerCallback} postHandler
+        * @description Add a handler that is called on any selecter, after the reply.end() has been called, and socket been drained and closed.
+        */
+       addPostHandler(handler) {
+               this.postHandlers.push(handler);
+       }
+
+
+       /**
+        * @param {string} selector - Selector that this handler should serve
+        * @param {GopherServer~handlerCallback} - The handler to serve this selector
+        */
+       addHandler(selector, handler) {
+               this.handlers.set(selector, handler);
        }
 
-       addFile(path, fn) {
-               this.addHandler(path, (request, reply) => {
+       /**
+        * @param {string} selector - The selector on which file should be available
+        * @param {string} filename - The file to send
+        * @description Send a file when selector is hit, this could be anything, even a gophermap
+        */
+       addFile(selector, fn) {
+               this.addHandler(selector, (request, reply) => {
                        reply.file(fn);
                });
        }
 
-       addMenu(path, menu) {
-               this.addHandler(path, (request, reply) => {
-                       for (var item of menu) {
-                               reply.menuItem(...item);
-                       }
-                       reply.end();
+       /**
+        * @param {string} - Selector
+        * @description Send a menu from selector
+        * @returns {GopherMenu} - The menu object that was created
+        */
+       addMenu(selector) {
+
+               var menu = new GopherMenu();
+
+               this.addHandler(selector, (request, reply) => {
+                       menu.send(reply);
                });
+               return (menu);
        }
 
-       addDynamicMenu(path, menu) {
+       fileInfo(fn) {
+               var info = {
+                       type: '9',
+                       stat: fs.statSync(fn)
+               };
 
-       }
+               if (info.stat.isDirectory()) {
+                       info.type = '1';
+               } else {
+                       var ext = path.extname(fn).toLowerCase();
+                       switch (ext) {
+                               case '.html':
+                               case '.htm':
+                               case '.txt':
+                               case '.md':
+                               case '.c':
+                               case '.cpp':
+                               case '.h':
+                               case '.hpp':
+                               case '.sh':
+                               case '.js':
+                               case '.json':
+                                       info.type = '0';
+                                       break;
+                               case '.gif':
+                                       info.type = 'g';
+                                       break;
+                               case '.jpg':
+                               case '.jpe':
+                               case '.jpeg':
+                               case '.png':
+                               case '.tga':
+                               case '.bmp':
+                               case '.ico':
+                                       info.type = 'I';
+                                       break;
+                       }
+               }
 
-       addDir(path, dir) {
-               throw new Error('Not implemented yet');
+               return (info);
        }
 
-       listen(a, b) {
+       /**
+        * @param {string} selector - The selector on which directory listing/menu should be available
+        * @param {string} dir - The directory to scan
+        * @param {GopherServer~addDirOptions} [options] - Options
+        * @description Attach directory to selector
+        */
+       addDir(selector, dir, options) {
+               /**
+                * @typedef {object} GopherServer~addDirOptions
+                * @property {bool} [recurse=true] - Recurse into subdirectories
+                * @property {bool} [useMap=true] - Read map files and use for menus instead of generating an index
+                * @property {integer} [showSizeAt=32768] - Filesize at which size is displayed next to the name
+                * @property {bool} [dotFiles=false] - Include dotFiles in the directory listing
+                * @property {string} [oldMapFileName='.cache'] - When useMap is enabled, look for this file (if no jsonmap found)
+                * @property {string} [jsonMapFileName='gophermap.json'] - When useMap is enabled, look for this file before falling back on oldMapFileName or directory index.
+                */
+
+               const confDefaults = {
+                       recurse: true,
+                       useMap: true,
+                       showSizeAt: 32768,
+                       dotFiles: false,
+                       oldMapFileName: '.cache',
+                       jsonMapFileName: 'gophermap.json'
+               };
+
+               options = options || {};
+
+               for (var key in confDefaults) {
+                       options[key] = options[key] || confDefaults[key];
+               }
+
+               var menu = false;
+
                var self = this;
-               var port = 70;
-               var callback = b;
+               var files = fs.readdirSync(dir);
+
+               var hasMap = false;
+
+               if (options.useMap) {
+                       hasMap = files.includes(options.jsonMapFileName);
+                       if (hasMap) {
+                               menu = this.addMenu(selector);
+                               menu.fromFile(path.join(dir, options.jsonMapFileName));
+                       } else {
+                               hasMap = files.includes(options.oldMapFileName);
+                               if (hasMap) {
+                                       this.addFile(selector, path.join(dir, options.oldMapFileName));
+                                       menu = true;
+                               }
+                       }
+               }
 
-               if (typeof a === 'number') {
-                       port = a;
-               } else if (typeof a === 'function') {
-                       callback = a;
+               if (!menu) {
+                       menu = this.addMenu(selector);
                }
 
-               console.log('Listening on port', port);
+               files.forEach((item) => {
+
+                       if (!options.dotFiles && item[0] === '.') {
+                               return;
+                       }
+
+                       var selectorName = selector + '/' + item;
+                       var longName = path.join(dir, item);
+                       var info = self.fileInfo(longName);
+
+                       if (info.stat.isDirectory()) {
+                               if (options.recurse) {
+                                       self.addDir(selectorName, longName, options);
+                               }
+                       } else if (info.stat.isFile()) {
+
+                               // Add selector for file
+                               self.addFile(selectorName, longName);
+                               // Add menu entry for file
+                               if (info.stat.size >= options.showSizeAt) {
+                                       var size = parseInt(info.stat.size);
+                                       if (size > 1024 * 1024 * 1024) {
+                                               size /= 1024 * 1024 * 1024;
+                                               size = size.toFixed(0) + ' GiB';
+                                       } else if (size > 1024 * 1024) {
+                                               size /= 1024 * 1024;
+                                               size = size.toFixed(0) + ' MiB';
+                                       } else if (size > 1024) {
+                                               size /= 1024;
+                                               size = size.toFixed(0) + ' KiB';
+                                       } else {
+                                               size = size + ' B';
+                                       }
+                                       item = item + ' (' + size + ')';
+                               }
+                       }
+
+                       var entry = {
+                               name: item,
+                               selector: selectorName,
+                               type: info.type
+                       };
+
+                       if (!hasMap) {
+                               if(info.stat.isFile() || options.recurse) {
+                                       menu.addEntry(entry);
+                               }
+                       }
+
+               });
+       }
+
+       /**
+        * @description Start listening for incoming connections
+        * @param {function} [callback] - Called when the server is started and listening
+        */
+       listen(callback) {
+               var self = this;
 
                this.server = net.createServer((socket) => {
                        socket.setEncoding('ascii');
+
+                       var serial = self.numRequests++;
+
                        socket.on('data', (data) => {
                                var args = data.toString().trim().split('\t');
 
+                               /**
+                                * @typedef {object} GopherServer~requestInformation
+                                * @property {string} selector - The selector sent by the client
+                                * @property {string} [query] - The search string, if any
+                                * @property {integer} serial - Which request was this (unique during server-lifetime only)
+                                * @property {GopherServer~handlerCallback} [handler] - Which handler (if any) handles this
+                                */
                                var request = {
-                                       path: args[0],
-                                       query: args[1]
-                               }
-                               var reply = new Reply(socket);
+                                       selector: args[0],
+                                       query: args[1],
+                                       serial: serial,
+                                       handler: false
+                               };
+
+                               var reply = new Reply(self.hostname, self.port, socket, () => {
+                                       for (var postHandler of self.postHandlers) {
+                                               postHandler(request, reply);
+                                       }
+                               });
 
-                               var handler = self.handlers.get(request.path);
+                               var handler = self.handlers.get(request.selector);
+                               if(handler) {
+                                       request.handler = handler;
+                               }
 
+                               for (var preHandler of self.preHandlers) {
+                                       preHandler(request, reply);
+                               }
 
                                if (handler) {
-                                       handler(request, reply)
+                                       handler(request, reply);
                                } else {
                                        this.handlers.get('err')(request, reply);
                                }
-                       })
+                       });
+
+                       socket.on('error', (e) => {
+                               console.log('Socket error (request serial ' + serial + '), Error:', e.message);
+                       });
+
                });
 
                this.server.on('error', (e) => {
                        console.log('Server error:', e);
                });
 
-               this.server.listen(port, () => {
+               this.server.listen(this.port, () => {
                        if (callback) {
                                callback();
                        }
@@ -179,4 +488,11 @@ class GopherServer {
        }
 }
 
+/**
+ * @callback GopherServer~handlerCallback
+ * @param {GopherServer~requestInformation} request - Information about the request made by the client
+ * @param {Reply} reply - Used to send data back to the client
+ * @description This method is responsible for replying to incoming requests.
+ */
+
 module.exports = GopherServer;
\ No newline at end of file