typescriptvue.jsviteserver-side-rendering

Can you set ./server.ts itself as an entry point for the SSR build?


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).


Solution

  • 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"
      },