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
JavaScript
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;