import assert from "node:assert";
import { createHash } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import glob from "fast-glob";
import type { Plugin, ResolvedConfig } from "vite";
type Route = {
fileName: string;
relativePath: string;
relativePathNoExt: string;
absolutePath: string;
originalUrlPath: string;
// Helper to redirect for dev
devRedirectHelperUrlPath: string;
};
type Routes = Route[];
type RoutesManifest = Record<
string,
{
resourceName: string;
resourceURI: string;
hash: string;
originalUrlPath: string;
// Helper to redirect for dev
devRedirectHelperUrlPath: string;
}
>;
async function generateRoutesManifest(
routes: Routes,
hashGenerator: (route: Route) => Promise<string> | string
) {
const routesManifest: RoutesManifest = {};
for (const route of routes) {
const hash = await hashGenerator(route);
const resourceName = route.relativePathNoExt.replace("src/app/routes/", "");
routesManifest[resourceName] = {
resourceName: resourceName,
resourceURI: `${resourceName}.${hash}`,
originalUrlPath: route.originalUrlPath,
devRedirectHelperUrlPath: route.devRedirectHelperUrlPath,
hash,
};
}
return routesManifest;
}
function generateManifestContent(manifest: RoutesManifest): string {
return `// This file is auto-generated by a Vite plugin\nexport const routesManifest = ${JSON.stringify(manifest, null, 2)} as const;\n`;
}
export function routesManifest(): Plugin {
// Correct path for the output file, inside the worker directory
const manifestFilePath = resolve(
process.cwd(),
"src/worker/routesManifest.generated.ts"
);
let viteConfig: ResolvedConfig;
let routes: Routes = [];
return {
name: "vite-plugin-cloudflare-chatgpt-apps-sdk:routes-manifest",
sharedDuringBuild: true,
config(config) {
const clientEnv = config.environments?.client.build?.rollupOptions?.input;
if (clientEnv) {
// Configuration has defined client inputs, meaning user made changes, don't do anything.
this.error(
"You've provided hardcoded entrypoints in your configuration. This is currently not supported"
);
}
// Client environment not defined, generate entrypoints for all HTML defined in app/routes
const matchedRoutes = glob
.sync(["index.html", "src/app/routes/**/*.html"], {
onlyFiles: true,
objectMode: true,
})
.map((file) => {
const relativePath = file.path;
const relativePathNoExt = relativePath.replace(/\.html$/, "");
let originalUrlPath = "/";
let resolvedUrlPath = "/";
if (file.path !== "index.html") {
originalUrlPath = `/${relativePathNoExt}`;
resolvedUrlPath = originalUrlPath.replace("src/app/routes/", "");
}
return {
relativePath,
relativePathNoExt,
originalUrlPath,
fileName: file.name,
absolutePath: resolve(process.cwd(), file.path),
devRedirectHelperUrlPath: resolvedUrlPath,
};
});
routes = matchedRoutes;
return {
environments: {
client: {
build: {
rollupOptions: {
input: matchedRoutes.map((route) => route.absolutePath),
},
},
},
},
};
},
async configResolved(resolvedConfig) {
viteConfig = resolvedConfig;
if (viteConfig.command === "serve") {
if (routes.length === 0) {
this.warn(
"[plugin vite-plugin-cloudflare-chatgpt-apps-sdk:routes-manifest] No routes + entrypoints found. Cannot generate resource manifest in development."
);
return;
}
const devManifest = await generateRoutesManifest(routes, () => {
return `dev-${Date.now()}`;
});
writeFileSync(manifestFilePath, generateManifestContent(devManifest));
this.info(
"[plugin vite-plugin-cloudflare-chatgpt-apps-sdk:routes-manifest] Generated routes manifest for development. You may r + enter to regenerate the routes manifest."
);
}
},
// Just for development, where we redirect top-level urls to their respective /app/routes equivalent
// This will not happen in production.
configureServer: {
handler: function routManifestMiddleware(server) {
const redirectMap = new Map<string, string>();
for (const route of routes) {
redirectMap.set(
route.devRedirectHelperUrlPath,
route.originalUrlPath
);
}
server.middlewares.use((req, res, next) => {
const urlToCheck = req.originalUrl || req.url;
if (urlToCheck && redirectMap.has(urlToCheck)) {
const redirectTo = redirectMap.get(urlToCheck)!;
this.info(
`Redirecting dev server URL: ${req.url} -> ${redirectTo}`
);
res.statusCode = 302;
res.setHeader("Location", redirectTo);
res.end(); // End the response
return;
}
next();
});
},
},
buildApp: {
order: "pre",
async handler(builder) {
// Cloudflare build's the worker environment before the client.
// But we need to generate the resource hash so the worker envioronment can retrieve
// the resource hash for it's own build.
// Before the worker environment is built, this hook is called to build the client first.
// Caveat is that we build the client twice since Cloudflare will build it after, but whatever.
const clientEnvironment = builder.environments.client;
assert(clientEnvironment, `No "client" environment found in builder`);
if (!clientEnvironment.isBuilt) {
await builder.build(clientEnvironment);
}
},
},
writeBundle: {
sequential: true,
order: "post",
async handler(options) {
if (this.environment.name !== "client") {
return;
}
const outDir = options.dir;
if (!outDir) {
return;
}
if (routes.length === 0) {
this.warn(
"No routes + entrypoints found. Cannot generate resource manifest for build."
);
return;
}
// We could have used the manifest chunks here to generate the hash and reading the file.
// But since we already did it for dev, lets just do the same here.
const buildRoutesManifest = await generateRoutesManifest(
routes,
async (route) => {
const distPath = resolve(outDir, route.relativePath);
if (!existsSync(distPath)) {
const err = new Error(
`Could not find route ${route.relativePath} in output build directory for the client. ${distPath}`
);
this.error(err.message);
}
const routeHtml = readFileSync(distPath, "utf-8");
const hash = createHash("sha256")
.update(routeHtml)
.digest("hex")
.slice(0, 8);
this.info(
`Generated resource hash for ${route.relativePath} hash: ${hash}`
);
return hash;
}
);
writeFileSync(
manifestFilePath,
generateManifestContent(buildRoutesManifest)
);
this.info("Generated routes manifest for build");
},
},
};
}