flightcontrolhq / superjson

Safely serialize JavaScript expressions to a superset of JSON, which includes Dates, BigInts, and more.
https://www.flightcontrol.dev?ref=superjson
MIT License
4.17k stars 90 forks source link

Support for Server Actions in Next.js #291

Open Finkes opened 4 months ago

Finkes commented 4 months ago

I'm using the next-superjson-plugin to pass data from next.js server components to client components and this works like a charm.

Besides server components next.js also provides server actions, a new way of fetching data from the backend to to client by directly calling a backend function from the client side.

Server actions have the same serialization problem like passing data from server components to client components: behind the scenes data is serialized as JSON string on the server and then passed to the client. Therefore the following example doesn't work as expected, since the custom type is lost on the client side

"use server"

export async function serverAction(){
  return new Prisma.Decimal(10)
}

// client side
const response = await serverAction()
console.log(typeof response) // returns string not decimal/object!

As a workaround we can use SuperJSON.serialize() and SuperJSON.deserialize() like this:

"use server"

export async function serverAction(){
  return SuperJSON.serialize(new Prisma.Decimal(10))
}

// client side
const response = SuperJSON.deserialize(await serverAction())

However with this solution we are loosing the TypeScript type safety and we have to assign the return types manually.

Is there any way to integrate SuperJSON into next.js server actions? Maybe there is a way to provide a custom serializer to next.js?

Skn0tt commented 4 months ago

Interesting! I think ideally, next-superjson-plugin would be performing a compile-time transform to cover this as well. I'm not sure if that's possible though, are there any good ways of detecting a server action definition or call inside an AST?

For a more manual solution, do you think this TypeScript hack would work?

"use server"

function wrapWithSuperJSON<ServerAction extends () => Promise<any>>(serverAction: ServerAction): ServerAction {
  if (typeof window === 'undefined') return serverAction.then(SuperJSON.serialize)
  return serverAction.then(SuperJSON.deserialize)
}

export const serverAction = wrapWithSuperJSON(() => new Prisma.Decimal(10))

// client side
const response = await serverAction()

I haven't tried this out, so let me know if this doesn't work.

Finkes commented 4 months ago

Thank you @Skn0tt for your quick support! I really appreciate that. I agree, making next-superjson-plugin handle this automatically would be great.

are there any good ways of detecting a server action definition or call inside an AST?

I'm pretty sure there is a way, but unfortunately I don't have a deeper understanding on how things work behind the scenes, yet.

I tried your proposal, but it looks like the deserialization function isn't executed at all:

"user server"

function wrapWithSuperJSON<ServerAction extends () => Promise<any>>(
  serverAction: ServerAction,
): ServerAction {
  if (typeof window === "undefined")
    return serverAction().then(SuperJSON.serialize) as any as ServerAction;
  return serverAction().then(SuperJSON.deserialize) as any as ServerAction;
}

export const serverAction = async () =>
  wrapWithSuperJSON(async () => {
    return Promise.resolve(new Prisma.Decimal(10));
  });

// client side
const result = await serverAction();
console.log(result);

Output:

image

But thanks to your proposal I have found another workaround which involves wrappers on both sides:

// wrapper functions

/**
 * Wrap a next.js server action with SuperJSON (serialize)
 * @param serverAction
 */
export function serializeWithSuperJSON<ServerAction extends () => Promise<any>>(
  serverAction: ServerAction,
): ReturnType<ServerAction> {
  return serverAction().then(
    SuperJSON.serialize,
  ) as any as ReturnType<ServerAction>;
}

/**
 * Wrap a next.js server action with SuperJSON (deserialize)
 * @param serverAction
 */
export function deserializeWithSuperJSON<
  ServerAction extends () => Promise<any>,
>(serverAction: ServerAction) {
  return serverAction().then(SuperJSON.deserialize) as any as Promise<
    ReturnType<ServerAction>
  >;
}

// server side
"user server"

export async function serverAction() {
  return await serializeWithSuperJSON(async () => {
    return new Prisma.Decimal(10);
  });
}

// client side
const result = await deserializeWithSuperJSON(serverAction);
console.log(Prisma.Decimal.isDecimal(result));

Note that it looks like server actions require the function keyword as described by this error message.

Skn0tt commented 4 months ago

Good to hear you found a workaround! It's really hard to keep up with all the new shenanigans Next.js comes up with, and I have a feeling that supporting server actions in next-superjson-plugin will be close to impossible. Ideally, Next.js added something like tRPCs transformer option: https://trpc.io/docs/server/data-transformers#using-superjson