Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./ht
import {
contentCacheKey,
isCompressibleContentType,
isContentHashedAsset,
makeStaticCompressionCache,
negotiateStaticEncoding,
resolveStaticCacheControl,
Expand Down Expand Up @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/staticAssetDelivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
contentCacheKey,
isCompressibleContentType,
isContentHashedAsset,
makeStaticCompressionCache,
negotiateStaticEncoding,
resolveStaticCacheControl,
Expand All @@ -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(
Expand Down
18 changes: 14 additions & 4 deletions apps/server/src/staticAssetDelivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
21 changes: 20 additions & 1 deletion scripts/classify-deployment-diff.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,20 @@ 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
desktop=false

select_all() {
server=true
server_code=true
discord=true
vscode=true
mobile=true
Expand All @@ -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
;;
Expand All @@ -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[@]}"
Expand All @@ -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}"
Expand Down
16 changes: 11 additions & 5 deletions scripts/classify-deployment-diff.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading