Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
47 changes: 38 additions & 9 deletions backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { routes } from "./routes.ts";
import { isProductionDeploymentCutoverActive } from "./services/deploymentCutoverState.ts";
import { validateTotpStorageConfig } from "./services/multiFactorAuth.ts";
import { validateWebAuthnConfig } from "./services/webAuthn.ts";
import { staticFileResponse } from "./staticFileResponse.ts";

interface DashboardSocketData {
closeHandlers: Array<() => void>;
Expand All @@ -45,6 +46,9 @@ interface DashboardSocketRequest {
const SERVER_IDLE_TIMEOUT_SECONDS = 240;
const DEPLOYMENT_CUTOVER_SOCKET_CLOSE_CODE = 1012;
const DEPLOYMENT_CUTOVER_SOCKET_CLOSE_REASON = "Dashboard release cutover in progress";
const IMMUTABLE_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable";
const REVALIDATED_ASSET_CACHE_CONTROL = "no-cache";
const HASHED_ASSET_NAME = /-[\da-z]{8}\.[\da-z]+$/iu;

function dashboardSocketRequest(data: string | Buffer): DashboardSocketRequest {
try {
Expand Down Expand Up @@ -265,21 +269,37 @@ export function createServer(
}
return withRequestSecurity(
request,
await staticResponse(url.pathname),
await staticResponse(request, url.pathname),
server
);
},
websocket,
});
}

async function fileResponse(filePath: string, contentType?: string): Promise<Response> {
const headers: Record<string, string> = { "Cache-Control": "no-store" };
if (contentType) headers["Content-Type"] = contentType;
return new Response(Bun.file(filePath), { headers });
function cacheControlForStaticFile(frontendRoot: string, filePath: string): string {
const relativePath = path.relative(frontendRoot, filePath);
const isHashedAsset =
relativePath.startsWith(`assets${path.sep}`) &&
HASHED_ASSET_NAME.test(path.basename(relativePath));
return isHashedAsset
? IMMUTABLE_ASSET_CACHE_CONTROL
: REVALIDATED_ASSET_CACHE_CONTROL;
}

async function staticResponse(pathname: string): Promise<Response> {
async function fileResponse(
request: Request,
frontendRoot: string,
filePath: string,
contentType?: string
): Promise<Response> {
return staticFileResponse(request, filePath, {
cacheControl: cacheControlForStaticFile(frontendRoot, filePath),
contentType,
});
}

async function staticResponse(request: Request, pathname: string): Promise<Response> {
let decodedPath: string;
try {
decodedPath = decodeURIComponent(pathname.replace(/^\/+/u, "")).replace(
Expand All @@ -296,6 +316,9 @@ async function staticResponse(pathname: string): Promise<Response> {
if (decodedPathname === "/health") {
return new Response("Not found", { status: 404 });
}
if (/\.(?:br|gz)$/iu.test(decodedPathname)) {
return new Response("Not found", { status: 404 });
}

const frontendPath = resolveFrontendPath();
const indexPath = path.join(frontendPath, "index.html");
Expand Down Expand Up @@ -335,7 +358,9 @@ async function staticResponse(pathname: string): Promise<Response> {
!hasHiddenStaticSegment(relativeRealPath)
) {
const stat = await fsp.stat(realDirectPath);
if (stat.isFile()) return fileResponse(realDirectPath);
if (stat.isFile()) {
return fileResponse(request, realRoot, realDirectPath);
}
}
} catch {
// Continue with hashed asset lookup or SPA routing below.
Expand All @@ -357,7 +382,9 @@ async function staticResponse(pathname: string): Promise<Response> {
return new Response("Not found", { status: 404 });
}
const stat = await fsp.stat(realAssetPath);
if (stat.isFile()) return fileResponse(realAssetPath);
if (stat.isFile()) {
return fileResponse(request, realRoot, realAssetPath);
}
} catch {
return new Response("Not found", { status: 404 });
}
Expand All @@ -377,7 +404,9 @@ async function staticResponse(pathname: string): Promise<Response> {
return new Response("Not found", { status: 404 });
}
const stat = await fsp.stat(realIndexPath);
if (stat.isFile()) return fileResponse(realIndexPath, "text/html");
if (stat.isFile()) {
return fileResponse(request, realRoot, realIndexPath, "text/html");
}
} catch {
// Fall through to a generic not-found response.
}
Expand Down
164 changes: 164 additions & 0 deletions backend/src/staticFileResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import type { Stats } from "node:fs";
import fs from "node:fs/promises";

interface StaticFileResponseOptions {
cacheControl: string;
contentType?: string;
}

type ContentEncoding = "br" | "gzip";

interface StaticRepresentation {
contentEncoding?: ContentEncoding;
filePath: string;
stat: Stats;
}

const COMPRESSION_SIDECARS: ReadonlyArray<{
contentEncoding: ContentEncoding;
extension: ".br" | ".gz";
}> = [
{ contentEncoding: "br", extension: ".br" },
{ contentEncoding: "gzip", extension: ".gz" },
];

function encodingQuality(headerValue: string | null, encoding: string): number {
if (!headerValue) return 0;

let explicitQuality: number | undefined;
let wildcardQuality: number | undefined;
for (const item of headerValue.split(",")) {
const [rawEncoding = "", ...parameters] = item.split(";");
const normalizedEncoding = rawEncoding.trim().toLowerCase();
let quality = 1;
for (const parameter of parameters) {
const normalizedParameter = parameter.trim().toLowerCase();
if (normalizedParameter.startsWith("q")) {
const match = normalizedParameter.match(
/^q\s*=\s*(0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/u
);
quality = match ? Number(match[1]) : 0;
}
}
if (normalizedEncoding === encoding) explicitQuality = quality;
if (normalizedEncoding === "*") wildcardQuality = quality;
}

return explicitQuality ?? wildcardQuality ?? 0;
}

async function compressedRepresentations(
filePath: string
): Promise<StaticRepresentation[]> {
const representations: StaticRepresentation[] = [];
for (const { contentEncoding, extension } of COMPRESSION_SIDECARS) {
const sidecarPath = `${filePath}${extension}`;
try {
const stat = await fs.lstat(sidecarPath);
if (stat.isFile()) {
representations.push({
contentEncoding,
filePath: sidecarPath,
stat,
});
}
} catch {
// This representation was not generated for the source file.
}
}
return representations;
}

function preferredRepresentation(
request: Request,
source: StaticRepresentation,
compressed: StaticRepresentation[]
): StaticRepresentation {
return (
compressed
.map((representation, priority) => ({
priority,
quality: encodingQuality(
request.headers.get("accept-encoding"),
representation.contentEncoding ?? ""
),
representation,
}))
.filter(({ quality }) => quality > 0)
.toSorted(
(left, right) =>
right.quality - left.quality || left.priority - right.priority
)[0]?.representation ?? source
);
}

function entityTagFor(representation: StaticRepresentation): string {
const encodingSuffix = representation.contentEncoding
? `-${representation.contentEncoding}`
: "";
return `W/"${representation.stat.size.toString(16)}-${Math.trunc(
representation.stat.mtimeMs
).toString(16)}${encodingSuffix}"`;
}

function normalizedEntityTag(entityTag: string): string {
return entityTag.trim().replace(/^W\//iu, "");
}

function isEntityTagMatch(headerValue: string, entityTag: string): boolean {
const normalizedCurrent = normalizedEntityTag(entityTag);
return headerValue.split(",").some((candidate) => {
const trimmed = candidate.trim();
return trimmed === "*" || normalizedEntityTag(trimmed) === normalizedCurrent;
});
}

function isNotModified(request: Request, entityTag: string, modifiedAt: Date): boolean {
if (!["GET", "HEAD"].includes(request.method.toUpperCase())) return false;

const ifNoneMatch = request.headers.get("if-none-match");
if (ifNoneMatch !== null) {
return isEntityTagMatch(ifNoneMatch, entityTag);
}

const ifModifiedSince = request.headers.get("if-modified-since");
if (!ifModifiedSince) return false;
const conditionalTimestamp = Date.parse(ifModifiedSince);
return (
Number.isFinite(conditionalTimestamp) &&
Math.floor(modifiedAt.getTime() / 1000) <= Math.floor(conditionalTimestamp / 1000)
);
}

/**
* Serves a verified static file with conditional requests and negotiated
* precompressed representations.
*/
export async function staticFileResponse(
request: Request,
filePath: string,
{ cacheControl, contentType }: StaticFileResponseOptions
): Promise<Response> {
const sourceStat = await fs.stat(filePath);
const source: StaticRepresentation = { filePath, stat: sourceStat };
const compressed = await compressedRepresentations(filePath);
const representation = preferredRepresentation(request, source, compressed);
const entityTag = entityTagFor(representation);
const headers = new Headers({
"Cache-Control": cacheControl,
ETag: entityTag,
"Last-Modified": sourceStat.mtime.toUTCString(),
});

const resolvedContentType = contentType ?? Bun.file(filePath).type;
if (resolvedContentType) headers.set("Content-Type", resolvedContentType);
if (compressed.length > 0) headers.set("Vary", "Accept-Encoding");
if (representation.contentEncoding) {
headers.set("Content-Encoding", representation.contentEncoding);
}

if (isNotModified(request, entityTag, sourceStat.mtime)) {
return new Response(undefined, { headers, status: 304 });
}
return new Response(Bun.file(representation.filePath), { headers });
}
14 changes: 13 additions & 1 deletion backend/test/bunNativeServerBehavior.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,9 @@ describe("Bun-native dashboard backend", () => {
const appRoute = await fetch(`${state.baseUrl}/tasks`);
expect(appRoute.status).toBe(200);
expect(appRoute.headers.get("content-type")).toContain("text/html");
expect(appRoute.headers.get("cache-control")).toBe("no-cache");
expect(appRoute.headers.get("etag")).toBeTruthy();
expect(appRoute.headers.get("last-modified")).toBeTruthy();
expect(appRoute.headers.get("content-security-policy")).toContain(
`connect-src 'self' ${state.baseUrl.replace(/^http/u, "ws")}`
);
Expand All @@ -530,11 +533,20 @@ describe("Bun-native dashboard backend", () => {

const rootChunk = await fetch(`${state.baseUrl}/index-fixture.js`);
expect(rootChunk.status).toBe(200);
expect(rootChunk.headers.get("cache-control")).toBe("no-store");
expect(rootChunk.headers.get("cache-control")).toBe("no-cache");
expect(rootChunk.headers.get("etag")).toBeTruthy();
expect(rootChunk.headers.get("last-modified")).toBeTruthy();
expect(rootChunk.headers.get("x-request-id")).not.toBe(
appRoute.headers.get("x-request-id")
);

const cachedRootChunk = await fetch(`${state.baseUrl}/index-fixture.js`, {
headers: {
"If-None-Match": rootChunk.headers.get("etag") ?? "",
},
});
expect(cachedRootChunk.status).toBe(304);

const missingChunk = await fetch(
`${state.baseUrl}/assets/index-missing-after-deploy.js`
);
Expand Down
Loading