diff --git a/README.md b/README.md index c0858e2..c82b6f4 100644 --- a/README.md +++ b/README.md @@ -77,16 +77,13 @@ Create a `vite.config.ts` file: import { defineConfig } from 'vite' import srvx from 'vite-plugin-srvx' -export default defineConfig(({ mode }) => ({ - build: { - outDir: mode === 'server' ? 'dist' : 'dist/public', - }, +export default defineConfig({ plugins: [ ...srvx({ entry: './src/server.ts', }), ], -})) +}) ``` ### 4. Run the development server @@ -110,10 +107,8 @@ Add these scripts to your `package.json`: { "scripts": { "dev": "vite", - "build": "npm run build:client && npm run build:server", - "build:client": "vite build", - "build:server": "vite build --mode server", - "preview": "srvx dist/server.js" + "build": "vite build && vite build --mode server", + "start": "srvx dist/server.js" } } ``` @@ -125,13 +120,13 @@ npm run build ``` This will: -1. Build your frontend (HTML, CSS, JS) to `dist/public` -2. Build your srvx server to `dist/server.js` +1. Build your frontend (HTML, CSS, JS) to `dist/public` (first `vite build`) +2. Build your srvx server to `dist/server.js` (second `vite build --mode server`) Run your production build: ```bash -npm run preview +npm run start # or directly: srvx dist/server.js ``` @@ -152,6 +147,11 @@ interface SrvxOptions { // Server output filename (default: 'server.js') serverOutFile?: string + // Target framework for deployment (e.g., 'vercel') + // When set to 'vercel' OR when VERCEL=1 env var is set (auto-detected), + // outputs to dist/api/index.js for Vercel Edge Functions + framework?: 'vercel' + // Development server options // Patterns to exclude from the srvx handler (will be handled by Vite instead) exclude?: (string | RegExp)[] @@ -164,7 +164,7 @@ interface SrvxOptions { } ``` -> **Note:** The plugin returns an array of two plugins (dev server + build), so use the spread operator: `...srvx({})` +> **Note:** The plugin returns an array of three plugins (dev server + client build + server build), so use the spread operator: `...srvx({})` ### Example with custom options @@ -173,9 +173,6 @@ import { defineConfig } from 'vite' import srvx from 'vite-plugin-srvx' export default defineConfig(({ mode }) => ({ - build: { - outDir: mode === 'server' ? 'build' : 'build/public', - }, plugins: [ ...srvx({ entry: './src/server.ts', @@ -194,11 +191,13 @@ export default defineConfig(({ mode }) => ({ Then build with: ```bash -npm run build:client # builds to build/public -npm run build:server # builds to build/app.js +npm run build +# This runs: vite build && vite build --mode server +# - Client build outputs to build/public +# - Server build outputs to build/app.js ``` -And run: `srvx build/app.js` (it will serve static files from `build/public`) +And run: `srvx build/app.js` (it will automatically serve static files from `build/public`) ### Using Individual Plugins (Advanced) @@ -206,15 +205,13 @@ If you need more control, you can import the plugins separately: ```typescript import { defineConfig } from 'vite' -import { devServer, srvxBuild } from 'vite-plugin-srvx' +import { devServer, clientBuild, srvxBuild } from 'vite-plugin-srvx' export default defineConfig(({ mode }) => ({ - build: { - outDir: mode === 'server' ? 'dist' : 'dist/public', - }, plugins: [ devServer({ entry: './src/server.ts' }), - srvxBuild({ entry: './src/server.ts' }), + clientBuild({ outDir: 'dist' }), + srvxBuild({ entry: './src/server.ts', outDir: 'dist' }), ], })) ``` @@ -235,16 +232,18 @@ The `devServer` plugin creates a Vite middleware that: ### Production Build -The `srvxBuild` plugin uses Vite's mode system: +The plugin uses Vite's mode system with three separate plugins: 1. **Client build** (`vite build`): + - `clientBuild` plugin is active (mode !== 'server') - Builds frontend to `dist/public` - - Plugin is inactive (mode !== 'server') + - `srvxBuild` plugin is inactive 2. **Server build** (`vite build --mode server`): - - Plugin activates (mode === 'server') + - `srvxBuild` plugin is active (mode === 'server') - Sets `ssr: true` via the `config` hook - Builds server to `dist/server.js` + - `clientBuild` plugin is inactive 3. **Run with srvx**: - `srvx dist/server.js` @@ -254,16 +253,18 @@ This approach follows the same pattern as [@hono/vite-build](https://github.com/ This gives you the best of both worlds: srvx's universal server API and Vite's lightning-fast development experience! -## Example +## Examples -Check out the [example](./example) directory for a full working example. +Check out the [examples](./examples) directory for full working examples: +- [examples/basic](./examples/basic) - Basic srvx + Vite setup +- [examples/vercel](./examples/vercel) - Vercel Edge Functions deployment -To run the example: +To run an example: ```bash pnpm install pnpm build -cd example +cd examples/basic # or examples/vercel pnpm dev ``` diff --git a/src/dev-server.ts b/src/dev-server.ts index 85d298b..b4e10c7 100644 --- a/src/dev-server.ts +++ b/src/dev-server.ts @@ -1,199 +1,201 @@ -import type { Plugin, ViteDevServer } from 'vite' -import type { IncomingMessage, ServerResponse } from 'http' -import fs from 'fs' -import path from 'path' +import fs from "fs"; +import type { IncomingMessage, ServerResponse } from "http"; +import path from "path"; +import type { Plugin, ViteDevServer } from "vite"; export interface DevServerOptions { - entry?: string - exclude?: (string | RegExp)[] - injectClientScript?: boolean - loadModule?: (server: ViteDevServer, entry: string) => Promise + entry?: string; + exclude?: (string | RegExp)[]; + injectClientScript?: boolean; + loadModule?: (server: ViteDevServer, entry: string) => Promise; } export const defaultOptions: Partial = { - entry: './src/server.ts', - exclude: [ - /.*\.tsx?$/, - /.*\.ts$/, - /.*\.jsx?$/, - /.*\.css$/, - /.*\.scss$/, - /.*\.sass$/, - /.*\.less$/, - /.*\.styl$/, - /.*\.png$/, - /.*\.jpg$/, - /.*\.jpeg$/, - /.*\.gif$/, - /.*\.svg$/, - /.*\.webp$/, - /^\/@.+$/, - /^\/node_modules\/.*/, - /\?import$/, - ], - injectClientScript: true, -} + entry: "./src/server.ts", + exclude: [ + /.*\.tsx?$/, + /.*\.ts$/, + /.*\.jsx?$/, + /.*\.css$/, + /.*\.scss$/, + /.*\.sass$/, + /.*\.less$/, + /.*\.styl$/, + /.*\.png$/, + /.*\.jpg$/, + /.*\.jpeg$/, + /.*\.gif$/, + /.*\.svg$/, + /.*\.webp$/, + /^\/@.+$/, + /^\/node_modules\/.*/, + /\?import$/, + ], + injectClientScript: true, +}; interface SrvxApp { - fetch: (request: Request) => Response | Promise + fetch: (request: Request) => Response | Promise; } function createMiddleware(server: ViteDevServer, options: DevServerOptions) { - return async ( - req: IncomingMessage, - res: ServerResponse, - next: () => void - ) => { - const config = server.config - const base = config.base === '/' ? '' : config.base - - if (req.url === '/' || req.url === base || req.url === `${base}/`) { - const indexPath = path.join(config.root, 'index.html') - if (fs.existsSync(indexPath)) { - const html = await server.transformIndexHtml( - req.url, - fs.readFileSync(indexPath, 'utf-8') - ) - res.statusCode = 200 - res.setHeader('Content-Type', 'text/html') - res.setHeader('Content-Length', Buffer.byteLength(html)) - res.end(html) - return - } - } - - const exclude = options.exclude ?? defaultOptions.exclude ?? [] - - for (const pattern of exclude) { - if (req.url) { - if (pattern instanceof RegExp) { - if (pattern.test(req.url)) { - return next() - } - } else if (typeof pattern === 'string') { - if (req.url.startsWith(pattern)) { - return next() - } - } - } - } - - if (req.url?.startsWith(base)) { - const publicDir = config.publicDir - if (publicDir && fs.existsSync(publicDir)) { - const filePath = path.join(publicDir, req.url.replace(base, '')) - if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { - return next() - } - } - } - - let app: SrvxApp | undefined - - try { - const loadModule = options.loadModule ?? ((server, entry) => server.ssrLoadModule(entry)) - const module = await loadModule(server, options.entry!) - - if ('default' in module) { - app = module.default as SrvxApp - } else { - app = module as SrvxApp - } - - if (!app?.fetch) { - throw new Error('No fetch handler found in the entry module') - } - } catch (e) { - return next() - } - - const protocol = (req.socket as any).encrypted ? 'https' : 'http' - const host = req.headers.host || 'localhost' - const url = `${protocol}://${host}${req.url}` - - const headers = new Headers() - for (const [key, value] of Object.entries(req.headers)) { - if (value !== undefined) { - if (Array.isArray(value)) { - for (const v of value) { - headers.append(key, v) - } - } else { - headers.set(key, value) - } - } - } - - const body = req.method !== 'GET' && req.method !== 'HEAD' ? req : undefined - - const request = new Request(url, { - method: req.method, - headers, - body: body as any, - }) - - const response = await app.fetch(request) - - res.statusCode = response.status - response.headers.forEach((value, key) => { - res.setHeader(key, value) - }) - - if (options.injectClientScript !== false) { - const contentType = response.headers.get('content-type') - if (contentType?.includes('text/html')) { - const body = await response.text() - - let script = `` - - const nonce = response.headers.get('content-security-policy-nonce') - if (nonce) { - script = `` - } - - const injectedBody = body.replace('', `${script}`) - - res.setHeader('content-length', Buffer.byteLength(injectedBody)) - res.end(injectedBody) - return - } - } - - if (response.body) { - const reader = response.body.getReader() - const stream = async () => { - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - res.write(value) - } - res.end() - } catch (error) { - res.end() - } - } - await stream() - } else { - res.end() - } - } + return async ( + req: IncomingMessage, + res: ServerResponse, + next: () => void, + ) => { + const config = server.config; + const base = config.base === "/" ? "" : config.base; + + if (req.url === "/" || req.url === base || req.url === `${base}/`) { + const indexPath = path.join(config.root, "index.html"); + if (fs.existsSync(indexPath)) { + const html = await server.transformIndexHtml( + req.url, + fs.readFileSync(indexPath, "utf-8"), + ); + res.statusCode = 200; + res.setHeader("Content-Type", "text/html"); + res.setHeader("Content-Length", Buffer.byteLength(html)); + res.end(html); + return; + } + } + + const exclude = options.exclude ?? defaultOptions.exclude ?? []; + + for (const pattern of exclude) { + if (req.url) { + if (pattern instanceof RegExp) { + if (pattern.test(req.url)) { + return next(); + } + } else if (typeof pattern === "string") { + if (req.url.startsWith(pattern)) { + return next(); + } + } + } + } + + if (req.url?.startsWith(base)) { + const publicDir = config.publicDir; + if (publicDir && fs.existsSync(publicDir)) { + const filePath = path.join(publicDir, req.url.replace(base, "")); + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + return next(); + } + } + } + + let app: SrvxApp | undefined; + + try { + const loadModule = + options.loadModule ?? ((server, entry) => server.ssrLoadModule(entry)); + const module = await loadModule(server, options.entry!); + + if ("default" in module) { + app = module.default as SrvxApp; + } else { + app = module as SrvxApp; + } + + if (!app?.fetch) { + throw new Error("No fetch handler found in the entry module"); + } + } catch (e) { + return next(); + } + + const protocol = (req.socket as any).encrypted ? "https" : "http"; + const host = req.headers.host || "localhost"; + const url = `${protocol}://${host}${req.url}`; + + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (value !== undefined) { + if (Array.isArray(value)) { + for (const v of value) { + headers.append(key, v); + } + } else { + headers.set(key, value); + } + } + } + + const body = + req.method !== "GET" && req.method !== "HEAD" ? req : undefined; + + const request = new Request(url, { + method: req.method, + headers, + body: body as any, + }); + + const response = await app.fetch(request); + + res.statusCode = response.status; + response.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + + if (options.injectClientScript !== false) { + const contentType = response.headers.get("content-type"); + if (contentType?.includes("text/html")) { + const body = await response.text(); + + let script = ``; + + const nonce = response.headers.get("content-security-policy-nonce"); + if (nonce) { + script = ``; + } + + const injectedBody = body.replace("", `${script}`); + + res.setHeader("content-length", Buffer.byteLength(injectedBody)); + res.end(injectedBody); + return; + } + } + + if (response.body) { + const reader = response.body.getReader(); + const stream = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + res.write(value); + } + res.end(); + } catch (error) { + res.end(); + } + }; + await stream(); + } else { + res.end(); + } + }; } export function devServer(options?: DevServerOptions): Plugin { - const entry = options?.entry ?? defaultOptions.entry - - return { - name: 'vite-plugin-srvx-dev', - apply: 'serve', - configureServer(server) { - const mergedOptions: DevServerOptions = { - ...defaultOptions, - ...options, - entry, - } - - server.middlewares.use(createMiddleware(server, mergedOptions)) - }, - } + const entry = options?.entry ?? defaultOptions.entry; + + return { + name: "vite-plugin-srvx-dev", + apply: "serve", + configureServer(server) { + const mergedOptions: DevServerOptions = { + ...defaultOptions, + ...options, + entry, + }; + + server.middlewares.use(createMiddleware(server, mergedOptions)); + }, + }; }