diff --git a/README.md b/README.md index 077c187c3..eb55b9dd7 100644 --- a/README.md +++ b/README.md @@ -593,7 +593,7 @@ npx wrangler dev # or deploy ``` npm run build -- --with-deno -DENO_DEPLOY_TOKEN=... deployctl deploy --project=... --prod serve.ts --exclude node_modules +DENO_DEPLOY_TOKEN=... deployctl deploy --project=... --prod dist/serve.js --exclude node_modules ``` ## Community diff --git a/examples/08_cookies/dev.js b/examples/08_cookies/dev.js index 751cef49d..2148f5a86 100644 --- a/examples/08_cookies/dev.js +++ b/examples/08_cookies/dev.js @@ -1,6 +1,6 @@ import express from 'express'; import cookieParser from 'cookie-parser'; -import { connectMiddleware } from 'waku/dev'; +import { unstable_connectMiddleware as connectMiddleware } from 'waku/dev'; const withSsr = process.argv[2] === '--with-ssr'; diff --git a/examples/08_cookies/start.js b/examples/08_cookies/start.js index aaef72a0e..04be1245a 100644 --- a/examples/08_cookies/start.js +++ b/examples/08_cookies/start.js @@ -2,7 +2,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import path from 'node:path'; import express from 'express'; import cookieParser from 'cookie-parser'; -import { connectMiddleware } from 'waku/prd'; +import { unstable_connectMiddleware as connectMiddleware } from 'waku/prd'; const withSsr = process.argv[2] === '--with-ssr'; @@ -12,9 +12,8 @@ const app = express(); app.use(cookieParser()); app.use( connectMiddleware({ - entries: import( - pathToFileURL(path.join(root, 'dist', 'entries.js')).toString() - ), + loadEntries: () => + import(pathToFileURL(path.join(root, 'dist', 'entries.js')).toString()), unstable_prehook: (req) => { return { count: Number(req.orig.cookies.count) || 0 }; }, diff --git a/packages/waku/src/cli.ts b/packages/waku/src/cli.ts index bd9571b2a..af92a488d 100755 --- a/packages/waku/src/cli.ts +++ b/packages/waku/src/cli.ts @@ -97,26 +97,30 @@ async function runBuild(options: { ssr: boolean }) { ...options, config, env: process.env as any, - vercel: - values['with-vercel'] ?? !!process.env.VERCEL - ? { - type: values['with-vercel-static'] ? 'static' : 'serverless', - } - : undefined, - cloudflare: !!values['with-cloudflare'], - deno: !!values['with-deno'], + deploy: + (values['with-vercel'] ?? !!process.env.VERCEL + ? values['with-vercel-static'] + ? 'vercel-static' + : 'vercel-serverless' + : undefined) || + (values['with-cloudflare'] ? 'cloudflare' : undefined) || + (values['with-deno'] ? 'deno' : undefined), }); } async function runStart(options: { ssr: boolean }) { const { distDir, publicDir, entriesJs } = await resolveConfig(config); - const entries = import( - pathToFileURL(path.resolve(distDir, entriesJs)).toString() - ); + const loadEntries = () => + import(pathToFileURL(path.resolve(distDir, entriesJs)).toString()); const app = new Hono(); app.use( '*', - honoPrdMiddleware({ ...options, config, entries, env: process.env as any }), + honoPrdMiddleware({ + ...options, + config, + loadEntries, + env: process.env as any, + }), ); app.use('*', serveStatic({ root: path.join(distDir, publicDir) })); const port = parseInt(process.env.PORT || '8080', 10); diff --git a/packages/waku/src/config.ts b/packages/waku/src/config.ts index 8440149bc..6194fa287 100644 --- a/packages/waku/src/config.ts +++ b/packages/waku/src/config.ts @@ -44,6 +44,12 @@ export interface Config { * Defaults to "entries.js". */ entriesJs?: string; + /** + * The serve.js file relative distDir. + * This file is used for deployment. + * Defaults to "serve.js". + */ + serveJs?: string; /** * Prefix for HTTP requests to indicate RSC requests. * Defaults to "RSC". diff --git a/packages/waku/src/dev.ts b/packages/waku/src/dev.ts index 278b6583a..b8f16fc03 100644 --- a/packages/waku/src/dev.ts +++ b/packages/waku/src/dev.ts @@ -1,5 +1,5 @@ -export { honoMiddleware } from './lib/middleware/hono-dev.js'; -export { connectMiddleware } from './lib/middleware/connect-dev.js'; +export { honoMiddleware as unstable_honoMiddleware } from './lib/middleware/hono-dev.js'; +export { connectMiddleware as unstable_connectMiddleware } from './lib/middleware/connect-dev.js'; export { createHandler as unstable_createHandler } from './lib/handlers/handler-dev.js'; export { build } from './lib/builder/build.js'; diff --git a/packages/waku/src/lib/builder/build.ts b/packages/waku/src/lib/builder/build.ts index 0b4f99f45..4b19f1372 100644 --- a/packages/waku/src/lib/builder/build.ts +++ b/packages/waku/src/lib/builder/build.ts @@ -41,11 +41,10 @@ import { rscIndexPlugin } from '../plugins/vite-plugin-rsc-index.js'; 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 { emitVercelOutput } from './output-vercel.js'; import { emitCloudflareOutput } from './output-cloudflare.js'; -import { emitDenoOutput } from './output-deno.js'; // TODO this file and functions in it are too long. will fix. @@ -139,8 +138,8 @@ const buildServerBundle = async ( commonEntryFiles: Record, clientEntryFiles: Record, serverEntryFiles: Record, - reExportHonoMiddleware: boolean, - reExportConnectMiddleware: boolean, + ssr: boolean, + serve: 'vercel' | 'cloudflare' | 'deno' | false, ) => { const serverBuildOutput = await buildVite({ plugins: [ @@ -157,18 +156,29 @@ const buildServerBundle = async ( }, serverEntryFiles, }), - rscEntriesPlugin({ - entriesFile, - reExportHonoMiddleware, - reExportConnectMiddleware, - }), rscEnvPlugin({ config }), + ...(serve + ? [ + rscServePlugin({ + ...config, + entriesFile, + srcServeFile: decodeFilePathFromAbsolute( + joinPath( + fileURLToFilePath(import.meta.url), + `../serve-${serve}.js`, + ), + ), + ssr, + }), + ] + : []), ], ssr: { resolve: { conditions: ['react-server', 'workerd'], externalConditions: ['react-server', 'workerd'], }, + external: ['hono', 'hono/cloudflare-workers'], noExternal: /^(?!node:)/, }, define: { @@ -506,9 +516,12 @@ export async function build(options: { config?: Config; ssr?: boolean; env?: Record; - vercel?: { type: 'static' | 'serverless' } | undefined; - cloudflare?: boolean; - deno?: boolean; + deploy?: + | 'vercel-static' + | 'vercel-serverless' + | 'cloudflare' + | 'deno' + | undefined; }) { (globalThis as any).__WAKU_PRIVATE_ENV__ = options.env || {}; const config = await resolveConfig(options.config || {}); @@ -532,8 +545,10 @@ export async function build(options: { commonEntryFiles, clientEntryFiles, serverEntryFiles, - !!options.cloudflare || !!options.deno, - !!options.vercel, + !!options.ssr, + (options.deploy === 'vercel-serverless' ? 'vercel' : false) || + (options.deploy === 'cloudflare' ? 'cloudflare' : false) || + (options.deploy === 'deno' ? 'deno' : false), ); await buildClientBundle( rootDir, @@ -558,22 +573,16 @@ export async function build(options: { !!options.ssr, ); - if (options.vercel) { + if (options.deploy?.startsWith('vercel-')) { await emitVercelOutput( rootDir, config, rscFiles, htmlFiles, !!options.ssr, - options.vercel.type, + options.deploy.slice('vercel-'.length) as 'static' | 'serverless', ); - } - - if (options.cloudflare) { - await emitCloudflareOutput(rootDir, config, !!options.ssr); - } - - if (options.deno) { - await emitDenoOutput(rootDir, config, !!options.ssr); + } else if (options.deploy === 'cloudflare') { + await emitCloudflareOutput(rootDir, config); } } diff --git a/packages/waku/src/lib/builder/output-cloudflare.ts b/packages/waku/src/lib/builder/output-cloudflare.ts index 36f2841ec..cc56d7919 100644 --- a/packages/waku/src/lib/builder/output-cloudflare.ts +++ b/packages/waku/src/lib/builder/output-cloudflare.ts @@ -7,55 +7,17 @@ import type { ResolvedConfig } from '../config.js'; export const emitCloudflareOutput = async ( rootDir: string, config: ResolvedConfig, - ssr: boolean, ) => { - const outputDir = path.resolve('.'); - const relativeRootDir = path.relative(outputDir, rootDir); - const entriesFile = path.join( - relativeRootDir, - config.distDir, - config.entriesJs, - ); - const publicDir = path.join( - relativeRootDir, - config.distDir, - config.publicDir, - ); - if (!existsSync(path.join(outputDir, 'serve.js'))) { + if (!existsSync(path.join(rootDir, 'wrangler.toml'))) { writeFileSync( - path.join(outputDir, 'serve.js'), - ` -import { Hono } from 'hono'; -import { serveStatic } from 'hono/cloudflare-workers'; - -const entries = import('./${entriesFile}'); -const { honoMiddleware } = await entries; -let serveWaku; - -const app = new Hono(); -app.use('*', (c, next) => serveWaku(c, next)); -app.use('*', serveStatic({ root: './' })); -export default { - async fetch(request, env, ctx) { - if (!serveWaku) { - serveWaku = honoMiddleware({ entries, ssr: ${ssr}, env }); - } - return app.fetch(request, env, ctx); - } -} -`, - ); - } - if (!existsSync(path.join(outputDir, 'wrangler.toml'))) { - writeFileSync( - path.join(outputDir, 'wrangler.toml'), + path.join(rootDir, 'wrangler.toml'), ` name = "waku-project" -main = "serve.js" +main = "${config.distDir}/${config.serveJs}" compatibility_date = "2023-12-06" [site] -bucket = "./${publicDir}" +bucket = "./${config.distDir}/${config.publicDir}" `, ); } diff --git a/packages/waku/src/lib/builder/output-deno.ts b/packages/waku/src/lib/builder/output-deno.ts deleted file mode 100644 index b1502e8f7..000000000 --- a/packages/waku/src/lib/builder/output-deno.ts +++ /dev/null @@ -1,43 +0,0 @@ -import path from 'node:path'; -import { existsSync, writeFileSync } from 'node:fs'; - -import type { ResolvedConfig } from '../config.js'; - -// XXX this can be very limited. FIXME if anyone has better knowledge. -export const emitDenoOutput = async ( - rootDir: string, - config: ResolvedConfig, - ssr: boolean, -) => { - const outputDir = path.resolve('.'); - const relativeRootDir = path.relative(outputDir, rootDir); - const entriesFile = path.join( - relativeRootDir, - config.distDir, - config.entriesJs, - ); - const publicDir = path.join( - relativeRootDir, - config.distDir, - config.publicDir, - ); - if (!existsSync(path.join(outputDir, 'serve.ts'))) { - writeFileSync( - path.join(outputDir, 'serve.ts'), - ` -import { Hono } from "https://deno.land/x/hono/mod.ts"; -import { serveStatic } from "https://deno.land/x/hono/middleware.ts"; - -const entries = import('./${entriesFile}'); -const { honoMiddleware } = await entries; -const env = Deno.env.toObject(); - -const app = new Hono(); -app.use('*', honoMiddleware({ entries, ssr: ${ssr}, env })); -app.use("*", serveStatic({ root: "${publicDir}" })); - -Deno.serve(app.fetch); -`, - ); - } -}; diff --git a/packages/waku/src/lib/builder/output-vercel.ts b/packages/waku/src/lib/builder/output-vercel.ts index 2462d9d51..ca03c56e4 100644 --- a/packages/waku/src/lib/builder/output-vercel.ts +++ b/packages/waku/src/lib/builder/output-vercel.ts @@ -14,11 +14,7 @@ export const emitVercelOutput = async ( ) => { const publicDir = path.join(rootDir, config.distDir, config.publicDir); const outputDir = path.resolve('.vercel', 'output'); - cpSync( - path.join(rootDir, config.distDir, config.publicDir), - path.join(outputDir, 'static'), - { recursive: true }, - ); + cpSync(publicDir, path.join(outputDir, 'static'), { recursive: true }); if (type === 'serverless') { // for serverless function @@ -37,7 +33,7 @@ export const emitVercelOutput = async ( ); const vcConfigJson = { runtime: 'nodejs18.x', - handler: 'serve.js', + handler: `${config.distDir}/${config.serveJs}`, launcherType: 'Nodejs', }; writeFileSync( @@ -48,40 +44,6 @@ export const emitVercelOutput = async ( path.join(serverlessDir, 'package.json'), JSON.stringify({ type: 'module' }, null, 2), ); - writeFileSync( - path.join(serverlessDir, 'serve.js'), - ` -import path from 'node:path'; -import fs from 'node:fs'; - -const entries = import(path.resolve('${config.distDir}', '${config.entriesJs}')); -const { connectMiddleware } = await entries; -const env = process.env; - -export default function handler(req, res) { - connectMiddleware({ entries, ssr: ${ssr}, env })(req, res, () => { - const { pathname } = new URL(req.url, 'http://localhost'); - const fname = path.join( - '${config.distDir}', - '${config.publicDir}', - pathname, - path.extname(pathname) ? '' : '${config.indexHtml}', - ); - if (fs.existsSync(fname)) { - if (fname.endsWith('.html')) { - res.setHeader('content-type', 'text/html; charset=utf-8'); - } else if (fname.endsWith('.txt')) { - res.setHeader('content-type', 'text/plain'); - } - fs.createReadStream(fname).pipe(res); - return; - } - res.statusCode = 404; - res.end(); - }); -} -`, - ); } const overrides = Object.fromEntries( diff --git a/packages/waku/src/lib/builder/serve-cloudflare.ts b/packages/waku/src/lib/builder/serve-cloudflare.ts new file mode 100644 index 000000000..2fc5d252e --- /dev/null +++ b/packages/waku/src/lib/builder/serve-cloudflare.ts @@ -0,0 +1,24 @@ +import { Hono } from 'hono'; +import { serveStatic } from 'hono/cloudflare-workers'; + +import { honoMiddleware } from '../middleware/hono-prd.js'; + +const ssr = !!import.meta.env.WAKU_BUILD_SSR; +const loadEntries = () => import(import.meta.env.WAKU_ENTRIES_FILE!); +let serveWaku: ReturnType | undefined; + +const app = new Hono(); +app.use('*', (c, next) => serveWaku!(c, next)); +app.use('*', serveStatic({ root: './' })); +export default { + async fetch( + request: Request, + env: Record, + ctx: Parameters[2], + ) { + if (!serveWaku) { + serveWaku = honoMiddleware({ loadEntries, ssr, env }); + } + return app.fetch(request, env, ctx); + }, +}; diff --git a/packages/waku/src/lib/builder/serve-deno.ts b/packages/waku/src/lib/builder/serve-deno.ts new file mode 100644 index 000000000..c37dd0c33 --- /dev/null +++ b/packages/waku/src/lib/builder/serve-deno.ts @@ -0,0 +1,22 @@ +/* eslint import/no-unresolved: off */ + +// @ts-expect-error no types +import { Hono } from 'https://deno.land/x/hono/mod.ts'; +// @ts-expect-error no types +import { serveStatic } from 'https://deno.land/x/hono/middleware.ts'; + +import { honoMiddleware } from '../middleware/hono-prd.js'; + +const ssr = !!import.meta.env.WAKU_BUILD_SSR; +const distDir = import.meta.env.WAKU_CONFIG_DIST_DIR; +const publicDir = import.meta.env.WAKU_CONFIG_PUBLIC_DIR; +const loadEntries = () => import(import.meta.env.WAKU_ENTRIES_FILE!); +// @ts-expect-error no types +const env = Deno.env.toObject(); + +const app = new Hono(); +app.use('*', honoMiddleware({ loadEntries, ssr, env })); +app.use('*', serveStatic({ root: `${distDir}/${publicDir}` })); + +// @ts-expect-error no types +Deno.serve(app.fetch); diff --git a/packages/waku/src/lib/builder/serve-vercel.ts b/packages/waku/src/lib/builder/serve-vercel.ts new file mode 100644 index 000000000..a1e4f036d --- /dev/null +++ b/packages/waku/src/lib/builder/serve-vercel.ts @@ -0,0 +1,35 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { connectMiddleware } from '../middleware/connect-prd.js'; + +const ssr = !!import.meta.env.WAKU_BUILD_SSR; +const distDir = import.meta.env.WAKU_CONFIG_DIST_DIR; +const publicDir = import.meta.env.WAKU_CONFIG_PUBLIC_DIR; +const indexHtml = import.meta.env.WAKU_CONFIG_INDEX_HTML; +const loadEntries = () => import(import.meta.env.WAKU_ENTRIES_FILE!); +const env: Record = process.env as any; + +export default function handler(req: IncomingMessage, res: ServerResponse) { + connectMiddleware({ loadEntries, ssr, env })(req, res, () => { + const { pathname } = new URL(req.url!, 'http://localhost'); + const fname = path.join( + `${distDir}`, + `${publicDir}`, + pathname, + path.extname(pathname) ? '' : `${indexHtml}`, + ); + if (fs.existsSync(fname)) { + if (fname.endsWith('.html')) { + res.setHeader('content-type', 'text/html; charset=utf-8'); + } else if (fname.endsWith('.txt')) { + res.setHeader('content-type', 'text/plain'); + } + fs.createReadStream(fname).pipe(res); + return; + } + res.statusCode = 404; + res.end(); + }); +} diff --git a/packages/waku/src/lib/config.ts b/packages/waku/src/lib/config.ts index 044f257bd..2b5ec998a 100644 --- a/packages/waku/src/lib/config.ts +++ b/packages/waku/src/lib/config.ts @@ -24,6 +24,7 @@ export async function resolveConfig(config: Config) { indexHtml: 'index.html', mainJs: 'main.tsx', entriesJs: 'entries.js', + serveJs: 'serve.js', rscPath: 'RSC', htmlHead: DEFAULT_HTML_HEAD, ...config, diff --git a/packages/waku/src/lib/handlers/handler-prd.ts b/packages/waku/src/lib/handlers/handler-prd.ts index 0a8bed70a..4f09a06d0 100644 --- a/packages/waku/src/lib/handlers/handler-prd.ts +++ b/packages/waku/src/lib/handlers/handler-prd.ts @@ -19,14 +19,16 @@ export function createHandler< env?: Record; unstable_prehook?: (req: Req, res: Res) => Context; unstable_posthook?: (req: Req, res: Res, ctx: Context) => void; - entries: Promise; + loadEntries: () => Promise; }): Handler { - const { config, ssr, unstable_prehook, unstable_posthook, entries } = options; + const { config, ssr, unstable_prehook, unstable_posthook, loadEntries } = + options; if (!unstable_prehook && unstable_posthook) { throw new Error('prehook is required if posthook is provided'); } (globalThis as any).__WAKU_PRIVATE_ENV__ = options.env || {}; const configPromise = resolveConfig(config || {}); + const entries = loadEntries(); return async (req, res, next) => { const config = await configPromise; diff --git a/packages/waku/src/lib/plugins/vite-plugin-rsc-entries.ts b/packages/waku/src/lib/plugins/vite-plugin-rsc-entries.ts deleted file mode 100644 index 47b356848..000000000 --- a/packages/waku/src/lib/plugins/vite-plugin-rsc-entries.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Plugin } from 'vite'; - -export function rscEntriesPlugin(opts: { - entriesFile: string; - reExportHonoMiddleware: boolean; - reExportConnectMiddleware: boolean; -}): Plugin { - return { - name: 'rsc-entries-plugin', - transform(code, id, options) { - if (!options?.ssr) { - return; - } - // FIXME does this work on windows? - if (id === opts.entriesFile) { - if (opts.reExportHonoMiddleware) { - code += ` -export { honoMiddleware } from 'waku/prd';`; - } - if (opts.reExportConnectMiddleware) { - code += ` -export { connectMiddleware } from 'waku/prd';`; - } - return code; - } - }, - }; -} diff --git a/packages/waku/src/lib/plugins/vite-plugin-rsc-serve.ts b/packages/waku/src/lib/plugins/vite-plugin-rsc-serve.ts new file mode 100644 index 000000000..f73a6fc34 --- /dev/null +++ b/packages/waku/src/lib/plugins/vite-plugin-rsc-serve.ts @@ -0,0 +1,33 @@ +import type { Plugin } from 'vite'; + +export function rscServePlugin(opts: { + serveJs: string; + distDir: string; + publicDir: string; + indexHtml: string; + entriesFile: string; + srcServeFile: string; + ssr: boolean; +}): Plugin { + return { + name: 'rsc-serve-plugin', + config(viteConfig) { + const { input } = viteConfig.build?.rollupOptions ?? {}; + if (input && !(typeof input === 'string') && !(input instanceof Array)) { + input[opts.serveJs.replace(/\.js$/, '')] = opts.srcServeFile; + } + viteConfig.define = { + ...viteConfig.define, + 'import.meta.env.WAKU_BUILD_SSR': JSON.stringify(opts.ssr ? 'yes' : ''), + 'import.meta.env.WAKU_ENTRIES_FILE': JSON.stringify(opts.entriesFile), + 'import.meta.env.WAKU_CONFIG_DIST_DIR': JSON.stringify(opts.distDir), + 'import.meta.env.WAKU_CONFIG_PUBLIC_DIR': JSON.stringify( + opts.publicDir, + ), + 'import.meta.env.WAKU_CONFIG_INDEX_HTML': JSON.stringify( + opts.indexHtml, + ), + }; + }, + }; +} diff --git a/packages/waku/src/prd.ts b/packages/waku/src/prd.ts index 47fde5fb0..2b50b66e4 100644 --- a/packages/waku/src/prd.ts +++ b/packages/waku/src/prd.ts @@ -1,4 +1,4 @@ -export { honoMiddleware } from './lib/middleware/hono-prd.js'; -export { connectMiddleware } from './lib/middleware/connect-prd.js'; +export { honoMiddleware as unstable_honoMiddleware } from './lib/middleware/hono-prd.js'; +export { connectMiddleware as unstable_connectMiddleware } from './lib/middleware/connect-prd.js'; export { createHandler as unstable_createHandler } from './lib/handlers/handler-prd.js';