Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
lithdew opened this issue Apr 18, 2024 · 1 comment
Labels
ssr Everything around SSR the story

Comments

@lithdew
Copy link
Contributor

lithdew commented Apr 18, 2024

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

  • OS: [e.g. macOS, Windows, Linux]
  • Browser: [e.g. Chrome, Safari, Firefox]
  • Version: [e.g. 91.1]

Additional context

No response

@tannerlinsley
Copy link
Collaborator

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

@SeanCassiere SeanCassiere added the ssr Everything around SSR the story label Apr 19, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
ssr Everything around SSR the story
Projects
None yet
Development

No branches or pull requests

3 participants