Skip to content

Instantly share code, notes, and snippets.

@rmarscher
Last active April 7, 2025 17:28
Show Gist options
  • Save rmarscher/ee4f11fdb8751b474ebd0c8a5b89e127 to your computer and use it in GitHub Desktop.
Save rmarscher/ee4f11fdb8751b474ebd0c8a5b89e127 to your computer and use it in GitHub Desktop.
Cloudflare Pages Vite deploy plugin for Waku
import { randomBytes } from "node:crypto";
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import os from "node:os";
import path from "node:path";
import type { Plugin } from "vite";
import {
unstable_emitPlatformData as emitPlatformData,
unstable_builderConstants as builderConstants,
unstable_getBuildOptions as getBuildOptions,
} from "waku/server";
const { DIST_SERVE_JS, DIST_PUBLIC, SRC_ENTRIES } = builderConstants;
const SERVE_JS = DIST_SERVE_JS;
const WORKER_JS_NAME = "_worker.js";
const ROUTES_JSON_NAME = "_routes.json";
const HEADERS_NAME = "_headers";
type StaticRoutes = { version: number; include: string[]; exclude: string[] };
const getFiles = (dir: string, files: string[] = []): string[] => {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
getFiles(fullPath, files);
} else {
files.push(fullPath);
}
}
return files;
};
const getServeJsContent = (
srcEntriesFile: string,
honoEnhancerFile: string | undefined,
) => `
import { serverEngine, importHono } from 'waku/unstable_hono';
const { Hono } = await importHono();
const loadEntries = () => import('${srcEntriesFile}');
const loadHonoEnhancer = async () => {
${
honoEnhancerFile
? `return (await import('${honoEnhancerFile}')).default;`
: `return (fn) => fn;`
}
};
let serve;
let app;
const createApp = (app) => {
app.use((c, next) => serve(c, next));
app.notFound(async (c) => {
const assetsFetcher = c.env.ASSETS;
const url = new URL(c.req.raw.url);
const errorHtmlUrl = url.origin + '/404.html';
const notFoundStaticAssetResponse = await assetsFetcher.fetch(
new URL(errorHtmlUrl),
);
if (
notFoundStaticAssetResponse &&
notFoundStaticAssetResponse.status < 400
) {
return c.body(notFoundStaticAssetResponse.body, 404);
}
return c.text('404 Not Found', 404);
});
return app;
};
export default {
async fetch(request, env, ctx) {
if (!serve) {
serve = serverEngine({ cmd: 'start', loadEntries, env, unstable_onError: new Set() });
}
if (!app) {
const honoEnhancer = await loadHonoEnhancer();
app = honoEnhancer(createApp)(new Hono());
}
return app.fetch(request, env, ctx);
},
};
`;
const getIndexJsContent = (serveJs: string) => `
import server from './${serveJs}'
export default {
...server
}
`;
function copyFiles(srcDir: string, destDir: string, extensions: string[]) {
const files = readdirSync(srcDir, { withFileTypes: true });
for (const file of files) {
const srcPath = path.join(srcDir, file.name);
const destPath = path.join(destDir, file.name);
if (file.isDirectory()) {
mkdirSync(destPath, { recursive: true });
copyFiles(srcPath, destPath, extensions);
} else if (extensions.some((ext) => file.name.endsWith(ext))) {
copyFileSync(srcPath, destPath);
}
}
}
function copyDirectory(srcDir: string, destDir: string) {
const files = readdirSync(srcDir, { withFileTypes: true });
for (const file of files) {
const srcPath = path.join(srcDir, file.name);
const destPath = path.join(destDir, file.name);
if (file.isDirectory()) {
mkdirSync(destPath, { recursive: true });
copyDirectory(srcPath, destPath);
} else {
copyFileSync(srcPath, destPath);
}
}
}
function separatePublicAssetsFromFunctions({
outDir,
functionDir,
assetsDir,
}: {
outDir: string;
functionDir: string;
assetsDir: string;
}) {
const tempDist = path.join(
os.tmpdir(),
`dist_${randomBytes(16).toString("hex")}`
);
const tempPublicDir = path.join(tempDist, DIST_PUBLIC);
const workerPublicDir = path.join(functionDir, DIST_PUBLIC);
// Create a temp dir to prepare the separated files
rmSync(tempDist, { recursive: true, force: true });
mkdirSync(tempDist, { recursive: true });
// Move the current dist dir to the temp dir
// Folders are copied instead of moved to avoid issues on Windows
copyDirectory(outDir, tempDist);
rmSync(outDir, { recursive: true, force: true });
// Create empty directories at the desired deploy locations
// for the function and the assets
mkdirSync(functionDir, { recursive: true });
mkdirSync(assetsDir, { recursive: true });
// Move tempDist/public to assetsDir
copyDirectory(tempPublicDir, assetsDir);
rmSync(tempPublicDir, { recursive: true, force: true });
// Move tempDist to functionDir
copyDirectory(tempDist, functionDir);
rmSync(tempDist, { recursive: true, force: true });
// Traverse assetsDir and copy specific files to functionDir/public
mkdirSync(workerPublicDir, { recursive: true });
copyFiles(assetsDir, workerPublicDir, [
".txt",
".html",
".json",
".js",
".css",
]);
}
// Since this plugin is currently added in `unstable_viteConfigs` in waku.config.ts,
// assume it should be used if deploy is empty.
function isDeployTarget(deploy: string | undefined) {
return !deploy || deploy === "cloudflare-pages";
}
export function deployCloudflarePagesPlugin(opts: {
srcDir: string;
distDir: string;
privateDir: string;
unstable_honoEnhancer: string | undefined;
}): Plugin {
const buildOptions = getBuildOptions();
let rootDir: string;
let entriesFile: string;
let honoEnhancerFile: string | undefined;
return {
name: "deploy-cloudflare-pages-plugin",
config(viteConfig) {
const { deploy, unstable_phase } = buildOptions;
if (unstable_phase !== "buildServerBundle" || !isDeployTarget(deploy)) {
return;
}
const { input } = viteConfig.build?.rollupOptions ?? {};
if (input && !(typeof input === "string") && !(input instanceof Array)) {
input[SERVE_JS.replace(/\.js$/, "")] = `${opts.srcDir}/${SERVE_JS}`;
}
},
configResolved(config) {
rootDir = config.root;
entriesFile = `${rootDir}/${opts.srcDir}/${SRC_ENTRIES}`;
if (opts.unstable_honoEnhancer) {
honoEnhancerFile = `${rootDir}/${opts.unstable_honoEnhancer}`;
}
const { deploy, unstable_phase } = buildOptions;
if (
(unstable_phase !== "buildServerBundle" &&
unstable_phase !== "buildSsrBundle") || !isDeployTarget(deploy)
) {
return;
}
config.ssr.target = "webworker";
config.ssr.resolve ||= {};
config.ssr.resolve.conditions ||= [];
config.ssr.resolve.conditions.push("worker");
config.ssr.resolve.externalConditions ||= [];
config.ssr.resolve.externalConditions.push("worker");
},
resolveId(source) {
if (source === `${opts.srcDir}/${SERVE_JS}`) {
return source;
}
},
load(id) {
if (id === `${opts.srcDir}/${SERVE_JS}`) {
return getServeJsContent(entriesFile, honoEnhancerFile);
}
},
async closeBundle() {
const { deploy, unstable_phase } = buildOptions;
if (unstable_phase !== "buildDeploy" || !isDeployTarget(deploy)) {
return;
}
const outDir = path.join(rootDir, opts.distDir);
const assetsDistDir = path.join(outDir, "_assets_tmp");
const workerDistDir = path.join(outDir, WORKER_JS_NAME);
// Move the public static assets to a separate folder from the server files
separatePublicAssetsFromFunctions({
outDir,
assetsDir: assetsDistDir,
functionDir: workerDistDir,
});
const workerEntrypoint = path.join(outDir, WORKER_JS_NAME, 'index.js');
if (!existsSync(workerEntrypoint)) {
writeFileSync(workerEntrypoint, getIndexJsContent(SERVE_JS));
}
// Create _routes.json if one doesn't already exist in the public dir
// https://developers.cloudflare.com/pages/functions/routing/#functions-invocation-routes
const routesFile = path.join(outDir, ROUTES_JSON_NAME);
if (!existsSync(path.join(assetsDistDir, ROUTES_JSON_NAME))) {
// exclude strategy
const staticPaths: string[] = ["/assets/*"];
const paths = getFiles(assetsDistDir);
for (const p of paths) {
const basePath = path.dirname(p.replace(assetsDistDir, "")) || "/";
const name = path.basename(p);
const entry =
name === "index.html"
? basePath + (basePath !== "/" ? "/" : "")
: path.join(basePath, name.replace(/\.html$/, ""));
if (
entry.startsWith("/assets/") ||
entry.startsWith("/" + WORKER_JS_NAME + "/") ||
entry === "/" + WORKER_JS_NAME ||
entry === "/" + ROUTES_JSON_NAME ||
entry === "/" + HEADERS_NAME
) {
continue;
}
if (!staticPaths.includes(entry)) {
staticPaths.push(entry);
}
}
const MAX_CLOUDFLARE_RULES = 100;
if (staticPaths.length + 1 > MAX_CLOUDFLARE_RULES) {
throw new Error(
`The number of static paths exceeds the limit of ${MAX_CLOUDFLARE_RULES}. ` +
`You need to create a custom ${ROUTES_JSON_NAME} file in the public folder. ` +
`See https://developers.cloudflare.com/pages/functions/routing/#functions-invocation-routes`
);
}
const staticRoutes: StaticRoutes = {
version: 1,
include: ["/*"],
exclude: staticPaths,
};
writeFileSync(routesFile, JSON.stringify(staticRoutes));
}
// Move the public files to the root of the dist folder
const publicPaths = readdirSync(assetsDistDir);
for (const p of publicPaths) {
renameSync(
path.join(assetsDistDir, p),
path.join(outDir, p)
);
}
rmSync(path.join(assetsDistDir), {
recursive: true,
force: true,
});
await emitPlatformData(workerDistDir);
const wranglerTomlFile = path.join(rootDir, "wrangler.toml");
const wranglerJsonFile = path.join(rootDir, "wrangler.json");
const wranglerJsoncFile = path.join(rootDir, "wrangler.jsonc");
if (
!existsSync(wranglerTomlFile) &&
!existsSync(wranglerJsonFile) &&
!existsSync(wranglerJsoncFile)
) {
writeFileSync(
wranglerJsonFile,
`
{
"name": "waku-project",
"pages_build_output_dir": "./dist",
// https://developers.cloudflare.com/workers/platform/compatibility-dates
"compatibility_date": "2024-11-11",
// nodejs_als is required for Waku server-side request context
// It can be removed if only building static pages
"compatibility_flags": ["nodejs_als"],
// https://developers.cloudflare.com/workers/static-assets/binding/
"assets": {
"binding": "ASSETS",
"directory": "./dist/assets",
"html_handling": "drop-trailing-slash",
"not_found_handling": "404-page",
}
}
`
);
}
},
};
}

Cloudflare Pages Vite deploy plugin for Waku

npm create waku --template 07_cloudflare

Then copy in vite-plugin-deploy-cloudflare-pages.ts and update waku.config.ts.

Then update the scripts section of package.json:

  "scripts": {
    "build-cf-types": "wrangler types",
    "dev": "waku dev",
    "build": "waku build",
    "start": "wrangler pages dev",
    "deploy": "waku build && wrangler pages deploy"
  },

The --with-cloudflare param for waku build is not needed with this configuration. The custom vite plugin added in waku.config.ts will run when no deploy cli argument is present.

import { defineConfig } from 'waku/config';
import { deployCloudflarePagesPlugin } from './vite-plugin-deploy-cloudflare-pages';
const deployCloudflarePages = () => {
return {
plugins: [
deployCloudflarePagesPlugin({
distDir: 'dist',
privateDir: 'private',
srcDir: 'src',
unstable_honoEnhancer: undefined,
}),
],
};
}
export default defineConfig({
unstable_honoEnhancer: './waku.hono-enhancer',
middleware: [
'waku/middleware/context',
'waku/middleware/dev-server',
'./waku.cloudflare-middleware',
'waku/middleware/handler',
],
unstable_viteConfigs: {
"build-analyze": deployCloudflarePages,
"build-server": deployCloudflarePages,
"build-ssr": deployCloudflarePages,
"build-client": deployCloudflarePages,
"build-deploy": deployCloudflarePages,
},
});
{
"name": "waku-project",
// https://developers.cloudflare.com/pages/functions/wrangler-configuration/
"pages_build_output_dir": "./dist",
"compatibility_date": "2024-11-11",
"compatibility_flags": ["nodejs_als"],
"vars": {
"MAX_ITEMS": "10"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment