I scaffolded a new vite project using pnpm create vite
. To make this reproducible, these were my choices when prompted:
I wanted to use TypeScript for the generated ./server.js
, and not only the code in ./src
, so I "converted" (i.e. renamed for now) server.js
to server.ts
.
At this point I realized I'd have to build ./server.ts
myself, and have vite build the so called server side entry point ./src/entry-server.ts
.
I'm using vite to avoid the nightmare of having to configure building TypeScript, so I'm wondering if it's feasible to set ./server.ts
itself as an entry point for the SSR build? That, and still have SSR working.
The part where I'm stuck is how to actually import ./src/entry-server.ts
, now that ./server.ts
is my entry point (i.e. render = (await import('./dist/server/entry-server.js')).render
doesn't work anymore now that entry-server.js
is no longer getting built).
I got this working. Here's what I did, in case it's useful to someone else.
./src/server.ts
is the vite ssr entry point and the script that node runs to start an express server. See package.json
further down to see how it's run.
import express from 'express'
import { ViteDevServer } from 'vite'
import { ssr } from '@/ssr'
const PORT = process.env.PORT && parseInt(process.env.PORT, 10) || 5173
const STATIC_URL = process.env.STATIC_URL || '/static/'
const isProd = process.env.NODE_ENV === 'prod'
async function createServer() {
let vite: ViteDevServer | undefined
const app = express()
if (isProd) {
// Serve static files.
app.use(STATIC_URL, express.static('./dist/client'))
} else {
// Add vite middleware for static file serving, dev ssr, and hmr.
const { createServer } = await import('vite')
vite = await createServer({
server: { middlewareMode: true },
appType: 'custom',
base: STATIC_URL
})
app.use(vite.middlewares)
}
// Serve HTML
app.use('', async (req, res) => {
try {
// TODO: Validate pageName and props.
const url = req.originalUrl
const queryParams = new URLSearchParams(
Object.entries(req.query).map(([key, value]) => [key, String(value)])
)
const pageName = queryParams.get('page')
if (!pageName) {
res.status(400).send('A page name is required.')
return
}
// TODO: Get props from request.
const props = { msg: 'Hello from SSR!' }
// Render the page.
const html = await ssr(pageName, props, url, vite)
res.status(200).set({ 'Content-Type': 'text/html' }).send(html)
} catch (e) {
const err = e as Error
console.log(err)
res.status(500).end(err.stack)
}
})
return app
}
createServer().then((app) => {
app.listen(PORT, '127.0.0.1', () => {
console.log(`Server started at http://127.0.0.1:${PORT}`)
})
}).catch((err: Error) => {
logger.error(err)
})
./src/ssr.ts
uses import.meta.glob to preload components in prod (but a dynamic import like '(await import(./pages/${pageName}.vue
)).default' would also work). ssr.ts
also caches index.html
in prod, but reloads it on each request in dev.
./src/ssr.ts
import fs from 'node:fs/promises'
import path from 'path'
import { ViteDevServer } from 'vite'
import { Component, createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
export const isProd = process.env.NODE_ENV === 'prod'
const __dirname = import.meta.dirname
// loadIndexHtml loads and caches index html in prod.
// It always reloads the html in dev.
let indexHtmlCache: string = ''
async function loadIndexHtml(originalUrl: string, vite?: ViteDevServer) {
if (isProd && indexHtmlCache === '') {
indexHtmlCache = await fs.readFile('./dist/client/index.html', 'utf-8')
} else if (vite) {
indexHtmlCache = await fs.readFile('./index.html', 'utf-8')
indexHtmlCache = await vite.transformIndexHtml(originalUrl, indexHtmlCache)
}
if (indexHtmlCache === '') {
throw Error("Failed to load index html.")
}
return indexHtmlCache
}
export async function ssr(
pageName: string,
props: any,
originalUrl: string,
vite?: ViteDevServer
) {
let page: Component | undefined = undefined
if (!isProd && !vite) {
throw Error("The vite parameter wasn't provided but is required in dev.")
}
const indexHtml = await loadIndexHtml(originalUrl, vite)
// Load page component.
if (isProd) {
const pages: Record<string, { default: Component }> = import.meta.glob(
'./pages/*.vue', { eager: true, import: 'default' }
)
page = pages[`./pages/${pageName}.vue`]
} else if (vite) {
page = (await vite.ssrLoadModule(
path.resolve(__dirname, `./pages/${pageName}.vue`), { fixStacktrace: true }
)).default
}
if (!page) {
throw new Error(`Page "${pageName}" not found.`)
}
// SSR render the page component and embed it into the index html.
const app = createSSRApp(page, props)
const pageHtml = await renderToString(app)
const html = indexHtml!
.replace(`<!--page-html-->`, pageHtml ?? '')
.replace(`<!--page-name-->`, `window.__PAGE_NAME__ = '${pageName}'`)
.replace(`<!--initial-state-->`, `window.__INITIAL_STATE__ = ${JSON.stringify(props)}`)
return html
}
./vite.config.ts
import path from 'path'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
const PORT = process.env.PORT && parseInt(process.env.PORT, 10) || 5173
const STATIC_URL = process.env.STATIC_URL || '/static/'
const __dirname = import.meta.dirname
export default defineConfig((config) => {
const buildTarget = process.env.BUILD_TARGET
// Common configuration used for both dev and building.
const baseConfig = {
plugins: [vue()],
base: STATIC_URL,
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
server: {
host: '127.0.0.1',
port: PORT,
hmr: {
host: '127.0.0.1',
},
},
}
// Build-specific options
if (buildTarget === 'client') {
return {
...baseConfig,
build: {
outDir: 'dist/client'
}
}
} else if (buildTarget === 'server') {
return {
...baseConfig,
publicDir: false,
build: {
ssr: 'src/server.ts',
outDir: 'dist/server',
},
}
} else {
return baseConfig
}
})
package.json
scripts section:
"scripts": {
"build:client": "BUILD_TARGET=client vite build",
"build:server": "BUILD_TARGET=server vite build",
"build": "pnpm build:client && pnpm build:server",
"dev": "nodemon --exec tsx src/server.ts --watch . --ignore dist --ignore logs --ignore node_modules",
"prod": "pnpm build && NODE_ENV=prod node dist/server/server.js"
},