Skip to content

Commit 39ce7fa

Browse files
committed
fix(server): validate static cache files before streaming
1 parent 8f977fc commit 39ce7fa

3 files changed

Lines changed: 271 additions & 16 deletions

File tree

‎apps/server/src/http.ts‎

Lines changed: 83 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import * as FileSystem from "effect/FileSystem";
1212
import * as Layer from "effect/Layer";
1313
import * as Option from "effect/Option";
1414
import * as Path from "effect/Path";
15+
import * as Schema from "effect/Schema";
1516
import * as Stream from "effect/Stream";
1617
import { cast } from "effect/Function";
1718
import {
@@ -437,10 +438,66 @@ export const attachmentUploadRouteLayer = HttpRouter.add(
437438
}),
438439
);
439440

440-
export const staticAndDevRouteLayer = HttpRouter.add(
441-
"GET",
442-
"*",
443-
Effect.gen(function* () {
441+
const decodeBuildManifest = Schema.decodeUnknownEffect(
442+
Schema.fromJsonString(
443+
Schema.Record(
444+
Schema.String,
445+
Schema.Struct({
446+
file: Schema.String,
447+
css: Schema.optional(Schema.Array(Schema.String)),
448+
assets: Schema.optional(Schema.Array(Schema.String)),
449+
}),
450+
),
451+
),
452+
);
453+
454+
const loadImmutableBuildAssets = Effect.gen(function* () {
455+
const config = yield* ServerConfig.ServerConfig;
456+
const staticDir =
457+
config.staticDir ?? (config.devUrl ? yield* ServerConfig.resolveStaticDir() : undefined);
458+
if (!staticDir) return new Set<string>();
459+
const fileSystem = yield* FileSystem.FileSystem;
460+
const path = yield* Path.Path;
461+
return yield* fileSystem.readFileString(path.join(staticDir, ".vite", "manifest.json")).pipe(
462+
Effect.flatMap(decodeBuildManifest),
463+
Effect.map(
464+
(manifest) =>
465+
new Set(
466+
Object.values(manifest).flatMap((entry) => [
467+
entry.file,
468+
...(entry.css ?? []),
469+
...(entry.assets ?? []),
470+
]),
471+
),
472+
),
473+
Effect.orElseSucceed(() => new Set<string>()),
474+
);
475+
});
476+
477+
const openStaticFile = Effect.fn("openStaticFile")(function* (filePath: string) {
478+
const fileSystem = yield* FileSystem.FileSystem;
479+
// Reject directories and special files before opening. Response metadata comes from the handle.
480+
const pathInfo = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null));
481+
if (pathInfo?.type !== "File") return null;
482+
const file = yield* fileSystem.open(filePath, { flag: "r" });
483+
const info = yield* file.stat;
484+
return info.type === "File" ? { file, info } : null;
485+
});
486+
487+
const streamStaticFile = (file: FileSystem.File, size: bigint) =>
488+
Stream.unfold(
489+
0n,
490+
Effect.fnUntraced(function* (offset: bigint) {
491+
if (offset >= size) return;
492+
const remaining = size - offset;
493+
const bytes = yield* file.readAlloc(remaining < 65_536n ? remaining : 65_536n);
494+
if (Option.isNone(bytes)) return;
495+
return [bytes.value, offset + BigInt(bytes.value.byteLength)] as const;
496+
}),
497+
);
498+
499+
const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")(
500+
function* (immutableBuildAssets: ReadonlySet<string>) {
444501
const request = yield* HttpServerRequest.HttpServerRequest;
445502
const url = HttpServerRequest.toURL(request);
446503

@@ -467,7 +524,6 @@ export const staticAndDevRouteLayer = HttpRouter.add(
467524
});
468525
}
469526

470-
const fileSystem = yield* FileSystem.FileSystem;
471527
const path = yield* Path.Path;
472528
const staticRoot = path.resolve(staticDir);
473529
const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname;
@@ -501,19 +557,20 @@ export const staticAndDevRouteLayer = HttpRouter.add(
501557
}
502558
}
503559

504-
let fileInfo = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null));
505-
if (!fileInfo || fileInfo.type !== "File") {
560+
let opened = yield* openStaticFile(filePath);
561+
if (!opened) {
506562
filePath = path.resolve(staticRoot, "index.html");
507-
fileInfo = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null));
508-
if (!fileInfo || fileInfo.type !== "File") {
563+
opened = yield* openStaticFile(filePath);
564+
if (!opened) {
509565
return HttpServerResponse.text("Not Found", { status: 404 });
510566
}
511567
}
568+
const fileInfo = opened.info;
512569

513-
// Only Vite's content-hashed assets are immutable. Decide from the served
514-
// file so a missing old chunk cannot cache the SPA fallback for a year.
570+
// A hash-like name is not enough: custom static files can use the same naming pattern.
515571
const relativePath = path.relative(staticRoot, filePath).replaceAll("\\", "/");
516-
const immutable = /^assets\/.+-[\w-]{8}\.[^/]+$/.test(relativePath);
572+
const immutable =
573+
/^assets\/.+-[\w-]{8}\.[^/]+$/.test(relativePath) && immutableBuildAssets.has(relativePath);
517574
const headers: Record<string, string> = {
518575
"Cache-Control": immutable ? "public, max-age=31536000, immutable" : "no-cache",
519576
};
@@ -553,12 +610,22 @@ export const staticAndDevRouteLayer = HttpRouter.add(
553610
path.extname(filePath) === ".html"
554611
? "text/html; charset=utf-8"
555612
: (Mime.getType(filePath) ?? "application/octet-stream");
556-
// The server omits HEAD bodies after response middleware, so compression
557-
// can select the same headers as GET without opening the lazy file stream.
558-
return HttpServerResponse.stream(fileSystem.stream(filePath), {
613+
// The request scope closes the handle for GET, HEAD, 304, errors, and cancellation.
614+
// HEAD still passes through compression, which selects headers without reading the stream.
615+
return HttpServerResponse.stream(streamStaticFile(opened.file, fileInfo.size), {
559616
headers,
560617
contentType,
561618
contentLength: Number(fileInfo.size),
562619
});
563-
}),
620+
},
621+
Effect.catchTag("PlatformError", () =>
622+
Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
623+
),
624+
);
625+
626+
// Read the installed build's manifest once. Unknown files use revalidation.
627+
export const staticAndDevRouteLayer = Layer.unwrap(
628+
loadImmutableBuildAssets.pipe(
629+
Effect.map((assets) => HttpRouter.add("GET", "*", handleStaticAndDevRequest(assets))),
630+
),
564631
);

‎apps/server/src/server.test.ts‎

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1700,6 +1700,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
17001700
const path = yield* Path.Path;
17011701
const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-hashes-" });
17021702
yield* fileSystem.makeDirectory(path.join(staticDir, "assets"));
1703+
yield* fileSystem.makeDirectory(path.join(staticDir, ".vite"));
1704+
yield* fileSystem.writeFileString(
1705+
path.join(staticDir, ".vite", "manifest.json"),
1706+
`{
1707+
"index.html": { "file": "assets/index-AbCd0123.js", "isEntry": true },
1708+
"large.js": { "file": "assets/large-aBcD9876.js" }
1709+
}`,
1710+
);
17031711
yield* fileSystem.writeFileString(path.join(staticDir, "index.html"), "<html>app</html>");
17041712
yield* fileSystem.writeFileString(
17051713
path.join(staticDir, "assets", "index-AbCd0123.js"),
@@ -1764,6 +1772,185 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
17641772
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
17651773
);
17661774

1775+
for (const manifest of [
1776+
{ label: "missing", contents: null },
1777+
{ label: "nonmatching", contents: '{"other.js":{"file":"assets/other-AbCd0123.js"}}' },
1778+
{ label: "malformed", contents: "{not-json" },
1779+
]) {
1780+
it.effect(`revalidates hash-like static filenames with a ${manifest.label} manifest`, () =>
1781+
Effect.gen(function* () {
1782+
const fileSystem = yield* FileSystem.FileSystem;
1783+
const path = yield* Path.Path;
1784+
const staticDir = yield* fileSystem.makeTempDirectoryScoped({
1785+
prefix: "t3-static-mutable-",
1786+
});
1787+
yield* fileSystem.makeDirectory(path.join(staticDir, "assets"));
1788+
if (manifest.contents !== null) {
1789+
yield* fileSystem.makeDirectory(path.join(staticDir, ".vite"));
1790+
yield* fileSystem.writeFileString(
1791+
path.join(staticDir, ".vite", "manifest.json"),
1792+
manifest.contents,
1793+
);
1794+
}
1795+
const filePath = path.join(staticDir, "assets", "config-20260904.js");
1796+
yield* fileSystem.writeFileString(filePath, "first config");
1797+
yield* buildAppUnderTest({ config: { staticDir } });
1798+
1799+
const initial = yield* HttpClient.get("/assets/config-20260904.js");
1800+
assert.equal(initial.headers["cache-control"], "no-cache");
1801+
assert.equal(yield* initial.text, "first config");
1802+
1803+
yield* fileSystem.writeFileString(filePath, "replacement config");
1804+
const changed = yield* HttpClient.get("/assets/config-20260904.js", {
1805+
headers: { "if-none-match": initial.headers.etag! },
1806+
});
1807+
assert.equal(changed.status, 200);
1808+
assert.equal(changed.headers["cache-control"], "no-cache");
1809+
assert.notEqual(changed.headers.etag, initial.headers.etag);
1810+
assert.equal(yield* changed.text, "replacement config");
1811+
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
1812+
);
1813+
}
1814+
1815+
it.effect("binds static metadata and bytes to one file across atomic replacement", () =>
1816+
Effect.gen(function* () {
1817+
const fileSystem = yield* FileSystem.FileSystem;
1818+
const path = yield* Path.Path;
1819+
const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-replace-" });
1820+
const beforeOpenPath = path.join(staticDir, "before-open.txt");
1821+
const afterOpenPath = path.join(staticDir, "after-open.txt");
1822+
const original = "original bytes";
1823+
const replacement = "replacement bytes with a different size";
1824+
for (const filePath of [beforeOpenPath, afterOpenPath]) {
1825+
yield* fileSystem.writeFileString(filePath, original);
1826+
yield* fileSystem.writeFileString(`${filePath}.next`, replacement);
1827+
}
1828+
const replaced = new Set<string>();
1829+
const replaceOnce = Effect.fnUntraced(function* (filePath: string) {
1830+
if (replaced.has(filePath)) return;
1831+
replaced.add(filePath);
1832+
yield* fileSystem.rename(`${filePath}.next`, filePath);
1833+
});
1834+
const replacingFileSystem = FileSystem.FileSystem.of({
1835+
...fileSystem,
1836+
stat: (filePath) =>
1837+
fileSystem
1838+
.stat(filePath)
1839+
.pipe(
1840+
Effect.tap(() => (filePath === beforeOpenPath ? replaceOnce(filePath) : Effect.void)),
1841+
),
1842+
open: (filePath, options) =>
1843+
fileSystem
1844+
.open(filePath, options)
1845+
.pipe(
1846+
Effect.tap(() => (filePath === afterOpenPath ? replaceOnce(filePath) : Effect.void)),
1847+
),
1848+
});
1849+
yield* buildAppUnderTest({ config: { staticDir } }).pipe(
1850+
Effect.provideService(FileSystem.FileSystem, replacingFileSystem),
1851+
);
1852+
1853+
for (const [name, expected] of [
1854+
["before-open.txt", replacement],
1855+
["after-open.txt", original],
1856+
] as const) {
1857+
const response = yield* HttpClient.get(`/${name}`, {
1858+
headers: { "accept-encoding": "identity" },
1859+
});
1860+
assert.equal(response.status, 200);
1861+
assert.equal(response.headers["content-length"], String(expected.length));
1862+
assert.isTrue(response.headers.etag?.startsWith(`W/"${expected.length.toString(16)}-`));
1863+
assert.equal(yield* response.text, expected);
1864+
assert.isTrue(replaced.has(path.join(staticDir, name)));
1865+
assert.equal(yield* fileSystem.readFileString(path.join(staticDir, name)), replacement);
1866+
}
1867+
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
1868+
);
1869+
1870+
it.effect("closes static file handles after GET, HEAD, 304, and request cancellation", () =>
1871+
Effect.gen(function* () {
1872+
const fileSystem = yield* FileSystem.FileSystem;
1873+
const path = yield* Path.Path;
1874+
const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-close-" });
1875+
const filePath = path.join(staticDir, "index.html");
1876+
const body = "<p>file content</p>".repeat(1024);
1877+
yield* fileSystem.writeFileString(filePath, body);
1878+
const closed = yield* Queue.unbounded<FileSystem.File>();
1879+
const blocked = yield* Deferred.make<void>();
1880+
const active = new Set<FileSystem.File>();
1881+
let blockAfterOpen = false;
1882+
let bodyReads = 0;
1883+
const trackedFileSystem = FileSystem.FileSystem.of({
1884+
...fileSystem,
1885+
open: (candidate, options) =>
1886+
Effect.gen(function* () {
1887+
if (candidate !== filePath) return yield* fileSystem.open(candidate, options);
1888+
let opened: FileSystem.File | undefined;
1889+
// Registered first, so this signal runs after the real descriptor-close finalizer.
1890+
yield* Effect.addFinalizer(() =>
1891+
Effect.gen(function* () {
1892+
if (opened === undefined) return;
1893+
active.delete(opened);
1894+
yield* Queue.offer(closed, opened);
1895+
}),
1896+
);
1897+
const file = yield* fileSystem.open(candidate, options);
1898+
opened = file;
1899+
active.add(file);
1900+
if (blockAfterOpen) {
1901+
yield* Deferred.succeed(blocked, undefined);
1902+
return yield* Effect.never;
1903+
}
1904+
return new Proxy(file, {
1905+
get(target, key) {
1906+
if (key === "readAlloc") {
1907+
return (size: FileSystem.SizeInput) => {
1908+
bodyReads += 1;
1909+
return target.readAlloc(size);
1910+
};
1911+
}
1912+
return Reflect.get(target, key, target);
1913+
},
1914+
});
1915+
}),
1916+
});
1917+
yield* buildAppUnderTest({ config: { staticDir } }).pipe(
1918+
Effect.provideService(FileSystem.FileSystem, trackedFileSystem),
1919+
);
1920+
1921+
const get = yield* HttpClient.get("/");
1922+
assert.equal(yield* get.text, body);
1923+
yield* Queue.take(closed);
1924+
assert.equal(active.size, 0);
1925+
assert.isAbove(bodyReads, 0);
1926+
const readsAfterGet = bodyReads;
1927+
1928+
const head = yield* HttpClient.head("/", { headers: { "accept-encoding": "gzip" } });
1929+
assert.equal(head.status, 200);
1930+
assert.equal(head.headers["content-encoding"], "gzip");
1931+
assert.equal(yield* head.text, "");
1932+
yield* Queue.take(closed);
1933+
assert.equal(active.size, 0);
1934+
assert.equal(bodyReads, readsAfterGet);
1935+
1936+
const unchanged = yield* HttpClient.get("/", {
1937+
headers: { "if-none-match": get.headers.etag! },
1938+
});
1939+
assert.equal(unchanged.status, 304);
1940+
yield* Queue.take(closed);
1941+
assert.equal(active.size, 0);
1942+
assert.equal(bodyReads, readsAfterGet);
1943+
1944+
blockAfterOpen = true;
1945+
const cancelled = yield* HttpClient.get("/").pipe(Effect.forkChild);
1946+
yield* Deferred.await(blocked);
1947+
assert.equal(active.size, 1);
1948+
yield* Fiber.interrupt(cancelled);
1949+
yield* Queue.take(closed);
1950+
assert.equal(active.size, 0);
1951+
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
1952+
);
1953+
17671954
it.effect("redirects to dev URL when configured", () =>
17681955
Effect.gen(function* () {
17691956
yield* buildAppUnderTest({

‎apps/web/vite.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ export default defineConfig(() => {
268268
build: {
269269
outDir: "dist",
270270
emptyOutDir: true,
271+
manifest: true,
271272
sourcemap: buildSourcemap,
272273
},
273274
test: {

0 commit comments

Comments
 (0)