From 7f08b5edf86bccf40e7e95488ce81957b952a3e3 Mon Sep 17 00:00:00 2001 From: Shamshad Ansari <149064103+shamshad-ansari@users.noreply.github.com> Date: Sun, 8 Feb 2026 17:03:09 -0600 Subject: [PATCH 1/3] Fix(webapp): Remove Vary: User-Agent header from hashed assets (#13852) --- packages/webapp/package.js | 2 +- packages/webapp/webapp_server.js | 13 ++-- packages/webapp/webapp_tests.js | 109 +++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 6 deletions(-) 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..0bd800088a 100644 --- a/packages/webapp/webapp_server.js +++ b/packages/webapp/webapp_server.js @@ -681,11 +681,14 @@ 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 identified by a hash (info.hash) are unique per + // architecture (modern vs legacy), so Vary: User-Agent is redundant. + // + // However, in Development/Test mode, hashes might be identical or missing, + // so we force Vary to be safe. + const varyAgentMode = Meteor.settings.packages?.webapp?.varyAgent ?? true; + + if (info.cacheable && (!info.hash || Meteor.isDevelopment) && varyAgentMode) { res.setHeader('Vary', 'User-Agent'); } diff --git a/packages/webapp/webapp_tests.js b/packages/webapp/webapp_tests.js index 60e6ba8c34..dcc99fdb26 100644 --- a/packages/webapp/webapp_tests.js +++ b/packages/webapp/webapp_tests.js @@ -432,3 +432,112 @@ 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 hashedJs = '/optim-hashed.js'; + + // Inject mock production files (with hashes) into the manifest. + WebAppInternals.staticFilesByArch[arch][hashedJs] = { + content: 'console.log("prod")', + absolutePath: '/tmp/mock-prod.js', + cacheable: true, + hash: 'js-hash-123', + 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 from Hashed JS files' + ); + + } 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'; + + // Inject mock development file (no hash). + WebAppInternals.staticFilesByArch[arch][unhashedJs] = { + content: 'console.log("dev")', + absolutePath: '/tmp/mock-dev.js', + cacheable: true, + hash: null, + 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 on files without a hash to prevent cache poisoning' + ); + } finally { + delete WebAppInternals.staticFilesByArch[arch][unhashedJs]; + } + } +); + +Tinytest.addAsync( + 'webapp - hashed files identical across user-agents', + async function (test) { + const arch = 'web.browser'; + const hashedPath = '/cdn-consistency-test.js'; + const url = Meteor.absoluteUrl(hashedPath); + + // Inject a single hashed file to prove it serves identically to all browsers. + WebAppInternals.staticFilesByArch[arch][hashedPath] = { + content: 'console.log("consistent-cdn")', + absolutePath: '/tmp/mock-consistent.js', + cacheable: true, + hash: 'unique-hash-999', + type: 'js' + }; + + try { + // Request with Modern User-Agent (variable from webapp_tests.js scope) + const resModern = await asyncGet(url, { + headers: { 'User-Agent': modernUserAgent } + }); + + // Request with Legacy User-Agent (variable from webapp_tests.js scope) + 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 From b60d552401f8b219af18999c7e9c91b76a1ec966 Mon Sep 17 00:00:00 2001 From: Shamshad Ansari <149064103+shamshad-ansari@users.noreply.github.com> Date: Sun, 8 Feb 2026 17:13:26 -0600 Subject: [PATCH 2/3] Removed accidental check --- packages/webapp/webapp_server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webapp/webapp_server.js b/packages/webapp/webapp_server.js index 0bd800088a..0399bcfcd0 100644 --- a/packages/webapp/webapp_server.js +++ b/packages/webapp/webapp_server.js @@ -688,7 +688,7 @@ WebAppInternals.staticFilesMiddleware = async function( // so we force Vary to be safe. const varyAgentMode = Meteor.settings.packages?.webapp?.varyAgent ?? true; - if (info.cacheable && (!info.hash || Meteor.isDevelopment) && varyAgentMode) { + if (info.cacheable && !info.hash && varyAgentMode) { res.setHeader('Vary', 'User-Agent'); } From 67b9d06273410813d5099bc7811c6b3e3d9228ac Mon Sep 17 00:00:00 2001 From: Shamshad Ansari <149064103+shamshad-ansari@users.noreply.github.com> Date: Tue, 10 Feb 2026 19:34:36 -0600 Subject: [PATCH 3/3] Fix(webapp): Remove Vary: User-Agent header from hashed assets Optimizes CDN caching by removing the Vary header when the URL contains a unique file hash. Changes: - webapp_server.js: Implemented logic to strip header only when hash is present in the URL path. - webapp_tests.js: Added comprehensive tests for optimization (prod), safety (dev), and configuration. - webapp.md: Added documentation for Static Assets & Caching behavior. --- packages/webapp/webapp_server.js | 15 +++--- packages/webapp/webapp_tests.js | 84 ++++++++++++++++++++++++-------- v3-docs/docs/packages/webapp.md | 22 +++++++++ 3 files changed, 96 insertions(+), 25 deletions(-) diff --git a/packages/webapp/webapp_server.js b/packages/webapp/webapp_server.js index 0399bcfcd0..740f231447 100644 --- a/packages/webapp/webapp_server.js +++ b/packages/webapp/webapp_server.js @@ -681,14 +681,17 @@ WebAppInternals.staticFilesMiddleware = async function( // We cache them ~forever (1yr). const maxAge = info.cacheable ? 1000 * 60 * 60 * 24 * 365 : 0; - // Resources identified by a hash (info.hash) are unique per - // architecture (modern vs legacy), so Vary: User-Agent is redundant. + // 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. // - // However, in Development/Test mode, hashes might be identical or missing, - // so we force Vary to be safe. - const varyAgentMode = Meteor.settings.packages?.webapp?.varyAgent ?? true; + // 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 && !info.hash && varyAgentMode) { + 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 dcc99fdb26..73de53b1f1 100644 --- a/packages/webapp/webapp_tests.js +++ b/packages/webapp/webapp_tests.js @@ -437,24 +437,24 @@ Tinytest.addAsync( 'webapp - vary header optimization (hashed assets)', async function (test) { const arch = 'web.browser'; - const hashedJs = '/optim-hashed.js'; + const hash = 'js-hash-123'; + const hashedJs = `/optim-hashed.${hash}.js`; - // Inject mock production files (with hashes) into the manifest. WebAppInternals.staticFilesByArch[arch][hashedJs] = { content: 'console.log("prod")', absolutePath: '/tmp/mock-prod.js', cacheable: true, - hash: 'js-hash-123', + 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 from Hashed JS files' + 'Vary: User-Agent should be removed when the URL contains the file hash' ); } finally { @@ -468,13 +468,12 @@ Tinytest.addAsync( async function (test) { const arch = 'web.browser'; const unhashedJs = '/safety-unhashed.js'; - - // Inject mock development file (no hash). + WebAppInternals.staticFilesByArch[arch][unhashedJs] = { content: 'console.log("dev")', absolutePath: '/tmp/mock-dev.js', cacheable: true, - hash: null, + hash: 'dev-internal-hash', type: 'js' }; @@ -484,7 +483,7 @@ Tinytest.addAsync( test.isTrue( varyHeader.includes('user-agent'), - 'Vary: User-Agent MUST be present on files without a hash to prevent cache poisoning' + 'Vary: User-Agent MUST be present when the URL does NOT contain the hash' ); } finally { delete WebAppInternals.staticFilesByArch[arch][unhashedJs]; @@ -493,28 +492,75 @@ Tinytest.addAsync( ); Tinytest.addAsync( - 'webapp - hashed files identical across user-agents', + 'webapp - vary header respects includeVaryUserAgent setting', async function (test) { const arch = 'web.browser'; - const hashedPath = '/cdn-consistency-test.js'; - const url = Meteor.absoluteUrl(hashedPath); + 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 = {}; - // Inject a single hashed file to prove it serves identically to all browsers. - WebAppInternals.staticFilesByArch[arch][hashedPath] = { - content: 'console.log("consistent-cdn")', - absolutePath: '/tmp/mock-consistent.js', + WebAppInternals.staticFilesByArch[arch][unhashedJs] = { + content: 'console.log("config-test")', + absolutePath: '/tmp/mock-config.js', cacheable: true, - hash: 'unique-hash-999', + 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 { - // Request with Modern User-Agent (variable from webapp_tests.js scope) const resModern = await asyncGet(url, { headers: { 'User-Agent': modernUserAgent } }); - // Request with Legacy User-Agent (variable from webapp_tests.js scope) const resLegacy = await asyncGet(url, { headers: { 'User-Agent': legacyUserAgent } }); diff --git a/v3-docs/docs/packages/webapp.md b/v3-docs/docs/packages/webapp.md index 09f4454e15..276200436b 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. + ### Dynamic Runtime Configuration In some cases it is valuable to be able to control the **meteor_runtime_config** variable that initializes Meteor at runtime.