UNPKG

derby

Version:

MVC framework making it easy to write realtime, collaborative applications that run in both Node.js and browsers.

456 lines (455 loc) 19.1 kB
var __extends = (this && this.__extends) || (function () { var extendStatics = function (d, b) { extendStatics = Object.setPrototypeOf || ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; }; return extendStatics(d, b); }; return function (d, b) { if (typeof b !== "function" && b !== null) throw new TypeError("Class extends value " + String(b) + " is not a constructor or null"); extendStatics(d, b); function __() { this.constructor = d; } d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.AppForClient = exports.App = exports.createAppPage = void 0; /* * App.js * * Provides the glue between views, controllers, and routes for an * application's functionality. Apps are responsible for creating pages. * */ var events_1 = require("events"); var path_1 = require("path"); var racer_1 = require("racer"); var racer_2 = require("racer"); var components = require("./components"); var Page_1 = require("./Page"); var routes_1 = require("./routes"); var derbyTemplates = require("./templates"); var util_1 = require("./templates/util"); var templates = derbyTemplates.templates; // TODO: Change to Map once we officially drop support for ES5. global.APPS = global.APPS || {}; function createAppPage(derby) { var pageCtor = ((derby && derby.Page) || Page_1.PageForClient); // Inherit from Page/PageForServer so that we can add controller functions as prototype // methods on this app's pages var AppPage = /** @class */ (function (_super) { __extends(AppPage, _super); function AppPage() { return _super !== null && _super.apply(this, arguments) || this; } return AppPage; }(pageCtor)); return AppPage; } exports.createAppPage = createAppPage; /* * APP EVENTS * 'error', Error 'model', Model - a new root model was created by the app, server and client 'page', Page - a new Page was created by the app, server and client 'ready', Page - app is ready to attach to server-rendered HTML, client only 'load', Page - app is fully loaded, client only 'destroyPage', Page - current Page is about to be destroyed, client only 'route', Page - app is about to invoke a route handler, client only 'routeDone', Page, routeType - app has finished running a route handler, client only */ var App = /** @class */ (function (_super) { __extends(App, _super); function App(derby, name, filename, options) { var _this = this; var _a, _b; _this = _super.call(this) || this; _this.use = racer_2.util.use; _this.serverUse = racer_2.util.serverUse; if (options == null) { options = {}; } _this.derby = derby; _this.name = name; _this.filename = filename; _this.scriptHash = (_a = options.scriptHash) !== null && _a !== void 0 ? _a : ''; _this.appMetadata = (_b = options.appMetadata) !== null && _b !== void 0 ? _b : {}; _this.Page = createAppPage(derby); _this.proto = _this.Page.prototype; _this.views = new templates.Views(); _this.tracksRoutes = (0, routes_1.routes)(_this); _this._pendingComponentMap = {}; return _this; } App.prototype.loadViews = function (_viewFilename, _viewName) { }; App.prototype.loadStyles = function (_filename, _options) { }; App.prototype.component = function (name, constructor, isDependency) { if (typeof name === 'function') { constructor = name; name = null; } if (typeof constructor !== 'function') { if (typeof name === 'string') { throw new Error("Missing component constructor argument for ".concat(name, " with constructor of ").concat(JSON.stringify(constructor))); } throw new Error("Missing component constructor argument. Cannot use passed constructor of ".concat(JSON.stringify(constructor))); } var viewProp = constructor.view; var viewIs, viewFilename, viewSource, viewDependencies; // Always using an object for the static `view` property is preferred if (viewProp && typeof viewProp === 'object') { viewIs = viewProp.is; viewFilename = viewProp.file; viewSource = viewProp.source; viewDependencies = viewProp.dependencies; } else { // Ignore other properties when `view` is an object. It is possible that // properties could be inherited from a parent component when extending it. // // DEPRECATED: constructor.prototype.name and constructor.prototype.view // use the equivalent static properties instead // @ts-expect-error Ignore deprecated props viewIs = constructor.is || constructor.prototype.name; viewFilename = constructor.view || constructor.prototype.view; } var viewName = name || viewIs || (viewFilename && (0, path_1.basename)(viewFilename, '.html')); if (!viewName) { throw new Error('No view specified for component'); } if (viewFilename && viewSource) { throw new Error('Component may not specify both a view file and source'); } // TODO: DRY. This is copy-pasted from ./templates var mapName = viewName.replace(/:index$/, ''); (0, util_1.checkKeyIsSafe)(mapName); var currentView = this.views.nameMap[mapName]; var currentConstructor = (currentView && currentView.componentFactory) ? currentView.componentFactory.constructorFn : this._pendingComponentMap[mapName]; // Avoid registering the same component twice; we want to avoid the overhead // of loading view files from disk again. This is also what prevents // circular dependencies from infinite looping if (currentConstructor === constructor) return; // Calling app.component() overrides existing views or components. Prevent // dependencies from doing this without warning if (isDependency && currentView && !currentView.fromSerialized) { throw new Error('Dependencies cannot override existing views. Already registered "' + viewName + '"'); } // This map is used to prevent infinite loops from circular dependencies this._pendingComponentMap[mapName] = constructor; // Recursively register component dependencies if (viewDependencies) { for (var i = 0; i < viewDependencies.length; i++) { var dependency = viewDependencies[i]; if (Array.isArray(dependency)) { this.component(dependency[0], dependency[1], true); } else { this.component(null, dependency, true); } } } // Register or find views specified by the component var view; if (viewFilename) { this.loadViews(viewFilename, viewName); view = this.views.find(viewName); } else if (viewSource) { this.addViews(viewSource, viewName); view = this.views.find(viewName); } else if (viewName) { // Look for a previously registered view matching the component name. view = this.views.find(viewName); // If no match, register a new empty view, for backwards compatibility. if (!view) { view = this.views.register(viewName, ''); } } else { view = this.views.register(viewName, ''); } if (!view) { var message = this.views.findErrorMessage(viewName); throw new Error(message); } // Inherit from Component components.extendComponent(constructor); // Associate the appropriate view with the component constructor view.componentFactory = components.createFactory(constructor); delete this._pendingComponentMap[mapName]; // Make chainable return this; }; // This function is overriden by requiring 'derby/parsing' App.prototype.addViews = function (_viewFileName, _namespace) { throw new Error('Parsing not available. Registering a view from source should not be used ' + 'in application code. Instead, specify a filename with view.file.'); }; App.prototype.onRoute = function (callback, page, next, done) { var _this = this; if (this._waitForAttach) { // Cancel any routing before the initial page attachment. Instead, do a // render once derby is ready this._cancelAttach = true; return; } this.emit('route', page); // HACK: To update render in transitional routes page.model.set('$render.params', page.params); page.model.set('$render.url', page.params.url); page.model.set('$render.query', page.params.query); // If transitional if (done) { var _done = function () { _this.emit('routeDone', page, 'transition'); done(); }; callback.call(page, page, page.model, page.params, next, _done); return; } callback.call(page, page, page.model, page.params, next); }; return App; }(events_1.EventEmitter)); exports.App = App; var AppForClient = /** @class */ (function (_super) { __extends(AppForClient, _super); function AppForClient(derby, name, filename, options) { var _this = _super.call(this, derby, name, filename, options) || this; _this._init(options); return _this; } // Overriden on server AppForClient.prototype._init = function (_options) { this._waitForAttach = true; this._cancelAttach = false; this.model = (0, racer_1.createModel)(); var serializedViews = this._views(); serializedViews(derbyTemplates, this.views); // Must init async so that app.on('model') listeners can be added. // Must also wait for content ready so that bundle is fully downloaded. this._contentReady(); }; AppForClient.prototype._views = function () { return require('./_views'); }; AppForClient.prototype._finishInit = function () { var data = this._getAppData(); racer_2.util.isProduction = data.nodeEnv === 'production'; var previousAppInfo; if (!racer_2.util.isProduction) { previousAppInfo = global.APPS[this.name]; if (previousAppInfo) { previousAppInfo.app._destroyCurrentPage(); } global.APPS[this.name] = { app: this, initialState: data, }; } this.model.createConnection(data); this.emit('model', this.model); if (!racer_2.util.isProduction) this._autoRefresh(); this.model.unbundle(data); var page = this.createPage(); // @ts-expect-error TODO resolve type error page.params = this.model.get('$render.params'); this.emit('ready', page); this._waitForAttach = false; // Instead of attaching, do a route and render if a link was clicked before // the page finished attaching, or if this is a new app from hot reload. if (this._cancelAttach || previousAppInfo) { this.history.refresh(); return; } // Since an attachment failure is *fatal* and could happen as a result of a // browser extension like AdBlock, an invalid template, or a small bug in // Derby or Saddle, re-render from scratch on production failures if (racer_2.util.isProduction) { try { page.attach(); } catch (err) { this.history.refresh(); console.warn('attachment error', err.stack); } } else { page.attach(); } this.emit('load', page); }; AppForClient.prototype._getAppData = function () { var script = this._getAppStateScript(); if (script) { return AppForClient._parseInitialData(script.textContent); } else { return global.APPS[this.name].initialState; } }; // Modified from: https://github.com/addyosmani/jquery.parts/blob/master/jquery.documentReady.js AppForClient.prototype._contentReady = function () { var _this = this; // Is the DOM ready to be used? Set to true once it occurs. var isReady = false; // The ready event handler function onDOMContentLoaded() { if (document.addEventListener) { document.removeEventListener('DOMContentLoaded', onDOMContentLoaded, false); } else { // we're here because readyState !== 'loading' in oldIE // which is good enough for us to call the dom ready! // @ts-expect-error IE api document.detachEvent('onreadystatechange', onDOMContentLoaded); } onDOMReady(); } var finishInit = function () { _this._finishInit(); }; // Handle when the DOM is ready function onDOMReady() { // Make sure that the DOM is not already loaded if (isReady) return; // Make sure body exists, at least, in case IE gets a little overzealous (ticket #5443). if (!document.body) return setTimeout(onDOMReady, 0); // Remember that the DOM is ready isReady = true; // Make sure this is always async and then finishing init setTimeout(finishInit, 0); } // The DOM ready check for Internet Explorer function doScrollCheck() { if (isReady) return; try { // If IE is used, use the trick by Diego Perini // http://javascript.nwbox.com/IEContentLoaded/ // @ts-expect-error IE only api check document.documentElement.doScroll('left'); } catch (err) { setTimeout(doScrollCheck, 0); return; } // and execute any waiting functions onDOMReady(); } // Catch cases where called after the browser event has already occurred. if (document.readyState !== 'loading') return onDOMReady(); // Mozilla, Opera and webkit nightlies currently support this event if (document.addEventListener) { // Use the handy event callback document.addEventListener('DOMContentLoaded', onDOMContentLoaded, false); // A fallback to window.onload, that will always work window.addEventListener('load', onDOMContentLoaded, false); // If IE event model is used // @ts-expect-error IE event model } else if (document.attachEvent) { // ensure firing before onload, // maybe late but safe also for iframes // @ts-expect-error IE api document.attachEvent('onreadystatechange', onDOMContentLoaded); // A fallback to window.onload, that will always work // @ts-expect-error `attachEvent` checked above window.attachEvent('onload', onDOMContentLoaded); // If IE and not a frame // continually check to see if the document is ready var toplevel = void 0; try { toplevel = window.frameElement == null; } catch (err) { /* ignore, not IE */ } // @ts-expect-error IE api if (document.documentElement.doScroll && toplevel) { doScrollCheck(); } } }; AppForClient.prototype._getAppStateScript = function () { return document.querySelector('script[data-derby-app-state]'); }; AppForClient.prototype.createPage = function () { this._destroyCurrentPage(); var ClientPage = this.Page; var page = new ClientPage(this, this.model); this.page = page; this.emit('page', page); return page; }; AppForClient.prototype._destroyCurrentPage = function () { if (this.page) { this.emit('destroyPage', this.page); this.page.destroy(); } }; AppForClient.prototype._autoRefresh = function (_backend) { var _this = this; var connection = this.model.connection; connection.on('connected', function () { connection.send({ derby: 'app', name: _this.name, hash: _this.scriptHash }); }); connection.on('receive', function (request) { if (request.data.derby) { var message = request.data; request.data = null; _this._handleMessage(message.derby, message); } }); }; AppForClient.prototype._handleMessage = function (action, message) { if (action === 'refreshViews') { var fn = new Function('return ' + message.views)(); // jshint ignore:line fn(derbyTemplates, this.views); var ns = this.model.get('$render.ns'); this.page.render(ns); } else if (action === 'refreshStyles') { var styleElement = document.querySelector('style[data-filename="' + message.filename + '"]'); if (styleElement) styleElement.innerHTML = message.css; } else if (action === 'reload') { this.model.whenNothingPending(function () { var location = window.location; window.location = location; }); } }; AppForClient._parseInitialData = function (jsonString) { try { return JSON.parse(jsonString); } catch (error) { var message = error.message || ''; var match = message.match(/^Unexpected token/); if (match) { var p = parseInt(match[2], 10); var stringContext = jsonString.substring(Math.min(0, p - 30), Math.max(p + 30, jsonString.length - 1)); throw new Error('Parse failure: ' + error.message + ' context: \'' + stringContext + '\''); } throw error; } }; return AppForClient; }(App)); exports.AppForClient = AppForClient;