diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 82de56b29b7..f759400f91e 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -43,6 +43,7 @@ import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./ht import { contentCacheKey, isCompressibleContentType, + isContentHashedAsset, makeStaticCompressionCache, negotiateStaticEncoding, resolveStaticCacheControl, @@ -389,19 +390,25 @@ export const staticAndDevRouteLayer = HttpRouter.add( data, contentType, cacheControl: resolveStaticCacheControl(staticRelativePath), - cacheKey: staticCacheKey(filePath, fileInfo), + // Hashed assets carry their content in their name, so metadata keying is + // both cheap and correct. Stable-name files (favicon, manifest, ...) can + // change content across a hot swap while keeping mtime/size, which would + // let the compression cache serve stale bytes -- key those by content, + // as index.html already is above. + cacheKey: isContentHashedAsset(staticRelativePath) + ? staticCacheKey(filePath, fileInfo) + : contentCacheKey(data), acceptEncoding: request.headers["accept-encoding"], }); }), ); /** - * Identifies a build of a file for the compression cache, without hashing - * payloads that can run to megabytes. The stat is taken before the bytes are - * read, so a rebuild landing between the two can only orphan an entry under - * the superseded key, never publish those bytes under the newer one. Files - * whose contents change without their name are keyed by content instead; the - * rest carry a content hash in their filename already. + * Identifies a build of a hashed asset for the compression cache without + * hashing payloads that can run to megabytes. Only used for files whose name + * already carries a content hash (see isContentHashedAsset), so the name + * changes whenever the bytes do and metadata keying cannot collide across a + * rebuild or hot swap. Stable-name files are keyed by content at the call site. */ function staticCacheKey(filePath: string, info: FileSystem.File.Info): string { const mtimeMs = info.mtime.pipe( diff --git a/apps/server/src/staticAssetDelivery.test.ts b/apps/server/src/staticAssetDelivery.test.ts index 9f3eff34e33..c9e78f2c36d 100644 --- a/apps/server/src/staticAssetDelivery.test.ts +++ b/apps/server/src/staticAssetDelivery.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { contentCacheKey, isCompressibleContentType, + isContentHashedAsset, makeStaticCompressionCache, negotiateStaticEncoding, resolveStaticCacheControl, @@ -26,6 +27,23 @@ describe("contentCacheKey", () => { }); }); +describe("isContentHashedAsset", () => { + it("recognizes vite content-hashed bundle assets", () => { + expect(isContentHashedAsset("assets/index-DxV9k2Qp.js")).toBe(true); + expect(isContentHashedAsset("/assets/style-a1b2c3d4.css")).toBe(true); + }); + + it("rejects stable-name files that a hot swap can change in place", () => { + // These keep their name across builds, so the compression cache must key + // them by content, not path/mtime/size -- otherwise an atomic asset swap + // that preserves metadata could serve stale bytes. + expect(isContentHashedAsset("index.html")).toBe(false); + expect(isContentHashedAsset("favicon.ico")).toBe(false); + expect(isContentHashedAsset("manifest.webmanifest")).toBe(false); + expect(isContentHashedAsset("assets/logo.svg")).toBe(false); + }); +}); + describe("resolveStaticCacheControl", () => { it("marks content-hashed bundle assets immutable", () => { expect(resolveStaticCacheControl("assets/index-DxV9k2Qp.js")).toBe( diff --git a/apps/server/src/staticAssetDelivery.ts b/apps/server/src/staticAssetDelivery.ts index fa7d41ec255..5eb1136e3f8 100644 --- a/apps/server/src/staticAssetDelivery.ts +++ b/apps/server/src/staticAssetDelivery.ts @@ -38,11 +38,21 @@ const brotliCompress = NodeUtil.promisify(NodeZlib.brotliCompress); export type StaticContentEncoding = "br" | "gzip"; -export function resolveStaticCacheControl(relativePath: string): string { +/** + * Whether a static file's name already encodes its content (a Vite hashed + * asset under `assets/`). Such files are safe to cache forever and safe to key + * by path/metadata, because the name changes whenever the bytes do. Every other + * served file keeps a stable name across builds, so a hot asset swap that + * preserves mtime and size could otherwise let the compression cache serve + * stale bytes -- those must be keyed by content instead. + */ +export function isContentHashedAsset(relativePath: string): boolean { const normalized = relativePath.replaceAll("\\", "/").replace(/^\/+/, ""); - return normalized.startsWith("assets/") && HASHED_ASSET_PATTERN.test(normalized) - ? IMMUTABLE_CACHE_CONTROL - : REVALIDATE_CACHE_CONTROL; + return normalized.startsWith("assets/") && HASHED_ASSET_PATTERN.test(normalized); +} + +export function resolveStaticCacheControl(relativePath: string): string { + return isContentHashedAsset(relativePath) ? IMMUTABLE_CACHE_CONTROL : REVALIDATE_CACHE_CONTROL; } /** diff --git a/scripts/classify-deployment-diff.sh b/scripts/classify-deployment-diff.sh index 66c6bea9c6a..7c5f13adc6b 100755 --- a/scripts/classify-deployment-diff.sh +++ b/scripts/classify-deployment-diff.sh @@ -41,6 +41,12 @@ printf 'Changed paths: %d runtime, %d non-runtime\n' \ "${#runtime_paths[@]}" "${#non_runtime_paths[@]}" server=false +# server_code tracks whether the SERVER PROCESS's own code (not just the web +# bundle it serves) changed. apps/web changes leave server=true but keep the +# server hot-swappable: the running process serves dist/client live from disk, +# so new assets can be swapped in without a restart. Only a true server_code +# change forces a process restart. +server_code=false discord=false vscode=false mobile=false @@ -48,6 +54,7 @@ desktop=false select_all() { server=true + server_code=true discord=true vscode=true mobile=true @@ -70,10 +77,13 @@ for path in "${runtime_paths[@]}"; do ;; apps/server/*) server=true + server_code=true ;; apps/web/*) # The web application is served by both standalone servers and packaged - # desktop clients. + # desktop clients. It changes the served bundle but not the server process + # code, so the server can be hot-swapped (assets replaced on disk) rather + # than restarted. Desktop bundles its own copy, so it still rebuilds. server=true desktop=true ;; @@ -95,6 +105,14 @@ if [[ "${server}" == "true" || "${discord}" == "true" || "${vscode}" == "true" | deploy=true fi +# The server target is affected purely through its served web bundle (no server +# process code changed), so ops can hot-swap dist/client on the running server +# instead of restarting it. Desktop, if flagged, still rebuilds independently. +web_hot_swap=false +if [[ "${server}" == "true" && "${server_code}" == "false" ]]; then + web_hot_swap=true +fi + if [[ "${deploy}" == "true" ]]; then printf 'Runtime-affecting paths:\n' printf ' %s\n' "${runtime_paths[@]}" @@ -104,6 +122,7 @@ fi printf 'deploy=%s\n' "${deploy}" printf 'server=%s\n' "${server}" +printf 'web_hot_swap=%s\n' "${web_hot_swap}" printf 'discord=%s\n' "${discord}" printf 'vscode=%s\n' "${vscode}" printf 'mobile=%s\n' "${mobile}" diff --git a/scripts/classify-deployment-diff.test.sh b/scripts/classify-deployment-diff.test.sh index d0c2a00621d..b11346eadb8 100755 --- a/scripts/classify-deployment-diff.test.sh +++ b/scripts/classify-deployment-diff.test.sh @@ -41,10 +41,16 @@ assert_scope apps/discord-bot/src/main.ts $'deploy=true\ndiscord=true\nserver=fa assert_scope apps/vscode/src/extension.ts $'deploy=true\ndiscord=false\nserver=false\nvscode=true\nmobile=false\ndesktop=false' assert_scope apps/mobile/src/App.tsx $'deploy=true\ndiscord=false\nserver=false\nvscode=false\nmobile=true\ndesktop=false' assert_scope apps/desktop/src/main.ts $'deploy=true\ndiscord=false\nserver=false\nvscode=false\nmobile=false\ndesktop=true' -assert_scope apps/server/src/server.ts $'deploy=true\ndiscord=false\nserver=true\nvscode=false\nmobile=false\ndesktop=false' -assert_scope apps/web/src/App.tsx $'deploy=true\ndiscord=false\nserver=true\nvscode=false\nmobile=false\ndesktop=true' -assert_scope packages/client-runtime/src/index.ts $'deploy=true\ndiscord=true\nserver=true\nvscode=true\nmobile=true\ndesktop=true' -assert_scope pnpm-lock.yaml $'deploy=true\ndiscord=true\nserver=true\nvscode=true\nmobile=true\ndesktop=true' -assert_scope docs/deployment.md $'deploy=false\ndiscord=false\nserver=false\nvscode=false\nmobile=false\ndesktop=false' +# Server process code changed -> must restart, not hot-swap. +assert_scope apps/server/src/server.ts $'deploy=true\ndiscord=false\nserver=true\nvscode=false\nmobile=false\ndesktop=false\nweb_hot_swap=false' +# Web-only change -> server target affected purely via its served bundle, so it +# is hot-swappable (assets replaced on the running server); desktop still rebuilds. +assert_scope apps/web/src/App.tsx $'deploy=true\ndiscord=false\nserver=true\nvscode=false\nmobile=false\ndesktop=true\nweb_hot_swap=true' +# Cross-boundary packages force a full restart, so no hot-swap. +assert_scope packages/client-runtime/src/index.ts $'deploy=true\ndiscord=true\nserver=true\nvscode=true\nmobile=true\ndesktop=true\nweb_hot_swap=false' +assert_scope pnpm-lock.yaml $'deploy=true\ndiscord=true\nserver=true\nvscode=true\nmobile=true\ndesktop=true\nweb_hot_swap=false' +# Discord-only change never touches the server, so no web hot-swap either. +assert_scope apps/discord-bot/src/handler.ts $'deploy=true\nserver=false\nweb_hot_swap=false' +assert_scope docs/deployment.md $'deploy=false\ndiscord=false\nserver=false\nvscode=false\nmobile=false\ndesktop=false\nweb_hot_swap=false' echo "deployment classifier tests passed"