diff --git a/examples/httpserver-app.js b/examples/httpserver-app.js new file mode 100644 index 000000000..d367f6a16 --- /dev/null +++ b/examples/httpserver-app.js @@ -0,0 +1,5 @@ +var response = require("ringo/jsgi/response"); + +module.exports = function(req) { + return response.html("Hello World!"); +}; \ No newline at end of file diff --git a/examples/httpserver-default.js b/examples/httpserver-default.js new file mode 100644 index 000000000..f7af1f7d8 --- /dev/null +++ b/examples/httpserver-default.js @@ -0,0 +1,34 @@ +var {HttpServer} = require("../lib/main"); + +var httpServer = new HttpServer(); +httpServer.enableSessions({ + "name": "myapp" +}); + +// init the application context +var appContext = httpServer.serveApplication("/", module.resolve("./app"), { + "sessions": true +}); +// and add a websocket to it +appContext.addWebSocket("/events", function() {}); + +// initialize static file serving +var staticContext = httpServer.serveStatic("/static", module.resolve("./"), { + "allowDirectoryListing": true +}); + +// http listener +httpServer.createHttpListener({ + "port": 8080 +}); + +// https listener +httpServer.createHttpsListener({ + "port": 8443, + "keyStore": module.resolve("./keystore"), + "keyStorePassword": "secret", + "keyManagerPassword": "secret" +}); + +// start +httpServer.jetty.start(); \ No newline at end of file diff --git a/examples/httpserver-fluent.js b/examples/httpserver-fluent.js new file mode 100644 index 000000000..49cfe4fd2 --- /dev/null +++ b/examples/httpserver-fluent.js @@ -0,0 +1,30 @@ +var httpServer = require("../lib/main"); +var builder = httpServer.build() + // enable sessions with a custom node name + .enableSessions({ + "name": "test1" + }) + // serve application + .serveApplication("/", module.resolve("./app"), { + "sessions": true + }) + // add websocket - this must be called after serveApplication + // as it operates on the current context of the builder + .addWebSocket("/websocket", function() {}) + // static file serving + .serveStatic("/static", module.resolve("./"), { + "allowDirectoryListing": true + }) + // http listener + .http({ + "port": 8080 + }) + // https listener + .https({ + "port": 8443, + "keyStore": module.resolve("./keystore"), + "keyStorePassword": "secret", + "keyManagerPassword": "secret" + }) + // start up the server + .start(); \ No newline at end of file diff --git a/examples/httpserver-jettyxml.js b/examples/httpserver-jettyxml.js new file mode 100644 index 000000000..a03c30052 --- /dev/null +++ b/examples/httpserver-jettyxml.js @@ -0,0 +1,10 @@ +var httpServer = require("../lib/main"); +var builder = httpServer.build("config/jetty.xml") + // serve application + .serveApplication("/", module.resolve("./app")) + // static file serving + .serveStatic("/static", module.resolve("./"), { + "allowDirectoryListing": true + }) + // start up the server + .start(); \ No newline at end of file diff --git a/examples/keystore b/examples/keystore new file mode 100644 index 000000000..9f9b1add3 Binary files /dev/null and b/examples/keystore differ diff --git a/modules/ringo/httpserver/builder.js b/modules/ringo/httpserver/builder.js new file mode 100644 index 000000000..bb88eb2b9 --- /dev/null +++ b/modules/ringo/httpserver/builder.js @@ -0,0 +1,76 @@ +const HttpServer = require("./httpserver"); + +const HttpServerBuilder = module.exports = function HttpServerBuilder(options) { + if (!(this instanceof HttpServerBuilder)) { + return new HttpServerBuilder(options); + } + Object.defineProperties(this, { + "server": { + "value": new HttpServer(options) + }, + "currentContext": { + "value": null, + "writable": true + } + }); + return this; +}; + +HttpServerBuilder.prototype.toString = function() { + return "[HttpServerBuilder]"; +}; + +HttpServerBuilder.prototype.configure = function(xmlPath) { + this.server.configure(xmlPath); + return this; +}; + +HttpServerBuilder.prototype.serveApplication = function(mountpoint, app, options) { + this.currentContext = this.server.serveApplication(mountpoint, app, options); + return this; +}; + +HttpServerBuilder.prototype.serveStatic = function(mountpoint, directory, options) { + this.currentContext = this.server.serveStatic(mountpoint, directory, options); + return this; +}; + +HttpServerBuilder.prototype.http = function(options) { + this.server.createHttpListener(options); + return this; +}; + +HttpServerBuilder.prototype.https = function(options) { + this.server.createHttpsListener(options); + return this; +}; + +HttpServerBuilder.prototype.enableSessions = function(options) { + this.server.enableSessions(options); + return this; +}; + +HttpServerBuilder.prototype.enableConnectionStatistics = function() { + this.server.enableConnectionStatistics(); + return this; +}; + +HttpServerBuilder.prototype.start = function() { + this.server.start(); + return this; +}; + +HttpServerBuilder.prototype.addWebSocket = function(path, onConnect, onCreate, initParams) { + this.currentContext.addWebSocket(path, onConnect, onCreate, initParams); + return this; +}; + +HttpServerBuilder.prototype.addEventSource = function(path, onConnect, initParams) { + this.currentContext.addEventSource(path, onConnect, initParams); + return this; +}; + +HttpServerBuilder.prototype.addFilter = function(path, filter, initParams) { + this.currentContext.addFilter(path, filter, initParams); + return this; +}; diff --git a/modules/ringo/httpserver/context/application.js b/modules/ringo/httpserver/context/application.js new file mode 100644 index 000000000..f141345f4 --- /dev/null +++ b/modules/ringo/httpserver/context/application.js @@ -0,0 +1,73 @@ +const log = require("ringo/logging").getLogger(module.id); +const Context = require("./context"); +const {JsgiServlet} = org.ringojs.jsgi; +const {WebSocketServlet, WebSocketCreator} = org.eclipse.jetty.websocket.servlet; +const {EventSourceServlet} = org.eclipse.jetty.servlets; +const EventSource = require("../eventsource"); +const WebSocket = require("../websocket"); + +const ApplicationContext = module.exports = function ApplicationContext() { + Context.apply(this, arguments); + return this; +}; + +ApplicationContext.prototype = Object.create(Context.prototype); +ApplicationContext.prototype.constructor = ApplicationContext; + +ApplicationContext.prototype.serve = function(app, engine) { + log.info("Adding JSGI application {} -> {}", + this.contextHandler.getContextPath(), app); + engine = engine || require("ringo/engine").getRhinoEngine(); + let servlet = null; + const params = {}; + if (typeof(app) === "string") { + params["app-module"] = app; + servlet = new JsgiServlet(engine); + } else if (typeof(app) === "function") { + servlet = new JsgiServlet(engine, app); + } else { + throw new Error("Application must be either a function or the path " + + "to a module exporting a function"); + } + return this.addServlet("/*", servlet, params); +}; + +ApplicationContext.prototype.addWebSocket = function(path, onConnect, onCreate, initParams) { + log.info("Starting websocket support"); + + const webSocketCreator = new WebSocketCreator({ + "createWebSocket": function(request, response) { + if (typeof(onCreate) === "function" && onCreate(request, response) !== true) { + return null; + } + const socket = new WebSocket(); + socket.addListener("connect", function(session) { + socket.session = session; + if (typeof onConnect === "function") { + onConnect(socket, session); + } + }); + + return socket.impl; + } + }); + + this.addServlet(path, new WebSocketServlet({ + "configure": function(factory) { + factory.setCreator(webSocketCreator); + } + }), initParams); +}; + +ApplicationContext.prototype.addEventSource = function(path, onconnect, initParams) { + log.info("Starting eventsource support"); + this.addServlet(path, new EventSourceServlet({ + "newEventSource": function(request) { + const socket = new EventSource(); + if (typeof onconnect === "function") { + onconnect(socket, request); + } + return socket.impl; + } + }), initParams); +}; diff --git a/modules/ringo/httpserver/context/context.js b/modules/ringo/httpserver/context/context.js new file mode 100644 index 000000000..3700b2a6b --- /dev/null +++ b/modules/ringo/httpserver/context/context.js @@ -0,0 +1,85 @@ +const log = require("ringo/logging").getLogger(module.id); +const {ServletContextHandler, ServletHolder, FilterHolder} = org.eclipse.jetty.servlet; +const {StatisticsHandler} = org.eclipse.jetty.server.handler; +const {EnumSet} = java.util; +const {DispatcherType} = javax.servlet; + +const Context = module.exports = function Context(parentContainer, mountpoint, options) { + let statisticsHandler = null; + if (options.statistics === true) { + // add statistics handler and use it as parent container for + // the context handler created below + statisticsHandler = new StatisticsHandler(); + parentContainer.addHandler(statisticsHandler); + parentContainer = statisticsHandler; + } + const contextHandler = new ServletContextHandler(parentContainer, mountpoint, + options.sessions, options.security); + if (options.virtualHosts) { + contextHandler.setVirtualHosts(Array.isArray(options.virtualHosts) ? + options.virtualHosts : [String(options.virtualHosts)]); + } + const sessionHandler = contextHandler.getSessionHandler(); + if (sessionHandler !== null) { + if (Number.isInteger(options.sessionsMaxInactiveInterval)) { + sessionHandler.setMaxInactiveInterval(options.sessionsMaxInactiveInterval); + } + const sessionCookieConfig = sessionHandler.getSessionCookieConfig(); + sessionCookieConfig.setHttpOnly(options.httpOnlyCookies); + sessionCookieConfig.setSecure(options.secureCookies); + if (typeof(options.cookieName) === "string") { + sessionCookieConfig.setName(options.cookieName); + } + sessionCookieConfig.setDomain(options.cookieDomain); + sessionCookieConfig.setPath(options.cookiePath); + sessionCookieConfig.setMaxAge(options.cookieMaxAge); + } + + Object.defineProperties(this, { + "statisticsHandler": { + "value": statisticsHandler, + "enumerable": true + }, + "contextHandler": { + "value": contextHandler, + "enumerable": true + } + }); + + return this; +}; + +Context.prototype.getKey = function() { + const mountpoint = this.contextHandler.getContextPath(); + const virtualHosts = this.contextHandler.getVirtualHosts(); + if (virtualHosts !== null && virtualHosts.length > 0) { + return String(virtualHosts) + mountpoint; + } + return mountpoint; +}; + +Context.prototype.addServlet = function(path, servlet, initParams) { + log.debug("Adding servlet {} -> {}", path, "->", servlet); + const servletHolder = new ServletHolder(servlet); + if (initParams != null && initParams.constructor === Object) { + for each (let [key, value] in Iterator(initParams)) { + servletHolder.setInitParameter(key, value); + } + } + this.contextHandler.addServlet(servletHolder, path); + return servletHolder; +}; + +Context.prototype.addFilter = function(path, filter, initParams) { + log.debug("Adding filter {} -> {}", path, "->", filter); + const filterHolder = new FilterHolder(filter); + filterHolder.setName(filter.getClass().getName()); + if (initParams != null && initParams.constructor === Object) { + for each (let [key, value] in Iterator(initParams)) { + filterHolder.setInitParameter(key, value); + } + } + this.contextHandler.addFilter(filterHolder, path, + EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC)); + return filterHolder; +}; \ No newline at end of file diff --git a/modules/ringo/httpserver/context/static.js b/modules/ringo/httpserver/context/static.js new file mode 100644 index 000000000..e04c4627c --- /dev/null +++ b/modules/ringo/httpserver/context/static.js @@ -0,0 +1,20 @@ +const log = require("ringo/logging").getLogger(module.id); +const Context = require("./context"); +const {DefaultServlet} = org.eclipse.jetty.servlet; + +const StaticContext = module.exports = function StaticContext() { + Context.apply(this, arguments); + return this; +}; + +StaticContext.prototype = Object.create(Context.prototype); +StaticContext.prototype.constructor = StaticContext; + + +StaticContext.prototype.serve = function(directory, initParameters) { + log.debug("Adding static handler {} -> {}", + this.contextHandler.getContextPath(), directory); + const repo = getRepository(directory); + this.contextHandler.setResourceBase(repo.exists() ? repo.getPath() : directory); + return this.addServlet("/*", DefaultServlet, initParameters); +}; \ No newline at end of file diff --git a/modules/ringo/httpserver/eventsource.js b/modules/ringo/httpserver/eventsource.js new file mode 100644 index 000000000..8e7764d81 --- /dev/null +++ b/modules/ringo/httpserver/eventsource.js @@ -0,0 +1,97 @@ +const log = require("ringo/logging").getLogger(module.id); +const {EventSource} = org.eclipse.jetty.servlets; +const {JavaEventEmitter} = require("ringo/events"); + +/** + * Provides support for EventSource in the HTTP server. + * + * EventSource is an event emitter that supports the + * following events: + * + * * **open**: called when a new eventsource connection is accepted + * * **close**: called when an established eventsource connection closes + * @name EventSource + */ +const EventSourceWrapper = module.exports = function() { + let conn = null; + + /** + * Closes the EventSource connection. + * @name EventSource.instance.close + */ + this.close = function() { + if (conn) { + conn.close(); + log.debug("Closed connection", conn); + } + }; + + /** + * Send a default event to the client + * @param {String} msg a string + * @name EventSource.instance.data + */ + this.data = function(msg) { + if (conn) { + try { + conn.data(msg); + } catch (e) { + if (log.isDebugEnabled()) { + log.error("Error sending data to {}:", conn, e); + } + conn = null; + this.emit("close"); + } + } + }; + + /** + * Send a named event + * @param {String} name a string + * @param {String} msg a string + * @name EventSource.instance.event + */ + this.event = function(name, msg) { + if (conn) { + try { + conn.event(name, msg); + } catch (e) { + if (log.isDebugEnabled()) { + log.error("Error sending '{}' event to {}:", name, conn, e); + } + conn = null; + this.emit("close"); + } + } + }; + /** + * Send a comment + * @param {String} msg a string + * @name EventSource.instance.comment + */ + this.comment = function(msg) { + if (conn) { + try { + conn.comment(msg); + } catch (e) { + if (log.isDebugEnabled()) { + log.error("Error sending comment to {}:", conn, e); + } + conn = null; + this.emit("close"); + } + } + }; + + /** @ignore **/ + this.setConnection = function(connection) { + conn = connection; + }; + + JavaEventEmitter.call(this, [EventSource]); + this.addListener("open", function(connection) { + conn = connection; + }); + + return this; +}; diff --git a/modules/ringo/httpserver/httpserver.js b/modules/ringo/httpserver/httpserver.js new file mode 100644 index 000000000..f85d59165 --- /dev/null +++ b/modules/ringo/httpserver/httpserver.js @@ -0,0 +1,311 @@ +const log = require('ringo/logging').getLogger(module.id); +const {XmlConfiguration} = org.eclipse.jetty.xml; +const {Server, HttpConfiguration, HttpConnectionFactory, + ServerConnector, SslConnectionFactory, + SecureRequestCustomizer, ServerConnectionStatistics} = org.eclipse.jetty.server; +const {HandlerCollection, ContextHandlerCollection} = org.eclipse.jetty.server.handler; +const {ConnectionStatistics} = org.eclipse.jetty.io; +const {HTTP_1_1} = org.eclipse.jetty.http.HttpVersion; +const {DefaultSessionIdManager} = org.eclipse.jetty.server.session; +const {SslContextFactory} = org.eclipse.jetty.util.ssl; + +const objects = require("ringo/utils/objects"); +const ApplicationContext = require("./context/application"); +const StaticContext = require("./context/static"); +const fs = require("fs"); + +const HttpServer = module.exports = function HttpServer(options) { + if (!(this instanceof HttpServer)) { + return new HttpServer(options); + } + + const jetty = new Server(); + + let xmlConfig = null; + + Object.defineProperties(this, { + "jetty": { + "value": jetty, + "enumerable": true + }, + "xmlConfig": { + "get": function() { + return xmlConfig; + }, + "set": function(config) { + if (!(config instanceof XmlConfiguration)) { + throw new Error("Invalid jetty xml configuration"); + } + xmlConfig = config; + xmlConfig.configure(jetty); + }, + "enumerable": true + }, + "contexts": { + "value": {}, + "enumerable": true + } + }); + + if (options !== null && options !== undefined) { + if (typeof(options) === "string") { + // path to jetty xml configuration + this.configure(options); + } else if (typeof(options) === "object" && options.constructor === Object) { + jetty.setStopAtShutdown(options.stopAtShutdown !== false); + jetty.setStopTimeout(options.stopTimeout || 1000); + jetty.setDumpAfterStart(options.dumpBeforeStart === true); + jetty.setDumpBeforeStop(options.dumpBeforeStop === true); + } + } + return this; +}; + +HttpServer.prototype.toString = function() { + return "[HttpServer]"; +}; + +HttpServer.prototype.configure = function(xmlPath) { + const xmlResource = getResource(xmlPath); + if (!xmlResource.exists()) { + throw Error('Jetty XML configuration "' + xmlResource + '" not found'); + } + return this.xmlConfig = new XmlConfiguration(xmlResource.inputStream); +}; + +HttpServer.prototype.createHttpConfig = function(options) { + options = objects.merge(options || {}, { + "requestHeaderSize": 8129, + "outputBufferSize": 32768, + "responseHeaderSize": 8129, + "secureScheme": "https" + }); + const httpConfig = new HttpConfiguration(); + httpConfig.setRequestHeaderSize(options.requestHeaderSize); + httpConfig.setOutputBufferSize(options.outputBufferSize); + httpConfig.setResponseHeaderSize(options.responseHeaderSize); + httpConfig.setSecureScheme(options.secureScheme); + httpConfig.setSendServerVersion(options.sendServerVersion === true); + httpConfig.setSendDateHeader(options.sendDateHeader !== false); + return httpConfig; +}; + +HttpServer.prototype.createConnector = function(connectionFactory, options) { + const connector = new ServerConnector(this.jetty, options.acceptors || -1, + options.selectors || -1, connectionFactory); + connector.setHost(options.host); + connector.setPort(options.port); + connector.setIdleTimeout(options.idleTimeout || 30000); + connector.setSoLingerTime(options.soLingerTime || -1); + connector.setAcceptorPriorityDelta(options.acceptorPriorityDelta || 0); + connector.setAcceptQueueSize(options.acceptQueueSize || 0); + if (typeof(options.name) === "string") { + connector.setName(options.name); + } + return connector; +}; + +HttpServer.prototype.createHttpConnector = function(options) { + options = objects.merge(options || {}, { + "host": "0.0.0.0", + "port": 8080 + }); + const httpConfig = this.createHttpConfig(options); + const connectionFactory = new HttpConnectionFactory(httpConfig); + return this.createConnector(connectionFactory, options); +}; + +HttpServer.prototype.createSslContextFactory = function(options) { + options = objects.merge(options || {}, { + "verbose": false, + "includeCipherSuites": [], + "excludeCipherSuites": [ + "^SSL_.*", + "^TLS_DHE_.*", + "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA", + "TLS_ECDH_RSA_WITH_3DES_EDE_CBC_SHA" + ], + "includeProtocols": ["TLSv1.2"] + }); + const sslContextFactory = new SslContextFactory(); + sslContextFactory.setKeyStorePath(options.keyStore); + sslContextFactory.setKeyStoreType(options.keyStoreType || "JKS"); + sslContextFactory.setKeyStorePassword(options.keyStorePassword); + sslContextFactory.setKeyManagerPassword(options.keyManagerPassword); + sslContextFactory.setTrustStorePath(options.trustStore || options.keyStore); + sslContextFactory.setTrustStorePassword(options.trustStorePassword || + options.keyStorePassword); + sslContextFactory.setIncludeCipherSuites(options.includeCipherSuites); + sslContextFactory.setExcludeCipherSuites(options.excludeCipherSuites); + sslContextFactory.setIncludeProtocols(options.includeProtocols); + sslContextFactory.setExcludeProtocols(options.excludeProtocols); + sslContextFactory.setRenegotiationAllowed(options.allowRenegotiation === true); + if (options.verbose === true) { + log.info(sslContextFactory.dump()); + } + return sslContextFactory; +}; + +HttpServer.prototype.createHttpsConnector = function(options) { + options = objects.merge(options || {}, { + "host": "0.0.0.0", + "port": 8443, + "sniHostCheck": true, + "stsMaxAgeSeconds": -1, + "stsIncludeSubdomains": false + }); + const sslContextFactory = this.createSslContextFactory(options); + const sslConnectionFactory = new SslConnectionFactory(sslContextFactory, + HTTP_1_1.toString()); + const httpsConfig = this.createHttpConfig(options); + const customizer = new SecureRequestCustomizer(); + customizer.setSniHostCheck(options.sniHostCheck === true); + if (!isNaN(options.stsMaxAgeSeconds)) { + customizer.setStsMaxAge(options.stsMaxAgeSeconds); + } + customizer.setStsIncludeSubDomains(options.stsIncludeSubdomains === true); + httpsConfig.addCustomizer(customizer); + const httpConnectionFactory = new HttpConnectionFactory(httpsConfig); + return this.createConnector([sslConnectionFactory, httpConnectionFactory], options); +}; + +HttpServer.prototype.createHttpListener = function(options) { + const connector = this.createHttpConnector(options); + this.jetty.addConnector(connector); + return connector; +}; + +HttpServer.prototype.createHttpsListener = function(options) { + const connector = this.createHttpsConnector(options); + this.jetty.addConnector(connector); + return connector; +}; + +HttpServer.prototype.getHandlerCollection = function() { + let handlerCollection = this.jetty.getHandler(); + if (handlerCollection === null) { + handlerCollection = new HandlerCollection(); + this.jetty.setHandler(handlerCollection); + } + return handlerCollection; +}; + +HttpServer.prototype.getContextHandlerCollection = function() { + const handlerCollection = this.getHandlerCollection(); + let contextHandlerCollection = + handlerCollection.getChildHandlerByClass(ContextHandlerCollection); + if (contextHandlerCollection === null) { + contextHandlerCollection = new ContextHandlerCollection(); + handlerCollection.addHandler(contextHandlerCollection); + } + return contextHandlerCollection; +}; + +HttpServer.prototype.addContext = function(context) { + this.contexts[context.getKey()] = context; + if (this.jetty.isRunning()) { + context.contextHandler.start(); + } + return context; +}; + +HttpServer.prototype.enableSessions = function(options) { + options || (options = {}); + + // if random is null, jetty will fall back to a SecureRandom in its initRandom() + const sessionIdManager = new DefaultSessionIdManager(this.jetty, options.random || null); + sessionIdManager.setWorkerName(options.name || "node1"); + this.jetty.setSessionIdManager(sessionIdManager); + return sessionIdManager; +}; + +HttpServer.prototype.serveApplication = function(mountpoint, app, options) { + if (typeof(mountpoint) !== "string") { + throw new Error("Missing mountpoint argument"); + } + options || (options = {}); + options = { + "security": options.security !== false, + "sessions": options.sessions !== false, + "sessionsMaxInactiveInterval": options.sessionsMaxInactiveInterval || null, + "cookieName": options.cookieName || null, + "cookieDomain": options.cookieDomain || null, + "cookiePath": options.cookiePath || null, + "cookieMaxAge": options.cookieMaxAge || -1, + "httpOnlyCookies": options.httpOnlyCookies !== false, + "secureCookies": options.secureCookies === true, + "statistics": options.statistics === true, + "virtualHosts": options.virtualHosts + }; + const parentContainer = this.getContextHandlerCollection(); + const context = new ApplicationContext(parentContainer, mountpoint, options); + context.serve(app); + return this.addContext(context); +}; + +HttpServer.prototype.serveStatic = function(mountpoint, directory, options) { + if (typeof(mountpoint) !== "string") { + throw new Error("Missing mountpoint argument"); + } + if (typeof(directory) !== "string") { + throw new Error("Missing directory argument"); + } else if (!fs.exists(directory) || !fs.isDirectory(directory)) { + throw new Error("Directory '" + directory + "' doesn't exist or is not a directory"); + } + options || (options = {}); + const initParameters = { + "acceptRanges": options.acceptRanges === true, + "dirAllowed": options.allowDirectoryListing === true, + "gzip": options.gzip === true, + "stylesheet": options.stylesheet || null, + "etags": options.etags !== false, + "maxCacheSize": options.maxCacheSize || 0, + "maxCachedFileSize": options.maxCachedFileSize || 0, + "maxCachedFiles": options.maxCachedFiles || 0, + "cacheControl": options.cacheControl || null, + "otherGzipFileExtensions": options.gzipExtensions || null + }; + const parentContainer = this.getContextHandlerCollection(); + const context = new StaticContext(parentContainer, mountpoint, { + "security": options.security === true, + "sessions": options.sessions === true, + "virtualHosts": options.virtualHosts + }); + context.serve(directory, initParameters); + return this.addContext(context); +}; + +HttpServer.prototype.enableConnectionStatistics = function() { + ServerConnectionStatistics.addToAllConnectors(this.jetty); +}; + +HttpServer.prototype.getConnectionStatistics = function() { + let connectors = this.jetty.getConnectors(); + return connectors.map(function(connector) { + return { + "name": connector.getName(), + "host": connector.getHost(), + "port": connector.getPort(), + "statistics": connector.getBean(ConnectionStatistics) + } + }); +}; + +HttpServer.prototype.start = function() { + this.jetty.start(); + this.jetty.getConnectors().forEach(function(connector) { + log.info("Server on {}:{} started", connector.getHost(), connector.getPort()); + }); +}; + +HttpServer.prototype.stop = function() { + return this.jetty.stop(); +}; + +HttpServer.prototype.destroy = function() { + return this.jetty.destroy(); +}; + +HttpServer.prototype.isRunning = function() { + return this.jetty.isRunning(); +}; \ No newline at end of file diff --git a/modules/ringo/httpserver/index.js b/modules/ringo/httpserver/index.js new file mode 100644 index 000000000..5d0575562 --- /dev/null +++ b/modules/ringo/httpserver/index.js @@ -0,0 +1,155 @@ +const log = require("ringo/logging").getLogger(module.id); +const system = require("system"); +const fs = require("fs"); + +const HttpServerBuilder = require("./builder"); +const HttpServer = exports.HttpServer = require("./httpserver"); +const utils = require("./utils"); +let httpServer = null; +let options = null; + +exports.build = function(options) { + return new HttpServerBuilder(options); +}; + +/** + * Daemon life cycle function invoked by init script. Creates a new Server with + * the application at `appPath`. If the application exports a function called + * `init`, it will be invoked with the new server as argument. + * + * @param {String} path Optional application file name or module id. + * If undefined, the first command line argument will be used as application. + * If there are no command line arguments, module `main` in the current + * working directory is used. + * @returns {Server} the Server instance. + */ +exports.init = function(path) { + // parse command line options + options = {}; + const args = system.args.slice(1); + try { + // remove command from command line arguments + options = utils.parseOptions(args, options); + } catch (error) { + log.error("Error parsing options:", error); + system.exit(1); + } + + options.path = path; + if (options.path == undefined) { + if (args[0]) { + // take app module from command line + options.path = fs.resolve(fs.workingDirectory(), args[0]); + } else { + options.path = fs.workingDirectory(); + } + } + // if argument is a directory assume app in main.js + if (fs.isDirectory(options.path)) { + options.path = fs.join(options.path, "main"); + } + if (!fs.exists(options.path)) { + throw new Error("Module " + options.path + " does not exist"); + } + + log.info("Start app module at", options.path); + + httpServer = new HttpServer(); + httpServer.serveApplication("/", options.path); + httpServer.createHttpListener(options); + const app = require(options.path); + if (typeof(app.init) === "function") { + app.init(httpServer); + } + return httpServer; +}; + +/** + * Daemon life cycle function invoked by init script. Starts the Server created + * by `init()`. If the application exports a function called `start`, it will be + * invoked with the server as argument immediately after it has started. + * + * @returns {Server} the Server instance. + */ +exports.start = function() { + if (httpServer !== null && httpServer.isRunning()) { + return httpServer; + } + httpServer.start(); + const app = require(options.path); + if (typeof(app.start) === "function") { + app.start(httpServer); + } + return httpServer; +}; + +/** + * Daemon life cycle function invoked by init script. Stops the Server started + * by `start()`. + * @returns {Server} the Server instance. If the application exports a function + * called `stop`, it will be invoked with the server as argument immediately + * before it is stopped. + * + * @returns {Server} the Server instance. + */ +exports.stop = function() { + if (httpServer !== null && !httpServer.isRunning()) { + return httpServer; + } + const app = require(options.path); + if (typeof app.stop === "function") { + app.stop(httpServer); + } + httpServer.stop(); + return httpServer; +}; + +/** + * Daemon life cycle function invoked by init script. Frees any resources + * occupied by the Server instance. If the application exports a function + * called `destroy`, it will be invoked with the server as argument. + * + * @returns {Server} the Server instance. + */ +exports.destroy = function() { + if (httpServer !== null) { + const app = require(options.path); + if (typeof(app.destroy) === "function") { + app.destroy(httpServer); + } + httpServer.destroy(); + } + try { + return httpServer; + } finally { + httpServer = null; + } +}; + +/** + * Main function to start an HTTP server from the command line. + * It automatically adds a shutdown hook which will stop and destroy the server at the JVM termination. + * + * @param {String} path optional application file name or module id. + * @returns {Server} the Server instance. + * @example // starts the current module via module.id as web application + * require("ringo/httpserver").main(module.id); + * + * // starts the module "./app/actions" as web application + * require("ringo/httpserver").main(module.resolve('./app/actions')); + */ +exports.main = function(path) { + exports.init(path); + exports.start(); + require("ringo/engine").addShutdownHook(function() { + exports.stop(); + exports.destroy(); + }); + // return the server instance + return httpServer; +}; + + +if (require.main == module) { + exports.main(); +} diff --git a/modules/ringo/httpserver/utils.js b/modules/ringo/httpserver/utils.js new file mode 100644 index 000000000..6f484743a --- /dev/null +++ b/modules/ringo/httpserver/utils.js @@ -0,0 +1,33 @@ +const {Parser} = require("ringo/args"); +const system = require("system"); + +exports.parseOptions = function(args, defaults) { + const parser = new Parser(); + + parser.addOption("a", "app-name", "APP", "The exported property name of the JSGI app (default: 'app')"); + parser.addOption("j", "jetty-config", "PATH", "The jetty xml configuration file (default. 'config/jetty.xml')"); + parser.addOption("H", "host", "ADDRESS", "The IP address to bind to (default: 0.0.0.0)"); + parser.addOption("m", "mountpoint", "PATH", "The URI path where to mount the application (default: /)"); + parser.addOption("p", "port", "PORT", "The TCP port to listen on (default: 80)"); + parser.addOption("s", "static-dir", "DIR", "A directory with static resources to serve"); + parser.addOption("S", "static-mountpoint", "PATH", "The URI path where ot mount the static resources"); + parser.addOption("v", "virtual-host", "VHOST", "The virtual host name (default: undefined)"); + parser.addOption("h", "help", null, "Print help message to stdout"); + + const options = parser.parse(args, defaults); + if (options.help) { + print("Usage:"); + print("", cmd, "[OPTIONS]", "[PATH]"); + print("Options:"); + print(parser.help()); + system.exit(0); + } else if (options.port && !isFinite(options.port)) { + const port = parseInt(options.port, 10); + if (isNaN(port) || port < 1) { + throw new Error("Invalid value for port: " + options.port); + } + options.port = port; + } + + return options; +}; \ No newline at end of file diff --git a/modules/ringo/httpserver/websocket.js b/modules/ringo/httpserver/websocket.js new file mode 100644 index 000000000..249cae48b --- /dev/null +++ b/modules/ringo/httpserver/websocket.js @@ -0,0 +1,125 @@ +const {JavaEventEmitter} = require('ringo/events'); +const {WebSocketListener} = org.eclipse.jetty.websocket.api; +const {ByteBuffer} = java.nio; + +const WebSocket = module.exports = function() { + this.session = null; + + // make WebSocket a java event-emitter (mixin) + JavaEventEmitter.call(this, [WebSocketListener], { + "onWebSocketConnect": "connect", + "onWebSocketClose": "close", + "onWebSocketText": "text", + "onWebSocketBinary": "binary", + "onWebSocketError": "error" + }); + + return this; +}; + +/** @ignore */ +WebSocket.prototype.toString = function() { + return "[WebSocket]"; +}; + +/** + * Closes the WebSocket connection. + * @name WebSocket.instance.close + * @function + */ +WebSocket.prototype.close = function() { + if (!this.isOpen()) { + throw new Error("Not connected"); + } + this.session.close(); + this.session = null; +}; + +/** + * Send a string over the WebSocket. + * @param {String} message a string + * @name WebSocket.instance.send + * @deprecated + * @see #sendString + * @function + */ +WebSocket.prototype.send = function(message) { + return this.sendString(message); +}; + +/** + * Send a string over the WebSocket. This method + * blocks until the message has been transmitted + * @param {String} message a string + * @name WebSocket.instance.sendString + * @function + */ +WebSocket.prototype.sendString = function(message) { + if (!this.isOpen()) { + throw new Error("Not connected"); + } + this.session.getRemote().sendString(message); +}; + +/** + * Send a string over the WebSocket. This method + * does not wait until the message as been transmitted. + * @param {String} message a string + * @name WebSocket.instance.sendStringAsync + * @function + */ +WebSocket.prototype.sendStringAsync = function(message) { + if (!this.isOpen()) { + throw new Error("Not connected"); + } + return this.session.getRemote().sendStringByFuture(message); +}; + +/** + * Send a byte array over the WebSocket. This method + * blocks until the message as been transmitted. + * @param {ByteArray} byteArray The byte array to send + * @param {Number} offset Optional offset (defaults to zero) + * @param {Number} length Optional length (defaults to the + * length of the byte array) + * @name WebSocket.instance.sendBinary + * @function + */ +WebSocket.prototype.sendBinary = function(byteArray, offset, length) { + if (!this.isOpen()) { + throw new Error("Not connected"); + } + const buffer = ByteBuffer.wrap(byteArray, parseInt(offset, 10) || 0, + parseInt(length, 10) || byteArray.length); + return this.session.getRemote().sendBytes(buffer); +}; + +/** + * Send a byte array over the WebSocket. This method + * does not wait until the message as been transmitted. + * @param {ByteArray} byteArray The byte array to send + * @param {Number} offset Optional offset (defaults to zero) + * @param {Number} length Optional length (defaults to the + * length of the byte array) + * @name WebSocket.instance.sendBinaryAsync + * @returns {java.util.concurrent.Future} + * @function + */ +WebSocket.prototype.sendBinaryAsync = function(byteArray, offset, length) { + if (!this.isOpen()) { + throw new Error("Not connected"); + } + const buffer = ByteBuffer.wrap(byteArray, parseInt(offset, 10) || 0, + parseInt(length, 10) || byteArray.length); + return this.session.getRemote().sendBytesByFuture(buffer); +}; + +/** + * Check whether the WebSocket is open. + * @name WebSocket.instance.isOpen + * @return {Boolean} true if the connection is open + * @function + */ +WebSocket.prototype.isOpen = function() { + return this.session !== null && this.session.isOpen(); +};