feat(FN-4782): complete Step 3 — inject view chunk preload
Fusion-Task-Id: FN-4782 Fusion-Task-Lineage: 5d6b5632-a135-4d25-9382-57fa23e22484
This commit is contained in:
committed by
gsxdsm
parent
6bb70dc805
commit
aa105c9b80
123
packages/dashboard/src/__tests__/server-view-preload.test.ts
Normal file
123
packages/dashboard/src/__tests__/server-view-preload.test.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect } from "vitest";
|
||||||
|
import { TaskStore } from "@fusion/core";
|
||||||
|
import { createServer } from "../server.js";
|
||||||
|
import { createLoopbackIntegrationTest } from "./loopback-integration-test.js";
|
||||||
|
|
||||||
|
const serverViewPreloadIntegrationTest = await createLoopbackIntegrationTest("server-view-preload integration");
|
||||||
|
|
||||||
|
let tempRoots: string[] = [];
|
||||||
|
|
||||||
|
function makeTempDir(prefix: string): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), prefix));
|
||||||
|
tempRoots.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startServerWithFixture(clientDir: string) {
|
||||||
|
const rootDir = makeTempDir("fn-4782-root-");
|
||||||
|
const globalDir = makeTempDir("fn-4782-global-");
|
||||||
|
const store = new TaskStore(rootDir, globalDir);
|
||||||
|
await store.init();
|
||||||
|
|
||||||
|
const previousClientDir = process.env.FUSION_CLIENT_DIR;
|
||||||
|
process.env.FUSION_CLIENT_DIR = clientDir;
|
||||||
|
|
||||||
|
const app = createServer(store);
|
||||||
|
const server = await new Promise<import("node:http").Server>((resolve) => {
|
||||||
|
const s = app.listen(0, "127.0.0.1", () => resolve(s));
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
restoreEnv: () => {
|
||||||
|
process.env.FUSION_CLIENT_DIR = previousClientDir;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of tempRoots) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
tempRoots = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("server index preload injection", () => {
|
||||||
|
serverViewPreloadIntegrationTest("injects view chunk map and modulepreload bootstrap", async () => {
|
||||||
|
const clientDir = makeTempDir("fn-4782-client-");
|
||||||
|
mkdirSync(join(clientDir, ".vite"), { recursive: true });
|
||||||
|
writeFileSync(join(clientDir, "index.html"), "<!doctype html><html><head></head><body><div id=\"root\"></div></body></html>");
|
||||||
|
writeFileSync(
|
||||||
|
join(clientDir, ".vite", "manifest.json"),
|
||||||
|
JSON.stringify({ "components/AgentsView.tsx": { file: "assets/AgentsView-abc123.js" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { server, restoreEnv } = await startServerWithFixture(clientDir);
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("Missing server address");
|
||||||
|
|
||||||
|
const res = await fetch(`http://127.0.0.1:${address.port}/`);
|
||||||
|
const html = await res.text();
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(html).toContain("window.__FUSION_VIEW_CHUNKS__");
|
||||||
|
expect(html).toContain('"agents":"/assets/AgentsView-abc123.js"');
|
||||||
|
expect(html).toContain("modulepreload");
|
||||||
|
} finally {
|
||||||
|
restoreEnv();
|
||||||
|
await new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
serverViewPreloadIntegrationTest("serves index with empty chunk map when manifest is missing", async () => {
|
||||||
|
const clientDir = makeTempDir("fn-4782-client-no-manifest-");
|
||||||
|
writeFileSync(join(clientDir, "index.html"), "<!doctype html><html><head></head><body><div id=\"root\"></div></body></html>");
|
||||||
|
|
||||||
|
const { server, restoreEnv } = await startServerWithFixture(clientDir);
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("Missing server address");
|
||||||
|
|
||||||
|
const res = await fetch(`http://127.0.0.1:${address.port}/`);
|
||||||
|
const html = await res.text();
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(html).toContain("window.__FUSION_VIEW_CHUNKS__={}");
|
||||||
|
} finally {
|
||||||
|
restoreEnv();
|
||||||
|
await new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
serverViewPreloadIntegrationTest("escapes script poison in inlined chunk map", async () => {
|
||||||
|
const clientDir = makeTempDir("fn-4782-client-escape-");
|
||||||
|
mkdirSync(join(clientDir, ".vite"), { recursive: true });
|
||||||
|
writeFileSync(join(clientDir, "index.html"), "<!doctype html><html><head></head><body><div id=\"root\"></div></body></html>");
|
||||||
|
writeFileSync(
|
||||||
|
join(clientDir, ".vite", "manifest.json"),
|
||||||
|
JSON.stringify({ "components/AgentsView.tsx": { file: "assets/AgentsView-</script>-abc.js" } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { server, restoreEnv } = await startServerWithFixture(clientDir);
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("Missing server address");
|
||||||
|
|
||||||
|
const res = await fetch(`http://127.0.0.1:${address.port}/`);
|
||||||
|
const html = await res.text();
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(html).toContain("<\\/script>");
|
||||||
|
expect(html).not.toContain('assets/AgentsView-</script>-abc.js");(()=>');
|
||||||
|
} finally {
|
||||||
|
restoreEnv();
|
||||||
|
await new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -72,6 +72,7 @@ import {
|
|||||||
tasksBouncedToInProgressPerDay,
|
tasksBouncedToInProgressPerDay,
|
||||||
tasksEnteredInReviewPerDay,
|
tasksEnteredInReviewPerDay,
|
||||||
} from "./reliability-metrics.js";
|
} from "./reliability-metrics.js";
|
||||||
|
import { loadViewChunkManifest } from "./view-chunk-manifest.js";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -656,6 +657,52 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
? join(__dirname, "..", "dist", "client")
|
? join(__dirname, "..", "dist", "client")
|
||||||
: join(__dirname, "..", "client");
|
: join(__dirname, "..", "client");
|
||||||
|
|
||||||
|
let cachedIndexClientDir: string | null = null;
|
||||||
|
let cachedIndexHtml: string | null = null;
|
||||||
|
let cachedTemplatedIndexHtml: string | null = null;
|
||||||
|
|
||||||
|
const buildViewPreloadInjection = (chunkMap: Record<string, string>): string => {
|
||||||
|
const serializedChunkMap = JSON.stringify(chunkMap).replace(/<\//g, "<\\/");
|
||||||
|
return `<script>window.__FUSION_VIEW_CHUNKS__=${serializedChunkMap};(()=>{try{const chunkMap=window.__FUSION_VIEW_CHUNKS__||{};const projectId=localStorage.getItem("kb-dashboard-current-project");const scopedKey=projectId?"kb:"+projectId+":kb-dashboard-task-view":null;let taskView=(scopedKey&&localStorage.getItem(scopedKey))||localStorage.getItem("kb-dashboard-task-view");if(taskView==="devserver")taskView="dev-server";if(taskView==="roadmaps")taskView="board";if(typeof taskView!=="string"||taskView.startsWith("plugin:"))return;const chunkPath=chunkMap[taskView];if(!chunkPath)return;const link=document.createElement("link");link.rel="modulepreload";link.href=chunkPath;link.crossOrigin="";document.head.appendChild(link);}catch{}})();</script>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderIndexHtml = (): string => {
|
||||||
|
const resolvedClientDir = process.env.FUSION_CLIENT_DIR
|
||||||
|
? process.env.FUSION_CLIENT_DIR
|
||||||
|
: clientDir;
|
||||||
|
|
||||||
|
if (cachedTemplatedIndexHtml && cachedIndexClientDir === resolvedClientDir) {
|
||||||
|
return cachedTemplatedIndexHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexPath = join(resolvedClientDir, "index.html");
|
||||||
|
if (!cachedIndexHtml || cachedIndexClientDir !== resolvedClientDir) {
|
||||||
|
cachedIndexHtml = readFileSync(indexPath, "utf8");
|
||||||
|
cachedIndexClientDir = resolvedClientDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkMap = loadViewChunkManifest(resolvedClientDir);
|
||||||
|
const injection = buildViewPreloadInjection(chunkMap);
|
||||||
|
const marker = "<!-- fusion:view-preload -->";
|
||||||
|
const withInjectedHead = cachedIndexHtml.includes(marker)
|
||||||
|
? cachedIndexHtml.replace(marker, `${marker}\n${injection}`)
|
||||||
|
: cachedIndexHtml.replace("</head>", `${injection}</head>`);
|
||||||
|
|
||||||
|
cachedTemplatedIndexHtml = withInjectedHead;
|
||||||
|
return withInjectedHead;
|
||||||
|
};
|
||||||
|
|
||||||
|
const serveIndexHtml = (_req: express.Request, res: express.Response): void => {
|
||||||
|
try {
|
||||||
|
const html = renderIndexHtml();
|
||||||
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||||
|
res.setHeader("Cache-Control", "no-store, max-age=0");
|
||||||
|
res.status(200).send(html);
|
||||||
|
} catch {
|
||||||
|
res.status(404).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!isHeadless) {
|
if (!isHeadless) {
|
||||||
app.get("/version.json", (_req, res) => {
|
app.get("/version.json", (_req, res) => {
|
||||||
res.setHeader("Cache-Control", "no-store, max-age=0");
|
res.setHeader("Cache-Control", "no-store, max-age=0");
|
||||||
@@ -665,7 +712,10 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
app.use(express.static(clientDir));
|
if (existsSync(join(clientDir, "index.html"))) {
|
||||||
|
app.get(["/", "/index.html"], serveIndexHtml);
|
||||||
|
app.use(express.static(clientDir, { index: false }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create ChatStore for chat session management (available for SSE event forwarding)
|
// Create ChatStore for chat session management (available for SSE event forwarding)
|
||||||
@@ -1400,7 +1450,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
res.status(404).end();
|
res.status(404).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.sendFile(join(clientDir, "index.html"));
|
serveIndexHtml(req, res);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user