import { Meteor } from 'meteor/meteor' import { WebApp } from "meteor/webapp"; import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; let _disableSizeCheck = false; let disabledBrowsers = {}; let enableCallback = null; Meteor.AppCache = { config: options => { Object.keys(options).forEach(option => { value = options[option]; if (option === 'browsers') { disabledBrowsers = {}; value.each(browser => disabledBrowsers[browser] = false); } else if (option === 'onlineOnly') { value.forEach(urlPrefix => RoutePolicy.declare(urlPrefix, 'static-online') ); } else if (option === 'enableCallback') { enableCallback = value; } // option to suppress warnings for tests. else if (option === '_disableSizeCheck') { _disableSizeCheck = value; } else if (value === false) { disabledBrowsers[option] = true; } else if (value === true) { disabledBrowsers[option] = false; } else { throw new Error('Invalid AppCache config option: ' + option); } }); } }; const browserDisabled = request => { if (enableCallback) { return !enableCallback(request); } return disabledBrowsers[request.browser.name]; } // Cache of previously computed app.manifest files. const manifestCache = new Map; const shouldSkip = resource => resource.type === 'dynamic js' || (resource.type === 'json' && (resource.url.endsWith('.map') || resource.url.endsWith('.stats.json?meteor_js_resource=true'))); WebApp.addHtmlAttributeHook(request => browserDisabled(request) ? null : { manifest: "/app.manifest" } ); WebApp.connectHandlers.use((req, res, next) => { if (req.url !== '/app.manifest') { return next(); } const request = WebApp.categorizeRequest(req); // 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 (browserDisabled(request)) { res.writeHead(404); res.end(); return; } const cacheInfo = { // Provided by WebApp.categorizeRequest. modern: request.modern, }; // Also provided by WebApp.categorizeRequest. cacheInfo.arch = request.arch; // The true hash of the client manifest for this arch, regardless of // AUTOUPDATE_VERSION or Autoupdate.autoupdateVersion. cacheInfo.clientHash = WebApp.clientHash(cacheInfo.arch); if (Package.autoupdate) { const { // New in Meteor 1.7.1 (autoupdate@1.5.0), this versions object maps // client architectures (e.g. "web.browser") to client hashes that // reflect AUTOUPDATE_VERSION and Autoupdate.autoupdateVersion. versions, // The legacy way of forcing a particular version, supported here // just in case Autoupdate.versions is not defined. autoupdateVersion, } = Package.autoupdate.Autoupdate; const version = versions ? versions[cacheInfo.arch].version : autoupdateVersion; if (typeof version === "string" && version !== cacheInfo.clientHash) { cacheInfo.autoupdateVersion = version; } } const cacheKey = JSON.stringify(cacheInfo); if (! manifestCache.has(cacheKey)) { manifestCache.set(cacheKey, computeManifest(cacheInfo)); } const manifest = manifestCache.get(cacheKey); res.setHeader('Content-Type', 'text/cache-manifest'); res.setHeader('Content-Length', manifest.length); return res.end(manifest); }); function computeManifest(cacheInfo) { let manifest = "CACHE MANIFEST\n\n"; // 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 to ensure that the client updates if client resources change, // include a hash of client resources in the manifest. manifest += `# ${cacheInfo.clientHash}\n`; // When using the autoupdate package, also include // AUTOUPDATE_VERSION. Otherwise the client will get into an // infinite loop of reloads when the browser doesn't fetch the new // app HTML which contains the new version, and autoupdate will // reload again trying to get the new code. if (typeof cacheInfo.autoupdateVersion === "string") { manifest += `# ${cacheInfo.autoupdateVersion}\n`; } manifest += "\n"; manifest += "CACHE:\n"; manifest += "/\n"; eachResource(cacheInfo, resource => { const { url } = resource; if (resource.where !== 'client' || RoutePolicy.classify(url) || shouldSkip(resource)) { return; } manifest += 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"; eachResource(cacheInfo, (resource, arch, prefix) => { const { url } = resource; if (resource.where !== 'client' || RoutePolicy.classify(url) || shouldSkip(resource)) { return; } if (! resource.cacheable) { // 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) manifest += `${url} ${url}?${resource.hash}\n`; } if (resource.type === 'asset' && prefix.length > 0 && url.startsWith(prefix)) { // If the URL has a prefix like /__browser.legacy or /__cordova, add // a fallback from the un-prefixed URL to the fully prefixed URL, so // that legacy/cordova browsers can load assets offline without // using an explicit prefix. When the client is online, these assets // will simply come from the modern web.browser bundle, which does // not prefix its asset URLs. Using a fallback rather than just // duplicating the resources in the manifest is important because of // appcache size limits. manifest += `${url.slice(prefix.length)} ${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"; [ ...RoutePolicy.urlPrefixesFor('network'), ...RoutePolicy.urlPrefixesFor('static-online') ].forEach(urlPrefix => manifest += `${urlPrefix}\n`); manifest += "*\n"; // content length needs to be based on bytes return Buffer.from(manifest, "utf8"); } function eachResource({ modern, arch, }, callback) { const manifest = WebApp.clientPrograms[arch].manifest; let prefix = ""; if (! modern) { manifest.some(({ url }) => { if (url && url.startsWith("/__")) { prefix = url.split("/", 2).join("/"); return true; } }); } manifest.forEach(resource => { callback(resource, arch, prefix); }); } function sizeCheck() { const RESOURCE_SIZE_LIMIT = 5 * 1024 * 1024; // 5MB const largeSizes = [ // Check size of each known architecture independently. "web.browser", "web.browser.legacy", ].filter((arch) => !!WebApp.clientPrograms[arch]) .map((arch) => { let totalSize = 0; WebApp.clientPrograms[arch].manifest.forEach(resource => { if (resource.where === 'client' && ! RoutePolicy.classify(resource.url) && ! shouldSkip(resource)) { totalSize += resource.size; } }); return { arch, size: totalSize, } }) .filter(({ size }) => size > RESOURCE_SIZE_LIMIT); if (largeSizes.length > 0) { Meteor._debug([ "** You are using the appcache package, but the size of", "** one or more of your cached resources is larger than", "** the recommended maximum size of 5MB which may break", "** your app in some browsers!", "** ", ...largeSizes.map(data => `** ${data.arch}: ${(data.size / 1024 / 1024).toFixed(1)}MB`), "** ", "** See http://docs.meteor.com/#appcache for more", "** information and fixes." ].join("\n")); } } // Run the size check after user code has had a chance to run. That way, // the size check can take into account files that the user does not // want cached. Otherwise, the size check warning will still print even // if the user excludes their large files with // `Meteor.AppCache.config({onlineOnly: files})`. Meteor.startup(() => _disableSizeCheck || sizeCheck());