UNPKG

11.7 kBJavaScriptView Raw
1/*
2 * App.server.js
3 *
4 * Application level functionality that is
5 * only applicable to the server.
6 *
7 */
8
9var crypto = require('crypto');
10var fs = require('fs');
11var path = require('path');
12var chokidar = require('chokidar');
13var through = require('through');
14var derbyTemplates = require('derby-templates');
15var racer = require('racer');
16var util = racer.util;
17var App = require('./App');
18var files = require('./files');
19
20var STYLE_EXTENSIONS = ['.css'];
21var VIEW_EXTENSIONS = ['.html'];
22var COMPILERS = {
23 '.css': files.cssCompiler
24, '.html': files.htmlCompiler
25};
26
27App.prototype._init = function(options) {
28 this.scriptFilename = null;
29 this.scriptMapFilename = null;
30 this.scriptBaseUrl = (options && options.scriptBaseUrl) || '';
31 this.scriptMapBaseUrl = (options && options.scriptMapBaseUrl) || '';
32 this.scriptCrossOrigin = (options && options.scriptCrossOrigin) || false;
33 this.scriptUrl = null;
34 this.scriptMapUrl = null;
35 this.agents = null;
36 this.styleExtensions = STYLE_EXTENSIONS.slice();
37 this.viewExtensions = VIEW_EXTENSIONS.slice();
38 this.compilers = util.copyObject(COMPILERS);
39
40 this.serializedDir = path.dirname(this.filename || '') + '/derby-serialized';
41 this.serializedBase = this.serializedDir + '/' + this.name;
42 if (fs.existsSync(this.serializedBase + '.json')) {
43 this.deserialize();
44 this.loadViews = function() {};
45 this.loadStyles = function() {};
46 return;
47 }
48 this.views.register('Page',
49 '<!DOCTYPE html>' +
50 '<meta charset="utf-8">' +
51 '<view is="{{$render.prefix}}TitleElement"></view>' +
52 '<view is="{{$render.prefix}}Styles"></view>' +
53 '<view is="{{$render.prefix}}Head"></view>' +
54 '<view is="{{$render.prefix}}BodyElement"></view>',
55 {serverOnly: true}
56 );
57 this.views.register('TitleElement',
58 '<title><view is="{{$render.prefix}}Title"></view></title>'
59 );
60 this.views.register('BodyElement',
61 '<body class="{{$bodyClass($render.ns)}}">' +
62 '<view is="{{$render.prefix}}Body"></view>'
63 );
64 this.views.register('Title', 'Derby App');
65 this.views.register('Styles', '', {serverOnly: true});
66 this.views.register('Head', '', {serverOnly: true});
67 this.views.register('Body', '');
68 this.views.register('Tail', '');
69};
70
71App.prototype.createPage = function(req, res, next) {
72 var model = req.model || new racer.Model();
73 this.emit('model', model);
74 var page = new this.Page(this, model, req, res);
75 if (next) {
76 model.on('error', function(err){
77 model.hasErrored = true;
78 next(err);
79 });
80 page.on('error', next);
81 }
82 return page;
83};
84
85App.prototype.bundle = function(backend, options, cb) {
86 var app = this;
87 if (typeof options === 'function') {
88 cb = options;
89 options = null;
90 }
91 options || (options = {});
92 if (options.minify == null) options.minify = util.isProduction;
93 // Turn all of the app's currently registered views into a javascript
94 // function that can recreate them in the client
95 var viewsSource = this._viewsSource(options);
96 var bundleFiles = [];
97 backend.once('bundle', function(bundle) {
98 bundle.require(path.dirname(__dirname), {expose: 'derby'});
99 // Hack to inject the views script into the Browserify bundle by replacing
100 // the empty _views.js file with the generated source
101 var viewsFilename = require.resolve('./_views');
102 bundle.transform(function(filename) {
103 if (filename !== viewsFilename) return through();
104 return through(
105 function write() {}
106 , function end() {
107 this.queue(viewsSource);
108 this.queue(null);
109 }
110 );
111 }, {global: true});
112 bundle.on('file', function(filename) {
113 bundleFiles.push(filename);
114 });
115 app.emit('bundle', bundle);
116 });
117 backend.bundle(app.filename, options, function(err, source, map) {
118 if (err) return cb(err);
119 app.scriptHash = crypto.createHash('md5').update(source).digest('hex');
120 source = source.replace('{{DERBY_SCRIPT_HASH}}', app.scriptHash);
121 source = source.replace(/['"]{{DERBY_BUNDLED_AT}}['"]/, Date.now());
122 if (!util.isProduction) {
123 app._autoRefresh(backend);
124 app._watchBundle(bundleFiles);
125 }
126 cb(null, source, map);
127 });
128};
129
130App.prototype.writeScripts = function(backend, dir, options, cb) {
131 var app = this;
132 this.bundle(backend, options, function(err, source, map) {
133 if (err) return cb(err);
134 dir = path.join(dir, 'derby');
135 if (!fs.existsSync(dir)) fs.mkdirSync(dir);
136 var filename = app.name + '-' + app.scriptHash;
137 var base = path.join(dir, filename);
138 app.scriptUrl = app.scriptBaseUrl + '/derby/' + filename + '.js';
139
140 // Write current map and bundle files
141 if (!(options && options.disableScriptMap)) {
142 app.scriptMapUrl = app.scriptMapBaseUrl + '/derby/' + filename + '.map.json';
143 source += '\n//# sourceMappingURL=' + app.scriptMapUrl;
144 app.scriptMapFilename = base + '.map.json';
145 fs.writeFileSync(app.scriptMapFilename, map, 'utf8');
146 }
147 app.scriptFilename = base + '.js';
148 fs.writeFileSync(app.scriptFilename, source, 'utf8');
149
150 // Delete app bundles with same name in development so files don't
151 // accumulate. Don't do this automatically in production, since there could
152 // be race conditions with multiple processes intentionally running
153 // different versions of the app in parallel out of the same directory,
154 // such as during a rolling restart.
155 if (!util.isProduction) {
156 var appPrefix = app.name + '-';
157 var currentBundlePrefix = appPrefix + app.scriptHash;
158 var filenames = fs.readdirSync(dir);
159 for (var i = 0; i < filenames.length; i++) {
160 var filename = filenames[i];
161 if (filename.indexOf(appPrefix) !== 0) {
162 // Not a bundle for this app, skip.
163 continue;
164 }
165 if (filename.indexOf(currentBundlePrefix) === 0) {
166 // Current (newly written) bundle for this app, skip.
167 continue;
168 }
169 // Older bundle for this app, clean it up.
170 var oldFilename = path.join(dir, filename);
171 fs.unlinkSync(oldFilename);
172 }
173 }
174 cb && cb();
175 });
176};
177
178App.prototype._viewsSource = function(options) {
179 return '/*DERBY_SERIALIZED_VIEWS*/' +
180 'module.exports = ' + this.views.serialize(options) + ';' +
181 '/*DERBY_SERIALIZED_VIEWS_END*/';
182};
183
184App.prototype.serialize = function() {
185 if (!fs.existsSync(this.serializedDir)) {
186 fs.mkdirSync(this.serializedDir);
187 }
188 // Don't minify the views (which doesn't include template source), since this
189 // is for use on the server
190 var viewsSource = this._viewsSource({server: true, minify: true});
191 fs.writeFileSync(this.serializedBase + '.views.js', viewsSource, 'utf8');
192 var scriptUrl = (this.scriptUrl.indexOf(this.scriptBaseUrl) === 0) ?
193 this.scriptUrl.slice(this.scriptBaseUrl.length) :
194 this.scriptUrl;
195 var scriptMapUrl = (this.scriptMapUrl.indexOf(this.scriptMapBaseUrl) === 0) ?
196 this.scriptMapUrl.slice(this.scriptMapBaseUrl.length) :
197 this.scriptMapUrl;
198 var serialized = JSON.stringify({
199 scriptBaseUrl: this.scriptBaseUrl
200 , scriptMapBaseUrl: this.scriptMapBaseUrl
201 , scriptUrl: scriptUrl
202 , scriptMapUrl: scriptMapUrl
203 });
204 fs.writeFileSync(this.serializedBase + '.json', serialized, 'utf8');
205};
206
207App.prototype.deserialize = function() {
208 var serializedViews = require(this.serializedBase + '.views.js');
209 var serialized = require(this.serializedBase + '.json');
210 serializedViews(derbyTemplates, this.views);
211 this.scriptUrl = (this.scriptBaseUrl || serialized.scriptBaseUrl) + serialized.scriptUrl;
212 this.scriptMapUrl = (this.scriptMapBaseUrl || serialized.scriptMapBaseUrl) + serialized.scriptMapUrl;
213};
214
215App.prototype.loadViews = function(filename, namespace) {
216 var data = files.loadViewsSync(this, filename, namespace);
217 for (var i = 0, len = data.views.length; i < len; i++) {
218 var item = data.views[i];
219 this.views.register(item.name, item.source, item.options);
220 }
221 if (!util.isProduction) this._watchViews(data.files, filename, namespace);
222 // Make chainable
223 return this;
224};
225
226App.prototype.loadStyles = function(filename, options) {
227 this._loadStyles(filename, options);
228 var stylesView = this.views.find('Styles');
229 stylesView.source += '<view is="' + filename + '"></view>';
230 // Make chainable
231 return this;
232};
233
234App.prototype._loadStyles = function(filename, options) {
235 var styles = files.loadStylesSync(this, filename, options);
236
237 var filepath = '';
238 if (!util.isProduction) {
239 /**
240 * Mark the path to file as an attribute
241 * Used in development to add event watchers and autorefreshing of styles
242 * SEE: local file, method this._watchStyles
243 * SEE: file ./App.js, method App._autoRefresh()
244 */
245 filepath = ' data-filename="' + filename + '"';
246 }
247 var source = '<style' + filepath + '>' + styles.css + '</style>';
248
249 this.views.register(filename, source, {
250 serverOnly: true
251 });
252
253 if (!util.isProduction) {
254 this._watchStyles(styles.files, filename, options);
255 }
256
257 return styles;
258};
259
260App.prototype._watchViews = function(filenames, filename, namespace) {
261 var app = this;
262 var watcher = chokidar.watch(filenames);
263 watcher.on('change', function() {
264 watcher.close();
265 app.loadViews(filename, namespace);
266 app._updateScriptViews();
267 app._refreshClients();
268 });
269};
270
271App.prototype._watchStyles = function(filenames, filename, options) {
272 var app = this;
273 var watcher = chokidar.watch(filenames);
274 watcher.on('change', function() {
275 watcher.close();
276 var styles = app._loadStyles(filename, options);
277 app._updateScriptViews();
278 app._refreshStyles(filename, styles);
279 });
280};
281
282App.prototype._watchBundle = function(filenames) {
283 if (!process.send) return;
284 var app = this;
285 var watcher = chokidar.watch(filenames);
286 watcher.on('change', function() {
287 watcher.close();
288 process.send({type: 'reload'});
289 });
290};
291
292App.prototype._updateScriptViews = function() {
293 if (!this.scriptFilename) return;
294 var script = fs.readFileSync(this.scriptFilename, 'utf8');
295 var i = script.indexOf('/*DERBY_SERIALIZED_VIEWS*/');
296 var before = script.slice(0, i);
297 var i = script.indexOf('/*DERBY_SERIALIZED_VIEWS_END*/');
298 var after = script.slice(i + 30);
299 var viewsSource = this._viewsSource();
300 fs.writeFileSync(this.scriptFilename, before + viewsSource + after, 'utf8');
301};
302
303App.prototype._autoRefresh = function(backend) {
304 var agents = this.agents = {};
305 var app = this;
306
307 backend.use('receive', function(request, next) {
308 var data = request.data;
309 if (data.derby) {
310 app._handleMessage(request.agent, data.derby, data);
311 }
312 next();
313 });
314};
315
316App.prototype._handleMessage = function(agent, action, message) {
317 if (action === 'app') {
318 if (message.name !== this.name) {
319 return;
320 }
321 if (message.hash !== this.scriptHash) {
322 return agent.send({derby: 'reload'});
323 }
324 this._addAgent(agent);
325 }
326};
327
328App.prototype._addAgent = function(agent) {
329 this.agents[agent.clientId] = agent;
330 var app = this;
331 agent.stream.once('end', function() {
332 delete app.agents[agent.clientId];
333 });
334};
335
336App.prototype._refreshClients = function() {
337 if (!this.agents) return;
338 var views = this.views.serialize({minify: true});
339 var message = {
340 derby: 'refreshViews',
341 views: views
342 };
343 for (var id in this.agents) {
344 this.agents[id].send(message);
345 }
346};
347
348App.prototype._refreshStyles = function(filename, styles) {
349 if (!this.agents) return;
350 var data = {filename: filename, css: styles.css};
351 var message = {
352 derby: 'refreshStyles',
353 filename: filename,
354 css: styles.css
355 };
356 for (var id in this.agents) {
357 this.agents[id].send(message);
358 }
359};