Move boilerplate HTML from tools to webapp

This breaks a strong dependency between the webapp package and the
bundler.  Now we can change the standard page format, add more hooks,
etc without changing the tools (and hot code push works properly).

This is an incompatible change to the definition of the
browser-program-pre1 format: it no longer contains a "page" field
pointing to a boilerplate defined using ad hoc ##FOO##
substitution. Instead, the manifest can contain "head" and "body"
entries for data added with compileStep.appendDocument().  (Note that in
Blaze, <body> in a template file no longer calls appendDocument.)

WebApp.addHtmlAttributeHook callbacks now return attribute objects (like
those that Spacebars supports) rather than strings.
This commit is contained in:
David Glasser
2014-03-17 21:56:52 -07:00
parent ddaf139b66
commit b2632d45c5
9 changed files with 178 additions and 122 deletions

View File

@@ -2,6 +2,14 @@
* Log out a user's other sessions when they change their password.
* Move boilerplate HTML from tools to webapp. Changes internal
Webapp.addHtmlAttributeHook API incompatibly.
## v0.8.0
(Currently being stabilized. Features Blaze.)
## v0.7.2

View File

@@ -57,7 +57,7 @@ var browserEnabled = function(request) {
WebApp.addHtmlAttributeHook(function (request) {
if (browserEnabled(request))
return 'manifest="/app.manifest"';
return { manifest: "/app.manifest" };
else
return null;
});

View File

@@ -7,9 +7,7 @@ Package.on_use(function (api) {
api.use('deps');
api.use('minimongo'); // for idStringify
api.export('ObserveSequence');
// XXX this does also run on the server but as long as deps is not
// documented to run there let's not try
api.add_files(['observe_sequence.js'], 'client');
api.add_files(['observe_sequence.js']);
});
Package.on_test(function (api) {

View File

@@ -101,6 +101,9 @@ StarTranslator._writeClientProg = function (bundlePath, clientProgPath) {
var clientManifest = {
"format": "browser-program-pre1",
"manifest": origClientManifest.manifest,
// XXX Haven't updated this for the app.html -> head/body change, but
// surely we don't need to because code in pre-star apps doesn't
// even read this file?
"page": "app.html",
"static": "static",
"staticCacheable": "static_cacheable"

View File

@@ -0,0 +1,22 @@
<html {{htmlAttributes}}>
<head>
{{#each css}} <link rel="stylesheet" href="{{bundledJsCssPrefix}}{{url}}">{{/each}}
{{#if inlineScriptsAllowed}}
<script type='text/javascript'>__meteor_runtime_config__ = {{meteorRuntimeConfig}};</script>
{{else}}
<script type='text/javascript' src='{{rootUrlPathPrefix}}/meteor_runtime_config.js'></script>
{{/if}}
{{#each js}} <script type="text/javascript" src="{{bundledJsCssPrefix}}{{url}}"></script>
{{/each}}
{{#if inlineScriptsAllowed}}
<script type='text/javascript'>{{{reloadSafetyBelt}}}</script>
{{else}}
<script type='text/javascript' src='{{rootUrlPathPrefix}}/meteor_reload_safetybelt.js'></script>
{{/if}}
{{{head}}}
</head>
<body>
{{{body}}}
</body>
</html>

View File

@@ -8,7 +8,9 @@ Npm.depends({connect: "2.9.0",
useragent: "2.0.7"});
Package.on_use(function (api) {
api.use(['logging', 'underscore', 'routepolicy'], 'server');
api.use(['logging', 'underscore', 'routepolicy', 'spacebars-compiler',
'spacebars', 'htmljs'],
'server');
api.use(['underscore'], 'client');
api.use(['application-configuration', 'follower-livedata'], {
unordered: true
@@ -21,5 +23,10 @@ Package.on_use(function (api) {
api.export(['WebApp', 'main', 'WebAppInternals'], 'server');
api.export(['WebApp'], 'client');
api.add_files('webapp_server.js', 'server');
// This is a spacebars template, but we process it manually with the spacebars
// compiler rather than letting the 'templating' package (which isn't fully
// supported on the server yet) handle it. That also means that it doesn't
// contain the outer "<template>" tag.
api.add_files('boilerplate.html', 'server', {isAsset: true});
api.add_files('webapp_client.js', 'client');
});

View File

@@ -25,10 +25,10 @@ var bundledJsCssPrefix;
// CSS. This prevents you from displaying the page in that case, and instead
// reloads it, presumably all on the new version now.
var RELOAD_SAFETYBELT = "\n" +
"if (typeof Package === 'undefined' || \n" +
" ! Package.webapp || \n" +
" ! Package.webapp.WebApp || \n" +
" ! Package.webapp.WebApp._isCssLoaded()) \n" +
"if (typeof Package === 'undefined' ||\n" +
" ! Package.webapp ||\n" +
" ! Package.webapp.WebApp ||\n" +
" ! Package.webapp.WebApp._isCssLoaded())\n" +
" document.location.reload(); \n";
// Keepalives so that when the outer server dies unceremoniously and
@@ -131,14 +131,17 @@ WebApp.categorizeRequest = function (req) {
// be added to the '<html>' tag. Each function is passed a 'request' object (see
// #BrowserIdentification) and should return a string,
var htmlAttributeHooks = [];
var htmlAttributes = function (template, request) {
var attributes = '';
var getHtmlAttributes = function (request) {
var combinedAttributes = {};
_.each(htmlAttributeHooks || [], function (hook) {
var attribute = hook(request);
if (attribute !== null && attribute !== undefined && attribute !== '')
attributes += ' ' + attribute;
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 template.replace('##HTML_ATTRIBUTES##', attributes);
return combinedAttributes;
};
WebApp.addHtmlAttributeHook = function (hook) {
htmlAttributeHooks.push(hook);
@@ -432,13 +435,17 @@ var runWebAppServer = function () {
});
// Will be updated by main before we listen.
var boilerplateHtml = null;
var boilerplateTemplate = null;
var boilerplateBaseData = null;
var boilerplateByAttributes = {};
app.use(function (req, res, next) {
if (! appUrl(req.url))
return next();
if (!boilerplateHtml)
throw new Error("boilerplateHtml should be set before listening!");
if (!boilerplateTemplate)
throw new Error("boilerplateTemplate should be set before listening!");
if (!boilerplateBaseData)
throw new Error("boilerplateBaseData should be set before listening!");
var headers = {
@@ -459,9 +466,31 @@ var runWebAppServer = function () {
res.end();
return undefined;
}
var htmlAttributes = getHtmlAttributes(request);
// The only thing that changes from request to request (for now) are the
// HTML attributes (used by, eg, appcache), so we can memoize based on that.
var attributeKey = JSON.stringify(htmlAttributes);
if (!_.has(boilerplateByAttributes, attributeKey)) {
try {
var boilerplateData = _.extend({htmlAttributes: htmlAttributes},
boilerplateBaseData);
var boilerplateInstance = boilerplateTemplate.extend({
data: boilerplateData
});
var boilerplateHtmlJs = boilerplateInstance.render();
boilerplateByAttributes[attributeKey] = "<!DOCTYPE html>\n" +
HTML.toHTML(boilerplateHtmlJs, boilerplateInstance);
} catch (e) {
res.writeHead(500, headers);
res.end();
return undefined;
}
}
res.writeHead(200, headers);
var requestSpecificHtml = htmlAttributes(boilerplateHtml, request);
res.write(requestSpecificHtml);
res.write(boilerplateByAttributes[attributeKey]);
res.end();
return undefined;
});
@@ -559,37 +588,46 @@ var runWebAppServer = function () {
// '--keepalive' is a use of the option.
var expectKeepalives = _.contains(argv, '--keepalive');
var boilerplateHtmlPath = path.join(clientDir, clientJson.page);
boilerplateHtml = fs.readFileSync(boilerplateHtmlPath, 'utf8');
boilerplateBaseData = {
css: [],
js: [],
head: '',
body: '',
inlineScriptsAllowed: WebAppInternals.inlineScriptsAllowed(),
meteorRuntimeConfig: JSON.stringify(__meteor_runtime_config__),
reloadSafetyBelt: RELOAD_SAFETYBELT,
rootUrlPathPrefix: __meteor_runtime_config__.ROOT_URL_PATH_PREFIX || '',
bundledJsCssPrefix: bundledJsCssPrefix ||
__meteor_runtime_config__.ROOT_URL_PATH_PREFIX || ''
};
// Include __meteor_runtime_config__ in the app html, as an inline script if
// it's not forbidden by CSP.
if (WebAppInternals.inlineScriptsAllowed()) {
boilerplateHtml = boilerplateHtml.replace(
/##RUNTIME_CONFIG##/,
"<script type='text/javascript'>__meteor_runtime_config__ = " +
JSON.stringify(__meteor_runtime_config__) + ";</script>");
boilerplateHtml = boilerplateHtml.replace(
/##RELOAD_SAFETYBELT##/,
"<script type='text/javascript'>"+RELOAD_SAFETYBELT+"</script>");
} else {
boilerplateHtml = boilerplateHtml.replace(
/##RUNTIME_CONFIG##/,
"<script type='text/javascript' src='##ROOT_URL_PATH_PREFIX##/meteor_runtime_config.js'></script>"
);
boilerplateHtml = boilerplateHtml.replace(
/##RELOAD_SAFETYBELT##/,
"<script type='text/javascript' src='##ROOT_URL_PATH_PREFIX##/meteor_reload_safetybelt.js'></script>");
_.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 = fs.readFileSync(
path.join(clientDir, item.path), 'utf8');
}
if (item.type === 'body') {
boilerplateBaseData.body = fs.readFileSync(
path.join(clientDir, item.path), 'utf8');
}
});
}
boilerplateHtml = boilerplateHtml.replace(
/##ROOT_URL_PATH_PREFIX##/g,
__meteor_runtime_config__.ROOT_URL_PATH_PREFIX || "");
boilerplateHtml = boilerplateHtml.replace(
/##BUNDLED_JS_CSS_PREFIX##/g,
bundledJsCssPrefix ||
__meteor_runtime_config__.ROOT_URL_PATH_PREFIX || "");
var boilerplateTemplateSource = Assets.getText("boilerplate.html");
var boilerplateRenderCode = Spacebars.compile(
boilerplateTemplateSource, { isBody: true });
// Use 'new Function' instead of eval to avoid capturing local variables of
// this context.
var boilerplateRender = new Function("return " + boilerplateRenderCode)();
boilerplateTemplate = UI.Component.extend({
kind: "MainPage",
render: boilerplateRender
});
// only start listening after all the startup code has run.
var localPort = parseInt(process.env.PORT) || 0;

View File

@@ -59,11 +59,6 @@
//
// - format: "browser-program-pre1" for this version
//
// - page: path to the template for the HTML to serve when a browser
// loads a page that is part of the application. In the file,
// some strings of the format ##FOO## will be replaced with
// appropriate values at runtime by the webapp package.
//
// - manifest: array of resources to serve with HTTP, each an object:
// - path: path of file relative to program.json
// - where: "client"
@@ -74,15 +69,11 @@
// - size: size of file in bytes
// - hash: sha1 hash of the file contents
// - sourceMap: optional path to source map file (relative to program.json)
// Additionally there will be an entry with where equal to
// "internal", path equal to page (above), and hash equal to the
// sha1 of page (before replacements). Currently this is used to
// trigger HTML5 appcache reloads at the right time (if the
// 'appcache' package is being used).
//
// Convention:
//
// page is 'app.html'.
// Additionally there may be a manifest entry with where equal to
// "internal", type "head" or "body", and a path and hash. These contain
// chunks of HTML which should be inserted in the boilerplate HTML page's
// <head> or <body> respectively.
//
//
// == Format of a program when arch is "os.*" ==
@@ -876,42 +867,6 @@ _.extend(ClientTarget.prototype, {
self.css[0].setUrlToHash(".css", "?meteor_css_resource=true");
},
// XXX Instead of packaging the boilerplate in the client program, the
// template should be part of WebApp, and we should make sure that all
// information that it needs is in the manifest (ie, make sure to include head
// and body). Then it will just need to do one level of templating instead
// of two. Alternatively, use spacebars with unipackage.load here.
generateHtmlBoilerplate: function () {
var self = this;
var html = [];
html.push('<!DOCTYPE html>\n' +
'<html##HTML_ATTRIBUTES##>\n' +
'<head>\n');
_.each(self.css, function (css) {
html.push(' <link rel="stylesheet" href="##BUNDLED_JS_CSS_PREFIX##');
html.push(_.escape(css.url));
html.push('">\n');
});
html.push('\n\n##RUNTIME_CONFIG##\n\n');
_.each(self.js, function (js) {
html.push(' <script type="text/javascript" src="##BUNDLED_JS_CSS_PREFIX##');
html.push(_.escape(js.url));
html.push('"></script>\n');
});
html.push('\n\n##RELOAD_SAFETYBELT##');
html.push('\n\n');
html.push(self.head.join('\n')); // unescaped!
html.push('\n' +
'</head>\n' +
'<body>\n');
html.push(self.body.join('\n')); // unescaped!
html.push('\n' +
'</body>\n' +
'</html>\n');
return new Buffer(html.join(''), 'utf8');
},
// Output the finished target to disk
//
// Returns the path (relative to 'builder') of the control file for
@@ -920,7 +875,6 @@ _.extend(ClientTarget.prototype, {
var self = this;
builder.reserve("program.json");
builder.reserve("app.html");
// Helper to iterate over all resources that we serve over HTTP.
var eachResource = function (f) {
@@ -988,20 +942,24 @@ _.extend(ClientTarget.prototype, {
manifest.push(manifestItem);
});
// HTML boilerplate (the HTML served to make the client load the
// JS and CSS files and start the app)
var htmlBoilerplate = self.generateHtmlBoilerplate();
builder.write('app.html', { data: htmlBoilerplate });
manifest.push({
path: 'app.html',
where: 'internal',
hash: Builder.sha1(htmlBoilerplate)
_.each(['head', 'body'], function (type) {
var data = self[type].join('\n');
if (data) {
var dataBuffer = new Buffer(data, 'utf8');
var dataFile = builder.writeToGeneratedFilename(
type + '.html', { data: dataBuffer });
manifest.push({
path: dataFile,
where: 'internal',
type: type,
hash: Builder.sha1(dataBuffer)
});
}
});
// Control file
builder.writeJson('program.json', {
format: "browser-program-pre1",
page: 'app.html',
manifest: manifest
});
return "program.json";

View File

@@ -15,6 +15,12 @@ var tmpDir = function () {
};
var runTest = function () {
var readManifest = function (tmpOutputDir) {
return JSON.parse(fs.readFileSync(
path.join(tmpOutputDir, "programs", "client", "program.json"),
"utf8")).manifest;
};
console.log("nodeModules: 'skip'");
assert.doesNotThrow(function () {
var tmpOutputDir = tmpDir();
@@ -38,10 +44,13 @@ var runTest = function () {
.isDirectory());
// verify that contents are minified
var appHtml = fs.readFileSync(path.join(tmpOutputDir, "programs",
"client", "app.html"), 'utf8');
assert(/src=\"##BUNDLED_JS_CSS_PREFIX##\/[0-9a-f]{40,40}.js\"/.test(appHtml));
assert(!(/src=\"##BUNDLED_JS_CSS_PREFIX##\/packages/.test(appHtml)));
var manifest = readManifest(tmpOutputDir);
_.each(manifest, function (item) {
if (item.type !== 'js')
return;
// Just a hash, and no "packages/".
assert(/^[0-9a-f]{40,40}\.js$/.test(item.path));
});
});
console.log("nodeModules: 'skip', no minify");
@@ -58,14 +67,25 @@ var runTest = function () {
// sanity check -- main.js has expected contents.
assert.strictEqual(fs.readFileSync(path.join(tmpOutputDir, "main.js"), "utf8"),
bundler._mainJsContents);
// verify that contents are not minified
var appHtml = fs.readFileSync(path.join(tmpOutputDir, "programs",
"client", "app.html"), 'utf8');
assert(!(/src=\"##BUNDLED_JS_CSS_PREFIX##\/[0-9a-f]{40,40}.js\"/.test(appHtml)));
assert(/src=\"##BUNDLED_JS_CSS_PREFIX##\/packages\/meteor/.test(appHtml));
assert(/src=\"##BUNDLED_JS_CSS_PREFIX##\/packages\/deps/.test(appHtml));
// verify that tests aren't included
assert(!(/src=\"##BUNDLED_JS_CSS_PREFIX##\/package-tests\/meteor/.test(appHtml)));
var manifest = readManifest(tmpOutputDir);
var foundMeteor = false;
var foundDeps = false;
_.each(manifest, function (item) {
if (item.type !== 'js')
return;
// No minified hash.
assert(!/^[0-9a-f]{40,40}\.js$/.test(item.path));
// No tests.
assert(!/:tests/.test(item.path));
if (item.path === 'packages/meteor.js')
foundMeteor = true;
if (item.path === 'packages/deps.js')
foundDeps = true;
});
assert(foundMeteor);
assert(foundDeps);
});
console.log("nodeModules: 'skip', no minify, testPackages: ['meteor']");
@@ -82,10 +102,12 @@ var runTest = function () {
// sanity check -- main.js has expected contents.
assert.strictEqual(fs.readFileSync(path.join(tmpOutputDir, "main.js"), "utf8"),
bundler._mainJsContents);
// verify that tests for the meteor package are included
var appHtml = fs.readFileSync(path.join(tmpOutputDir, "programs",
"client", "app.html"));
assert(/src=\"##BUNDLED_JS_CSS_PREFIX##\/packages\/meteor:tests\.js/.test(appHtml));
var manifest = readManifest(tmpOutputDir);
assert(_.find(manifest, function (item) {
return item.type === 'js' && item.path === 'packages/meteor:tests.js';
}));
});
console.log("nodeModules: 'copy'");