diff --git a/packages/webapp/package.js b/packages/webapp/package.js index 6a2bb3d3ff..ed92e1b55b 100644 --- a/packages/webapp/package.js +++ b/packages/webapp/package.js @@ -1,6 +1,6 @@ Package.describe({ summary: "Serves a Meteor app over HTTP", - version: "2.1.0", + version: "2.1.1", }); Npm.depends({ diff --git a/packages/webapp/webapp_server.js b/packages/webapp/webapp_server.js index ad76808974..740f231447 100644 --- a/packages/webapp/webapp_server.js +++ b/packages/webapp/webapp_server.js @@ -681,11 +681,17 @@ WebAppInternals.staticFilesMiddleware = async function( // We cache them ~forever (1yr). const maxAge = info.cacheable ? 1000 * 60 * 60 * 24 * 365 : 0; - if (info.cacheable) { - // Since we use req.headers["user-agent"] to determine whether the - // client should receive modern or legacy resources, tell the client - // to invalidate cached resources when/if its user agent string - // changes in the future. + // Resources whose URL already contains the content hash are immutable + // and unique per architecture (modern vs legacy), so Vary: User-Agent + // is unnecessary and harms CDN cache efficiency. + // + // If the requested URL does not contain the hash (e.g. development + // or unhashed assets), we keep Vary: User-Agent to prevent cache + // poisoning across different browsers. + const includeVaryUserAgent = + Meteor.settings.packages?.webapp?.includeVaryUserAgent ?? true; + + if (info.cacheable && !pathname.includes(info.hash) && includeVaryUserAgent) { res.setHeader('Vary', 'User-Agent'); } diff --git a/packages/webapp/webapp_tests.js b/packages/webapp/webapp_tests.js index 60e6ba8c34..73de53b1f1 100644 --- a/packages/webapp/webapp_tests.js +++ b/packages/webapp/webapp_tests.js @@ -432,3 +432,158 @@ Tinytest.addAsync("webapp - parse url queries", async function (test) { i++; } }); + +Tinytest.addAsync( + 'webapp - vary header optimization (hashed assets)', + async function (test) { + const arch = 'web.browser'; + const hash = 'js-hash-123'; + const hashedJs = `/optim-hashed.${hash}.js`; + + WebAppInternals.staticFilesByArch[arch][hashedJs] = { + content: 'console.log("prod")', + absolutePath: '/tmp/mock-prod.js', + cacheable: true, + hash: hash, + type: 'js' + }; + + try { + const resJs = await asyncGet(Meteor.absoluteUrl(hashedJs)); + const varyJs = (resJs.headers['vary'] || '').toLowerCase(); + + test.isFalse( + varyJs.includes('user-agent'), + 'Vary: User-Agent should be removed when the URL contains the file hash' + ); + + } finally { + delete WebAppInternals.staticFilesByArch[arch][hashedJs]; + } + } +); + +Tinytest.addAsync( + 'webapp - vary header safety (unhashed assets)', + async function (test) { + const arch = 'web.browser'; + const unhashedJs = '/safety-unhashed.js'; + + WebAppInternals.staticFilesByArch[arch][unhashedJs] = { + content: 'console.log("dev")', + absolutePath: '/tmp/mock-dev.js', + cacheable: true, + hash: 'dev-internal-hash', + type: 'js' + }; + + try { + const res = await asyncGet(Meteor.absoluteUrl(unhashedJs)); + const varyHeader = (res.headers['vary'] || '').toLowerCase(); + + test.isTrue( + varyHeader.includes('user-agent'), + 'Vary: User-Agent MUST be present when the URL does NOT contain the hash' + ); + } finally { + delete WebAppInternals.staticFilesByArch[arch][unhashedJs]; + } + } +); + +Tinytest.addAsync( + 'webapp - vary header respects includeVaryUserAgent setting', + async function (test) { + const arch = 'web.browser'; + const unhashedJs = '/config-test.js'; + + const originalSettings = Meteor.settings.packages?.webapp?.includeVaryUserAgent; + + if (!Meteor.settings.packages) Meteor.settings.packages = {}; + if (!Meteor.settings.packages.webapp) Meteor.settings.packages.webapp = {}; + + WebAppInternals.staticFilesByArch[arch][unhashedJs] = { + content: 'console.log("config-test")', + absolutePath: '/tmp/mock-config.js', + cacheable: true, + hash: 'internal-hash', + type: 'js' + }; + + try { + Meteor.settings.packages.webapp.includeVaryUserAgent = false; + const resDisabled = await asyncGet(Meteor.absoluteUrl(unhashedJs)); + const varyDisabled = (resDisabled.headers['vary'] || '').toLowerCase(); + + test.isFalse( + varyDisabled.includes('user-agent'), + 'Should NOT have Vary header when setting is false' + ); + + Meteor.settings.packages.webapp.includeVaryUserAgent = true; + const resEnabled = await asyncGet(Meteor.absoluteUrl(unhashedJs)); + const varyEnabled = (resEnabled.headers['vary'] || '').toLowerCase(); + + test.isTrue( + varyEnabled.includes('user-agent'), + 'Should HAVE Vary header when setting is true' + ); + + } finally { + delete WebAppInternals.staticFilesByArch[arch][unhashedJs]; + Meteor.settings.packages.webapp.includeVaryUserAgent = originalSettings; + } + } +); + +// Verification: Ensure that a URL containing a specific hash serves the exact same +// content and headers to all browsers (Modern vs Legacy). +// This proves that removing 'Vary: User-Agent' is safe because the file content +// is determined solely by the unique hash in the URL, not by the requesting browser. +Tinytest.addAsync( + 'webapp - hashed files identical across user-agents', + async function (test) { + const arch = 'web.browser'; + const hash = 'unique-hash-999'; + const hashedPath = `/cdn-consistency-test.${hash}.js`; + const url = Meteor.absoluteUrl(hashedPath); + + WebAppInternals.staticFilesByArch[arch][hashedPath] = { + content: 'console.log("consistent-cdn")', + absolutePath: '/tmp/mock-consistent.js', + cacheable: true, + hash: hash, + type: 'js' + }; + + try { + const resModern = await asyncGet(url, { + headers: { 'User-Agent': modernUserAgent } + }); + + const resLegacy = await asyncGet(url, { + headers: { 'User-Agent': legacyUserAgent } + }); + + test.equal( + resModern.content, + resLegacy.content, + 'Hashed URLs must serve identical content to all browsers' + ); + + const varyModern = (resModern.headers['vary'] || '').toLowerCase(); + const varyLegacy = (resLegacy.headers['vary'] || '').toLowerCase(); + + test.isFalse( + varyModern.includes('user-agent'), + 'Modern browser request should not see Vary: User-Agent' + ); + test.isFalse( + varyLegacy.includes('user-agent'), + 'Legacy browser request should not see Vary: User-Agent' + ); + } finally { + delete WebAppInternals.staticFilesByArch[arch][hashedPath]; + } + } +); \ No newline at end of file diff --git a/v3-docs/docs/packages/webapp.md b/v3-docs/docs/packages/webapp.md index bd71bcbc22..7f57b476ee 100644 --- a/v3-docs/docs/packages/webapp.md +++ b/v3-docs/docs/packages/webapp.md @@ -103,6 +103,28 @@ We're using the [connect-route](https://www.npmjs.com/package/connect-route) NPM And finally, if you decide to use this technique you'll want to make sure you understand how conflicting client side routing will affect user experience. +### Static Assets & Caching + +By default, Meteor serves different bundles (Modern vs. Legacy) based on the user's browser. Historically, this required the `Vary: User-Agent` header on all responses, which forced CDNs to fragment their cache (storing separate copies for Chrome, Safari, etc.). + +`webapp` now includes an automatic optimization to solve this: + +* **Production (Hashed Filenames):** Files with the hash in the pathname (e.g., `/app.abc12345.js`) are served **without** the `Vary: User-Agent` header. Since the pathname uniquely identifies the content, CDNs can safely cache a single copy for all users. +* **Development (Unhashed Filenames):** Files without the hash in the pathname (e.g., `/packages/promise.js?hash=abc123`) **keep** the `Vary: User-Agent` header to ensure safety during development. + +This behavior is enabled by default. You can control it via `Meteor.settings`: +```json +{ + "packages": { + "webapp": { + "includeVaryUserAgent": true + } + } +} +``` + +Setting `includeVaryUserAgent` to `false` will disable the header for **all** static files. + ### React SSR Optimization (Meteor 3.4) **Experimental: Disable Boilerplate Response** ([PR#13855](https://github.com/meteor/meteor/pull/13855))