diff --git a/src/lib/rsc-route-normalization.test.ts b/src/lib/rsc-route-normalization.test.ts new file mode 100644 index 00000000..bbf619cf --- /dev/null +++ b/src/lib/rsc-route-normalization.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { normalizeProxiedRscFetch } from '../pages/_layout' +import { normalizeRscFetchUrl } from './rsc-route-normalization' + +const currentHref = 'https://docs.tempo.xyz/docs/guide/payments/send-a-payment' +const origin = 'https://docs.tempo.xyz' + +describe('normalizeRscFetchUrl', () => { + it.each([ + [ + 'keeps cross-origin RSC requests on the current origin', + 'https://tempo.xyz/RSC/R/docs/guide/payments.txt?query=', + 'https://docs.tempo.xyz/RSC/R/docs/guide/payments.txt?query=', + ], + [ + 'normalizes proxied developers route payloads', + 'https://tempo.xyz/RSC/R/developers/docs/tools.txt?query=', + 'https://docs.tempo.xyz/RSC/R/docs/tools.txt?query=', + ], + [ + 'normalizes the proxied developers root payload', + 'https://tempo.xyz/RSC/R/developers.txt?query=', + 'https://docs.tempo.xyz/RSC/R/_root.txt?query=', + ], + [ + 'preserves search and hash fragments', + 'https://tempo.xyz/RSC/R/docs/tools.txt?query=abc#flight', + 'https://docs.tempo.xyz/RSC/R/docs/tools.txt?query=abc#flight', + ], + [ + 'leaves non-RSC asset requests alone', + 'https://tempo.xyz/assets/index.js', + 'https://tempo.xyz/assets/index.js', + ], + ['leaves relative non-RSC requests alone', '/api/og?title=Tools', '/api/og?title=Tools'], + ])('%s', (_name, input, expected) => { + expect(normalizeRscFetchUrl(input, currentHref, origin)).toBe(expected) + }) +}) + +describe('normalizeProxiedRscFetch', () => { + it.each([ + [ + 'rewrites cross-origin RSC requests to the current origin', + 'https://tempo.xyz/RSC/R/docs/tools.txt?query=', + 'https://docs.tempo.xyz/RSC/R/docs/tools.txt?query=', + ], + [ + 'rewrites proxied developers RSC requests', + 'https://tempo.xyz/RSC/R/developers/docs/tools.txt?query=', + 'https://docs.tempo.xyz/RSC/R/docs/tools.txt?query=', + ], + [ + 'leaves non-RSC requests unchanged', + 'https://tempo.xyz/assets/index.js', + 'https://tempo.xyz/assets/index.js', + ], + ])('%s', async (_name, input, expected) => { + const requests: unknown[] = [] + const fetch = (request: unknown) => { + requests.push(request) + return Promise.resolve(new Response()) + } + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + __tempoNormalizeProxiedRscFetch: false, + fetch, + location: { + href: currentHref, + origin, + }, + }, + }) + + try { + Function(normalizeProxiedRscFetch)() + await globalThis.window.fetch(input) + expect(requests).toEqual([expected]) + } finally { + Reflect.deleteProperty(globalThis, 'window') + } + }) +}) diff --git a/src/lib/rsc-route-normalization.ts b/src/lib/rsc-route-normalization.ts new file mode 100644 index 00000000..52dc3d51 --- /dev/null +++ b/src/lib/rsc-route-normalization.ts @@ -0,0 +1,10 @@ +export function normalizeRscFetchUrl(url: string, currentHref: string, origin: string) { + const requestUrl = new URL(url, currentHref) + if (!requestUrl.pathname.startsWith('/RSC/R/')) return url + + const pathname = requestUrl.pathname + .replace(/\/RSC\/R\/developers\.txt$/, '/RSC/R/_root.txt') + .replace(/\/RSC\/R\/developers\//, '/RSC/R/') + + return new URL(pathname + requestUrl.search + requestUrl.hash, origin).toString() +} diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index bd04ff85..8ecf6e7d 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -1,5 +1,36 @@ import type { PropsWithChildren } from 'react' +export const normalizeProxiedRscFetch = ` +(() => { + if (window.__tempoNormalizeProxiedRscFetch) return; + window.__tempoNormalizeProxiedRscFetch = true; + const originalFetch = window.fetch.bind(window); + window.fetch = (input, init) => { + const url = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + const requestUrl = new URL(url, window.location.href); + let rewritten = url; + if (requestUrl.pathname.startsWith('/RSC/R/')) { + const pathname = requestUrl.pathname + .replace(/\\/RSC\\/R\\/developers\\.txt$/, '/RSC/R/_root.txt') + .replace(/\\/RSC\\/R\\/developers\\//, '/RSC/R/'); + rewritten = new URL( + pathname + requestUrl.search + requestUrl.hash, + window.location.origin, + ).toString(); + } + + if (rewritten === url) return originalFetch(input, init); + if (typeof input === 'string' || input instanceof URL) return originalFetch(rewritten, init); + + return originalFetch(new Request(rewritten, input), init); + }; +})(); +` + export default function Layout( props: PropsWithChildren<{ path: string @@ -15,6 +46,8 @@ export default function Layout( type="font/woff2" crossOrigin="anonymous" /> + {/* biome-ignore lint/security/noDangerouslySetInnerHtml: static bootstrap must run before the RSC client bundle. */} +