|
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", |
|
} |
|
} |
|
` |
|
); |
|
} |
|
}, |
|
}; |
|
} |
|
|