router
router copied to clipboard
Server-only referenced variables inside of server functions are not treeshaken away in browser bundle
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
Is this something Vinxi needs to improve on @nksaraf? Or is this solely my fault? :)
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