diff --git a/backend/src/releaseManifest.ts b/backend/src/releaseManifest.ts index 6fa16524d..a32c8a765 100644 --- a/backend/src/releaseManifest.ts +++ b/backend/src/releaseManifest.ts @@ -736,14 +736,29 @@ function fallbackGitCommit(releaseRoot: string): string { } } +function developmentRuntimeReleaseIdentity(releaseRoot: string): RuntimeReleaseIdentity { + const commit = fallbackGitCommit(releaseRoot); + return { + backendCommit: commit, + frontendCommit: commit, + ready: true, + source: commit === "unknown" ? "unknown" : "git", + }; +} + export async function loadRuntimeReleaseIdentity( releaseRoot = PROCESS_RELEASE_ROOT, environment = process.env.NODE_ENV, backendBuildCommit = getBackendBuildCommit() ): Promise { + if (environment !== "production" && backendBuildCommit === "development") { + return developmentRuntimeReleaseIdentity(releaseRoot); + } + let isLoadingManifest = true; try { const realReleaseRoot = await fsp.realpath(releaseRoot); const manifest = await loadReleaseManifest(realReleaseRoot); + isLoadingManifest = false; await verifyReleaseArtifacts(realReleaseRoot, manifest); try { await verifyReleaseBuildIdentities(realReleaseRoot, manifest); @@ -786,18 +801,20 @@ export async function loadRuntimeReleaseIdentity( source: "manifest", }; } catch (error) { + const isManifestMissing = + isLoadingManifest && (error as NodeJS.ErrnoException).code === "ENOENT"; + const isDevelopmentFallback = environment !== "production" && isManifestMissing; + if (isDevelopmentFallback) { + return developmentRuntimeReleaseIdentity(releaseRoot); + } const commit = fallbackGitCommit(releaseRoot); - const isMissing = (error as NodeJS.ErrnoException).code === "ENOENT"; - const isDevelopmentFallback = environment !== "production" && isMissing; return { backendCommit: commit, frontendCommit: commit, - ...(!isDevelopmentFallback && { - issue: isMissing - ? ("manifest-missing" as const) - : ("manifest-invalid" as const), - }), - ready: isDevelopmentFallback, + issue: isManifestMissing + ? ("manifest-missing" as const) + : ("manifest-invalid" as const), + ready: false, source: commit === "unknown" ? "unknown" : "git", }; } diff --git a/backend/src/server.ts b/backend/src/server.ts index 19fee468c..6414313ff 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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>; @@ -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 { @@ -265,7 +269,7 @@ export function createServer( } return withRequestSecurity( request, - await staticResponse(url.pathname), + await staticResponse(request, url.pathname), server ); }, @@ -273,13 +277,29 @@ export function createServer( }); } -async function fileResponse(filePath: string, contentType?: string): Promise { - const headers: Record = { "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 { +async function fileResponse( + request: Request, + frontendRoot: string, + filePath: string, + contentType?: string +): Promise { + return staticFileResponse(request, filePath, { + cacheControl: cacheControlForStaticFile(frontendRoot, filePath), + contentType, + }); +} + +async function staticResponse(request: Request, pathname: string): Promise { let decodedPath: string; try { decodedPath = decodeURIComponent(pathname.replace(/^\/+/u, "")).replace( @@ -296,6 +316,9 @@ async function staticResponse(pathname: string): Promise { 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"); @@ -335,7 +358,9 @@ async function staticResponse(pathname: string): Promise { !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. @@ -357,7 +382,9 @@ async function staticResponse(pathname: string): Promise { 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 }); } @@ -377,7 +404,9 @@ async function staticResponse(pathname: string): Promise { 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. } diff --git a/backend/src/services/pullRequestPreviewHost.ts b/backend/src/services/pullRequestPreviewHost.ts index c16c1a984..971f72bdd 100644 --- a/backend/src/services/pullRequestPreviewHost.ts +++ b/backend/src/services/pullRequestPreviewHost.ts @@ -12,6 +12,7 @@ import { readSync, realpathSync, renameSync, + rmdirSync, rmSync, writeFileSync, } from "node:fs"; @@ -596,7 +597,7 @@ function previewWorktreePath(config: PullRequestPreviewConfig): string { return config.managedWorktreePath; } -async function unregisterMissingPreviewWorktree( +async function unregisterPreviewWorktreeIfRegistered( config: PullRequestPreviewConfig, worktreePath: string, signal?: AbortSignal @@ -641,7 +642,11 @@ async function removePreviewWorktree( throw new Error("Refusing to remove an unmanaged preview worktree"); } if (!existsSync(resolvedWorktreePath)) { - return unregisterMissingPreviewWorktree(config, resolvedWorktreePath, signal); + return unregisterPreviewWorktreeIfRegistered( + config, + resolvedWorktreePath, + signal + ); } if (!isRealDirectory(resolvedWorktreePath)) { throw new Error("Preview worktree path must be a real directory"); @@ -709,6 +714,14 @@ async function ensurePreviewWorktree( if (!isRealDirectory(worktreePath)) { throw new Error("Preview worktree path must be a real directory"); } + if (readdirSync(worktreePath).length === 0) { + await unregisterPreviewWorktreeIfRegistered(config, worktreePath, signal); + if (existsSync(worktreePath)) { + rmdirSync(worktreePath); + } + } + } + if (existsSync(worktreePath)) { const { stdout: registeredRoot } = await runCommand( "git", ["-C", worktreePath, "rev-parse", "--show-toplevel"], @@ -732,7 +745,7 @@ async function ensurePreviewWorktree( signal, }); } else { - await unregisterMissingPreviewWorktree(config, worktreePath, signal); + await unregisterPreviewWorktreeIfRegistered(config, worktreePath, signal); await runCommand( "git", [ diff --git a/backend/src/staticFileResponse.ts b/backend/src/staticFileResponse.ts new file mode 100644 index 000000000..d21517357 --- /dev/null +++ b/backend/src/staticFileResponse.ts @@ -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 { + 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 { + 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 }); +} diff --git a/backend/test/bunNativeServerBehavior.test.ts b/backend/test/bunNativeServerBehavior.test.ts index 079ab5382..4d7f75b34 100644 --- a/backend/test/bunNativeServerBehavior.test.ts +++ b/backend/test/bunNativeServerBehavior.test.ts @@ -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")}` ); @@ -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` ); diff --git a/backend/test/httpApiBehavior.test.ts b/backend/test/httpApiBehavior.test.ts index 0b6d72083..3ece11697 100644 --- a/backend/test/httpApiBehavior.test.ts +++ b/backend/test/httpApiBehavior.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { brotliCompressSync, gzipSync } from "node:zlib"; import type { Server } from "bun"; import { afterAll, beforeAll, describe, expect, it } from "bun:test"; @@ -266,14 +267,17 @@ describe("Mira Dashboard backend integration", () => { ); await fs.writeFile(composeWrapper, "#!/bin/sh\nprintf 'compose:%s\\n' \"$*\"\n"); await fs.chmod(composeWrapper, 0o755); - await fs.writeFile( - path.join(frontendRoot, "index.html"), - '
' - ); - await fs.writeFile( - path.join(frontendRoot, "assets", "index-fixture.js"), - "export const isOk = true;\n" - ); + const frontendIndexPath = path.join(frontendRoot, "index.html"); + const frontendIndex = + '
'; + await fs.writeFile(frontendIndexPath, frontendIndex); + await fs.writeFile(`${frontendIndexPath}.br`, brotliCompressSync(frontendIndex)); + await fs.writeFile(`${frontendIndexPath}.gz`, gzipSync(frontendIndex)); + const frontendChunkPath = path.join(frontendRoot, "assets", "index-a1b2c3d4.js"); + const frontendChunk = "export const isOk = true;\n".repeat(32); + await fs.writeFile(frontendChunkPath, frontendChunk); + await fs.writeFile(`${frontendChunkPath}.br`, brotliCompressSync(frontendChunk)); + await fs.writeFile(`${frontendChunkPath}.gz`, gzipSync(frontendChunk)); process.env.MIRA_DASHBOARD_DB_PATH = path.join( testState.temporaryRoot, @@ -423,18 +427,62 @@ describe("Mira Dashboard backend integration", () => { }); it("serves the app shell only for app routes, not missing assets", async () => { - const appRoute = await fetch(`${testState.baseUrl}/tasks`); + const appRoute = await fetch(`${testState.baseUrl}/tasks`, { + headers: { "Accept-Encoding": "br, gzip" }, + }); 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("content-encoding")).toBe("br"); + expect(appRoute.headers.get("vary")).toContain("Accept-Encoding"); + expect(appRoute.headers.get("etag")).toBeTruthy(); + expect(appRoute.headers.get("last-modified")).toBeTruthy(); + expect(await appRoute.text()).toContain('
'); + const revalidatedAppRoute = await fetch(`${testState.baseUrl}/tasks`, { + headers: { + "Accept-Encoding": "br, gzip", + "If-Modified-Since": appRoute.headers.get("last-modified") ?? "", + }, + }); + expect(revalidatedAppRoute.status).toBe(304); const assetsPath = path.join(testState.temporaryRoot, "frontend", "assets"); const builtAssets = await fs.readdir(assetsPath); - const builtChunk = builtAssets.find((file) => /^index-.+\.js$/u.test(file)); + const builtChunk = builtAssets.find((file) => + /^index-[\da-z]{8}\.js$/u.test(file) + ); expect(builtChunk).toBeDefined(); - const rootChunk = await fetch(`${testState.baseUrl}/assets/${builtChunk}`); + const chunkUrl = `${testState.baseUrl}/assets/${builtChunk}`; + const rootChunk = await fetch(chunkUrl, { + headers: { "Accept-Encoding": "br, gzip" }, + }); expect(rootChunk.status).toBe(200); - expect(rootChunk.headers.get("cache-control")).toBe("no-store"); + expect(rootChunk.headers.get("cache-control")).toBe( + "public, max-age=31536000, immutable" + ); + expect(rootChunk.headers.get("content-encoding")).toBe("br"); + expect(rootChunk.headers.get("vary")).toContain("Accept-Encoding"); + expect(rootChunk.headers.get("etag")).toBeTruthy(); + expect(rootChunk.headers.get("last-modified")).toBeTruthy(); + expect(await rootChunk.text()).toContain("export const isOk = true"); + + const cachedChunk = await fetch(chunkUrl, { + headers: { + "Accept-Encoding": "br, gzip", + "If-None-Match": rootChunk.headers.get("etag") ?? "", + }, + }); + expect(cachedChunk.status).toBe(304); + + const gzipChunk = await fetch(chunkUrl, { + headers: { "Accept-Encoding": "br;q=0, gzip;q=1" }, + }); + expect(gzipChunk.headers.get("content-encoding")).toBe("gzip"); + expect(await gzipChunk.text()).toContain("export const isOk = true"); + + const directSidecar = await fetch(`${chunkUrl}.br`); + expect(directSidecar.status).toBe(404); const missingChunk = await fetch( `${testState.baseUrl}/assets/index-missing-after-deploy.js` diff --git a/backend/test/pullRequestPreview.test.ts b/backend/test/pullRequestPreview.test.ts index 940b48df3..7d58a5928 100644 --- a/backend/test/pullRequestPreview.test.ts +++ b/backend/test/pullRequestPreview.test.ts @@ -511,11 +511,12 @@ describe("managed pull request preview", () => { let didProxyReceiveDisposableToken = false; let didProxyReceiveUpstreamToken = false; let didProxyStartWithStartingRecord = false; - let isMissingWorktreeRegistered = true; + let isMissingWorktreeRegistered = false; const activeUnits = new Set(); const commands: string[] = []; mkdirSync(config.dashboardRoot, { recursive: true }); mkdirSync(config.gitCommonDirectory, { recursive: true }); + mkdirSync(worktreePath, { recursive: true }); chmodSync(root, 0o755); const processSpy = jest @@ -628,6 +629,13 @@ describe("managed pull request preview", () => { executable === "git" && commandArguments.includes("--show-toplevel") ) { + if (readdirSync(worktreePath).length === 0) { + return { + code: 128, + stderr: "fatal: not a git repository (or any of the parent directories): .git", + stdout: "", + }; + } return { code: 0, stderr: "", stdout: `${worktreePath}\n` }; } if (executable === "git" && commandArguments.includes("status")) { @@ -707,16 +715,10 @@ describe("managed pull request preview", () => { url: "https://preview-node.ts.net:5173", }); expect(statSync(root).mode & 0o777).toBe(0o755); - const staleRemovalIndex = commands.findIndex((command) => - command.includes( - `worktree remove --force --force ${config.managedWorktreePath}` - ) - ); const worktreeAddIndex = commands.findIndex((command) => command.includes(`worktree add --detach ${config.managedWorktreePath}`) ); - expect(staleRemovalIndex).toBeGreaterThanOrEqual(0); - expect(worktreeAddIndex).toBeGreaterThan(staleRemovalIndex); + expect(worktreeAddIndex).toBeGreaterThanOrEqual(0); expect(prepareStateSpy).toHaveBeenCalledTimes(1); expect(protectFromCancellation).toHaveBeenCalledTimes(1); expect(fetchSpy).toHaveBeenCalledWith( @@ -817,6 +819,8 @@ describe("managed pull request preview", () => { status: "stopped", }); + rmSync(worktreePath, { force: true, recursive: true }); + isMissingWorktreeRegistered = true; await expect( startPullRequestPreview(candidate, { config, @@ -826,6 +830,16 @@ describe("managed pull request preview", () => { commitSha: COMMIT, status: "running", }); + const staleRemovalIndex = commands.findIndex((command) => + command.includes( + `worktree remove --force --force ${config.managedWorktreePath}` + ) + ); + const recreatedWorktreeIndex = commands.findLastIndex((command) => + command.includes(`worktree add --detach ${config.managedWorktreePath}`) + ); + expect(staleRemovalIndex).toBeGreaterThanOrEqual(0); + expect(recreatedWorktreeIndex).toBeGreaterThan(staleRemovalIndex); expect(prepareStateSpy).toHaveBeenCalledTimes(3); await expect( startPullRequestPreview( diff --git a/backend/test/releaseManifest.test.ts b/backend/test/releaseManifest.test.ts index ade083904..472d6bb4d 100644 --- a/backend/test/releaseManifest.test.ts +++ b/backend/test/releaseManifest.test.ts @@ -536,6 +536,12 @@ describe("Dashboard release manifest", () => { issue: "manifest-invalid", ready: false, }); + await expect( + loadRuntimeReleaseIdentity(root, "test", TEST_COMMIT) + ).resolves.toMatchObject({ + issue: "manifest-invalid", + ready: false, + }); }); it("coalesces readiness verification and revalidates after invalidation", async () => { @@ -580,4 +586,22 @@ describe("Dashboard release manifest", () => { ready: true, }); }); + + it("uses the Git identity for source development when a generated manifest remains", async () => { + const root = temporaryReleaseRoot(); + await writeReleaseManifest(manifestOptions(root)); + runGit(root, ["init", "--initial-branch=main"]); + runGit(root, ["add", "."]); + runGit(root, ["commit", "-m", "Test development source"]); + const commit = runGit(root, ["rev-parse", "--short=8", "HEAD"]); + + await expect( + loadRuntimeReleaseIdentity(root, "development", "development") + ).resolves.toEqual({ + backendCommit: commit, + frontendCommit: commit, + ready: true, + source: "git", + }); + }); }); diff --git a/index.html b/index.html index e157c7c04..a06002b5f 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + diff --git a/public/favicon.png b/public/favicon.png index ef92187b8..18b0eaf82 100644 Binary files a/public/favicon.png and b/public/favicon.png differ diff --git a/scripts/frontendBuild.ts b/scripts/frontendBuild.ts index 5fa247ab5..64b7436f4 100644 --- a/scripts/frontendBuild.ts +++ b/scripts/frontendBuild.ts @@ -7,6 +7,11 @@ import { isReleaseBuildCommit, resolveBuildSourceIdentity, } from "../backend/scripts/buildSourceIdentity.ts"; +import { + assertFrontendBundleBudgets, + measureFrontendBundle, + writePrecompressedFrontendAssets, +} from "./frontendBuildArtifacts"; import reactCompilerPlugin from "./reactCompilerPlugin"; type FrontendBuildMode = "development" | "production"; @@ -55,6 +60,7 @@ export async function buildFrontend({ entrypoints: ["./index.html"], env: "PUBLIC_*", minify: isProduction, + metafile: true, naming: { asset: "assets/[name]-[hash].[ext]", chunk: "assets/[name]-[hash].[ext]", @@ -74,6 +80,9 @@ export async function buildFrontend({ if (!result.success) { throw new AggregateError(result.logs, "Frontend build failed"); } + if (!result.metafile) { + throw new Error("Frontend build did not produce bundle metadata"); + } await writeFile( path.join(resolvedOutdir, "build-identity.json"), @@ -88,4 +97,22 @@ export async function buildFrontend({ 2 )}\n` ); + + if (isProduction) { + const bundleMetrics = await measureFrontendBundle( + result.metafile, + resolvedOutdir + ); + await writeFile( + path.join(resolvedOutdir, "frontend-bundle-metrics.json"), + `${JSON.stringify(bundleMetrics, undefined, 2)}\n` + ); + assertFrontendBundleBudgets(bundleMetrics.measurements); + const compressedFileCount = await writePrecompressedFrontendAssets( + result.outputs.map(({ path: outputPath }) => outputPath) + ); + console.log( + `Frontend bundle: ${bundleMetrics.measurements.initialJavaScriptGzipBytes} bytes initial JS gzip, ${bundleMetrics.measurements.totalJavaScriptGzipBytes} bytes total JS gzip, ${compressedFileCount} compressed sidecars` + ); + } } diff --git a/scripts/frontendBuildArtifacts.ts b/scripts/frontendBuildArtifacts.ts new file mode 100644 index 000000000..4ab93dc34 --- /dev/null +++ b/scripts/frontendBuildArtifacts.ts @@ -0,0 +1,245 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { brotliCompressSync, constants, gzipSync } from "node:zlib"; + +const COMPRESSIBLE_EXTENSIONS = new Set([ + ".css", + ".html", + ".js", + ".json", + ".svg", + ".txt", + ".webmanifest", + ".xml", +]); +const MINIMUM_COMPRESSION_BYTES = 512; + +export interface FrontendBundleMeasurements { + initialJavaScriptGzipBytes: number; + initialJavaScriptRawBytes: number; + initialStylesheetGzipBytes: number; + initialStylesheetRawBytes: number; + largestJavaScriptGzipBytes: number; + totalJavaScriptGzipBytes: number; + totalJavaScriptRawBytes: number; +} + +type FrontendBundleBudget = keyof Pick< + FrontendBundleMeasurements, + | "initialJavaScriptGzipBytes" + | "initialStylesheetGzipBytes" + | "largestJavaScriptGzipBytes" + | "totalJavaScriptGzipBytes" +>; + +export const FRONTEND_BUNDLE_BUDGETS: Readonly> = { + initialJavaScriptGzipBytes: 350 * 1024, + initialStylesheetGzipBytes: 25 * 1024, + largestJavaScriptGzipBytes: 75 * 1024, + totalJavaScriptGzipBytes: 850 * 1024, +}; + +interface MeasuredOutput { + gzipBytes: number; + outputPath: string; + rawBytes: number; +} + +export interface FrontendBundleMetrics { + budgets: Readonly>; + formatVersion: 1; + initialFiles: MeasuredOutput[]; + measurements: FrontendBundleMeasurements; +} + +function normalizedOutputKey(outputKey: string): string { + return outputKey.replaceAll("\\", "/").replace(/^\.\//u, ""); +} + +function isPathWithin(directory: string, candidate: string): boolean { + return candidate === directory || candidate.startsWith(`${directory}${path.sep}`); +} + +function resolvedOutput(outdir: string, outputKey: string) { + const resolvedOutdir = path.resolve(outdir); + const normalizedKey = normalizedOutputKey(outputKey); + const cwdRelativePath = path.resolve(normalizedKey); + const resolvedPath = isPathWithin(resolvedOutdir, cwdRelativePath) + ? cwdRelativePath + : path.resolve(resolvedOutdir, normalizedKey); + if (resolvedPath === resolvedOutdir || !isPathWithin(resolvedOutdir, resolvedPath)) { + throw new Error(`Frontend build output escaped its directory: ${outputKey}`); + } + return { + filePath: resolvedPath, + relativePath: path.relative(resolvedOutdir, resolvedPath).replaceAll("\\", "/"), + }; +} + +function isIndexEntryPoint(entryPoint?: string): boolean { + if (!entryPoint) return false; + const normalized = entryPoint.replaceAll("\\", "/").replace(/^\.\//u, ""); + return normalized === "index.html" || normalized.endsWith("/index.html"); +} + +/** + * Resolves the static startup graph while excluding route and feature + * `dynamic-import` edges. + */ +export function initialFrontendOutputKeys(metafile: Bun.BuildMetafile): Set { + const outputs = metafile.outputs; + const keyByNormalizedPath = new Map( + Object.keys(outputs).map((outputKey) => [ + normalizedOutputKey(outputKey), + outputKey, + ]) + ); + const resolveOutputKey = (candidate: string): string | undefined => + Object.hasOwn(outputs, candidate) + ? candidate + : keyByNormalizedPath.get(normalizedOutputKey(candidate)); + const pending = Object.entries(outputs) + .filter(([, output]) => isIndexEntryPoint(output.entryPoint)) + .map(([outputKey]) => outputKey); + const initialOutputKeys = new Set(); + + while (pending.length > 0) { + const outputKey = pending.pop(); + if (!outputKey || initialOutputKeys.has(outputKey)) continue; + const output = outputs[outputKey]; + if (!output) continue; + initialOutputKeys.add(outputKey); + + if (output.cssBundle) { + const cssOutputKey = resolveOutputKey(output.cssBundle); + if (cssOutputKey) pending.push(cssOutputKey); + } + const staticImports = output.imports.filter( + ({ kind }) => kind !== "dynamic-import" + ); + for (const imported of staticImports) { + const importedOutputKey = resolveOutputKey(imported.path); + if (importedOutputKey) pending.push(importedOutputKey); + } + } + + return initialOutputKeys; +} + +function sumOutputs( + outputs: Iterable, + field: "gzipBytes" | "rawBytes" +): number { + let total = 0; + for (const output of outputs) total += output[field]; + return total; +} + +/** Measures the complete and initial production JavaScript/CSS graphs. */ +export async function measureFrontendBundle( + metafile: Bun.BuildMetafile, + outdir: string +): Promise { + const measuredOutputs = new Map(); + for (const outputKey of Object.keys(metafile.outputs)) { + const extension = path.extname(outputKey); + if (extension !== ".css" && extension !== ".js") continue; + const output = resolvedOutput(outdir, outputKey); + const contents = await readFile(output.filePath); + measuredOutputs.set(outputKey, { + gzipBytes: gzipSync(contents, { level: 9 }).byteLength, + outputPath: output.relativePath, + rawBytes: contents.byteLength, + }); + } + + const initialOutputKeys = initialFrontendOutputKeys(metafile); + const initialFiles = [...initialOutputKeys] + .map((outputKey) => measuredOutputs.get(outputKey)) + .filter((output): output is MeasuredOutput => output !== undefined) + .toSorted((left, right) => left.outputPath.localeCompare(right.outputPath)); + const initialJavaScript = initialFiles.filter(({ outputPath }) => + outputPath.endsWith(".js") + ); + const initialStylesheets = initialFiles.filter(({ outputPath }) => + outputPath.endsWith(".css") + ); + const allJavaScript: MeasuredOutput[] = []; + for (const output of measuredOutputs.values()) { + if (output.outputPath.endsWith(".js")) allJavaScript.push(output); + } + if (initialJavaScript.length === 0) { + throw new Error( + "Frontend bundle metadata did not contain an initial JavaScript graph" + ); + } + + return { + budgets: FRONTEND_BUNDLE_BUDGETS, + formatVersion: 1, + initialFiles, + measurements: { + initialJavaScriptGzipBytes: sumOutputs(initialJavaScript, "gzipBytes"), + initialJavaScriptRawBytes: sumOutputs(initialJavaScript, "rawBytes"), + initialStylesheetGzipBytes: sumOutputs(initialStylesheets, "gzipBytes"), + initialStylesheetRawBytes: sumOutputs(initialStylesheets, "rawBytes"), + largestJavaScriptGzipBytes: Math.max( + 0, + ...allJavaScript.map(({ gzipBytes }) => gzipBytes) + ), + totalJavaScriptGzipBytes: sumOutputs(allJavaScript, "gzipBytes"), + totalJavaScriptRawBytes: sumOutputs(allJavaScript, "rawBytes"), + }, + }; +} + +/** Fails production builds that exceed the checked-in network-size budgets. */ +export function assertFrontendBundleBudgets( + measurements: FrontendBundleMeasurements +): void { + const exceeded = Object.entries(FRONTEND_BUNDLE_BUDGETS).filter( + ([budget, limit]) => measurements[budget as FrontendBundleBudget] > limit + ); + if (exceeded.length === 0) return; + + throw new Error( + [ + "Frontend bundle budget exceeded:", + ...exceeded.map(([budget, limit]) => { + const actual = measurements[budget as FrontendBundleBudget]; + return `- ${budget}: ${actual} bytes (limit ${limit})`; + }), + ].join("\n") + ); +} + +/** Writes deterministic Brotli and gzip sidecars for compressible build outputs. */ +export async function writePrecompressedFrontendAssets( + outputPaths: Iterable +): Promise { + let compressedFileCount = 0; + + for (const outputPath of outputPaths) { + if (!COMPRESSIBLE_EXTENSIONS.has(path.extname(outputPath))) continue; + const contents = await readFile(outputPath); + if (contents.byteLength < MINIMUM_COMPRESSION_BYTES) continue; + + const brotliContents = brotliCompressSync(contents, { + params: { + [constants.BROTLI_PARAM_QUALITY]: 11, + }, + }); + if (brotliContents.byteLength < contents.byteLength) { + await writeFile(`${outputPath}.br`, brotliContents); + compressedFileCount += 1; + } + + const gzipContents = gzipSync(contents, { level: 9 }); + if (gzipContents.byteLength < contents.byteLength) { + await writeFile(`${outputPath}.gz`, gzipContents); + compressedFileCount += 1; + } + } + + return compressedFileCount; +} diff --git a/src/components/features/chat/ChatMarkdown.tsx b/src/components/features/chat/ChatMarkdown.tsx index c1c33fd81..2c918a818 100644 --- a/src/components/features/chat/ChatMarkdown.tsx +++ b/src/components/features/chat/ChatMarkdown.tsx @@ -2,10 +2,10 @@ import ReactJsonView from "@microlink/react-json-view"; import JSON5 from "json5"; import { Children, isValidElement, type ReactNode } from "react"; import ReactMarkdown, { type Components } from "react-markdown"; -import SyntaxHighlighter from "react-syntax-highlighter"; -import { monokai } from "react-syntax-highlighter/dist/esm/styles/hljs"; +import { monokaiSublime } from "react-syntax-highlighter/dist/esm/styles/hljs"; import remarkGfm from "remark-gfm"; +import { CodeSyntaxHighlighter } from "../../../lib/syntaxHighlighter"; import { cn } from "../../../utils/cn"; const JSON_LANGUAGES = new Set(["json", "json5", "jsonc"]); @@ -131,9 +131,9 @@ function ChatCodeBlock({ code, language }: { code: string; language: string }) {
{language}
- {code} - + ); } diff --git a/src/components/features/chat/ChatMessagesList.tsx b/src/components/features/chat/ChatMessagesList.tsx index ea178cdd1..9ca448904 100644 --- a/src/components/features/chat/ChatMessagesList.tsx +++ b/src/components/features/chat/ChatMessagesList.tsx @@ -11,16 +11,19 @@ import { } from "lucide-react"; import { type KeyboardEvent, + lazy, type PointerEvent, type RefObject, + Suspense, useEffect, + useLayoutEffect, useRef, useState, } from "react"; +import { loadLazyModule } from "../../../lib/lazyImportRecovery"; import { formatDate, formatSize } from "../../../utils/format"; import { EmptyState } from "../../ui/EmptyState"; -import { ChatMarkdown } from "./ChatMarkdown"; import { ChatMessageDetails } from "./ChatMessageDetails"; import type { ChatAttachmentDisplay, @@ -36,6 +39,25 @@ import { } from "./chatTypes"; import { chatErrorMessage } from "./chatUtilities"; +const ChatMarkdown = lazy(async () => { + const module = await loadLazyModule("chat-markdown", () => import("./ChatMarkdown")); + return { default: module.ChatMarkdown }; +}); + +function SettledChatMarkdown({ onLoad, text }: { onLoad: () => void; text: string }) { + const onLoadReference = useRef(onLoad); + + useLayoutEffect(() => { + onLoadReference.current = onLoad; + }, [onLoad]); + + useLayoutEffect(() => { + onLoadReference.current(); + }, [text]); + + return ; +} + const SCROLL_KEYS = new Set([ " ", "ArrowDown", @@ -685,7 +707,18 @@ export function ChatMessagesList({ ) : undefined} {shouldRenderPrimaryText ? ( - + + {row.message.text} + + } + > + + ) : undefined} - {content} - + ); } diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 4a8d12320..ddfee367c 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -25,6 +25,7 @@ import { } from "react"; import { DELIVERY_NAV_REFRESH_MS, useCacheEntry, usePullRequests } from "../../hooks"; +import { preloadRouteModule } from "../../lib/routeModules"; import { cn } from "../../utils/cn"; import { AppHeader } from "./AppHeader"; @@ -111,6 +112,8 @@ export function Layout({ children }: LayoutProperties) { key={item.to} to={item.to} aria-current={isActive ? "page" : undefined} + onFocus={() => preloadRouteModule(item.to)} + onMouseEnter={() => preloadRouteModule(item.to)} className={cn( "mb-1 flex min-h-11 items-center gap-2 rounded-lg px-3 py-2 transition-colors", isActive diff --git a/src/lib/lazyImportRecovery.ts b/src/lib/lazyImportRecovery.ts new file mode 100644 index 000000000..32ceb5732 --- /dev/null +++ b/src/lib/lazyImportRecovery.ts @@ -0,0 +1,122 @@ +const LAZY_IMPORT_RELOAD_COOLDOWN_MS = 60_000; +const LAZY_IMPORT_RELOAD_KEY_PREFIX = "mira-dashboard:lazy-import-reload:"; +const LAZY_IMPORT_FAILURE_MESSAGES = [ + "error loading dynamically imported module", + "failed to fetch dynamically imported module", + "failed to load module script", + "importing a module script failed", +] as const; + +interface RecoveryStorage { + getItem(key: string): string | null | undefined; + removeItem(key: string): void; + setItem(key: string, value: string): void; +} + +interface LazyImportRecoveryOptions { + now?: () => number; + reload?: () => void; + storage?: RecoveryStorage; +} + +const reloadAttempts = new Map(); + +function isLazyImportLoadFailure(error: unknown): boolean { + if (!(error instanceof Error) || error.name !== "TypeError") return false; + const message = error.message.toLowerCase(); + return LAZY_IMPORT_FAILURE_MESSAGES.some((candidate) => message.includes(candidate)); +} + +function browserSessionStorage(): RecoveryStorage | undefined { + try { + return sessionStorage; + } catch { + return undefined; + } +} + +function storedReloadAt( + storage: RecoveryStorage | undefined, + storageKey: string +): number | undefined { + try { + const value = storage?.getItem(storageKey); + if (!value) return undefined; + const timestamp = Number(value); + return Number.isFinite(timestamp) ? timestamp : undefined; + } catch { + return undefined; + } +} + +function clearReloadAttempt( + storage: RecoveryStorage | undefined, + storageKey: string +): void { + reloadAttempts.delete(storageKey); + try { + storage?.removeItem(storageKey); + } catch { + // The in-memory guard still prevents a reload loop in this page. + } +} + +function recordReloadAttempt( + storage: RecoveryStorage | undefined, + storageKey: string, + timestamp: number +): void { + reloadAttempts.set(storageKey, timestamp); + try { + storage?.setItem(storageKey, String(timestamp)); + } catch { + // The in-memory guard still protects the current page. + } +} + +/** + * Reloads an already-open tab once when a deployment removes a lazy chunk + * referenced by its previous entry bundle. + */ +export async function loadLazyModule( + moduleKey: string, + load: () => Promise, + options: LazyImportRecoveryOptions = {} +): Promise { + const storage = + options.storage === undefined ? browserSessionStorage() : options.storage; + const storageKey = `${LAZY_IMPORT_RELOAD_KEY_PREFIX}${moduleKey}`; + + try { + const loaded = await load(); + clearReloadAttempt(storage ?? undefined, storageKey); + return loaded; + } catch (error) { + if (!isLazyImportLoadFailure(error)) throw error; + + const now = (options.now ?? Date.now)(); + const lastReloadAt = + storedReloadAt(storage ?? undefined, storageKey) ?? + reloadAttempts.get(storageKey); + const elapsedSinceReload = + lastReloadAt === undefined ? undefined : now - lastReloadAt; + if ( + elapsedSinceReload !== undefined && + elapsedSinceReload >= 0 && + elapsedSinceReload < LAZY_IMPORT_RELOAD_COOLDOWN_MS + ) { + throw error; + } + + recordReloadAttempt(storage ?? undefined, storageKey, now); + try { + (options.reload ?? (() => location.reload()))(); + } catch { + throw error; + } + + return await new Promise(() => { + // Navigation replaces this document; keep Suspense pending meanwhile. + }); + } +} diff --git a/src/lib/routeModules.ts b/src/lib/routeModules.ts new file mode 100644 index 000000000..9046babab --- /dev/null +++ b/src/lib/routeModules.ts @@ -0,0 +1,53 @@ +type RouteModuleLoader = () => Promise; + +export const routeModules = { + agents: () => import("../pages/Agents"), + chat: () => import("../pages/Chat"), + dashboard: () => import("../pages/Dashboard"), + database: () => import("../pages/Database"), + delivery: () => import("../pages/Delivery"), + docker: () => import("../pages/Docker"), + files: () => import("../pages/Files"), + jobs: () => import("../pages/Jobs"), + login: () => import("../pages/Login"), + logs: () => import("../pages/Logs"), + moltbook: () => import("../pages/Moltbook"), + reports: () => import("../pages/Reports"), + sessions: () => import("../pages/Sessions"), + settings: () => import("../pages/Settings"), + tasks: () => import("../pages/Tasks"), + terminal: () => import("../pages/Terminal"), +}; + +const routeModulesByPath: Readonly> = { + "/": routeModules.dashboard, + "/agents": routeModules.agents, + "/chat": routeModules.chat, + "/database": routeModules.database, + "/delivery": routeModules.delivery, + "/docker": routeModules.docker, + "/files": routeModules.files, + "/jobs": routeModules.jobs, + "/logs": routeModules.logs, + "/moltbook": routeModules.moltbook, + "/reports": routeModules.reports, + "/sessions": routeModules.sessions, + "/settings": routeModules.settings, + "/tasks": routeModules.tasks, + "/terminal": routeModules.terminal, +}; + +/** Warms a module without turning a speculative preload failure into navigation. */ +export async function preloadModule(load: RouteModuleLoader): Promise { + try { + await load(); + } catch { + // A committed navigation retries through loadLazyModule and may recover. + } +} + +/** Preloads a registered route from hover/focus intent without reloading the page. */ +export function preloadRouteModule(pathname: string): void { + const load = routeModulesByPath[pathname]; + if (load) void preloadModule(load); +} diff --git a/src/lib/syntaxHighlighter.tsx b/src/lib/syntaxHighlighter.tsx new file mode 100644 index 000000000..12b643390 --- /dev/null +++ b/src/lib/syntaxHighlighter.tsx @@ -0,0 +1,116 @@ +import type { SyntaxHighlighterProps } from "react-syntax-highlighter"; +import { + Light as HighlightJsSyntaxHighlighter, + PrismLight as PrismSyntaxHighlighter, +} from "react-syntax-highlighter"; +import bash from "react-syntax-highlighter/dist/esm/languages/hljs/bash"; +import c from "react-syntax-highlighter/dist/esm/languages/hljs/c"; +import cpp from "react-syntax-highlighter/dist/esm/languages/hljs/cpp"; +import csharp from "react-syntax-highlighter/dist/esm/languages/hljs/csharp"; +import css from "react-syntax-highlighter/dist/esm/languages/hljs/css"; +import diff from "react-syntax-highlighter/dist/esm/languages/hljs/diff"; +import dockerfile from "react-syntax-highlighter/dist/esm/languages/hljs/dockerfile"; +import go from "react-syntax-highlighter/dist/esm/languages/hljs/go"; +import java from "react-syntax-highlighter/dist/esm/languages/hljs/java"; +import javascript from "react-syntax-highlighter/dist/esm/languages/hljs/javascript"; +import json from "react-syntax-highlighter/dist/esm/languages/hljs/json"; +import kotlin from "react-syntax-highlighter/dist/esm/languages/hljs/kotlin"; +import lua from "react-syntax-highlighter/dist/esm/languages/hljs/lua"; +import markdown from "react-syntax-highlighter/dist/esm/languages/hljs/markdown"; +import php from "react-syntax-highlighter/dist/esm/languages/hljs/php"; +import protobuf from "react-syntax-highlighter/dist/esm/languages/hljs/protobuf"; +import python from "react-syntax-highlighter/dist/esm/languages/hljs/python"; +import ruby from "react-syntax-highlighter/dist/esm/languages/hljs/ruby"; +import rust from "react-syntax-highlighter/dist/esm/languages/hljs/rust"; +import scala from "react-syntax-highlighter/dist/esm/languages/hljs/scala"; +import scss from "react-syntax-highlighter/dist/esm/languages/hljs/scss"; +import sql from "react-syntax-highlighter/dist/esm/languages/hljs/sql"; +import swift from "react-syntax-highlighter/dist/esm/languages/hljs/swift"; +import typescript from "react-syntax-highlighter/dist/esm/languages/hljs/typescript"; +import xml from "react-syntax-highlighter/dist/esm/languages/hljs/xml"; +import yaml from "react-syntax-highlighter/dist/esm/languages/hljs/yaml"; +import graphql from "react-syntax-highlighter/dist/esm/languages/prism/graphql"; +import { monokaiSublime } from "react-syntax-highlighter/dist/esm/styles/hljs"; + +const languages = { + bash, + c, + cpp, + csharp, + css, + diff, + dockerfile, + go, + html: xml, + java, + javascript, + json, + kotlin, + lua, + markdown, + php, + protobuf, + python, + ruby, + rust, + scala, + scss, + sql, + swift, + typescript, + xml, + yaml, +}; +const prismLanguages = { graphql }; + +function monokaiStyle(name: string) { + return monokaiSublime[name] ?? {}; +} + +const prismMonokaiSublime: NonNullable = { + 'code[class*="language-"]': monokaiStyle("hljs"), + 'pre[class*="language-"]': monokaiStyle("hljs"), + "attr-name": monokaiStyle("hljs-attribute"), + "attr-value": monokaiStyle("hljs-string"), + boolean: monokaiStyle("hljs-number"), + builtin: monokaiStyle("hljs-built_in"), + "class-name": monokaiStyle("hljs-title"), + comment: monokaiStyle("hljs-comment"), + constant: monokaiStyle("hljs-number"), + function: monokaiStyle("hljs-title"), + keyword: monokaiStyle("hljs-keyword"), + number: monokaiStyle("hljs-number"), + operator: monokaiStyle("hljs"), + property: monokaiStyle("hljs-attr"), + punctuation: monokaiStyle("hljs-tag"), + string: monokaiStyle("hljs-string"), + tag: monokaiStyle("hljs-name"), + variable: monokaiStyle("hljs-variable"), +}; + +for (const [name, language] of Object.entries(languages)) { + HighlightJsSyntaxHighlighter.registerLanguage(name, language); +} +for (const [name, language] of Object.entries(prismLanguages)) { + PrismSyntaxHighlighter.registerLanguage(name, language); +} + +/** + * Uses the smaller Highlight.js registry generally and Prism's GraphQL grammar + * only where Highlight.js has no equivalent language module. + */ +export function CodeSyntaxHighlighter({ + language, + style, + ...properties +}: SyntaxHighlighterProps) { + return language === "graphql" ? ( + + ) : ( + + ); +} diff --git a/src/router.tsx b/src/router.tsx index d66759b0c..d9091efbd 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -2,29 +2,81 @@ import { createRootRoute, createRoute, createRouter, + lazyRouteComponent, Outlet, redirect, } from "@tanstack/react-router"; import { Layout } from "./components/layout/Layout"; -import { Agents } from "./pages/Agents"; -import { Chat } from "./pages/Chat"; -import { Dashboard } from "./pages/Dashboard"; -import { Database } from "./pages/Database"; -import { Delivery } from "./pages/Delivery"; -import { Docker } from "./pages/Docker"; -import { Files } from "./pages/Files"; -import { Jobs } from "./pages/Jobs"; -import { Login } from "./pages/Login"; -import { Logs } from "./pages/Logs"; -import { Moltbook } from "./pages/Moltbook"; -import { Reports } from "./pages/Reports"; -import { Sessions } from "./pages/Sessions"; -import { Settings } from "./pages/Settings"; -import { Tasks } from "./pages/Tasks"; -import { Terminal } from "./pages/Terminal"; +import { loadLazyModule } from "./lib/lazyImportRecovery"; +import { routeModules } from "./lib/routeModules"; import { authActions, authStore } from "./stores/authStore"; +const Agents = lazyRouteComponent( + () => loadLazyModule("route-agents", routeModules.agents), + "Agents" +); +const Chat = lazyRouteComponent( + () => loadLazyModule("route-chat", routeModules.chat), + "Chat" +); +const Dashboard = lazyRouteComponent( + () => loadLazyModule("route-dashboard", routeModules.dashboard), + "Dashboard" +); +const Database = lazyRouteComponent( + () => loadLazyModule("route-database", routeModules.database), + "Database" +); +const Delivery = lazyRouteComponent( + () => loadLazyModule("route-delivery", routeModules.delivery), + "Delivery" +); +const Docker = lazyRouteComponent( + () => loadLazyModule("route-docker", routeModules.docker), + "Docker" +); +const Files = lazyRouteComponent( + () => loadLazyModule("route-files", routeModules.files), + "Files" +); +const Jobs = lazyRouteComponent( + () => loadLazyModule("route-jobs", routeModules.jobs), + "Jobs" +); +const Login = lazyRouteComponent( + () => loadLazyModule("route-login", routeModules.login), + "Login" +); +const Logs = lazyRouteComponent( + () => loadLazyModule("route-logs", routeModules.logs), + "Logs" +); +const Moltbook = lazyRouteComponent( + () => loadLazyModule("route-moltbook", routeModules.moltbook), + "Moltbook" +); +const Reports = lazyRouteComponent( + () => loadLazyModule("route-reports", routeModules.reports), + "Reports" +); +const Sessions = lazyRouteComponent( + () => loadLazyModule("route-sessions", routeModules.sessions), + "Sessions" +); +const Settings = lazyRouteComponent( + () => loadLazyModule("route-settings", routeModules.settings), + "Settings" +); +const Tasks = lazyRouteComponent( + () => loadLazyModule("route-tasks", routeModules.tasks), + "Tasks" +); +const Terminal = lazyRouteComponent( + () => loadLazyModule("route-terminal", routeModules.terminal), + "Terminal" +); + const rootRoute = createRootRoute({ component: () => , }); diff --git a/src/test/componentBehavior.test.tsx b/src/test/componentBehavior.test.tsx index 4d4903a9b..549ec9b5c 100644 --- a/src/test/componentBehavior.test.tsx +++ b/src/test/componentBehavior.test.tsx @@ -2615,6 +2615,7 @@ describe("shared component helpers", () => { expect( screen.getByText("Bash").closest("[class*='border-amber']") ).not.toContainElement(screen.getByText("answer")); + await waitFor(() => expect(onDynamicContentLoad).toHaveBeenCalled()); await user.click(screen.getByRole("button", { name: /follow/i })); expect(onUserScrollIntent).not.toHaveBeenCalled(); @@ -2839,6 +2840,13 @@ describe("shared component helpers", () => { rerender(); expect(screen.getByText(/covered/)).toBeInTheDocument(); + + rerender( + + ); + const graphQlKeyword = screen.getByText("query", { selector: ".token" }); + expect(graphQlKeyword).toHaveTextContent("query"); + expect(graphQlKeyword).toHaveStyle({ color: "#f92672" }); }); it("drives file explorer hook directory loading, JSON validation, and saves", async () => { diff --git a/src/test/frontendBuildArtifacts.test.ts b/src/test/frontendBuildArtifacts.test.ts new file mode 100644 index 000000000..b06fff253 --- /dev/null +++ b/src/test/frontendBuildArtifacts.test.ts @@ -0,0 +1,200 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { brotliDecompressSync, gunzipSync } from "node:zlib"; + +import { afterEach, describe, expect, it } from "bun:test"; + +import { + assertFrontendBundleBudgets, + FRONTEND_BUNDLE_BUDGETS, + initialFrontendOutputKeys, + measureFrontendBundle, + writePrecompressedFrontendAssets, +} from "../../scripts/frontendBuildArtifacts"; + +const temporaryRoots = new Set(); + +async function temporaryOutputRoot(): Promise { + const temporaryRoot = await fs.mkdtemp( + path.join(os.tmpdir(), "mira-frontend-build-test-") + ); + temporaryRoots.add(temporaryRoot); + await fs.mkdir(path.join(temporaryRoot, "assets"), { recursive: true }); + return temporaryRoot; +} + +afterEach(async () => { + for (const temporaryRoot of temporaryRoots) { + await fs.rm(temporaryRoot, { force: true, recursive: true }); + } + temporaryRoots.clear(); +}); + +describe("frontend build artifacts", () => { + it("measures only static imports in the initial bundle graph", async () => { + const outdir = await temporaryOutputRoot(); + const entryContents = "export const entry = true;\n"; + const sharedContents = "export const shared = true;\n"; + const lazyContents = "export const lazy = true;\n"; + const stylesheetContents = "body { color: white; }\n"; + await Promise.all([ + fs.writeFile(path.join(outdir, "assets", "entry.js"), entryContents), + fs.writeFile(path.join(outdir, "assets", "shared.js"), sharedContents), + fs.writeFile(path.join(outdir, "assets", "lazy.js"), lazyContents), + fs.writeFile(path.join(outdir, "assets", "styles.css"), stylesheetContents), + ]); + const metafile = { + inputs: {}, + outputs: { + "./assets/entry.js": { + bytes: entryContents.length, + cssBundle: "./assets/styles.css", + entryPoint: "index.html", + exports: [], + imports: [ + { + kind: "import-statement", + path: "./assets/shared.js", + }, + { + kind: "dynamic-import", + path: "./assets/lazy.js", + }, + ], + inputs: {}, + }, + "./assets/lazy.js": { + bytes: lazyContents.length, + exports: [], + imports: [], + inputs: {}, + }, + "./assets/shared.js": { + bytes: sharedContents.length, + exports: [], + imports: [], + inputs: {}, + }, + "./assets/styles.css": { + bytes: stylesheetContents.length, + exports: [], + imports: [], + inputs: {}, + }, + "./index.html": { + bytes: 0, + entryPoint: "index.html", + exports: [], + imports: [], + inputs: {}, + }, + }, + } satisfies Bun.BuildMetafile; + + expect(initialFrontendOutputKeys(metafile)).toEqual( + new Set([ + "./assets/entry.js", + "./assets/shared.js", + "./assets/styles.css", + "./index.html", + ]) + ); + + const metrics = await measureFrontendBundle(metafile, outdir); + expect(metrics.initialFiles.map(({ outputPath }) => outputPath)).toEqual([ + "assets/entry.js", + "assets/shared.js", + "assets/styles.css", + ]); + expect(metrics.measurements.initialJavaScriptRawBytes).toBe( + Buffer.byteLength(entryContents) + Buffer.byteLength(sharedContents) + ); + expect(metrics.measurements.initialStylesheetRawBytes).toBe( + Buffer.byteLength(stylesheetContents) + ); + expect(metrics.measurements.totalJavaScriptRawBytes).toBe( + Buffer.byteLength(entryContents) + + Buffer.byteLength(sharedContents) + + Buffer.byteLength(lazyContents) + ); + }); + + it("writes valid Brotli and gzip sidecars for compressible outputs", async () => { + const outdir = await temporaryOutputRoot(); + const outputPath = path.join(outdir, "assets", "entry.js"); + const contents = "export const repeated = true;\n".repeat(64); + await fs.writeFile(outputPath, contents); + + expect(await writePrecompressedFrontendAssets([outputPath])).toBe(2); + expect( + brotliDecompressSync(await fs.readFile(`${outputPath}.br`)).toString() + ).toBe(contents); + expect(gunzipSync(await fs.readFile(`${outputPath}.gz`)).toString()).toBe( + contents + ); + }); + + it("does not prepend outdir when metafile keys already include it", async () => { + const outdir = await temporaryOutputRoot(); + const entryContents = "export const entry = true;\n"; + await fs.writeFile(path.join(outdir, "assets", "entry.js"), entryContents); + const outdirKey = path.relative(process.cwd(), outdir).replaceAll("\\", "/"); + const outputKey = `${outdirKey}/assets/entry.js`; + const metafile = { + inputs: {}, + outputs: { + [outputKey]: { + bytes: entryContents.length, + entryPoint: "index.html", + exports: [], + imports: [], + inputs: {}, + }, + }, + } satisfies Bun.BuildMetafile; + + const metrics = await measureFrontendBundle(metafile, outdir); + expect(metrics.initialFiles).toEqual([ + expect.objectContaining({ + outputPath: "assets/entry.js", + rawBytes: Buffer.byteLength(entryContents), + }), + ]); + }); + + it("fails closed when build metadata has no initial JavaScript graph", async () => { + const outdir = await temporaryOutputRoot(); + const metafile = { + inputs: {}, + outputs: { + "./index.html": { + bytes: 0, + entryPoint: "index.html", + exports: [], + imports: [], + inputs: {}, + }, + }, + } satisfies Bun.BuildMetafile; + + await expect(measureFrontendBundle(metafile, outdir)).rejects.toThrow( + "initial JavaScript graph" + ); + }); + + it("reports the specific production budget that was exceeded", () => { + expect(() => + assertFrontendBundleBudgets({ + initialJavaScriptGzipBytes: + FRONTEND_BUNDLE_BUDGETS.initialJavaScriptGzipBytes + 1, + initialJavaScriptRawBytes: 0, + initialStylesheetGzipBytes: 0, + initialStylesheetRawBytes: 0, + largestJavaScriptGzipBytes: 0, + totalJavaScriptGzipBytes: 0, + totalJavaScriptRawBytes: 0, + }) + ).toThrow("initialJavaScriptGzipBytes"); + }); +}); diff --git a/src/test/lazyImportRecovery.test.ts b/src/test/lazyImportRecovery.test.ts new file mode 100644 index 000000000..01a288101 --- /dev/null +++ b/src/test/lazyImportRecovery.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it, jest } from "bun:test"; + +import { loadLazyModule } from "../lib/lazyImportRecovery"; +import { preloadModule } from "../lib/routeModules"; + +function recoveryStorage(initialValue?: string) { + let value = initialValue; + return { + getItem: jest.fn(() => value), + removeItem: jest.fn(() => { + value = undefined; + }), + setItem: jest.fn((_key: string, nextValue: string) => { + value = nextValue; + }), + }; +} + +describe("lazy import recovery", () => { + it("keeps speculative preload failures silent", async () => { + const importError = new TypeError("Failed to fetch dynamically imported module"); + const load = jest.fn(async () => { + throw importError; + }); + + await expect(preloadModule(load)).resolves.toBeUndefined(); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("clears the module reload guard after a successful import", async () => { + const storage = recoveryStorage("100"); + const routeModule = { Tasks: () => {} }; + + await expect( + loadLazyModule("route-tasks", async () => routeModule, { storage }) + ).resolves.toBe(routeModule); + expect(storage.removeItem).toHaveBeenCalledWith( + "mira-dashboard:lazy-import-reload:route-tasks" + ); + }); + + it("surfaces module evaluation errors without reloading the page", async () => { + const evaluationError = new TypeError( + "Cannot read properties of undefined (reading 'route')" + ); + const storage = recoveryStorage(); + const reload = jest.fn(); + + await expect( + loadLazyModule( + "route-evaluation-failure", + async () => { + throw evaluationError; + }, + { reload, storage } + ) + ).rejects.toBe(evaluationError); + expect(reload).not.toHaveBeenCalled(); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + + it("reloads once for a missing chunk and rejects a repeated failure", async () => { + const importError = new TypeError("Failed to fetch dynamically imported module"); + const storage = recoveryStorage(); + const reload = jest.fn(); + const firstImport = loadLazyModule( + "route-reports", + async () => { + throw importError; + }, + { + now: () => 10_000, + reload, + storage, + } + ); + + await Promise.resolve(); + await Promise.resolve(); + expect(reload).toHaveBeenCalledTimes(1); + expect(storage.setItem).toHaveBeenCalledWith( + "mira-dashboard:lazy-import-reload:route-reports", + "10000" + ); + void firstImport; + + await expect( + loadLazyModule( + "route-reports", + async () => { + throw importError; + }, + { + now: () => 10_001, + reload, + storage, + } + ) + ).rejects.toBe(importError); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("allows another recovery reload after the cooldown", async () => { + const importError = new TypeError("Importing a module script failed"); + const storage = recoveryStorage("10000"); + const reload = jest.fn(); + const importRequest = loadLazyModule( + "route-database", + async () => { + throw importError; + }, + { + now: () => 70_001, + reload, + storage, + } + ); + + await Promise.resolve(); + await Promise.resolve(); + expect(reload).toHaveBeenCalledTimes(1); + expect(storage.setItem).toHaveBeenCalledWith( + "mira-dashboard:lazy-import-reload:route-database", + "70001" + ); + void importRequest; + }); + + it("recovers when a stored reload timestamp is later than the current clock", async () => { + const importError = new TypeError("Importing a module script failed"); + const storage = recoveryStorage("90000"); + const reload = jest.fn(); + const importRequest = loadLazyModule( + "route-clock-reset", + async () => { + throw importError; + }, + { + now: () => 30_000, + reload, + storage, + } + ); + + await Promise.resolve(); + await Promise.resolve(); + expect(reload).toHaveBeenCalledTimes(1); + expect(storage.setItem).toHaveBeenCalledWith( + "mira-dashboard:lazy-import-reload:route-clock-reset", + "30000" + ); + void importRequest; + }); + + it("uses the in-memory loop guard when storage is unavailable", async () => { + const importError = new TypeError("error loading dynamically imported module"); + const storage = { + getItem: jest.fn(() => { + throw new Error("storage unavailable"); + }), + removeItem: jest.fn(() => { + throw new Error("storage unavailable"); + }), + setItem: jest.fn(() => { + throw new Error("storage unavailable"); + }), + }; + const reload = jest.fn(); + const firstImport = loadLazyModule( + "chat-markdown-storage-failure", + async () => { + throw importError; + }, + { + now: () => 20_000, + reload, + storage, + } + ); + + await Promise.resolve(); + await Promise.resolve(); + expect(reload).toHaveBeenCalledTimes(1); + void firstImport; + + await expect( + loadLazyModule( + "chat-markdown-storage-failure", + async () => { + throw importError; + }, + { + now: () => 20_001, + reload, + storage, + } + ) + ).rejects.toBe(importError); + expect(reload).toHaveBeenCalledTimes(1); + await expect( + loadLazyModule("chat-markdown-storage-failure", async () => "loaded", { + storage, + }) + ).resolves.toBe("loaded"); + }); + + it("surfaces the import error when the browser reload cannot start", async () => { + const importError = new TypeError("Failed to fetch dynamically imported module"); + const storage = recoveryStorage("invalid timestamp"); + + await expect( + loadLazyModule( + "route-delivery-reload-failure", + async () => { + throw importError; + }, + { + now: () => 30_000, + reload: () => { + throw new Error("navigation unavailable"); + }, + storage, + } + ) + ).rejects.toBe(importError); + }); +});