diff --git a/README.md b/README.md index f82419163..f6e0b435c 100644 --- a/README.md +++ b/README.md @@ -1007,7 +1007,7 @@ Note: When rendering in static mode, please be sure to return `render: 'static'` ``` npm run build -- --with-cloudflare -npx wrangler dev # or deploy +npx wrangler pages dev # or deploy ``` ### PartyKit (experimental) diff --git a/packages/waku/src/lib/builder/build.ts b/packages/waku/src/lib/builder/build.ts index b809bb51f..9f8b7834e 100644 --- a/packages/waku/src/lib/builder/build.ts +++ b/packages/waku/src/lib/builder/build.ts @@ -48,22 +48,21 @@ import { rscAnalyzePlugin } from '../plugins/vite-plugin-rsc-analyze.js'; import { nonjsResolvePlugin } from '../plugins/vite-plugin-nonjs-resolve.js'; import { rscTransformPlugin } from '../plugins/vite-plugin-rsc-transform.js'; import { rscEntriesPlugin } from '../plugins/vite-plugin-rsc-entries.js'; -import { rscServePlugin } from '../plugins/vite-plugin-rsc-serve.js'; import { rscEnvPlugin } from '../plugins/vite-plugin-rsc-env.js'; import { rscPrivatePlugin } from '../plugins/vite-plugin-rsc-private.js'; import { rscManagedPlugin } from '../plugins/vite-plugin-rsc-managed.js'; -import { emitVercelOutput } from './output-vercel.js'; -import { emitNetlifyOutput } from './output-netlify.js'; -import { emitCloudflareOutput } from './output-cloudflare.js'; -import { emitPartyKitOutput } from './output-partykit.js'; -import { emitAwsLambdaOutput } from './output-aws-lambda.js'; import { DIST_ENTRIES_JS, - DIST_SERVE_JS, DIST_PUBLIC, DIST_ASSETS, DIST_SSR, } from './constants.js'; +import { deployVercelPlugin } from '../plugins/vite-plugin-deploy-vercel.js'; +import { deployNetlifyPlugin } from '../plugins/vite-plugin-deploy-netlify.js'; +import { deployCloudflarePlugin } from '../plugins/vite-plugin-deploy-cloudflare.js'; +import { deployDenoPlugin } from '../plugins/vite-plugin-deploy-deno.js'; +import { deployPartykitPlugin } from '../plugins/vite-plugin-deploy-partykit.js'; +import { deployAwsLambdaPlugin } from '../plugins/vite-plugin-deploy-aws-lambda.js'; // TODO this file and functions in it are too long. will fix. @@ -84,6 +83,15 @@ const onwarn = (warning: RollupLog, defaultHandler: LoggingFunction) => { defaultHandler(warning); }; +const deployPlugins = (config: ResolvedConfig) => [ + deployVercelPlugin(config), + deployNetlifyPlugin(config), + deployCloudflarePlugin(config), + deployDenoPlugin(config), + deployPartykitPlugin(config), + deployAwsLambdaPlugin(config), +]; + const analyzeEntries = async (rootDir: string, config: ResolvedConfig) => { const wakuClientDist = decodeFilePathFromAbsolute( joinPath(fileURLToFilePath(import.meta.url), '../../../client.js'), @@ -183,14 +191,6 @@ const buildServerBundle = async ( clientEntryFiles: Record, serverEntryFiles: Record, serverModuleFiles: Record, - serve: - | 'vercel' - | 'netlify' - | 'cloudflare' - | 'partykit' - | 'deno' - | 'aws-lambda' - | false, isNodeCompatible: boolean, partial: boolean, ) => { @@ -237,22 +237,7 @@ const buildServerBundle = async ( ), }, }), - ...(serve - ? [ - rscServePlugin({ - ...config, - distServeJs: DIST_SERVE_JS, - distPublic: DIST_PUBLIC, - srcServeFile: decodeFilePathFromAbsolute( - joinPath( - fileURLToFilePath(import.meta.url), - `../serve-${serve}.js`, - ), - ), - serve, - }), - ] - : []), + ...deployPlugins(config), ], ssr: isNodeCompatible ? { @@ -670,6 +655,51 @@ export const publicIndexHtml = ${JSON.stringify(publicIndexHtml)}; await appendFile(distEntriesFile, code); }; +// For Deploy +const buildDeploy = async (rootDir: string, config: ResolvedConfig) => { + const DUMMY = 'dummy-entry'; + await buildVite({ + plugins: [ + { + // FIXME This is too hacky. There must be a better way. + name: 'dummy-entry-plugin', + resolveId(source) { + if (source === DUMMY) { + return source; + } + }, + load(id) { + if (id === DUMMY) { + return ''; + } + }, + generateBundle(_options, bundle) { + Object.entries(bundle).forEach(([key, value]) => { + if (value.name === DUMMY) { + delete bundle[key]; + } + }); + }, + }, + ...deployPlugins(config), + ], + publicDir: false, + build: { + emptyOutDir: false, + ssr: true, + rollupOptions: { + onwarn: (warning, warn) => { + if (!warning.message.startsWith('Generated an empty chunk:')) { + warn(warning); + } + }, + input: { [DUMMY]: DUMMY }, + }, + outDir: joinPath(rootDir, config.distDir), + }, + }); +}; + export async function build(options: { config: Config; env?: Record; @@ -700,8 +730,10 @@ export async function build(options: { platformObject.buildOptions ||= {}; platformObject.buildOptions.deploy = options.deploy; + platformObject.buildOptions.unstable_phase = 'analyzeEntries'; const { clientEntryFiles, serverEntryFiles, serverModuleFiles } = await analyzeEntries(rootDir, config); + platformObject.buildOptions.unstable_phase = 'buildServerBundle'; const serverBuildOutput = await buildServerBundle( rootDir, env, @@ -709,15 +741,10 @@ export async function build(options: { clientEntryFiles, serverEntryFiles, serverModuleFiles, - (options.deploy === 'vercel-serverless' ? 'vercel' : false) || - (options.deploy === 'netlify-functions' ? 'netlify' : false) || - (options.deploy === 'cloudflare' ? 'cloudflare' : false) || - (options.deploy === 'partykit' ? 'partykit' : false) || - (options.deploy === 'deno' ? 'deno' : false) || - (options.deploy === 'aws-lambda' ? 'aws-lambda' : false), isNodeCompatible, !!options.partial, ); + platformObject.buildOptions.unstable_phase = 'buildSsrBundle'; await buildSsrBundle( rootDir, env, @@ -728,6 +755,7 @@ export async function build(options: { isNodeCompatible, !!options.partial, ); + platformObject.buildOptions.unstable_phase = 'buildClientBundle'; const clientBuildOutput = await buildClientBundle( rootDir, env, @@ -737,6 +765,7 @@ export async function build(options: { serverBuildOutput, !!options.partial, ); + delete platformObject.buildOptions.unstable_phase; const distEntries = await import(filePathToFileURL(distEntriesFile)); @@ -763,27 +792,9 @@ export async function build(options: { clientBuildOutput, ); - if (options.deploy?.startsWith('vercel-')) { - await emitVercelOutput( - rootDir, - config, - DIST_SERVE_JS, - options.deploy.slice('vercel-'.length) as 'static' | 'serverless', - ); - } else if (options.deploy?.startsWith('netlify-')) { - await emitNetlifyOutput( - rootDir, - config, - DIST_SERVE_JS, - options.deploy.slice('netlify-'.length) as 'static' | 'functions', - ); - } else if (options.deploy === 'cloudflare') { - await emitCloudflareOutput(rootDir, config, DIST_SERVE_JS); - } else if (options.deploy === 'partykit') { - await emitPartyKitOutput(rootDir, config, DIST_SERVE_JS); - } else if (options.deploy === 'aws-lambda') { - await emitAwsLambdaOutput(config); - } + platformObject.buildOptions.unstable_phase = 'buildDeploy'; + await buildDeploy(rootDir, config); + delete platformObject.buildOptions.unstable_phase; await appendFile( distEntriesFile, diff --git a/packages/waku/src/lib/builder/output-aws-lambda.ts b/packages/waku/src/lib/builder/output-aws-lambda.ts deleted file mode 100644 index 997cdc429..000000000 --- a/packages/waku/src/lib/builder/output-aws-lambda.ts +++ /dev/null @@ -1,10 +0,0 @@ -import path from 'node:path'; -import { writeFileSync } from 'node:fs'; -import type { ResolvedConfig } from '../config.js'; - -export const emitAwsLambdaOutput = async (config: ResolvedConfig) => { - writeFileSync( - path.join(config.distDir, 'package.json'), - JSON.stringify({ type: 'module' }, null, 2), - ); -}; diff --git a/packages/waku/src/lib/builder/output-cloudflare.ts b/packages/waku/src/lib/builder/output-cloudflare.ts deleted file mode 100644 index cd173e05f..000000000 --- a/packages/waku/src/lib/builder/output-cloudflare.ts +++ /dev/null @@ -1,108 +0,0 @@ -import path from 'node:path'; -import { - existsSync, - readdirSync, - writeFileSync, - mkdirSync, - renameSync, - rmSync, -} from 'node:fs'; -import type { ResolvedConfig } from '../config.js'; -import { DIST_PUBLIC } from './constants.js'; - -const WORKER_JS_NAME = '_worker.js'; -const ROUTES_JSON_NAME = '_routes.json'; - -type StaticRoutes = { version: number; include: string[]; exclude: string[] }; - -export const emitCloudflareOutput = async ( - rootDir: string, - config: ResolvedConfig, - serveJs: string, -) => { - const outDir = path.join(rootDir, config.distDir); - - // Advanced-mode Cloudflare Pages imports _worker.js - // and can be configured with _routes.json to serve other static root files - mkdirSync(path.join(outDir, WORKER_JS_NAME)); - const outPaths = readdirSync(outDir); - for (const p of outPaths) { - if (p === WORKER_JS_NAME) { - continue; - } - renameSync(path.join(outDir, p), path.join(outDir, WORKER_JS_NAME, p)); - } - - const workerEntrypoint = path.join(outDir, WORKER_JS_NAME, 'index.js'); - if (!existsSync(workerEntrypoint)) { - writeFileSync( - workerEntrypoint, - ` -import server from './${serveJs}' - -export default { - ...server -} -`, - ); - } - - // 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); - const publicDir = path.join(outDir, WORKER_JS_NAME, DIST_PUBLIC); - if (!existsSync(path.join(publicDir, ROUTES_JSON_NAME))) { - const staticPaths: string[] = []; - const paths = readdirSync(publicDir, { - withFileTypes: true, - }); - for (const p of paths) { - if (p.isDirectory()) { - const entry = `/${p.name}/*`; - if (!staticPaths.includes(entry)) { - staticPaths.push(entry); - } - } else { - if (p.name === WORKER_JS_NAME) { - return; - } - staticPaths.push(`/${p.name}`); - } - } - 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( - path.join(outDir, WORKER_JS_NAME, DIST_PUBLIC), - ); - for (const p of publicPaths) { - renameSync( - path.join(outDir, WORKER_JS_NAME, DIST_PUBLIC, p), - path.join(outDir, p), - ); - } - rmSync(path.join(outDir, WORKER_JS_NAME, DIST_PUBLIC), { - recursive: true, - force: true, - }); - - const wranglerTomlFile = path.join(rootDir, 'wrangler.toml'); - if (!existsSync(wranglerTomlFile)) { - writeFileSync( - wranglerTomlFile, - ` -# See https://developers.cloudflare.com/pages/functions/wrangler-configuration/ -name = "waku-project" -compatibility_date = "2024-04-03" -compatibility_flags = [ "nodejs_als" ] -pages_build_output_dir = "./dist" -`, - ); - } -}; diff --git a/packages/waku/src/lib/builder/output-netlify.ts b/packages/waku/src/lib/builder/output-netlify.ts deleted file mode 100644 index c37e210cc..000000000 --- a/packages/waku/src/lib/builder/output-netlify.ts +++ /dev/null @@ -1,52 +0,0 @@ -import path from 'node:path'; -import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; - -import type { ResolvedConfig } from '../config.js'; -import { DIST_PUBLIC } from './constants.js'; - -export const emitNetlifyOutput = async ( - rootDir: string, - config: ResolvedConfig, - serveJs: string, - type: 'static' | 'functions', -) => { - if (type === 'functions') { - const functionsDir = path.join(rootDir, 'netlify/functions'); - mkdirSync(functionsDir, { - recursive: true, - }); - const notFoundFile = path.join( - rootDir, - config.distDir, - DIST_PUBLIC, - '404.html', - ); - const notFoundHtml = existsSync(notFoundFile) - ? readFileSync(notFoundFile, 'utf8') - : null; - writeFileSync( - path.join(functionsDir, 'serve.js'), - ` -globalThis.__WAKU_NOT_FOUND_HTML__ = ${JSON.stringify(notFoundHtml)}; -export { default } from '../../${config.distDir}/${serveJs}'; -export const config = { - preferStatic: true, - path: ['/', '/*'], -}; -`, - ); - } - const netlifyTomlFile = path.join(rootDir, 'netlify.toml'); - if (!existsSync(netlifyTomlFile)) { - writeFileSync( - netlifyTomlFile, - ` -[build] - command = "npm run build -- --with-netlify" - publish = "${config.distDir}/${DIST_PUBLIC}" -[functions] - included_files = ["${config.privateDir}/**"] -`, - ); - } -}; diff --git a/packages/waku/src/lib/builder/output-partykit.ts b/packages/waku/src/lib/builder/output-partykit.ts deleted file mode 100644 index 8a93b4573..000000000 --- a/packages/waku/src/lib/builder/output-partykit.ts +++ /dev/null @@ -1,29 +0,0 @@ -import path from 'node:path'; -import { existsSync, writeFileSync } from 'node:fs'; - -import type { ResolvedConfig } from '../config.js'; -import { DIST_PUBLIC } from './constants.js'; - -// XXX this can be very limited. FIXME if anyone has better knowledge. -export const emitPartyKitOutput = async ( - rootDir: string, - config: ResolvedConfig, - serveJs: string, -) => { - const partykitJsonFile = path.join(rootDir, 'partykit.json'); - if (!existsSync(partykitJsonFile)) { - writeFileSync( - partykitJsonFile, - JSON.stringify( - { - name: 'waku-project', - main: `${config.distDir}/${serveJs}`, - compatibilityDate: '2023-02-16', - serve: `./${config.distDir}/${DIST_PUBLIC}`, - }, - null, - 2, - ) + '\n', - ); - } -}; diff --git a/packages/waku/src/lib/builder/output-vercel.ts b/packages/waku/src/lib/builder/output-vercel.ts deleted file mode 100644 index eafdcc217..000000000 --- a/packages/waku/src/lib/builder/output-vercel.ts +++ /dev/null @@ -1,71 +0,0 @@ -import path from 'node:path'; -import { cpSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; - -import type { ResolvedConfig } from '../config.js'; -import { DIST_PUBLIC } from './constants.js'; - -// https://vercel.com/docs/build-output-api/v3 -export const emitVercelOutput = async ( - rootDir: string, - config: ResolvedConfig, - serveJs: string, - type: 'static' | 'serverless', -) => { - const publicDir = path.join(rootDir, config.distDir, DIST_PUBLIC); - const outputDir = path.resolve('.vercel', 'output'); - cpSync(publicDir, path.join(outputDir, 'static'), { recursive: true }); - - if (type === 'serverless') { - // for serverless function - const serverlessDir = path.join( - outputDir, - 'functions', - config.rscPath + '.func', - ); - mkdirSync(path.join(serverlessDir, config.distDir), { - recursive: true, - }); - cpSync( - path.join(rootDir, config.distDir), - path.join(serverlessDir, config.distDir), - { recursive: true }, - ); - if (existsSync(path.join(rootDir, config.privateDir))) { - cpSync( - path.join(rootDir, config.privateDir), - path.join(serverlessDir, config.privateDir), - { recursive: true, dereference: true }, - ); - } - const vcConfigJson = { - runtime: 'nodejs20.x', - handler: `${config.distDir}/${serveJs}`, - launcherType: 'Nodejs', - }; - writeFileSync( - path.join(serverlessDir, '.vc-config.json'), - JSON.stringify(vcConfigJson, null, 2), - ); - writeFileSync( - path.join(serverlessDir, 'package.json'), - JSON.stringify({ type: 'module' }, null, 2), - ); - } - - const routes = - type === 'serverless' - ? [ - { handle: 'filesystem' }, - { - src: config.basePath + '(.*)', - dest: config.basePath + config.rscPath + '/', - }, - ] - : undefined; - const configJson = { version: 3, routes }; - mkdirSync(outputDir, { recursive: true }); - writeFileSync( - path.join(outputDir, 'config.json'), - JSON.stringify(configJson, null, 2), - ); -}; diff --git a/packages/waku/src/lib/plugins/vite-plugin-deploy-aws-lambda.ts b/packages/waku/src/lib/plugins/vite-plugin-deploy-aws-lambda.ts new file mode 100644 index 000000000..34ab89a84 --- /dev/null +++ b/packages/waku/src/lib/plugins/vite-plugin-deploy-aws-lambda.ts @@ -0,0 +1,82 @@ +import path from 'node:path'; +import { existsSync, writeFileSync } from 'node:fs'; +import { normalizePath } from 'vite'; +import type { Plugin } from 'vite'; + +// HACK: Depending on a different plugin isn't ideal. +// Maybe we could put in vite config object? +import { SRC_ENTRIES } from './vite-plugin-rsc-managed.js'; + +import { unstable_getPlatformObject } from '../../server.js'; +import { EXTENSIONS } from '../config.js'; +import { + decodeFilePathFromAbsolute, + extname, + fileURLToFilePath, + joinPath, +} from '../utils/path.js'; +import { DIST_SERVE_JS, DIST_PUBLIC } from '../builder/constants.js'; + +const resolveFileName = (fname: string) => { + for (const ext of EXTENSIONS) { + const resolvedName = fname.slice(0, -extname(fname).length) + ext; + if (existsSync(resolvedName)) { + return resolvedName; + } + } + return fname; // returning the default one +}; + +const srcServeFile = decodeFilePathFromAbsolute( + joinPath( + fileURLToFilePath(import.meta.url), + '../../builder/serve-aws-lambda.js', + ), +); + +export function deployAwsLambdaPlugin(opts: { + srcDir: string; + distDir: string; +}): Plugin { + const platformObject = unstable_getPlatformObject(); + return { + name: 'deploy-aws-lambda-plugin', + config(viteConfig) { + const { deploy, unstable_phase } = platformObject.buildOptions || {}; + if (unstable_phase !== 'buildServerBundle' || deploy !== 'aws-lambda') { + return; + } + + // FIXME This seems too hacky (The use of viteConfig.root, '.', path.resolve and resolveFileName) + const entriesFile = normalizePath( + resolveFileName( + path.resolve( + viteConfig.root || '.', + opts.srcDir, + SRC_ENTRIES + '.jsx', + ), + ), + ); + const { input } = viteConfig.build?.rollupOptions ?? {}; + if (input && !(typeof input === 'string') && !(input instanceof Array)) { + input[DIST_SERVE_JS.replace(/\.js$/, '')] = srcServeFile; + } + viteConfig.define = { + ...viteConfig.define, + 'import.meta.env.WAKU_ENTRIES_FILE': JSON.stringify(entriesFile), + 'import.meta.env.WAKU_CONFIG_PUBLIC_DIR': JSON.stringify(DIST_PUBLIC), + }; + }, + closeBundle() { + const { deploy, unstable_phase } = platformObject.buildOptions || {}; + if (unstable_phase !== 'buildDeploy' || deploy !== 'aws-lambda') { + return; + } + + writeFileSync( + path.join(opts.distDir, 'package.json'), + JSON.stringify({ type: 'module' }, null, 2), + ); + }, + }; +} diff --git a/packages/waku/src/lib/plugins/vite-plugin-deploy-cloudflare.ts b/packages/waku/src/lib/plugins/vite-plugin-deploy-cloudflare.ts new file mode 100644 index 000000000..a7accb480 --- /dev/null +++ b/packages/waku/src/lib/plugins/vite-plugin-deploy-cloudflare.ts @@ -0,0 +1,178 @@ +import path from 'node:path'; +import { + existsSync, + mkdirSync, + readdirSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { normalizePath } from 'vite'; +import type { Plugin } from 'vite'; + +// HACK: Depending on a different plugin isn't ideal. +// Maybe we could put in vite config object? +import { SRC_ENTRIES } from './vite-plugin-rsc-managed.js'; + +import { unstable_getPlatformObject } from '../../server.js'; +import { EXTENSIONS } from '../config.js'; +import { + decodeFilePathFromAbsolute, + extname, + fileURLToFilePath, + joinPath, +} from '../utils/path.js'; +import { DIST_SERVE_JS, DIST_PUBLIC } from '../builder/constants.js'; + +const resolveFileName = (fname: string) => { + for (const ext of EXTENSIONS) { + const resolvedName = fname.slice(0, -extname(fname).length) + ext; + if (existsSync(resolvedName)) { + return resolvedName; + } + } + return fname; // returning the default one +}; + +const srcServeFile = decodeFilePathFromAbsolute( + joinPath( + fileURLToFilePath(import.meta.url), + '../../builder/serve-cloudflare.js', + ), +); + +const WORKER_JS_NAME = '_worker.js'; +const ROUTES_JSON_NAME = '_routes.json'; + +type StaticRoutes = { version: number; include: string[]; exclude: string[] }; + +export function deployCloudflarePlugin(opts: { + srcDir: string; + distDir: string; +}): Plugin { + const platformObject = unstable_getPlatformObject(); + let rootDir: string; + return { + name: 'deploy-cloudflare-plugin', + config(viteConfig) { + const { deploy, unstable_phase } = platformObject.buildOptions || {}; + if (unstable_phase !== 'buildServerBundle' || deploy !== 'cloudflare') { + return; + } + + // FIXME This seems too hacky (The use of viteConfig.root, '.', path.resolve and resolveFileName) + const entriesFile = normalizePath( + resolveFileName( + path.resolve( + viteConfig.root || '.', + opts.srcDir, + SRC_ENTRIES + '.jsx', + ), + ), + ); + const { input } = viteConfig.build?.rollupOptions ?? {}; + if (input && !(typeof input === 'string') && !(input instanceof Array)) { + input[DIST_SERVE_JS.replace(/\.js$/, '')] = srcServeFile; + } + viteConfig.define = { + ...viteConfig.define, + 'import.meta.env.WAKU_ENTRIES_FILE': JSON.stringify(entriesFile), + }; + }, + configResolved(config) { + rootDir = config.root; + }, + closeBundle() { + const { deploy, unstable_phase } = platformObject.buildOptions || {}; + if (unstable_phase !== 'buildDeploy' || deploy !== 'cloudflare') { + return; + } + + const outDir = path.join(rootDir, opts.distDir); + + // Advanced-mode Cloudflare Pages imports _worker.js + // and can be configured with _routes.json to serve other static root files + mkdirSync(path.join(outDir, WORKER_JS_NAME)); + const outPaths = readdirSync(outDir); + for (const p of outPaths) { + if (p === WORKER_JS_NAME) { + continue; + } + renameSync(path.join(outDir, p), path.join(outDir, WORKER_JS_NAME, p)); + } + + const workerEntrypoint = path.join(outDir, WORKER_JS_NAME, 'index.js'); + if (!existsSync(workerEntrypoint)) { + writeFileSync( + workerEntrypoint, + ` +import server from './${DIST_SERVE_JS}' + +export default { + ...server +} +`, + ); + } + + // 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); + const publicDir = path.join(outDir, WORKER_JS_NAME, DIST_PUBLIC); + if (!existsSync(path.join(publicDir, ROUTES_JSON_NAME))) { + const staticPaths: string[] = []; + const paths = readdirSync(publicDir, { + withFileTypes: true, + }); + for (const p of paths) { + if (p.isDirectory()) { + const entry = `/${p.name}/*`; + if (!staticPaths.includes(entry)) { + staticPaths.push(entry); + } + } else { + if (p.name === WORKER_JS_NAME) { + return; + } + staticPaths.push(`/${p.name}`); + } + } + 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( + path.join(outDir, WORKER_JS_NAME, DIST_PUBLIC), + ); + for (const p of publicPaths) { + renameSync( + path.join(outDir, WORKER_JS_NAME, DIST_PUBLIC, p), + path.join(outDir, p), + ); + } + rmSync(path.join(outDir, WORKER_JS_NAME, DIST_PUBLIC), { + recursive: true, + force: true, + }); + + const wranglerTomlFile = path.join(rootDir, 'wrangler.toml'); + if (!existsSync(wranglerTomlFile)) { + writeFileSync( + wranglerTomlFile, + ` +# See https://developers.cloudflare.com/pages/functions/wrangler-configuration/ +name = "waku-project" +compatibility_date = "2024-04-03" +compatibility_flags = [ "nodejs_als" ] +pages_build_output_dir = "./dist" +`, + ); + } + }, + }; +} diff --git a/packages/waku/src/lib/plugins/vite-plugin-rsc-serve.ts b/packages/waku/src/lib/plugins/vite-plugin-deploy-deno.ts similarity index 63% rename from packages/waku/src/lib/plugins/vite-plugin-rsc-serve.ts rename to packages/waku/src/lib/plugins/vite-plugin-deploy-deno.ts index 1ef85ca5a..6c2906d2a 100644 --- a/packages/waku/src/lib/plugins/vite-plugin-rsc-serve.ts +++ b/packages/waku/src/lib/plugins/vite-plugin-deploy-deno.ts @@ -1,5 +1,5 @@ -import { existsSync } from 'node:fs'; import path from 'node:path'; +import { existsSync } from 'node:fs'; import { normalizePath } from 'vite'; import type { Plugin } from 'vite'; @@ -7,8 +7,15 @@ import type { Plugin } from 'vite'; // Maybe we could put in vite config object? import { SRC_ENTRIES } from './vite-plugin-rsc-managed.js'; +import { unstable_getPlatformObject } from '../../server.js'; import { EXTENSIONS } from '../config.js'; -import { extname } from '../utils/path.js'; +import { + decodeFilePathFromAbsolute, + extname, + fileURLToFilePath, + joinPath, +} from '../utils/path.js'; +import { DIST_SERVE_JS, DIST_PUBLIC } from '../builder/constants.js'; const resolveFileName = (fname: string) => { for (const ext of EXTENSIONS) { @@ -20,23 +27,23 @@ const resolveFileName = (fname: string) => { return fname; // returning the default one }; -export function rscServePlugin(opts: { +const srcServeFile = decodeFilePathFromAbsolute( + joinPath(fileURLToFilePath(import.meta.url), '../../builder/serve-deno.js'), +); + +export function deployDenoPlugin(opts: { srcDir: string; - distServeJs: string; distDir: string; - distPublic: string; - srcServeFile: string; - serve: - | 'vercel' - | 'netlify' - | 'cloudflare' - | 'partykit' - | 'deno' - | 'aws-lambda'; }): Plugin { + const platformObject = unstable_getPlatformObject(); return { - name: 'rsc-serve-plugin', + name: 'deploy-deno-plugin', config(viteConfig) { + const { deploy, unstable_phase } = platformObject.buildOptions || {}; + if (unstable_phase !== 'buildServerBundle' || deploy !== 'deno') { + return; + } + // FIXME This seems too hacky (The use of viteConfig.root, '.', path.resolve and resolveFileName) const entriesFile = normalizePath( resolveFileName( @@ -49,28 +56,14 @@ export function rscServePlugin(opts: { ); const { input } = viteConfig.build?.rollupOptions ?? {}; if (input && !(typeof input === 'string') && !(input instanceof Array)) { - input[opts.distServeJs.replace(/\.js$/, '')] = opts.srcServeFile; + input[DIST_SERVE_JS.replace(/\.js$/, '')] = srcServeFile; } viteConfig.define = { ...viteConfig.define, 'import.meta.env.WAKU_ENTRIES_FILE': JSON.stringify(entriesFile), 'import.meta.env.WAKU_CONFIG_DIST_DIR': JSON.stringify(opts.distDir), - 'import.meta.env.WAKU_CONFIG_PUBLIC_DIR': JSON.stringify( - opts.distPublic, - ), + 'import.meta.env.WAKU_CONFIG_PUBLIC_DIR': JSON.stringify(DIST_PUBLIC), }; - if (opts.serve === 'partykit') { - viteConfig.build ||= {}; - viteConfig.build.rollupOptions ||= {}; - viteConfig.build.rollupOptions.external ||= []; - if (Array.isArray(viteConfig.build.rollupOptions.external)) { - viteConfig.build.rollupOptions.external.push('hono'); - } else { - throw new Error( - 'Unsupported: build.rollupOptions.external is not an array', - ); - } - } }, }; } diff --git a/packages/waku/src/lib/plugins/vite-plugin-deploy-netlify.ts b/packages/waku/src/lib/plugins/vite-plugin-deploy-netlify.ts new file mode 100644 index 000000000..c4a92e003 --- /dev/null +++ b/packages/waku/src/lib/plugins/vite-plugin-deploy-netlify.ts @@ -0,0 +1,127 @@ +import path from 'node:path'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { normalizePath } from 'vite'; +import type { Plugin } from 'vite'; + +// HACK: Depending on a different plugin isn't ideal. +// Maybe we could put in vite config object? +import { SRC_ENTRIES } from './vite-plugin-rsc-managed.js'; + +import { unstable_getPlatformObject } from '../../server.js'; +import { EXTENSIONS } from '../config.js'; +import { + decodeFilePathFromAbsolute, + extname, + fileURLToFilePath, + joinPath, +} from '../utils/path.js'; +import { DIST_SERVE_JS, DIST_PUBLIC } from '../builder/constants.js'; + +const resolveFileName = (fname: string) => { + for (const ext of EXTENSIONS) { + const resolvedName = fname.slice(0, -extname(fname).length) + ext; + if (existsSync(resolvedName)) { + return resolvedName; + } + } + return fname; // returning the default one +}; + +const srcServeFile = decodeFilePathFromAbsolute( + joinPath( + fileURLToFilePath(import.meta.url), + '../../builder/serve-netlify.js', + ), +); + +export function deployNetlifyPlugin(opts: { + srcDir: string; + distDir: string; + privateDir: string; +}): Plugin { + const platformObject = unstable_getPlatformObject(); + let rootDir: string; + return { + name: 'deploy-netlify-plugin', + config(viteConfig) { + const { deploy, unstable_phase } = platformObject.buildOptions || {}; + if ( + unstable_phase !== 'buildServerBundle' || + (deploy !== 'netlify-functions' && deploy !== 'netlify-static') + ) { + return; + } + + // FIXME This seems too hacky (The use of viteConfig.root, '.', path.resolve and resolveFileName) + const entriesFile = normalizePath( + resolveFileName( + path.resolve( + viteConfig.root || '.', + opts.srcDir, + SRC_ENTRIES + '.jsx', + ), + ), + ); + const { input } = viteConfig.build?.rollupOptions ?? {}; + if (input && !(typeof input === 'string') && !(input instanceof Array)) { + input[DIST_SERVE_JS.replace(/\.js$/, '')] = srcServeFile; + } + viteConfig.define = { + ...viteConfig.define, + 'import.meta.env.WAKU_ENTRIES_FILE': JSON.stringify(entriesFile), + }; + }, + configResolved(config) { + rootDir = config.root; + }, + closeBundle() { + const { deploy, unstable_phase } = platformObject.buildOptions || {}; + if ( + unstable_phase !== 'buildDeploy' || + (deploy !== 'netlify-functions' && deploy !== 'netlify-static') + ) { + return; + } + + if (deploy === 'netlify-functions') { + const functionsDir = path.join(rootDir, 'netlify/functions'); + mkdirSync(functionsDir, { + recursive: true, + }); + const notFoundFile = path.join( + rootDir, + opts.distDir, + DIST_PUBLIC, + '404.html', + ); + const notFoundHtml = existsSync(notFoundFile) + ? readFileSync(notFoundFile, 'utf8') + : null; + writeFileSync( + path.join(functionsDir, 'serve.js'), + ` +globalThis.__WAKU_NOT_FOUND_HTML__ = ${JSON.stringify(notFoundHtml)}; +export { default } from '../../${opts.distDir}/${DIST_SERVE_JS}'; +export const config = { + preferStatic: true, + path: ['/', '/*'], +}; +`, + ); + } + const netlifyTomlFile = path.join(rootDir, 'netlify.toml'); + if (!existsSync(netlifyTomlFile)) { + writeFileSync( + netlifyTomlFile, + ` +[build] + command = "npm run build -- --with-netlify" + publish = "${opts.distDir}/${DIST_PUBLIC}" +[functions] + included_files = ["${opts.privateDir}/**"] +`, + ); + } + }, + }; +} diff --git a/packages/waku/src/lib/plugins/vite-plugin-deploy-partykit.ts b/packages/waku/src/lib/plugins/vite-plugin-deploy-partykit.ts new file mode 100644 index 000000000..a294d6d08 --- /dev/null +++ b/packages/waku/src/lib/plugins/vite-plugin-deploy-partykit.ts @@ -0,0 +1,97 @@ +import path from 'node:path'; +import { existsSync, writeFileSync } from 'node:fs'; +import { normalizePath } from 'vite'; +import type { Plugin } from 'vite'; + +// HACK: Depending on a different plugin isn't ideal. +// Maybe we could put in vite config object? +import { SRC_ENTRIES } from './vite-plugin-rsc-managed.js'; + +import { unstable_getPlatformObject } from '../../server.js'; +import { EXTENSIONS } from '../config.js'; +import { + decodeFilePathFromAbsolute, + extname, + fileURLToFilePath, + joinPath, +} from '../utils/path.js'; +import { DIST_SERVE_JS, DIST_PUBLIC } from '../builder/constants.js'; + +const resolveFileName = (fname: string) => { + for (const ext of EXTENSIONS) { + const resolvedName = fname.slice(0, -extname(fname).length) + ext; + if (existsSync(resolvedName)) { + return resolvedName; + } + } + return fname; // returning the default one +}; + +const srcServeFile = decodeFilePathFromAbsolute( + joinPath( + fileURLToFilePath(import.meta.url), + '../../builder/serve-partykit.js', + ), +); + +export function deployPartykitPlugin(opts: { + srcDir: string; + distDir: string; +}): Plugin { + const platformObject = unstable_getPlatformObject(); + let rootDir: string; + return { + name: 'deploy-partykit-plugin', + config(viteConfig) { + const { deploy, unstable_phase } = platformObject.buildOptions || {}; + if (unstable_phase !== 'buildServerBundle' || deploy !== 'partykit') { + return; + } + + // FIXME This seems too hacky (The use of viteConfig.root, '.', path.resolve and resolveFileName) + const entriesFile = normalizePath( + resolveFileName( + path.resolve( + viteConfig.root || '.', + opts.srcDir, + SRC_ENTRIES + '.jsx', + ), + ), + ); + const { input } = viteConfig.build?.rollupOptions ?? {}; + if (input && !(typeof input === 'string') && !(input instanceof Array)) { + input[DIST_SERVE_JS.replace(/\.js$/, '')] = srcServeFile; + } + viteConfig.define = { + ...viteConfig.define, + 'import.meta.env.WAKU_ENTRIES_FILE': JSON.stringify(entriesFile), + }; + }, + configResolved(config) { + rootDir = config.root; + }, + closeBundle() { + const { deploy, unstable_phase } = platformObject.buildOptions || {}; + if (unstable_phase !== 'buildDeploy' || deploy !== 'partykit') { + return; + } + + const partykitJsonFile = path.join(rootDir, 'partykit.json'); + if (!existsSync(partykitJsonFile)) { + writeFileSync( + partykitJsonFile, + JSON.stringify( + { + name: 'waku-project', + main: `${opts.distDir}/${DIST_SERVE_JS}`, + compatibilityDate: '2023-02-16', + serve: `./${opts.distDir}/${DIST_PUBLIC}`, + }, + null, + 2, + ) + '\n', + ); + } + }, + }; +} diff --git a/packages/waku/src/lib/plugins/vite-plugin-deploy-vercel.ts b/packages/waku/src/lib/plugins/vite-plugin-deploy-vercel.ts new file mode 100644 index 000000000..4f0bfd1dc --- /dev/null +++ b/packages/waku/src/lib/plugins/vite-plugin-deploy-vercel.ts @@ -0,0 +1,146 @@ +import path from 'node:path'; +import { cpSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { normalizePath } from 'vite'; +import type { Plugin } from 'vite'; + +// HACK: Depending on a different plugin isn't ideal. +// Maybe we could put in vite config object? +import { SRC_ENTRIES } from './vite-plugin-rsc-managed.js'; + +import { unstable_getPlatformObject } from '../../server.js'; +import { EXTENSIONS } from '../config.js'; +import { + decodeFilePathFromAbsolute, + extname, + fileURLToFilePath, + joinPath, +} from '../utils/path.js'; +import { DIST_SERVE_JS, DIST_PUBLIC } from '../builder/constants.js'; + +const resolveFileName = (fname: string) => { + for (const ext of EXTENSIONS) { + const resolvedName = fname.slice(0, -extname(fname).length) + ext; + if (existsSync(resolvedName)) { + return resolvedName; + } + } + return fname; // returning the default one +}; + +const srcServeFile = decodeFilePathFromAbsolute( + joinPath(fileURLToFilePath(import.meta.url), '../../builder/serve-vercel.js'), +); + +export function deployVercelPlugin(opts: { + srcDir: string; + distDir: string; + basePath: string; + rscPath: string; + privateDir: string; +}): Plugin { + const platformObject = unstable_getPlatformObject(); + let rootDir: string; + return { + name: 'deploy-vercel-plugin', + config(viteConfig) { + const { deploy, unstable_phase } = platformObject.buildOptions || {}; + if ( + unstable_phase !== 'buildServerBundle' || + (deploy !== 'vercel-serverless' && deploy !== 'vercel-static') + ) { + return; + } + + // FIXME This seems too hacky (The use of viteConfig.root, '.', path.resolve and resolveFileName) + const entriesFile = normalizePath( + resolveFileName( + path.resolve( + viteConfig.root || '.', + opts.srcDir, + SRC_ENTRIES + '.jsx', + ), + ), + ); + const { input } = viteConfig.build?.rollupOptions ?? {}; + if (input && !(typeof input === 'string') && !(input instanceof Array)) { + input[DIST_SERVE_JS.replace(/\.js$/, '')] = srcServeFile; + } + viteConfig.define = { + ...viteConfig.define, + 'import.meta.env.WAKU_ENTRIES_FILE': JSON.stringify(entriesFile), + 'import.meta.env.WAKU_CONFIG_DIST_DIR': JSON.stringify(opts.distDir), + 'import.meta.env.WAKU_CONFIG_PUBLIC_DIR': JSON.stringify(DIST_PUBLIC), + }; + }, + configResolved(config) { + rootDir = config.root; + }, + closeBundle() { + const { deploy, unstable_phase } = platformObject.buildOptions || {}; + if ( + unstable_phase !== 'buildDeploy' || + (deploy !== 'vercel-serverless' && deploy !== 'vercel-static') + ) { + return; + } + + const publicDir = path.join(rootDir, opts.distDir, DIST_PUBLIC); + const outputDir = path.resolve('.vercel', 'output'); + cpSync(publicDir, path.join(outputDir, 'static'), { recursive: true }); + + if (deploy === 'vercel-serverless') { + // for serverless function + const serverlessDir = path.join( + outputDir, + 'functions', + opts.rscPath + '.func', + ); + mkdirSync(path.join(serverlessDir, opts.distDir), { + recursive: true, + }); + cpSync( + path.join(rootDir, opts.distDir), + path.join(serverlessDir, opts.distDir), + { recursive: true }, + ); + if (existsSync(path.join(rootDir, opts.privateDir))) { + cpSync( + path.join(rootDir, opts.privateDir), + path.join(serverlessDir, opts.privateDir), + { recursive: true, dereference: true }, + ); + } + const vcConfigJson = { + runtime: 'nodejs20.x', + handler: `${opts.distDir}/${DIST_SERVE_JS}`, + launcherType: 'Nodejs', + }; + writeFileSync( + path.join(serverlessDir, '.vc-config.json'), + JSON.stringify(vcConfigJson, null, 2), + ); + writeFileSync( + path.join(serverlessDir, 'package.json'), + JSON.stringify({ type: 'module' }, null, 2), + ); + } + + const routes = + deploy === 'vercel-serverless' + ? [ + { handle: 'filesystem' }, + { + src: opts.basePath + '(.*)', + dest: opts.basePath + opts.rscPath + '/', + }, + ] + : undefined; + const configJson = { version: 3, routes }; + mkdirSync(outputDir, { recursive: true }); + writeFileSync( + path.join(outputDir, 'config.json'), + JSON.stringify(configJson, null, 2), + ); + }, + }; +} diff --git a/packages/waku/src/server.ts b/packages/waku/src/server.ts index 38fd0ab10..2a806b85e 100644 --- a/packages/waku/src/server.ts +++ b/packages/waku/src/server.ts @@ -160,6 +160,12 @@ type PlatformObject = { | 'deno' | 'aws-lambda' | undefined; + unstable_phase?: + | 'analyzeEntries' + | 'buildServerBundle' + | 'buildSsrBundle' + | 'buildClientBundle' + | 'buildDeploy'; }; } & Record;