Merge branch 'appcache' into devel

Conflicts:
	History.md
This commit is contained in:
Nick Martin
2013-03-11 22:35:24 -07:00
15 changed files with 548 additions and 39 deletions

View File

@@ -1,6 +1,10 @@
## vNEXT
* Add new `appcache` package. Add this package to your project to speed
up page load and make hot code reload smoother using the HTML5
AppCache API. See http://docs.meteor.com/#appcache for details.
* You can now provide a `transform` option to collections, which is a
function that documents coming out of that collection are passed
through. `find`, `findOne`, `allow`, and `deny` now take `transform` options,

View File

@@ -12,7 +12,7 @@
// - manifest [list of resources in load order, each consists of an object]:
// {
// "path": relative path of file in the bundle, normalized to use forward slashes
// "where": "client" [could also be "server" in future]
// "where": "client", "internal" [could also be "server" in future]
// "type": "js", "css", or "static"
// "cacheable": (client) boolean, is it safe to ask the browser to cache this file
// "url": (client) relative url to download the resource, includes cache
@@ -231,11 +231,19 @@ var Bundle = function () {
// list of filenames
self.css = [];
// images and other static files added from packages
// map from environment, to list of filenames
self.static = {client: [], server: []};
// Map from environment, to path name (server relative), to contents
// of file as buffer.
self.files = {client: {}, client_cacheable: {}, server: {}};
// See description of manifest at the top
// See description of the manifest at the top.
// Note that in contrast to self.js etc., the manifest only includes
// files which are in the final bundler output: for example, if code
// is minified, the manifest includes the minify output file but not
// the individual input files that were combined.
self.manifest = [];
// list of segments of additional HTML for <head>/<body>
@@ -315,6 +323,7 @@ var Bundle = function () {
self[options.type].push(data);
} else if (options.type === "static") {
self.files[w][options.path] = data;
self.static[w].push(options.path);
} else {
throw new Error("Unknown type " + options.type);
}
@@ -529,6 +538,25 @@ _.extend(Bundle.prototype, {
// --- Static assets ---
var addClientFileToManifest = function (filepath, contents, type, cacheable, url) {
if (! contents instanceof Buffer)
throw new Error('contents must be a Buffer');
var normalized = filepath.split(path.sep).join('/');
if (normalized.charAt(0) === '/')
normalized = normalized.substr(1);
self.manifest.push({
// path is normalized to use forward slashes
path: (cacheable ? 'static_cacheable' : 'static') + '/' + normalized,
where: 'client',
type: type,
cacheable: cacheable,
url: url || '/' + normalized,
// contents is a Buffer and so correctly gives us the size in bytes
size: contents.length,
hash: self._hash(contents)
});
};
if (is_app) {
if (fs.existsSync(path.join(project_dir, 'public'))) {
var copied =
@@ -537,18 +565,8 @@ _.extend(Bundle.prototype, {
_.each(copied, function (fs_relative_path) {
var filepath = path.join(build_path, 'static', fs_relative_path);
var normalized = fs_relative_path.split(path.sep).join('/');
self.manifest.push({
// path is normalized to use forward slashes, so deliberately
// not using path.sep here
path: 'static/' + normalized,
type: 'static',
where: 'client',
cacheable: false,
url: '/' + normalized,
size: fs.statSync(filepath).size,
hash: self._hash(fs.readFileSync(filepath))
});
var contents = fs.readFileSync(filepath);
addClientFileToManifest(fs_relative_path, contents, 'static', false);
});
}
dependencies_json.app.push('public');
@@ -573,17 +591,7 @@ _.extend(Bundle.prototype, {
else
throw new Error('unable to find file: ' + file);
self.manifest.push({
// path is normalized to use forward slashes
path: 'static_cacheable' + file.split(path.sep).join('/'),
where: 'client',
type: type,
cacheable: true,
url: url,
// contents is a Buffer and so correctly gives us the size in bytes
size: contents.length,
hash: self._hash(contents)
});
addClientFileToManifest(file, contents, type, true, url);
};
_.each(self.js.client, function (file) { processClientCode('js', file); });
@@ -594,6 +602,7 @@ _.extend(Bundle.prototype, {
var full_path = path.join(build_path, 'static', rel_path);
files.mkdir_p(path.dirname(full_path), 0755);
fs.writeFileSync(full_path, self.files.client[rel_path]);
addClientFileToManifest(rel_path, self.files.client[rel_path], 'static', false);
}
// -- Client cache forever code --
@@ -613,8 +622,13 @@ _.extend(Bundle.prototype, {
fs.writeFileSync(full_path, self.files.server[rel_path]);
}
fs.writeFileSync(path.join(build_path, 'app.html'),
self._generate_app_html());
var app_html = self._generate_app_html();
fs.writeFileSync(path.join(build_path, 'app.html'), app_html);
self.manifest.push({
path: 'app.html',
where: 'internal',
hash: self._hash(app_html)
});
dependencies_json.core.push(path.join('lib', 'app.html.in'));
fs.writeFileSync(path.join(build_path, 'unsupported.html'),

View File

@@ -155,9 +155,9 @@ var appUrl = function (url) {
if (url === '/app.manifest')
return false;
// Avoid serving app HTML for declared network routes such as /sockjs/.
// Avoid serving app HTML for declared routes such as /sockjs/.
if (__meteor_bootstrap__._routePolicy &&
__meteor_bootstrap__._routePolicy.classify(url) === 'network')
__meteor_bootstrap__._routePolicy.classify(url))
return false;
// we currently return app HTML on all URLs by default

View File

@@ -10,3 +10,4 @@ code-prettify
jquery-waypoints
less
spiderable
appcache

View File

@@ -291,6 +291,7 @@ var toc = [
"Packages", [ [
"accounts-ui",
"appcache",
"amplify",
"backbone",
"bootstrap",

View File

@@ -107,7 +107,7 @@ clean, classically beautiful APIs.
<h2 id="resources">Developer Resources</h2>
<!-- https://github.com/blog/273-github-ribbons -->
<a href="http://github.com/meteor/meteor"><img class="github-ribbon visible-desktop" style="position: absolute; top: 0; right: 0; border: 0;" src="https://s3.amazonaws.com/github/ribbons/forkme_right_red_aa0000.png" alt="Fork me on GitHub"></a>
<a href="http://github.com/meteor/meteor"><img class="github-ribbon visible-desktop" style="position: absolute; top: 0; right: 0; border: 0;" src="/forkme_right_red_aa0000.png" alt="Fork me on GitHub"></a>
If anything in Meteor catches your interest, we hope you'll get involved
with the project!

View File

@@ -17,6 +17,7 @@ and removed with:
$ meteor remove <package_name>
{{> pkg_accounts_ui}}
{{> pkg_appcache}}
{{> pkg_amplify}}
{{> pkg_backbone}}
{{> pkg_bootstrap}}

View File

@@ -0,0 +1,92 @@
<template name="pkg_appcache">
{{#better_markdown}}
## `appcache`
The `appcache` package stores the static parts of a Meteor application
(the client side Javascript, HTML, CSS, and images) in the browser's
[application cache](https://en.wikipedia.org/wiki/AppCache). To enable
caching simply add the `appcache` package to your project.
* Once a user has visited a Meteor application for the first time and
the application has been cached, on subsequent visits the web page
loads faster because the browser can load the application out of the
cache without contacting the server first.
* Hot code pushes are loaded by the browser in the background while the
app continues to run. Once the new code has been fully loaded the
browser is able to switch over to the new code quickly.
* The application cache allows the application to be loaded even when
the browser doesn't have an Internet connection, and so enables using
the app offline.
(Note however that the `appcache` package by itself doesn't make
*data* available offline: in an application loaded offline, a Meteor
Collection will appear to be empty in the client until the Internet
becomes available and the browser is able to establish a livedata
connection).
The application cache works transparently in all supported browsers
except for Firefox, which pops up a message saying "This website is
asking to store data on your computer for offline use" and asks the
user whether to allow or deny the request. The application cache is
disabled on Firefox by default; to turn it on use:
Meteor.AppCache.config({firefox: true});
You can also disable the application cache for specific browsers:
Meteor.AppCache.config({
chrome: false,
firefox: true,
ie: false
});
The supported browsers that can be enabled or disabled are `android`,
`chrome`, `firefox`, `ie`, `mobileSafari` and `safari`.
Browsers limit the amount of data they will put in the application
cache, which can vary due to factors such as how much disk space is
free. Unfortunately if your application goes over the limit rather
than disabling the application cache altogether and running the
application online, the browser will instead fail that particular
*update* of the cache, leaving your users running old code.
Thus it's best to keep the size of the cache below 5MB. The
`appcache` package will print a warning on the Meteor server console
if the total size of the resources being cached is over 5MB.
If you have files too large to fit in the cache you can disable
caching by URL prefix. For example,
Meteor.AppCache.config({onlineOnly: ['/online/']});
causes files in your `public/online` directory to not be cached, and
so they will only be available online. You can then move your large
files into that directory and refer to them at the new URL:
<img src="/online/bigimage.jpg">
If you'd prefer not to move your files, you can use the file names
themselves as the URL prefix:
Meteor.AppCache.config({
onlineOnly: [
'/bigimage.jpg',
'/largedata.json'
]
});
though keep in mind that since the exclusion is by prefix (this is a
limitation of the application cache manifest), excluding
`/largedata.json` will also exclude such URLs as
`/largedata.json.orig` and `/largedata.json/file1`.
For more information about how Meteor interacts with the application
cache, see the
[AppCache page](https://github.com/meteor/meteor/wiki/AppCache)
in the Meteor wiki.
{{/better_markdown}}
</template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

93
packages/appcache/QA.md Normal file
View File

@@ -0,0 +1,93 @@
# QA Notes
## Viewing the app cache
Chrome: Navigate to chrome://appcache-internals/
Firefox: Open Tools / Advanced / Network. The section reading "The
following websites are allowed to store data for offline use" will
show the amount of data in the app cache ("1.2 MB"). If this number
is 0 the app is permitted to use the app cache but the app cache is
currently turned off.
## Setup
Create a simple static app and add the appcache package.
static.html:
````
<body>
some static content
</body>
````
If you're testing with Firefox, enable it:
static.js:
````
if (Meteor.isServer) {
Meteor.AppCache.config({
firefox: true
});
}
````
## App is cached offline
Run Meteor, load the app in the browser, stop Meteor. Reload the page
in the browser and observe the content is still visible.
## Hot code reload still works
Run Meteor, open the app in the browser. Make a change to
static.html. Observe the change appear in the web page.
Note that it is normal when using the app cache for the page reload to
be delayed a bit while the browser fetches the changed code in the
background.
Without app cache: (page goes blank) -> (browser fetches) -> (page renders)
With app cache: (browser fetches) -> (page goes blank) -> (page renders)
## Enabling / disabling the appcache turns the app cache on / off
Run Meteor, open the app in the browser.
Disable your browser in the appcache config. For example, if you're
using Chrome:
````
Meteor.AppCache.config({
chrome: false
});
````
Observe following the hot code reload the app is no longer cached.
Enable your browser again:
````
Meteor.AppCache.config({
chrome: true
});
````
Observe following the hot code reload the app is cached again.
## Removing the appcache package turns off app caching
Start Meteor, open the app in the browser.
Stop Meteor, remove the appcache package, remove or comment out the
call to Meteor.AppCache.config in static.js, start Meteor again.
Wait for the browser to reestablish its livedata connection. Observe
following the hot code reload that the app is no longer cached.

View File

@@ -0,0 +1,71 @@
(function() {
if (! window.applicationCache)
return;
var appCacheStatuses = [
'uncached',
'idle',
'checking',
'downloading',
'updateready',
'obsolete'
];
var updatingAppcache = false;
var reloadRetry = null;
var appcacheUpdated = false;
Meteor._reload.onMigrate('appcache', function(retry) {
if (appcacheUpdated)
return [true];
// An uncached application (one that does not have a manifest) cannot
// be updated.
if (window.applicationCache.status === window.applicationCache.UNCACHED)
return [true];
if (!updatingAppcache) {
try {
window.applicationCache.update();
} catch (e) {
Meteor._debug('applicationCache update error', e);
// There's no point in delaying the reload if we can't update the cache.
return [true];
}
updatingAppcache = true;
}
// Delay migration until the app cache has been updated.
reloadRetry = retry;
return false;
});
// If we're migrating and the app cache is now up to date, signal that
// we're now ready to migrate.
var cacheIsNowUpToDate = function() {
if (!updatingAppcache)
return;
appcacheUpdated = true;
reloadRetry();
};
window.applicationCache.addEventListener('updateready', cacheIsNowUpToDate, false);
window.applicationCache.addEventListener('noupdate', cacheIsNowUpToDate, false);
// We'll get the obsolete event on a 404 fetching the app.manifest:
// we had previously been running with an app cache, but the app
// cache has now been disabled or the appcache package removed.
// Reload to get the new non-cached code.
window.applicationCache.addEventListener('obsolete', (function() {
if (reloadRetry) {
cacheIsNowUpToDate();
}
else {
appcacheUpdated = true;
Meteor._reload.reload();
}
}), false);
})();

View File

@@ -0,0 +1,185 @@
(function() {
var app = __meteor_bootstrap__.app;
var bundle = __meteor_bootstrap__.bundle;
var crypto = __meteor_bootstrap__.require('crypto');
var fs = __meteor_bootstrap__.require('fs');
var path = __meteor_bootstrap__.require('path');
var knownBrowsers = ['android', 'chrome', 'firefox', 'ie', 'mobileSafari', 'safari'];
var browsersEnabledByDefault = ['android', 'chrome', 'ie', 'mobileSafari', 'safari'];
var enabledBrowsers = {};
_.each(browsersEnabledByDefault, function (browser) {
enabledBrowsers[browser] = true;
});
Meteor.AppCache = {
config: function(options) {
_.each(options, function (value, option) {
if (option === 'browsers') {
enabledBrowsers = {};
_.each(value, function (browser) {
enabledBrowsers[browser] = true;
});
}
else if (_.contains(knownBrowsers, option)) {
enabledBrowsers[option] = value;
}
else if (option === 'onlineOnly') {
_.each(value, function (urlPrefix) {
Meteor._routePolicy.declare(urlPrefix, 'static-online');
});
}
else {
throw new Error('Invalid AppCache config option: ' + option);
}
});
}
};
var browserEnabled = function(request) {
return enabledBrowsers[request.browser.name];
};
__meteor_bootstrap__.htmlAttributeHooks.push(function (request) {
if (browserEnabled(request))
return 'manifest="/app.manifest"';
else
return null;
});
app.use(function(req, res, next) {
if (req.url !== '/app.manifest') {
return next();
}
// Browsers will get confused if we unconditionally serve the
// manifest and then disable the app cache for that browser. If
// the app cache had previously been enabled for a browser, it
// will continue to fetch the manifest as long as it's available,
// even if we now are not including the manifest attribute in the
// app HTML. (Firefox for example will continue to display "this
// website is asking to store data on your computer for offline
// use"). Returning a 404 gets the browser to really turn off the
// app cache.
if (!browserEnabled(__meteor_bootstrap__.categorizeRequest(req))) {
res.writeHead(404);
res.end();
return;
}
// After the browser has downloaded the app files from the server and
// has populated the browser's application cache, the browser will
// *only* connect to the server and reload the application if the
// *contents* of the app manifest file has changed.
//
// So we have to ensure that if any static client resources change,
// something changes in the manifest file. We compute a hash of
// everything that gets delivered to the client during the initial
// web page load, and include that hash as a comment in the app
// manifest. That way if anything changes, the comment changes, and
// the browser will reload resources.
var hash = crypto.createHash('sha1');
hash.update(JSON.stringify(__meteor_runtime_config__), 'utf8');
_.each(bundle.manifest, function (resource) {
if (resource.where === 'client' || resource.where === 'internal') {
hash.update(resource.hash);
}
});
var digest = hash.digest('hex');
var manifest = "CACHE MANIFEST\n\n";
manifest += '# ' + digest + "\n\n";
manifest += "CACHE:" + "\n";
manifest += "/" + "\n";
_.each(bundle.manifest, function (resource) {
if (resource.where === 'client' &&
! Meteor._routePolicy.classify(resource.url)) {
manifest += resource.url;
// If the resource is not already cacheable (has a query
// parameter, presumably with a hash or version of some sort),
// put a version with a hash in the cache.
//
// Avoid putting a non-cacheable asset into the cache, otherwise
// the user can't modify the asset until the cache headers
// expire.
if (!resource.cacheable)
manifest += "?" + resource.hash;
manifest += "\n";
}
});
manifest += "\n";
manifest += "FALLBACK:\n";
manifest += "/ /" + "\n";
// Add a fallback entry for each uncacheable asset we added above.
//
// This means requests for the bare url (/image.png instead of
// /image.png?hash) will work offline. Online, however, the browser
// will send a request to the server. Users can remove this extra
// request to the server and have the asset served from cache by
// specifying the full URL with hash in their code (manually, with
// some sort of URL rewriting helper)
_.each(bundle.manifest, function (resource) {
if (resource.where === 'client' &&
! Meteor._routePolicy.classify(resource.url) &&
!resource.cacheable) {
manifest += resource.url + " " + resource.url +
"?" + resource.hash + "\n";
}
});
manifest += "\n";
manifest += "NETWORK:\n";
// TODO adding the manifest file to NETWORK should be unnecessary?
// Want more testing to be sure.
manifest += "/app.manifest" + "\n";
_.each(
[].concat(
Meteor._routePolicy.urlPrefixesFor('network'),
Meteor._routePolicy.urlPrefixesFor('static-online')
),
function (urlPrefix) {
manifest += urlPrefix + "\n";
}
);
manifest += "*" + "\n";
// content length needs to be based on bytes
var body = new Buffer(manifest);
res.setHeader('Content-Type', 'text/cache-manifest');
res.setHeader('Content-Length', body.length);
return res.end(body);
});
var sizeCheck = function() {
var totalSize = 0;
_.each(bundle.manifest, function (resource) {
if (resource.where === 'client') {
totalSize += resource.size;
}
});
if (totalSize > 5 * 1024 * 1024) {
Meteor._debug(
"** You are using the appcache package but the total size of the\n" +
"** cached resources is " +
(totalSize / 1024 / 1024).toFixed(1) + "MB.\n" +
"**\n" +
"** This is over the recommended maximum of 5 MB and may break your\n" +
"** app in some browsers! See http://docs.meteor.com/#appcache\n" +
"** for more information and fixes.\n"
);
}
};
sizeCheck();
})();

View File

@@ -0,0 +1,11 @@
Package.describe({
summary: "enable the application cache in the browser"
});
Package.on_use(function (api) {
api.use('reload', 'client');
api.use('routepolicy', 'server');
api.use('startup', 'client');
api.add_files('appcache-client.js', 'client');
api.add_files('appcache-server.js', 'server');
});

View File

@@ -1,3 +1,25 @@
// In addition to listing specific files to be cached, the browser
// application cache manifest allows URLs to be designated as NETWORK
// (always fetched from the Internet) and FALLBACK (which we use to
// serve app HTML on arbitrary URLs).
//
// The limitation of the manifest file format is that the designations
// are by prefix only: if "/foo" is declared NETWORK then "/foobar"
// will also be treated as a network route.
//
// Meteor._routePolicy is a low-level API for declaring the route type
// of URL prefixes:
//
// "network": for network routes that should not conflict with static
// resources. (For example, if "/sockjs/" is a network route, we
// shouldn't have "/sockjs/red-sock.jpg" as a static resource).
//
// "static-online": for static resources which should not be cached in
// the app cache. This is implemented by also adding them to the
// NETWORK section (as otherwise the browser would receive app HTML
// for them because of the FALLBACK section), but static-online routes
// don't need to be checked for conflict with static resources.
(function () {
// The route policy is a singleton in a running application, but we
@@ -17,8 +39,8 @@
},
checkType: function (type) {
if (! _.contains(['network'], type))
return 'the route type must be "network"';
if (! _.contains(['network', 'static-online'], type))
return 'the route type must be "network" or "static-online"';
return null;
},
@@ -35,6 +57,8 @@
checkForConflictWithStatic: function (urlPrefix, type, _testManifest) {
var self = this;
if (type === 'static-online')
return null;
var manifest = _testManifest || __meteor_bootstrap__.bundle.manifest;
var conflict = _.find(manifest, function (resource) {
return (resource.type === 'static' &&

View File

@@ -2,9 +2,8 @@ Tinytest.add("routepolicy", function (test) {
var policy = new Meteor.__RoutePolicyConstructor();
policy.declare('/sockjs/', 'network');
// App routes might look like this...
// policy.declare('/posts/', 'app');
// policy.declare('/about', 'app');
policy.declare('/bigphoto.jpg', 'static-online');
policy.declare('/anotherphoto.png', 'static-online');
test.equal(policy.classify('/'), null);
test.equal(policy.classify('/foo'), null);
@@ -13,11 +12,14 @@ Tinytest.add("routepolicy", function (test) {
test.equal(policy.classify('/sockjs/'), 'network');
test.equal(policy.classify('/sockjs/foo'), 'network');
// test.equal(policy.classify('/posts/'), 'app');
// test.equal(policy.classify('/posts/1234'), 'app');
test.equal(policy.classify('/bigphoto.jpg'), 'static-online');
test.equal(policy.classify('/bigphoto.jpg.orig'), 'static-online');
test.equal(policy.urlPrefixesFor('network'), ['/sockjs/']);
// test.equal(policy.urlPrefixesFor('app'), ['/about', '/posts/']);
test.equal(
policy.urlPrefixesFor('static-online'),
['/anotherphoto.png', '/bigphoto.jpg']
);
});
Tinytest.add("routepolicy - static conflicts", function (test) {
@@ -26,9 +28,14 @@ Tinytest.add("routepolicy - static conflicts", function (test) {
"path": "static/sockjs/socks-are-comfy.jpg",
"type": "static",
"where": "client",
"cacheable": false,
"url": "/sockjs/socks-are-comfy.jpg"
},
{
"path": "static/bigphoto.jpg",
"type": "static",
"where": "client",
"url": "/bigphoto.jpg"
}
];
var policy = new Meteor.__RoutePolicyConstructor();
@@ -36,4 +43,9 @@ Tinytest.add("routepolicy - static conflicts", function (test) {
policy.checkForConflictWithStatic('/sockjs/', 'network', manifest),
"static resource /sockjs/socks-are-comfy.jpg conflicts with network route /sockjs/"
);
test.equal(
policy.checkForConflictWithStatic('/bigphoto.jpg', 'static-online', manifest),
null
);
});