////////// Requires ////////// var fs = Npm.require("fs"); var http = Npm.require("http"); var os = Npm.require("os"); var path = Npm.require("path"); var url = Npm.require("url"); var crypto = Npm.require("crypto"); var connect = Npm.require('connect'); var useragent = Npm.require('useragent'); var send = Npm.require('send'); var Future = Npm.require('fibers/future'); var SHORT_SOCKET_TIMEOUT = 5*1000; var LONG_SOCKET_TIMEOUT = 120*1000; WebApp = {}; WebAppInternals = {}; var bundledJsCssPrefix; // Keepalives so that when the outer server dies unceremoniously and // doesn't kill us, we quit ourselves. A little gross, but better than // pidfiles. // XXX This should really be part of the boot script, not the webapp package. // Or we should just get rid of it, and rely on containerization. var initKeepalive = function () { var keepaliveCount = 0; process.stdin.on('data', function (data) { keepaliveCount = 0; }); process.stdin.resume(); setInterval(function () { keepaliveCount ++; if (keepaliveCount >= 3) { console.log("Failed to receive keepalive! Exiting."); process.exit(1); } }, 3000); }; var sha1 = function (contents) { var hash = crypto.createHash('sha1'); hash.update(contents); return hash.digest('hex'); }; var readUtf8FileSync = function (filename) { return Future.wrap(fs.readFile)(filename, 'utf8').wait(); }; // #BrowserIdentification // // We have multiple places that want to identify the browser: the // unsupported browser page, the appcache package, and, eventually // delivering browser polyfills only as needed. // // To avoid detecting the browser in multiple places ad-hoc, we create a // Meteor "browser" object. It uses but does not expose the npm // useragent module (we could choose a different mechanism to identify // the browser in the future if we wanted to). The browser object // contains // // * `name`: the name of the browser in camel case // * `major`, `minor`, `patch`: integers describing the browser version // // Also here is an early version of a Meteor `request` object, intended // to be a high-level description of the request without exposing // details of connect's low-level `req`. Currently it contains: // // * `browser`: browser identification object described above // * `url`: parsed url, including parsed query params // // As a temporary hack there is a `categorizeRequest` function on WebApp which // converts a connect `req` to a Meteor `request`. This can go away once smart // packages such as appcache are being passed a `request` object directly when // they serve content. // // This allows `request` to be used uniformly: it is passed to the html // attributes hook, and the appcache package can use it when deciding // whether to generate a 404 for the manifest. // // Real routing / server side rendering will probably refactor this // heavily. // e.g. "Mobile Safari" => "mobileSafari" var camelCase = function (name) { var parts = name.split(' '); parts[0] = parts[0].toLowerCase(); for (var i = 1; i < parts.length; ++i) { parts[i] = parts[i].charAt(0).toUpperCase() + parts[i].substr(1); } return parts.join(''); }; var identifyBrowser = function (userAgentString) { var userAgent = useragent.lookup(userAgentString); return { name: camelCase(userAgent.family), major: +userAgent.major, minor: +userAgent.minor, patch: +userAgent.patch }; }; // XXX Refactor as part of implementing real routing. WebAppInternals.identifyBrowser = identifyBrowser; WebApp.categorizeRequest = function (req) { return { browser: identifyBrowser(req.headers['user-agent']), url: url.parse(req.url, true) }; }; // HTML attribute hooks: functions to be called to determine any attributes to // be added to the '' tag. Each function is passed a 'request' object (see // #BrowserIdentification) and should return a string, var htmlAttributeHooks = []; var getHtmlAttributes = function (request) { var combinedAttributes = {}; _.each(htmlAttributeHooks || [], function (hook) { var attributes = hook(request); if (attributes === null) return; if (typeof attributes !== 'object') throw Error("HTML attribute hook must return null or object"); _.extend(combinedAttributes, attributes); }); return combinedAttributes; }; WebApp.addHtmlAttributeHook = function (hook) { htmlAttributeHooks.push(hook); }; // Serve app HTML for this URL? var appUrl = function (url) { if (url === '/favicon.ico' || url === '/robots.txt') return false; // NOTE: app.manifest is not a web standard like favicon.ico and // robots.txt. It is a file name we have chosen to use for HTML5 // appcache URLs. It is included here to prevent using an appcache // then removing it from poisoning an app permanently. Eventually, // once we have server side routing, this won't be needed as // unknown URLs with return a 404 automatically. if (url === '/app.manifest') return false; // Avoid serving app HTML for declared routes such as /sockjs/. if (RoutePolicy.classify(url)) return false; // we currently return app HTML on all URLs by default return true; }; // Calculate a hash of all the client resources downloaded by the // browser, including the application HTML, runtime config, code, and // static files. // // This hash *must* change if any resources seen by the browser // change, and ideally *doesn't* change for any server-only changes // (but the second is a performance enhancement, not a hard // requirement). var calculateClientHash = function (includeFilter) { var hash = crypto.createHash('sha1'); // Omit the old hashed client values in the new hash. These may be // modified in the new boilerplate. hash.update(JSON.stringify(_.omit(__meteor_runtime_config__, ['autoupdateVersion', 'autoupdateVersionRefreshable']), 'utf8')); _.each(WebApp.clientProgram.manifest, function (resource) { if ((! includeFilter || includeFilter(resource.type)) && (resource.where === 'client' || resource.where === 'internal')) { hash.update(resource.path); hash.update(resource.hash); } }); return hash.digest('hex'); }; // We need to calculate the client hash after all packages have loaded // to give them a chance to populate __meteor_runtime_config__. // // Calculating the hash during startup means that packages can only // populate __meteor_runtime_config__ during load, not during startup. // // Calculating instead it at the beginning of main after all startup // hooks had run would allow packages to also populate // __meteor_runtime_config__ during startup, but that's too late for // autoupdate because it needs to have the client hash at startup to // insert the auto update version itself into // __meteor_runtime_config__ to get it to the client. // // An alternative would be to give autoupdate a "post-start, // pre-listen" hook to allow it to insert the auto update version at // the right moment. Meteor.startup(function () { WebApp.clientHash = calculateClientHash(); WebApp.calculateClientHashRefreshable = function () { return calculateClientHash(function (name) { return name === "css"; }); }; WebApp.calculateClientHashNonRefreshable = function () { return calculateClientHash(function (name) { return name !== "css"; }); }; }); // When we have a request pending, we want the socket timeout to be long, to // give ourselves a while to serve it, and to allow sockjs long polls to // complete. On the other hand, we want to close idle sockets relatively // quickly, so that we can shut down relatively promptly but cleanly, without // cutting off anyone's response. WebApp._timeoutAdjustmentRequestCallback = function (req, res) { // this is really just req.socket.setTimeout(LONG_SOCKET_TIMEOUT); req.setTimeout(LONG_SOCKET_TIMEOUT); // Insert our new finish listener to run BEFORE the existing one which removes // the response from the socket. var finishListeners = res.listeners('finish'); // XXX Apparently in Node 0.12 this event is now called 'prefinish'. // https://github.com/joyent/node/commit/7c9b6070 res.removeAllListeners('finish'); res.on('finish', function () { res.setTimeout(SHORT_SOCKET_TIMEOUT); }); _.each(finishListeners, function (l) { res.on('finish', l); }); }; // Will be updated by main before we listen. var boilerplateFunc = null; var boilerplateBaseData = null; var memoizedBoilerplate = {}; // Given a request (as returned from `categorizeRequest`), return the // boilerplate HTML to serve for that request. Memoizes on HTML // attributes (used by, eg, appcache) and whether inline scripts are // currently allowed. var getBoilerplate = function (request) { var htmlAttributes = getHtmlAttributes(request); // The only thing that changes from request to request (for now) are // the HTML attributes (used by, eg, appcache) and whether inline // scripts are allowed, so we can memoize based on that. var boilerplateKey = JSON.stringify({ inlineScriptsAllowed: inlineScriptsAllowed, htmlAttributes: htmlAttributes }); if (! _.has(memoizedBoilerplate, boilerplateKey)) { var boilerplateData = _.extend({ htmlAttributes: htmlAttributes, inlineScriptsAllowed: WebAppInternals.inlineScriptsAllowed() }, boilerplateBaseData); memoizedBoilerplate[boilerplateKey] = "\n" + Blaze.toHTML(Blaze.With(boilerplateData, boilerplateFunc)); } return memoizedBoilerplate[boilerplateKey]; }; // Serve static files from the manifest or added with // `addStaticJs`. Exported for tests. // Options are: // - staticFiles: object mapping pathname of file in manifest -> { // path, cacheable, sourceMapUrl, type } // - clientDir: root directory for static files from client manifest WebAppInternals.staticFilesMiddleware = function (options, req, res, next) { if ('GET' != req.method && 'HEAD' != req.method) { next(); return; } var pathname = connect.utils.parseUrl(req).pathname; var staticFiles = options.staticFiles; var clientDir = options.clientDir; try { pathname = decodeURIComponent(pathname); } catch (e) { next(); return; } var serveStaticJs = function (s) { res.writeHead(200, { 'Content-type': 'application/javascript; charset=UTF-8' }); res.write(s); res.end(); }; if (pathname === "/meteor_runtime_config.js" && ! WebAppInternals.inlineScriptsAllowed()) { serveStaticJs("__meteor_runtime_config__ = " + JSON.stringify(__meteor_runtime_config__) + ";"); return; } else if (_.has(additionalStaticJs, pathname) && ! WebAppInternals.inlineScriptsAllowed()) { serveStaticJs(additionalStaticJs[pathname]); return; } if (!_.has(staticFiles, pathname)) { next(); return; } // We don't need to call pause because, unlike 'static', once we call into // 'send' and yield to the event loop, we never call another handler with // 'next'. var info = staticFiles[pathname]; // Cacheable files are files that should never change. Typically // named by their hash (eg meteor bundled js and css files). // We cache them ~forever (1yr). // // We cache non-cacheable files anyway. This isn't really correct, as users // can change the files and changes won't propagate immediately. However, if // we don't cache them, browsers will 'flicker' when rerendering // images. Eventually we will probably want to rewrite URLs of static assets // to include a query parameter to bust caches. That way we can both get // good caching behavior and allow users to change assets without delay. // https://github.com/meteor/meteor/issues/773 var maxAge = info.cacheable ? 1000 * 60 * 60 * 24 * 365 : 1000 * 60 * 60 * 24; // Set the X-SourceMap header, which current Chrome understands. // (The files also contain '//#' comments which FF 24 understands and // Chrome doesn't understand yet.) // // Eventually we should set the SourceMap header but the current version of // Chrome and no version of FF supports it. // // To figure out if your version of Chrome should support the SourceMap // header, // - go to chrome://version. Let's say the Chrome version is // 28.0.1500.71 and the Blink version is 537.36 (@153022) // - go to http://src.chromium.org/viewvc/blink/branches/chromium/1500/Source/core/inspector/InspectorPageAgent.cpp?view=log // where the "1500" is the third part of your Chrome version // - find the first revision that is no greater than the "153022" // number. That's probably the first one and it probably has // a message of the form "Branch 1500 - blink@r149738" // - If *that* revision number (149738) is at least 151755, // then Chrome should support SourceMap (not just X-SourceMap) // (The change is https://codereview.chromium.org/15832007) // // You also need to enable source maps in Chrome: open dev tools, click // the gear in the bottom right corner, and select "enable source maps". // // Firefox 23+ supports source maps but doesn't support either header yet, // so we include the '//#' comment for it: // https://bugzilla.mozilla.org/show_bug.cgi?id=765993 // In FF 23 you need to turn on `devtools.debugger.source-maps-enabled` // in `about:config` (it is on by default in FF 24). if (info.sourceMapUrl) res.setHeader('X-SourceMap', info.sourceMapUrl); if (info.type === "js") { res.setHeader("Content-Type", "application/javascript; charset=UTF-8"); } else if (info.type === "css") { res.setHeader("Content-Type", "text/css; charset=UTF-8"); } send(req, path.join(clientDir, info.path)) .maxage(maxAge) .hidden(true) // if we specified a dotfile in the manifest, serve it .on('error', function (err) { Log.error("Error serving static file " + err); res.writeHead(500); res.end(); }) .on('directory', function () { Log.error("Unexpected directory " + info.path); res.writeHead(500); res.end(); }) .pipe(res); }; var runWebAppServer = function () { var shuttingDown = false; var syncQueue = new Meteor._SynchronousQueue(); var getItemPathname = function (itemUrl) { return decodeURIComponent(url.parse(itemUrl).pathname); }; var staticFiles; var clientJsonPath; var clientDir; var clientJson; WebAppInternals.reloadClientProgram = function () { syncQueue.runTask(function() { try { // read the control for the client we'll be serving up clientJsonPath = path.join(__meteor_bootstrap__.serverDir, __meteor_bootstrap__.configJson.client); clientDir = path.dirname(clientJsonPath); clientJson = JSON.parse(readUtf8FileSync(clientJsonPath)); if (clientJson.format !== "web-program-pre1") throw new Error("Unsupported format for client assets: " + JSON.stringify(clientJson.format)); staticFiles = {}; _.each(clientJson.manifest, function (item) { if (item.url && item.where === "client") { staticFiles[getItemPathname(item.url)] = { path: item.path, cacheable: item.cacheable, // Link from source to its map sourceMapUrl: item.sourceMapUrl, type: item.type }; if (item.sourceMap) { // Serve the source map too, under the specified URL. We assume all // source maps are cacheable. staticFiles[getItemPathname(item.sourceMapUrl)] = { path: item.sourceMap, cacheable: true }; } } }); WebApp.clientProgram = { manifest: clientJson.manifest // XXX do we need a "root: clientDir" field here? it used to be here but // was unused. }; // Exported for tests. WebAppInternals.staticFiles = staticFiles; } catch (e) { Log.error("Error reloading the client program: " + e.message); process.exit(1); } }); }; WebAppInternals.reloadClientProgram(); if (! clientJsonPath || ! clientDir || ! clientJson) throw new Error("Client config file not parsed."); // webserver var app = connect(); // Auto-compress any json, javascript, or text. app.use(connect.compress()); // Packages and apps can add handlers that run before any other Meteor // handlers via WebApp.rawConnectHandlers. var rawConnectHandlers = connect(); app.use(rawConnectHandlers); // Strip off the path prefix, if it exists. app.use(function (request, response, next) { var pathPrefix = __meteor_runtime_config__.ROOT_URL_PATH_PREFIX; var url = Npm.require('url').parse(request.url); var pathname = url.pathname; // check if the path in the url starts with the path prefix (and the part // after the path prefix must start with a / if it exists.) if (pathPrefix && pathname.substring(0, pathPrefix.length) === pathPrefix && (pathname.length == pathPrefix.length || pathname.substring(pathPrefix.length, pathPrefix.length + 1) === "/")) { request.url = request.url.substring(pathPrefix.length); next(); } else if (pathname === "/favicon.ico" || pathname === "/robots.txt") { next(); } else if (pathPrefix) { response.writeHead(404); response.write("Unknown path"); response.end(); } else { next(); } }); // Parse the query string into res.query. Used by oauth_server, but it's // generally pretty handy.. app.use(connect.query()); // Serve static files from the manifest. // This is inspired by the 'static' middleware. app.use(function (req, res, next) { return WebAppInternals.staticFilesMiddleware({ staticFiles: staticFiles, clientDir: clientDir }, req, res, next); }); // Packages and apps can add handlers to this via WebApp.connectHandlers. // They are inserted before our default handler. var packageAndAppHandlers = connect(); app.use(packageAndAppHandlers); var suppressConnectErrors = false; // connect knows it is an error handler because it has 4 arguments instead of // 3. go figure. (It is not smart enough to find such a thing if it's hidden // inside packageAndAppHandlers.) app.use(function (err, req, res, next) { if (!err || !suppressConnectErrors || !req.headers['x-suppress-error']) { next(err); return; } res.writeHead(err.status, { 'Content-Type': 'text/plain' }); res.end("An error message"); }); app.use(function (req, res, next) { if (! appUrl(req.url)) return next(); if (!boilerplateFunc) throw new Error("boilerplateFunc should be set before listening!"); if (!boilerplateBaseData) throw new Error("boilerplateBaseData should be set before listening!"); var headers = { 'Content-Type': 'text/html; charset=utf-8' }; if (shuttingDown) headers['Connection'] = 'Close'; var request = WebApp.categorizeRequest(req); if (request.url.query && request.url.query['meteor_css_resource']) { // In this case, we're requesting a CSS resource in the meteor-specific // way, but we don't have it. Serve a static css file that indicates that // we didn't have it, so we can detect that and refresh. headers['Content-Type'] = 'text/css; charset=utf-8'; res.writeHead(200, headers); res.write(".meteor-css-not-found-error { width: 0px;}"); res.end(); return undefined; } var boilerplate; try { boilerplate = getBoilerplate(request); } catch (e) { Log.error("Error running template: " + e); res.writeHead(500, headers); res.end(); return undefined; } res.writeHead(200, headers); res.write(boilerplate); res.end(); return undefined; }); // Return 404 by default, if no other handlers serve this URL. app.use(function (req, res) { res.writeHead(404); res.end(); }); var httpServer = http.createServer(app); var onListeningCallbacks = []; // After 5 seconds w/o data on a socket, kill it. On the other hand, if // there's an outstanding request, give it a higher timeout instead (to avoid // killing long-polling requests) httpServer.setTimeout(SHORT_SOCKET_TIMEOUT); // Do this here, and then also in livedata/stream_server.js, because // stream_server.js kills all the current request handlers when installing its // own. httpServer.on('request', WebApp._timeoutAdjustmentRequestCallback); // For now, handle SIGHUP here. Later, this should be in some centralized // Meteor shutdown code. process.on('SIGHUP', Meteor.bindEnvironment(function () { shuttingDown = true; // tell others with websockets open that we plan to close this. // XXX: Eventually, this should be done with a standard meteor shut-down // logic path. httpServer.emit('meteor-closing'); httpServer.close(Meteor.bindEnvironment(function () { if (proxy) { try { proxy.call('removeBindingsForJob', process.env.GALAXY_JOB); } catch (e) { Log.error("Error removing bindings: " + e.message); process.exit(1); } } process.exit(0); }, "On http server close failed")); // Ideally we will close before this hits. Meteor.setTimeout(function () { Log.warn("Closed by SIGHUP but one or more HTTP requests may not have finished."); process.exit(1); }, 5000); }, function (err) { console.log(err); process.exit(1); })); // start up app _.extend(WebApp, { connectHandlers: packageAndAppHandlers, rawConnectHandlers: rawConnectHandlers, httpServer: httpServer, // For testing. suppressConnectErrors: function () { suppressConnectErrors = true; }, onListening: function (f) { if (onListeningCallbacks) onListeningCallbacks.push(f); else f(); }, // Hack: allow http tests to call connect.basicAuth without making them // Npm.depends on another copy of connect. (That would be fine if we could // have test-only NPM dependencies but is overkill here.) __basicAuth__: connect.basicAuth }); // Let the rest of the packages (and Meteor.startup hooks) insert connect // middlewares and update __meteor_runtime_config__, then keep going to set up // actually serving HTML. main = function (argv) { // main happens post startup hooks, so we don't need a Meteor.startup() to // ensure this happens after the galaxy package is loaded. var AppConfig = Package["application-configuration"].AppConfig; // We used to use the optimist npm package to parse argv here, but it's // overkill (and no longer in the dev bundle). Just assume any instance of // '--keepalive' is a use of the option. var expectKeepalives = _.contains(argv, '--keepalive'); var boilerplateTemplateSource = Assets.getText("boilerplate.html"); // Exported to allow client-side only changes to rebuild the boilerplate // without requiring a full server restart. WebAppInternals.generateBoilerplate = function () { syncQueue.runTask(function() { boilerplateBaseData = { // 'htmlAttributes' and 'inlineScriptsAllowed' are set at render // time, because they are allowed to change from request to // request. css: [], js: [], head: '', body: '', additionalStaticJs: _.map( additionalStaticJs, function (contents, pathname) { return { pathname: pathname, contents: contents }; } ), meteorRuntimeConfig: JSON.stringify(__meteor_runtime_config__), rootUrlPathPrefix: __meteor_runtime_config__.ROOT_URL_PATH_PREFIX || '', bundledJsCssPrefix: bundledJsCssPrefix || __meteor_runtime_config__.ROOT_URL_PATH_PREFIX || '' }; _.each(WebApp.clientProgram.manifest, function (item) { if (item.type === 'css' && item.where === 'client') { boilerplateBaseData.css.push({url: item.url}); } if (item.type === 'js' && item.where === 'client') { boilerplateBaseData.js.push({url: item.url}); } if (item.type === 'head') { boilerplateBaseData.head = readUtf8FileSync(path.join(clientDir, item.path)); } if (item.type === 'body') { boilerplateBaseData.body = readUtf8FileSync(path.join(clientDir, item.path)); } }); var boilerplateRenderCode = SpacebarsCompiler.compile( boilerplateTemplateSource, { isBody: true }); // Note that we are actually depending on eval's local environment capture // so that UI and HTML are visible to the eval'd code. boilerplateFunc = eval(boilerplateRenderCode); // Clear the memoized boilerplate cache. memoizedBoilerplate = {}; WebAppInternals.refreshableAssets = { allCss: boilerplateBaseData.css }; }); }; WebAppInternals.generateBoilerplate(); // only start listening after all the startup code has run. var localPort = parseInt(process.env.PORT) || 0; var host = process.env.BIND_IP; var localIp = host || '0.0.0.0'; httpServer.listen(localPort, localIp, Meteor.bindEnvironment(function() { if (expectKeepalives) console.log("LISTENING"); // must match run-app.js var proxyBinding; AppConfig.configurePackage('webapp', function (configuration) { if (proxyBinding) proxyBinding.stop(); if (configuration && configuration.proxy) { // TODO: We got rid of the place where this checks the app's // configuration, because this wants to be configured for some things // on a per-job basis. Discuss w/ teammates. proxyBinding = AppConfig.configureService( "proxy", "pre0", function (proxyService) { if (proxyService && ! _.isEmpty(proxyService)) { var proxyConf; // XXX Figure out a per-job way to specify bind location // (besides hardcoding the location for ADMIN_APP jobs). if (process.env.ADMIN_APP) { var bindPathPrefix = ""; if (process.env.GALAXY_APP !== "panel") { bindPathPrefix = "/" + bindPathPrefix + encodeURIComponent( process.env.GALAXY_APP ).replace(/\./g, '_'); } proxyConf = { bindHost: process.env.GALAXY_NAME, bindPathPrefix: bindPathPrefix, requiresAuth: true }; } else { proxyConf = configuration.proxy; } Log("Attempting to bind to proxy at " + proxyService); WebAppInternals.bindToProxy(_.extend({ proxyEndpoint: proxyService }, proxyConf)); } } ); } }); var callbacks = onListeningCallbacks; onListeningCallbacks = null; _.each(callbacks, function (x) { x(); }); }, function (e) { console.error("Error listening:", e); console.error(e && e.stack); })); if (expectKeepalives) initKeepalive(); return 'DAEMON'; }; }; var proxy; WebAppInternals.bindToProxy = function (proxyConfig) { var securePort = proxyConfig.securePort || 4433; var insecurePort = proxyConfig.insecurePort || 8080; var bindPathPrefix = proxyConfig.bindPathPrefix || ""; // XXX also support galaxy-based lookup if (!proxyConfig.proxyEndpoint) throw new Error("missing proxyEndpoint"); if (!proxyConfig.bindHost) throw new Error("missing bindHost"); if (!process.env.GALAXY_JOB) throw new Error("missing $GALAXY_JOB"); if (!process.env.GALAXY_APP) throw new Error("missing $GALAXY_APP"); if (!process.env.LAST_START) throw new Error("missing $LAST_START"); // XXX rename pid argument to bindTo. // XXX factor out into a 'getPid' function in a 'galaxy' package? var pid = { job: process.env.GALAXY_JOB, lastStarted: +(process.env.LAST_START), app: process.env.GALAXY_APP }; var myHost = os.hostname(); WebAppInternals.usingDdpProxy = true; // This is run after packages are loaded (in main) so we can use // Follower.connect. if (proxy) { // XXX the concept here is that our configuration has changed and // we have connected to an entirely new follower set, which does // not have the state that we set up on the follower set that we // were previously connected to, and so we need to recreate all of // our bindings -- analogous to getting a SIGHUP and rereading // your configuration file. so probably this should actually tear // down the connection and make a whole new one, rather than // hot-reconnecting to a different URL. proxy.reconnect({ url: proxyConfig.proxyEndpoint }); } else { proxy = Package["follower-livedata"].Follower.connect( proxyConfig.proxyEndpoint, { group: "proxy" } ); } var route = process.env.ROUTE; var ourHost = route.split(":")[0]; var ourPort = +route.split(":")[1]; var outstanding = 0; var startedAll = false; var checkComplete = function () { if (startedAll && ! outstanding) Log("Bound to proxy."); }; var makeCallback = function () { outstanding++; return function (err) { if (err) throw err; outstanding--; checkComplete(); }; }; // for now, have our (temporary) requiresAuth flag apply to all // routes created by this process. var requiresDdpAuth = !! proxyConfig.requiresAuth; var requiresHttpAuth = (!! proxyConfig.requiresAuth) && (pid.app !== "panel" && pid.app !== "auth"); // XXX a current limitation is that we treat securePort and // insecurePort as a global configuration parameter -- we assume // that if the proxy wants us to ask for 8080 to get port 80 traffic // on our default hostname, that's the same port that we would use // to get traffic on some other hostname that our proxy listens // for. Likewise, we assume that if the proxy can receive secure // traffic for our domain, it can assume secure traffic for any // domain! Hopefully this will get cleaned up before too long by // pushing that logic into the proxy service, so we can just ask for // port 80. // XXX BUG: if our configuration changes, and bindPathPrefix // changes, it appears that we will not remove the routes derived // from the old bindPathPrefix from the proxy (until the process // exits). It is not actually normal for bindPathPrefix to change, // certainly not without a process restart for other reasons, but // it'd be nice to fix. _.each(routes, function (route) { var parsedUrl = url.parse(route.url, /* parseQueryString */ false, /* slashesDenoteHost aka workRight */ true); if (parsedUrl.protocol || parsedUrl.port || parsedUrl.search) throw new Error("Bad url"); parsedUrl.host = null; parsedUrl.path = null; if (! parsedUrl.hostname) { parsedUrl.hostname = proxyConfig.bindHost; if (! parsedUrl.pathname) parsedUrl.pathname = ""; if (! parsedUrl.pathname.indexOf("/") !== 0) { // Relative path parsedUrl.pathname = bindPathPrefix + parsedUrl.pathname; } } var version = ""; var AppConfig = Package["application-configuration"].AppConfig; version = AppConfig.getStarForThisJob() || ""; var parsedDdpUrl = _.clone(parsedUrl); parsedDdpUrl.protocol = "ddp"; // Node has a hardcoded list of protocols that get '://' instead // of ':'. ddp needs to be added to that whitelist. Until then, we // can set the undocumented attribute 'slashes' to get the right // behavior. It's not clear whether than is by design or accident. parsedDdpUrl.slashes = true; parsedDdpUrl.port = '' + securePort; var ddpUrl = url.format(parsedDdpUrl); var proxyToHost, proxyToPort, proxyToPathPrefix; if (! _.has(route, 'forwardTo')) { proxyToHost = ourHost; proxyToPort = ourPort; proxyToPathPrefix = parsedUrl.pathname; } else { var parsedFwdUrl = url.parse(route.forwardTo, false, true); if (! parsedFwdUrl.hostname || parsedFwdUrl.protocol) throw new Error("Bad forward url"); proxyToHost = parsedFwdUrl.hostname; proxyToPort = parseInt(parsedFwdUrl.port || "80"); proxyToPathPrefix = parsedFwdUrl.pathname || ""; } if (route.ddp) { proxy.call('bindDdp', { pid: pid, bindTo: { ddpUrl: ddpUrl, insecurePort: insecurePort }, proxyTo: { tags: [version], host: proxyToHost, port: proxyToPort, pathPrefix: proxyToPathPrefix + '/websocket' }, requiresAuth: requiresDdpAuth }, makeCallback()); } if (route.http) { proxy.call('bindHttp', { pid: pid, bindTo: { host: parsedUrl.hostname, port: insecurePort, pathPrefix: parsedUrl.pathname }, proxyTo: { tags: [version], host: proxyToHost, port: proxyToPort, pathPrefix: proxyToPathPrefix }, requiresAuth: requiresHttpAuth }, makeCallback()); // Only make the secure binding if we've been told that the // proxy knows how terminate secure connections for us (has an // appropriate cert, can bind the necessary port..) if (proxyConfig.securePort !== null) { proxy.call('bindHttp', { pid: pid, bindTo: { host: parsedUrl.hostname, port: securePort, pathPrefix: parsedUrl.pathname, ssl: true }, proxyTo: { tags: [version], host: proxyToHost, port: proxyToPort, pathPrefix: proxyToPathPrefix }, requiresAuth: requiresHttpAuth }, makeCallback()); } } }); startedAll = true; checkComplete(); }; // (Internal, unsupported interface -- subject to change) // // Listen for HTTP and/or DDP traffic and route it somewhere. Only // takes effect when using a proxy service. // // 'url' is the traffic that we want to route, interpreted relative to // the default URL where this app has been told to serve itself. It // may not have a scheme or port, but it may have a host and a path, // and if no host is provided the path need not be absolute. The // following cases are possible: // // //somehost.com // All incoming traffic for 'somehost.com' // //somehost.com/foo/bar // All incoming traffic for 'somehost.com', but only when // the first two path components are 'foo' and 'bar'. // /foo/bar // Incoming traffic on our default host, but only when the // first two path components are 'foo' and 'bar'. // foo/bar // Incoming traffic on our default host, but only when the path // starts with our default path prefix, followed by 'foo' and // 'bar'. // // (Yes, these scheme-less URLs that start with '//' are legal URLs.) // // You can select either DDP traffic, HTTP traffic, or both. Both // secure and insecure traffic will be gathered (assuming the proxy // service is capable, eg, has appropriate certs and port mappings). // // With no 'forwardTo' option, the traffic is received by this process // for service by the hooks in this 'webapp' package. The original URL // is preserved (that is, if you bind "/a", and a user visits "/a/b", // the app receives a request with a path of "/a/b", not a path of // "/b"). // // With 'forwardTo', the process is instead sent to some other remote // host. The URL is adjusted by stripping the path components in 'url' // and putting the path components in the 'forwardTo' URL in their // place. For example, if you forward "//somehost/a" to // "//otherhost/x", and the user types "//somehost/a/b" into their // browser, then otherhost will receive a request with a Host header // of "somehost" and a path of "/x/b". // // The routing continues until this process exits. For now, all of the // routes must be set up ahead of time, before the initial // registration with the proxy. Calling addRoute from the top level of // your JS should do the trick. // // When multiple routes are present that match a given request, the // most specific route wins. When routes with equal specificity are // present, the proxy service will distribute the traffic between // them. // // options may be: // - ddp: if true, the default, include DDP traffic. This includes // both secure and insecure traffic, and both websocket and sockjs // transports. // - http: if true, the default, include HTTP/HTTPS traffic. // - forwardTo: if provided, should be a URL with a host, optional // path and port, and no scheme (the scheme will be derived from the // traffic type; for now it will always be a http or ws connection, // never https or wss, but we could add a forwardSecure flag to // re-encrypt). var routes = []; WebAppInternals.addRoute = function (url, options) { options = _.extend({ ddp: true, http: true }, options || {}); if (proxy) // In the future, lift this restriction throw new Error("Too late to add routes"); routes.push(_.extend({ url: url }, options)); }; // Receive traffic on our default URL. WebAppInternals.addRoute(""); runWebAppServer(); var inlineScriptsAllowed = true; WebAppInternals.inlineScriptsAllowed = function () { return inlineScriptsAllowed; }; WebAppInternals.setInlineScriptsAllowed = function (value) { inlineScriptsAllowed = value; }; WebAppInternals.setBundledJsCssPrefix = function (prefix) { bundledJsCssPrefix = prefix; }; // Packages can call `WebAppInternals.addStaticJs` to specify static // JavaScript to be included in the app. This static JS will be inlined, // unless inline scripts have been disabled, in which case it will be // served under `/`. var additionalStaticJs = {}; WebAppInternals.addStaticJs = function (contents) { additionalStaticJs["/" + sha1(contents) + ".js"] = contents; }; // Exported for tests WebAppInternals.getBoilerplate = getBoilerplate; WebAppInternals.additionalStaticJs = additionalStaticJs;