brillout / research

5 stars 0 forks source link

Vite Server RFC #3

Closed brillout closed 2 years ago

brillout commented 2 years ago

Authors: @cyco130, @brillout. (Let us know if you want to join us in writting this RFC.)

Structure of this RFC:

Motivation

Vite's native SSR API has enabled deep collaboration between SSR frameworks.

This RFC enables the same for deploy: there are an increasing number of deploy targets (Deno Edge, Supabase Functions, Netlify Functions, Vercel, Cloudflare Workers, AWS Lamda, AWS EC2, ...) and, instead of having each SSR framework reinventing the wheel, this RFC enables a common ecosystem of deploy integrations.

Deploy integration is deeply intertwined with the topic of how Vite should handle the server.

That's why this RFC is called Vite Server RFC. But the most important consequence of this RFC is a shared ecosystem of deploy integrations.

Vite is becoming the "Web Compiler Commons" that powers a flourishing ecosystem of tools and frameworks. This RFC spurs that.

High-Level Goal

The goal is to enable the following DX.

In this section, we intentionally skip middle- and low-level details. We discuss potential problems in comments to this RFC, and RFC related tickets (see Plan).

We show an example for Express.js and Cloudflare Workers.

Express.js Example

// vite.config.js

import { svelteKit } from 'sveltekit/plugin'
/* Or:
import { marko } from 'marko/plugin'
import { rakkas } from 'rakkas/plugin'
import { ssr } from 'vite-plugin-ssr/plugin'
*/

export default {
  // Note the new config `vite.config.js#server`
  server: './server.js'
  plugins: [
    svelteKit(),
  ],
}

Note that all these SSR frameworks (SvelteKit, Marko, Rakkas, vite-plugin-ssr) have shown intereset in such RFC.

// server.js

import express from 'express'
import { viteMiddleware } from 'vite/server/dev' // Vite's middleware for development
import { stripeMiddleware } from 'stripe/server'
import { expressAdapter } from 'vavite/express'

startServer()

async function startServer() {
  const app = express()

  app.use(expressAdapter([
    // `viteMiddleware` includes:
    //  - Vite's dev server
    //  - The middleware of Vite plugins, e.g. the SSR middleware of SevleteKit / ...
    viteMiddleware,
    stripeMiddleware,
    // ...
  ]))

  app.listen(3000)
  console.log(`Server running at http://localhost:3000`)
}

Vavite is a collection of adapters developed by @cyco130. Note that there is no lock-in here: both sides of adapters follow an open specification.

The server.js file above showcases Middleware Mode: the user manually sets up and controls the server. Middleware Mode is needed for advanced integrations such as Stripe. (This example makes Stripe integration seem easy, but a real world integration is more complex and warrants the need for Middleware Mode.)

The goal of this RFC is to also support Server Mode: the user uses Vite's built-in dev server. This means that, with Server Mode, the user doesn't have to write the server.js file.

Server Mode is the default but the user can opt-in Middleware Mode by setting vite.config.js#server.

If SSR frameworks show interests for it, we can develop a possibility for plugins (e.g. the SvelteKit plugin) to provide a custom built-in server for Server Mode, in replacement of Vite's built-in server.

CLI integration:

$ vite

The CLI command $ vite executes the server entry defined at vite.config.js#server (or, if missing, Vite's built-in server). It would support server HMR.

$ vite build

The CLI command $ vite build builds not only the browser-side JavaScript, but also the server entry defined at vite.config.js#server. (The user can then, for example, use the server production build for a production server on AWS EC2.)

Cloudflare Workers Example

// vite.config.js

import { cloudflareWorkers } from 'vite-plugin-cloudflare-workers'

import { svelteKit } from 'svleteKit/plugin'
/* Or:
import { marko } from 'marko/plugin'
import { rakkas } from 'rakkas/plugin'
import { ssr } from 'vite-plugin-ssr/plugin'
*/

export default {
  plugins: [
    svelteKit(),
    cloudflareWorkers({
      worker: './worker.js'
    })
  ]
}
// worker.js

// A Cloudflare Worker for production

import { viteMiddleware } from 'vite/server/prod' // Vite's middleware for production
import { stripeMiddleware } from 'stripe/server'
import { cloudflareAdapter } from 'vavite/cloudflare-workers'

const handler = cloudflareAdapter([
  // `viteMiddleware` includes the middlewares of all Vite plugins, e.g. the SSR middleware of SvelteKit
  viteMiddleware,
  stripeMiddleware,
  // ...
])

addEventListener('fetch', handler)

Here again, Middleware Mode is indispensable for advanced use cases. (FYI Stripe is actually showing interest in supporting Cloudflare Workers.) That said, vite-plugin-cloudflare-workers can provide a built-in worker to enable Server Mode for Cloudflare Workers.

CLI integration:

$ vite build

The CLI vite build takes care of everything, including bundling worker.js into a single file. (Plugins, such as vite-plugin-cloudflare-workers, can add a custom build step.)

$ vite deploy

Plugins, such as vite-plugin-cloudflare-workers, can provide a $ vite deploy implementation.

Vite's dev server assumes a filesystem, but no filesystem exists in the context of a Cloudflare worker. This means that $ vite uses Vite's built-in dev server instead of using worker.js.

Plan

We progressively implement parts of this RFC.

For example, we can start by implementing:

We progressively include implemented RFC parts into Vite releases. We don't foresee any breaking changes; we can hook into but not disrupt Vite's release lifecycle.

brillout commented 2 years ago

Server code

The user can choose between manually integrating tools, and/or using so-called plugs.

// server.js

// Manual integration

export { handler }

// The Telefunc middleware that exists today
import { telefunc } from 'telefunc'

async function handler(request) {
  const { url, method } = request
  const { pathname } = new URL(url)

  if (pathname === '/_telefunc') {
    const body = await request.text()
    const resp = await telefunc({ url, method, body })
    return new Response(resp.body, {
      headers: { 'content-type': resp.contentType },
      status: resp.statusCode,
    })
  }

  // Same with Rakkas/VPS
  // ...
}

This code is server agnostic: it can run in Cloudflare Workers, in an Express.js server, etc.

// server.js

// With plugs

export { handler }

import { createHandler } from 'vavite'
import { telefunc } from 'telefunc/vavite' // Telefunc would provide a vavite plug
import { rakkas } from 'rakkas/vavite' // Rakkas's vavite plug

// This returns a function with the same format than above: `Request => Promise<Response>`
const handler = createHandler([telefunc(), rakkas()])

Same here, this code is server agnostic

Or a combination:

// server.js

export const async function handler(request) {
  if( someCondition ) {
    // Do something custom
  } else {
    return createHandler([telefunc(), rakkas()])
  }
}

Deploy integration

// worker.js

// Cloudflare Workers

import { vavite } from 'vavite/cloudflare-workers' // Vavite's adapter for Cloudflare Workers
import { handler } from './server'

addEventListener('fetch', vavite(handler))

The user keeps full control over the worker

// express.js

// Express.js

// We use ESM with `package.json['type'] === 'module'`

import express from 'express'
import { vavite } from 'vavite/express' // Vavite's adapter for Express.js
import { handler } from './server'

const app = express()
app.use(vavite(handler))
app.listen(3000)

Here again the user keeps full control over the Express.js server

Zero-config

In the future, we can think of a zero-config thing, if we want. (I agree that Remix's approach of showing integration code makes sense; I still think there are use cases where zero-config makes sense though.)

// Zero-config mode, the user doesn't write any server code

{
  "scripts": {
    "dev": "vavite dev"
  },
  "dependencies": {
    "rakkas": "*",
    "telefunc": "*"
  }
}

vavite could automatically retrieve vavite plugs, e.g. with:

const packageJson = require('./path/to/user/package.json')
const plugs = Object.keys(packageJson.dependencies).map(dep => {
  try {
    const { __internal_self_installing_plug } = require(`${dep}/vavite`)
    return __internal_self_installing_plug
  } catch(_) {
    return null
  }
})

Vite agnostic project?

I purposely didn't include the Vite reloader: the entire thing is completely independent of Vite.

From an architectural point of view, all the vavite code does is to take a server agnostic handler and adapts it into a middleware for Express.js / Cloudflare Workers / etc. That's it: there is no concept of transpiling going on here.

That said, there would be a plugin vavite/vite, which would basically be the same than vavite/express or vavite/cloudflare-workers but for Vite's dev server:

// vite.config.js

import { vavite } from 'vavite/vite'

export default {
  // This installs the `handler()` defined in `server.js` to Vite's dev server.
  plugins: [ vavite('server.js') ]
}

For HMR we can use vite.ssrLoadModule() as usual; no need to use the httpServer.on("request", app) trick since we don't use Express.js at all.

Rename project?

I'm thinking maybe we should rename the project? I actually love the name vavite but maybe it's a bit confusing if it's agnostic to Vite?

Vavite will kill Express.js and Fastify

An interesting thing is that with vavite/cloudflare-worker and vavite/vite, we don't need Express.js anymore.

I was hoping that we would create an ecosystem of middlewares.

Yes. Any tool that works with the vavite server-agnostic format will automatically work with any deploy provider that has a vavite adapter.

For example, it's enough for NextAuth.js to be vavite-compatible to support all deploy environments. (FYI NextAuth.js currenlty doesn't support Cloudflare Workers.)

There is still a use case for Express.js for tools that don't support vavite yet. But vavite will eventually kill Express.js.

Epxress.js will finally die :D.

How about bundling for Cloudflare Workers and Vite Server RFC?

We basically don't need the RFC anymore...

All we need is the goold old ticket #5935. (Merging vite build --ssr into $ vite build and enabling custom build steps.)

The following would just work for Vite + Rakkas/VPS + Telefunc + Cloudflare Workers:

// vite.config.js

import { vavite } from 'vavite/vite'
import { cloudflareWorkers } from 'vite-plugin-cloudflare-workers'

export default {
  plugins: [
    // `server.js` is the file above (which integrates Rakkas + Telefunc)
    // This installs `handler()` to Vite's dev server.
    vavite('server.js'),

    // `worker.js` is the file above (also integrates Rakkas + Telefunc)
    // This adds a custom `esbuild` bundling step to `$ vite build`
    cloudflareWorkers('worker.js')
  ]
}

Note that vite-plugin-cloudflare-workers is completely agnostic to vavite since worker.js is a normal Cloudflare Worker.

The following happens when the user does $ vite build:

  • $ vite build (without --ssr)
  • $ vite build --ssr
  • esbuild for bundling worker.js into a single file

That's it!

With zero-config mode, things would just work without the user having to write a single line of server code... We may want to work on zero-config just so that SvelteKit, Nuxt, etc. start using vavite (they are relentless about users not having to write a single line of integration code).

I think that nails it. Or am I missing something here?

Vavite is going to be big :-).

brillout commented 2 years ago
// server.js

// This is hatTip code, which is:
//  1. Completely agnostic to any server environment (Express.js, Cloudflare Workers, etc.)
//  2. Completely agnostic to any bundler (Vite, webpack, etc.)

import { createHandler } from 'hattip'
import { telefunc } from 'telefunc/hattip' // Telefunc provides an hatTip adapter.
                                           // Such adatper is completely agnostic to Vite.
import { rakkas } from 'rakkas/hattip' // Rakkas's hatTip adapter

// Returns a function with the format `Request => Promise<Response>`
export const handler = createHandler([telefunc(), rakkas()])
// worker.js

// Cloudflare Workers

import { hattip } from 'hattip/cloudflare-workers' // hatTip's adapter for Cloudflare Workers
import { handler } from './server'

addEventListener('fetch', hattip(handler))
// vite.config.js

import { hattip } from 'hattip/vite'
import { cloudflareWorkers } from 'vite-plugin-cloudflare-workers'

export default {
  plugins: [
    // This basically installs `handler()` to Vite's dev server.
    hattip('server.js'),

    // The `vite-plugin-cloudflare-workers` is completely agnostic to hatTip
    //  - `worker.js` is just a normal Cloudflare Workers
    cloudflareWorkers('worker.js')
  ]
}
brillout commented 2 years ago

The ideas are implemented by https://github.com/hattipjs/hattip.