nksaraf / vinxi

The Full Stack JavaScript SDK
https://vinxi.vercel.app
MIT License
1.33k stars 57 forks source link

[Feature request][Server functions] Data transformer (e.g. SuperJSON) #240

Open simonedelmann opened 4 months ago

simonedelmann commented 4 months ago

One main benefit of server functions (at least for me) is, that server and client share the same types. This works well as long as the payload is encodable as JSON. But Dates/Sets/Maps/… will result in unexpected surprises.

tRPC solves this by adding a Data Transformer, which enhances JSON.stringify() and JSON.parse() / replaces it by SuperJSON. (https://trpc.io/docs/server/data-transformers)

It would be great to include such a feature to Vinxi‘s server functions, too.

// Maybe something like this??

import SuperJSON from ‘superjson‘

export default createApp({
    routers: [
        {
            name: "client",
            type: "spa",
            handler: "./index.html",
            plugins: () => [
                 serverFunctions.client({ transformer: SuperJSON, })
            ],
        },
        serverFunctions.router({
            middleware: "./app/middleware.tsx",
            transformer: SuperJSON,
        }),
    ],
});
simonedelmann commented 2 months ago

I made some attempt to implement this functionality myself, see files below. This should be trivial to implement into Vinxi. Please let me know if I should make a PR for this.

app.config.ts

import { fileURLToPath } from 'node:url'
// @ts-expect-error - no types yet
import { serverFunctions } from '@vinxi/server-functions/plugin'
// @ts-expect-error - no types yet
import { server } from '@vinxi/server-functions/server'
import react from '@vitejs/plugin-react-swc'
import { createApp } from 'vinxi'
import { normalize } from 'vinxi/lib/path'
import tsconfigPaths from 'vite-tsconfig-paths'

export default createApp({
  routers: [
    {
      name: 'client',
      target: 'browser',
      type: 'spa',
      handler: 'index.html',
      plugins: () => [
        tsconfigPaths(),
        serverFunctions.client({
          runtime: normalize(
            fileURLToPath(
              new URL('./vinxi/client-runtime.js', import.meta.url),
            ),
          ),
        }),
        react(),
      ],
    },

    {
      name: 'server-fns',
      type: 'http',
      base: '/_server',
      handler: fileURLToPath(
        new URL('./vinxi/server-handler.js', import.meta.url),
      ),
      target: 'server',
      plugins: async () => [server(), tsconfigPaths()],
    },
  ],
})

vinxi/client-runtime.js

import SuperJSON from 'superjson'

async function fetchServerAction(base, id, args) {
  const response = await fetch(base, {
    method: 'POST',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
      'server-action': id,
    },
    body: JSON.stringify(SuperJSON.serialize(args)),
  })

  return SuperJSON.deserialize(await response.json())
}

export function createServerReference(fn, id, name) {
  return new Proxy(fn, {
    get(target, prop, receiver) {
      if (prop === 'url') {
        return `${import.meta.env.SERVER_BASE_URL}/_serverid=${id}&name=${name}`
      }

      return Reflect.get(target, prop, receiver)
    },
    apply(target, thisArg, args) {
      return fetchServerAction(
        `${import.meta.env.SERVER_BASE_URL}/_server`,
        `${id}#${name}`,
        args,
      )
    },
  })
}

vinxi/server-handler.js

import SuperJSON from 'superjson'
/// <reference types="vinxi/types/server" />
import { eventHandler, getHeader, readBody, setHeader } from 'vinxi/http'
import invariant from 'vinxi/lib/invariant'
import { getManifest } from 'vinxi/manifest'

export async function handleServerAction(event) {
  invariant(event.method === 'POST', 'Invalid method')

  const serverReference = getHeader(event, 'server-action')
  if (serverReference) {
    invariant(typeof serverReference === 'string', 'Invalid server action')
    // This is the client-side case
    const [filepath, name] = serverReference.split('#')
    const action = (
      await getManifest(import.meta.env.ROUTER_NAME).chunks[filepath].import()
    )[name]
    const json = SuperJSON.deserialize(await readBody(event))
    const result = action.apply(null, json)
    try {
      // Wait for any mutations
      const response = await result
      setHeader(event, 'Content-Type', 'application/json')
      setHeader(event, 'Router', 'server-fns')

      return JSON.stringify(SuperJSON.serialize(response ?? null))
    } catch (x) {
      console.error(x)
      return new Response(
        JSON.stringify(SuperJSON.serialize({ error: x.message })),
        {
          status: 500,
          headers: {
            'Content-Type': 'application/json',
            'x-server-function': 'error',
          },
        },
      )
    }
  } else {
    throw new Error('Invalid request')
  }
}

export default eventHandler(handleServerAction)