router icon indicating copy to clipboard operation
router copied to clipboard

[Start]: accessing header inside `beforeLoad` throw error on the client

Open omarkhatibco opened this issue 1 year ago • 2 comments

Describe the bug

So I'm trying to load the cookies on the server, and pass them to the component using useRouteContext.

but it throw an error Module "node:async_hooks" has been externalized for browser compatibility. Cannot access "node:async_hooks.AsyncLocalStorage" in client code. which I think it needs a polyfill to fix it,

While in real world I don't need to pass the cookies to the client, but I need them to able to fetch from my backend on the server.

my thought here, that beforeload is running on the server, why the error is on the client?

Your Example Website or App

https://github.com/omarkhatibco/start-test

Steps to Reproduce the Bug or Issue

Check both __route.tsx and index.tsx

Expected behavior

Either it should work, or we should have something in the doc about how to access cookies / headers

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

omarkhatibco avatar Aug 23 '24 22:08 omarkhatibco

You cannot access headers in the beforeLoad function directly, since the beforeLoad callbacks fires both on the client and the server. Getting headers could work on the server, but would definitely fail on the client.

You might be able to do so using createServerFn, but it's not something we have tested. You could try this out.

const beforeLoadTestFn = createServerFn('GET', () => {
  const headers = getHeaders()
  const item = processHeaders(headers)
  return item
})

export const Route = createFileRoute('/')({
  beforeLoad: beforeLoadTestFn
})

SeanCassiere avatar Aug 24 '24 11:08 SeanCassiere

Hi @SeanCassiere,

Thanks for the clarification, the problem, if you have already an external server for your endpoints you want to avoid adding extra layer or proxying it through createServerFn

anyway, I was able to get the header using auto import such like this

const isBrowser = typeof window !== 'undefined'

export const apiClient = treaty<ElysiaApp>('localhost:3001', {
	fetch: {
		credentials: 'include',
		mode: 'cors',
	},
	async onRequest(path, options) {
		if (!isBrowser) {
			const getHeaders = await import('vinxi/http').then(
				(mod) => mod.getRequestHeaders,
			)
			const headers = getHeaders()

			return {
				headers,
			}
		}
	},
	async onResponse(response) {
		if (response.status === 401) {
			window.location.href = '/auth/login'
			console.error('token expired')
		}
	},
})

omarkhatibco avatar Aug 29 '24 14:08 omarkhatibco

hi, I'm facing the same issue, lets say you have a custom fetch client like this, that injects the headers if the request is coming from the SSR you could do the solution proposed above

import type { AppType } from "@Echidna-API";
import { hc } from "hono/client";
import { env } from "~/env";

const isBrowser = typeof window !== "undefined";

const honoClient = hc<AppType>(env.VITE_API_URL, {
	fetch: async (input, requestInit, _, __) => {
		const reqInit = requestInit ?? {};

		if (!isBrowser) {
			const getHeaders = await import("vinxi/http").then(
				(mod) => mod.getRequestHeaders,
			);
			reqInit.headers = getHeaders() as HeadersInit;
		}

		return fetch(input, reqInit);
	},
	init: {
		credentials: "include",
	},
});

export default honoClient;

and this will work in dev mode, but will fail at build time because vinxi/http will try get bundled to the client side and crash the build (before it finishes)

a dumb example for this would be

const isBrowser = typeof window !== "undefined";

const customFetch = async (url: string, options?: RequestInit) => {
	const reqInit = options ?? {};

	if (!isBrowser) {
		const getHeaders = await import("vinxi/http").then(
			(mod) => mod.getRequestHeaders,
		);
		const headers = getHeaders();

		reqInit.headers = {
			...reqInit.headers,
			cookie: headers.cookie ?? "",
		};
	}

	return fetch(url, reqInit);
};

export default customFetch;

and if you call customFetch from inside a beforeLoad it works, but fails at build time and even if you call the customFetch from inside a serverFn it still fails at build time.

the only solution is to call getHeaders() directly inside a serverFn but for example that would not work for my use case because then i would lose typesafe on my RPC hono client.

i haven't implement a example yet but i think the same problem would happen with something like TRPC

Yusuf007R avatar Nov 25 '24 05:11 Yusuf007R

@Yusuf007R as mentioned earlier, the process of getting headers using the proxied h3 function through vinxi/http is expected to fail when not wrapped in a createServerFn call, since you'd be attempting to call getHeaders (a server related function) on the client.

At the moment, we are working with what's proxied out of vinxi/http, which in turn is wrapping functions from nitro and h3 using getRequest.

TLDR; We don't expect the getHeaders function to work outside of createServerFn. You'd be falling back on whatever you'd normally be using for, in your case, getting cookies on the client.

If you want to brute force this, then you could perhaps try injecting your a function that returns your fetcher using Router context and injecting the headers in there.

// app/utils.ts
const getFetcher = (headers) => myCustomFetchFn(headers)

const getHeadersFromServer = createServerFn().handler(async () => {
	const headers = await getHeaders();
	return serializeMyHeadersUsingSomething(headers)
})

// app/router.tsx
const createRouter = ({ routeTree, context: { getFetcher } });

// app/routes/index.tsx
export const Route = createFileRoute('/')({
	beforeLoad: async ({ context }) => {
		const headers = await getHeadersFromServer();
		const fetcher = context.getFetcher(headers)

		const posts = await fetch('/api/posts').then(...)
	}
})

SeanCassiere avatar Nov 25 '24 20:11 SeanCassiere

yesterday I came up with a similar solution, basically having a serverFn that returns the headers but it is only executed on the server, that way the frontend doesn't need to do an unnecessary request and i imagine the server is not really doing a request it just calling the function right?

Yusuf007R avatar Nov 25 '24 21:11 Yusuf007R

yesterday I came up with a similar solution, basically having a serverFn that returns the headers but it is only executed on the server, that way the frontend doesn't need to do an unnecessary request and i imagine the server is not really doing a request it just calling the function right?

It's isomorphic. So on the initial request, the server is performing the calls and sending the result to the client. The isomorphic part here is that on the client, like on subsequent navigation, it calls a server function (automatically generated endpoint on the server) to get the necessary data which is sent down to the client for it to decide what it wants to do with it client-side.

SeanCassiere avatar Nov 25 '24 21:11 SeanCassiere

yes that's what i meant. but if anyone have this problem in the future here is my current solution:

you first create a serverfn that returns the headers

import { createServerFn } from "@tanstack/start";
import { getHeaders } from "vinxi/http";

const getServerHeaders = createServerFn().handler(async () => {
	return getHeaders();
});

export default getServerHeaders;

then my hono client its like this

import type { AppType } from "@Echidna-API";
import { hc } from "hono/client";
import { env } from "~/env";
import getServerHeaders from "~/utils/get-server-headers";

const isBrowser = typeof window !== "undefined";

const honoClient = hc<AppType>(env.VITE_API_URL, {
	fetch: async (input, requestInit, _, __) => {
		const reqInit = requestInit ?? {};
		if (!isBrowser) reqInit.headers = (await getServerHeaders()) as HeadersInit;
		return fetch(input, reqInit);
	},
	init: {
		credentials: "include",
	},
});

export default honoClient;

that way the headers are only injected on the request that are made from the server

Yusuf007R avatar Nov 25 '24 23:11 Yusuf007R