Merge branch 'devel' into doc/fix-tutorial-issues

This commit is contained in:
Gabriel Grubba
2026-03-31 21:06:52 -03:00
committed by GitHub
4 changed files with 189 additions and 6 deletions

View File

@@ -1,6 +1,6 @@
Package.describe({
summary: "Serves a Meteor app over HTTP",
version: "2.1.0",
version: "2.1.1",
});
Npm.depends({

View File

@@ -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');
}

View File

@@ -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];
}
}
);

View File

@@ -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))