TanStack / router

🤖 Fully typesafe Router for React (and friends) w/ built-in caching, 1st class search-param APIs, client-side cache integration and isomorphic rendering.
https://tanstack.com/router
MIT License
7.63k stars 547 forks source link

Server-only referenced variables inside of server functions are not treeshaken away in browser bundle #1486

Open lithdew opened 4 months ago

lithdew commented 4 months ago

Describe the bug

problem

Both client-runtime and server-runtime for TanStack Router server functions reference the same fetcher() function implementation in server-fns/fetcher.tsx.

This confuses Vite as fetcher() is mixed into both server-side and client-side (browser) code. Vite therefore assumes that server-only variables referenced inside of server functions are actually utilized client-side as well.

This causes variables referenced outside* of the "use server" pragma that are NOT referenced client-side to not be treeshaken away in the browser bundle.

how i stumbled upon it

I was trying to use TanStack router server functions and wanted to define a few variables outside of the "use server" pragma.

I had a PostgreSQL connection defined in a separate file that was used only inside of server functions, and noticed that the PostgreSQL connection library was included in the browser bundle when it shouldn't have been.

an alternative

I isolated the server function runtime from Solid Start to use for my own project. Solid Start's runtime does not face this same issue. For a second I thought it was an issue with Vinxi, though it did not turn out to be the case.

I included the isolated code at the bottom of this issue.

I found out why server functions included server-only referenced variables into the browser bundle while isolating the server function runtime from Solid Start as I noticed that the difference between Solid Start and TanStack Router's runtime implementation is that fetcher() imported from server-fns/fetcher.tsx is referenced in both server-side and client-side runtimes.

solution found but no pr filed

I was hoping to file a PR to solve this issue as all that needs to be done is to independently isolate fetcher() for both client-side and server-side runtime.

I can't really think of a clear/clean way to do this though and wanted to get some advice on this.

solid start isolated server function runtime

types.ts:

import type { HTTPEvent } from "vinxi/http";

export interface ResponseStub {
  status?: number;
  statusText?: string;
  headers: Headers;
}
export interface FetchEvent {
  request: Request;
  response: ResponseStub;
  clientAddress?: string;
  locals: RequestEventLocals;
  nativeEvent: HTTPEvent;
}
export interface RequestEventLocals {
  [key: string | symbol]: any;
}

fetchEvent.ts:

import {
  H3Event,
  appendResponseHeader,
  getRequestIP,
  getResponseHeader,
  getResponseHeaders,
  getResponseStatus,
  getResponseStatusText,
  getWebRequest,
  removeResponseHeader,
  setResponseHeader,
  setResponseStatus,
} from "vinxi/http";
import type { FetchEvent, ResponseStub } from "./types";

const fetchEventSymbol = Symbol("fetchEvent");

export function createFetchEvent(event: H3Event): FetchEvent {
  return {
    request: getWebRequest(event),
    response: createResponseStub(event),
    clientAddress: getRequestIP(event),
    locals: {},
    nativeEvent: event,
  };
}

export function getFetchEvent(h3Event: H3Event): FetchEvent {
  if (!(h3Event as any)[fetchEventSymbol]) {
    const fetchEvent = createFetchEvent(h3Event);
    (h3Event as any)[fetchEventSymbol] = fetchEvent;
  }

  return (h3Event as any)[fetchEventSymbol];
}

export function mergeResponseHeaders(h3Event: H3Event, headers: Headers) {
  for (const [key, value] of headers.entries()) {
    appendResponseHeader(h3Event, key, value);
  }
}

class HeaderProxy {
  constructor(private event: H3Event) {}
  get(key: string) {
    const h = getResponseHeader(this.event, key);
    return Array.isArray(h) ? h.join(", ") : (h as string) || null;
  }
  has(key: string) {
    return this.get(key) !== undefined;
  }
  set(key: string, value: string) {
    return setResponseHeader(this.event, key, value);
  }
  delete(key: string) {
    return removeResponseHeader(this.event, key);
  }
  append(key: string, value: string) {
    appendResponseHeader(this.event, key, value);
  }
  getSetCookie() {
    const cookies = getResponseHeader(this.event, "Set-Cookie");
    return Array.isArray(cookies) ? cookies : [cookies as string];
  }
  forEach(fn: (value: string, key: string, object: Headers) => void) {
    return Object.entries(getResponseHeaders(this.event)).forEach(
      ([key, value]) =>
        fn(
          Array.isArray(value) ? value.join(", ") : (value as string),
          key,
          this as unknown as Headers,
        ),
    );
  }
  entries() {
    return Object.entries(getResponseHeaders(this.event))
      .map(
        ([key, value]) =>
          [key, Array.isArray(value) ? value.join(", ") : value] as [
            string,
            string,
          ],
      )
      [Symbol.iterator]();
  }
  keys() {
    return Object.keys(getResponseHeaders(this.event))[Symbol.iterator]();
  }
  values() {
    return Object.values(getResponseHeaders(this.event))
      .map((value) =>
        Array.isArray(value) ? value.join(", ") : (value as string),
      )
      [Symbol.iterator]();
  }
  [Symbol.iterator]() {
    return this.entries()[Symbol.iterator]();
  }
}

function createResponseStub(event: H3Event): ResponseStub {
  return {
    get status() {
      return getResponseStatus(event);
    },
    set status(v) {
      setResponseStatus(event, v);
    },
    get statusText() {
      return getResponseStatusText(event);
    },
    set statusText(v) {
      setResponseStatus(event, getResponseStatus(), v);
    },
    headers: new HeaderProxy(event) as unknown as Headers,
  };
}

server-fns-runtime.ts (handles server-side server function invocations):

export function createServerReference(fn: Function, id: string, name: string) {
  if (typeof fn !== "function")
    throw new Error("Export from a 'use server' module must be a function");
  const baseURL = import.meta.env.SERVER_BASE_URL;
  return new Proxy(fn, {
    get(target, prop, receiver) {
      if (prop === "url") {
        return `${baseURL}/_server?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`;
      }
      if (prop === "GET") return receiver;
      if (prop === "withOptions") {
        return (options: RequestInit) => receiver;
      }
    },
    apply(target, thisArg, args) {
      return fn.apply(thisArg, args);
    },
  });
}

server-runtime.ts (handles client-side server function invocations):

import { deserialize, toJSONAsync } from "seroval";
import {
  CustomEventPlugin,
  DOMExceptionPlugin,
  EventPlugin,
  FormDataPlugin,
  HeadersPlugin,
  ReadableStreamPlugin,
  RequestPlugin,
  ResponsePlugin,
  URLPlugin,
  URLSearchParamsPlugin,
} from "seroval-plugins/web";

class SerovalChunkReader {
  reader: ReadableStreamDefaultReader<Uint8Array>;
  buffer: Uint8Array;
  done: boolean;
  constructor(stream: ReadableStream<Uint8Array>) {
    this.reader = stream.getReader();
    this.buffer = new Uint8Array(0);
    this.done = false;
  }

  async readChunk() {
    // if there's no chunk, read again
    const chunk = await this.reader.read();
    if (!chunk.done) {
      // repopulate the buffer
      let newBuffer = new Uint8Array(this.buffer.length + chunk.value.length);
      newBuffer.set(this.buffer);
      newBuffer.set(chunk.value, this.buffer.length);
      this.buffer = newBuffer;
    } else {
      this.done = true;
    }
  }

  async next(): Promise<any> {
    // Check if the buffer is empty
    if (this.buffer.length === 0) {
      // if we are already done...
      if (this.done) {
        return {
          done: true,
          value: undefined,
        };
      }
      // Otherwise, read a new chunk
      await this.readChunk();
      return await this.next();
    }
    // Read the "byte header"
    // The byte header tells us how big the expected data is
    // so we know how much data we should wait before we
    // deserialize the data
    const head = new TextDecoder().decode(this.buffer.subarray(1, 11));
    const bytes = Number.parseInt(head, 16); // ;0x00000000;
    // Check if the buffer has enough bytes to be parsed
    while (bytes > this.buffer.length - 12) {
      // If it's not enough, and the reader is done
      // then the chunk is invalid.
      if (this.done) {
        throw new Error("Malformed server function stream.");
      }
      // Otherwise, we read more chunks
      await this.readChunk();
    }
    // Extract the exact chunk as defined by the byte header
    const partial = new TextDecoder().decode(
      this.buffer.subarray(12, 12 + bytes),
    );
    // The rest goes to the buffer
    this.buffer = this.buffer.subarray(12 + bytes);

    // Deserialize the chunk
    return {
      done: false,
      value: deserialize(partial),
    };
  }

  async drain() {
    while (true) {
      const result = await this.next();
      if (result.done) {
        break;
      }
    }
  }
}

async function deserializeStream(id: string, response: Response) {
  if (!response.body) {
    throw new Error("missing body");
  }
  const reader = new SerovalChunkReader(response.body);

  const result = await reader.next();

  if (!result.done) {
    reader.drain().then(
      () => {
        // @ts-ignore
        delete $R[id];
      },
      () => {
        // no-op
      },
    );
  }

  return result.value;
}

let INSTANCE = 0;

function createRequest(
  base: string,
  id: string,
  instance: string,
  options: RequestInit,
) {
  return fetch(base, {
    method: "POST",
    ...options,
    headers: {
      ...options.headers,
      "X-Server-Id": id,
      "X-Server-Instance": instance,
    },
  });
}

const plugins = [
  CustomEventPlugin,
  DOMExceptionPlugin,
  EventPlugin,
  FormDataPlugin,
  HeadersPlugin,
  ReadableStreamPlugin,
  RequestPlugin,
  ResponsePlugin,
  URLSearchParamsPlugin,
  URLPlugin,
];

async function fetchServerFunction(
  base: string,
  id: string,
  options: Omit<RequestInit, "body">,
  args: any[],
) {
  const instance = `server-fn:${INSTANCE++}`;
  const response = await (args.length === 0
    ? createRequest(base, id, instance, options)
    : args.length === 1 && args[0] instanceof FormData
      ? createRequest(base, id, instance, { ...options, body: args[0] })
      : createRequest(base, id, instance, {
          ...options,
          body: JSON.stringify(
            await Promise.resolve(toJSONAsync(args, { plugins })),
          ),
          headers: { ...options.headers, "Content-Type": "application/json" },
        }));

  if (
    response.headers.get("Location") ||
    response.headers.get("X-Revalidate")
  ) {
    if (response.body) {
      (response as any).customBody = () => {
        return deserializeStream(instance, response);
      };
    }
    return response;
  }

  const contentType = response.headers.get("Content-Type");
  let result;
  if (contentType && contentType.startsWith("text/plain")) {
    result = await response.text();
  } else if (contentType && contentType.startsWith("application/json")) {
    result = await response.json();
  } else {
    result = await deserializeStream(instance, response);
  }
  if (response.headers.has("X-Error")) {
    throw result;
  }
  return result;
}

export function createServerReference(fn: Function, id: string, name: string) {
  const baseURL = import.meta.env.SERVER_BASE_URL;
  return new Proxy(fn, {
    get(target, prop, receiver) {
      if (prop === "url") {
        return `${baseURL}/_server?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`;
      }
      if (prop === "GET") {
        return receiver.withOptions({ method: "GET" });
      }
      if (prop === "withOptions") {
        const url = `${baseURL}/_server/?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`;
        return (options: RequestInit) => {
          const fn = async (...args: any[]) => {
            const encodeArgs =
              options.method && options.method.toUpperCase() === "GET";
            return fetchServerFunction(
              encodeArgs
                ? url +
                    (args.length
                      ? `&args=${encodeURIComponent(
                          JSON.stringify(
                            await Promise.resolve(
                              toJSONAsync(args, { plugins }),
                            ),
                          ),
                        )}`
                      : "")
                : `${baseURL}/_server`,
              `${id}#${name}`,
              options,
              encodeArgs ? [] : args,
            );
          };
          fn.url = url;
          return fn;
        };
      }
    },
    apply(target, thisArg, args) {
      return fetchServerFunction(
        `${baseURL}/_server`,
        `${id}#${name}`,
        {},
        args,
      );
    },
  });
}

server-handler.ts (the server function HTTP handler):

// <reference types="vinxi/types/server" />
import {
  crossSerializeStream,
  fromJSON,
  getCrossReferenceHeader,
} from "seroval";

import {
  CustomEventPlugin,
  DOMExceptionPlugin,
  EventPlugin,
  FormDataPlugin,
  HeadersPlugin,
  ReadableStreamPlugin,
  RequestPlugin,
  ResponsePlugin,
  URLPlugin,
  URLSearchParamsPlugin,
} from "seroval-plugins/web";

import {
  eventHandler,
  setHeader,
  setResponseStatus,
  type HTTPEvent,
} from "vinxi/http";

import invariant from "vinxi/lib/invariant";

import { getFetchEvent, mergeResponseHeaders } from "./fetchEvent";

function createChunk(data: string) {
  const encodeData = new TextEncoder().encode(data);
  const bytes = encodeData.length;
  const baseHex = bytes.toString(16);
  const totalHex = "00000000".substring(0, 8 - baseHex.length) + baseHex; // 32-bit
  const head = new TextEncoder().encode(`;0x${totalHex};`);

  const chunk = new Uint8Array(12 + bytes);
  chunk.set(head);
  chunk.set(encodeData, 12);
  return chunk;
}

function serializeToStream(id: string, value: any) {
  return new ReadableStream({
    start(controller) {
      crossSerializeStream(value, {
        scopeId: id,
        plugins: [
          CustomEventPlugin,
          DOMExceptionPlugin,
          EventPlugin,
          FormDataPlugin,
          HeadersPlugin,
          ReadableStreamPlugin,
          RequestPlugin,
          ResponsePlugin,
          URLSearchParamsPlugin,
          URLPlugin,
        ],
        onSerialize(data, initial) {
          controller.enqueue(
            createChunk(
              initial ? `(${getCrossReferenceHeader(id)},${data})` : data,
            ),
          );
        },
        onDone() {
          controller.close();
        },
        onError(error) {
          controller.error(error);
        },
      });
    },
  });
}

async function handleServerFunction(h3Event: HTTPEvent) {
  const event = getFetchEvent(h3Event);
  const request = event.request;

  const serverReference = request.headers.get("X-Server-Id");
  const instance = request.headers.get("X-Server-Instance");
  const url = new URL(request.url);
  let filepath: string | undefined | null, name: string | undefined | null;
  if (serverReference) {
    invariant(typeof serverReference === "string", "Invalid server function");
    [filepath, name] = serverReference.split("#");
  } else {
    filepath = url.searchParams.get("id");
    name = url.searchParams.get("name");
    if (!filepath || !name) throw new Error("Invalid request");
  }

  const serverFunction = (
    await import.meta.env.MANIFEST[import.meta.env.ROUTER_NAME]!.chunks[
      filepath!
    ]!.import()
  )[name!];
  let parsed: any[] = [];

  // grab bound arguments from url when no JS
  if (!instance || h3Event.method === "GET") {
    const args = url.searchParams.get("args");
    if (args) {
      const json = JSON.parse(args);
      (json.t
        ? (fromJSON(json, {
            plugins: [
              CustomEventPlugin,
              DOMExceptionPlugin,
              EventPlugin,
              FormDataPlugin,
              HeadersPlugin,
              ReadableStreamPlugin,
              RequestPlugin,
              ResponsePlugin,
              URLSearchParamsPlugin,
              URLPlugin,
            ],
          }) as any)
        : json
      ).forEach((arg: any) => parsed.push(arg));
    }
  }
  if (h3Event.method === "POST") {
    const contentType = request.headers.get("content-type");
    if (
      contentType?.startsWith("multipart/form-data") ||
      contentType?.startsWith("application/x-www-form-urlencoded")
    ) {
      parsed.push(await request.formData());
    } else if (contentType?.startsWith("application/json")) {
      parsed = fromJSON(await request.json(), {
        plugins: [
          CustomEventPlugin,
          DOMExceptionPlugin,
          EventPlugin,
          FormDataPlugin,
          HeadersPlugin,
          ReadableStreamPlugin,
          RequestPlugin,
          ResponsePlugin,
          URLSearchParamsPlugin,
          URLPlugin,
        ],
      });
    }
  }
  try {
    let result = await serverFunction(...parsed);

    // handle responses
    if (result instanceof Response && instance) {
      // forward headers
      if (result.headers) mergeResponseHeaders(h3Event, result.headers);

      // forward non-redirect statuses
      if (result.status && (result.status < 300 || result.status >= 400)) {
        setResponseStatus(h3Event, result.status);
      }

      if ((result as any).customBody) {
        result = await (result as any).customBody();
      } else if (result.body == undefined) {
        result = null;
      }
    }

    // handle no JS success case
    if (!instance) {
      let redirectUrl = new URL(request.headers.get("referer")!).toString();
      if (result instanceof Response && result.headers.has("Location")) {
        redirectUrl = new URL(
          result.headers.get("Location")!,
          new URL(request.url).origin + import.meta.env.SERVER_BASE_URL,
        ).toString();
      }

      const isError = result instanceof Error;

      return new Response(null, {
        status: 302,
        headers: {
          Location: redirectUrl,
          ...(result
            ? {
                "Set-Cookie": `flash=${JSON.stringify({
                  url: url.pathname + encodeURIComponent(url.search),
                  result: isError ? result.message : result,
                  error: isError,
                  input: [
                    ...parsed.slice(0, -1),
                    [...parsed[parsed.length - 1].entries()],
                  ],
                })}; Secure; HttpOnly;`,
              }
            : {}),
        },
      });
    }

    setHeader(h3Event, "content-type", "text/javascript");

    return serializeToStream(instance, result);
  } catch (x) {
    if (x instanceof Response) {
      // forward headers
      if (x.headers) {
        mergeResponseHeaders(h3Event, x.headers);
      }

      // forward non-redirect statuses
      if (x.status && (!instance || x.status < 300 || x.status >= 400)) {
        setResponseStatus(h3Event, x.status);
      }

      if ((x as any).customBody) {
        x = (x as any).customBody();
      } else if (x.body == undefined) {
        x = null;
      }
    } else {
      const error =
        x instanceof Error ? x.message : typeof x === "string" ? x : "true";
      setHeader(h3Event, "X-Error", error);
    }
    if (instance) {
      setHeader(h3Event, "content-type", "text/javascript");
      return serializeToStream(instance, x);
    }
    return x;
  }
}

export default eventHandler(handleServerFunction);

Your Example Website or App

N/A

Steps to Reproduce the Bug or Issue

Define variables outside of the server function that are used inside of server functions that do not get referenced client-side. They will appear in the client-side bundle.

Expected behavior

Server-only referenced variables should be treeshaken away in the browser bundle.

Screenshots or Videos

No response

Platform

Additional context

No response

tannerlinsley commented 4 months ago

Is this something Vinxi needs to improve on @nksaraf? Or is this solely my fault? :)

lithdew commented 2 months ago

As an update, the source of this issue appears to be due to verbatimModuleSyntax not playing well with Vinxi. https://github.com/nksaraf/vinxi/issues/308