diff --git a/src/lib/docs-routing.test.ts b/src/lib/docs-routing.test.ts new file mode 100644 index 00000000..53b2ea6a --- /dev/null +++ b/src/lib/docs-routing.test.ts @@ -0,0 +1,54 @@ +import fs from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +type Redirect = { + source: string + destination: string + permanent?: boolean + has?: unknown +} + +const vercelConfig = JSON.parse( + fs.readFileSync(path.join(process.cwd(), 'vercel.json'), 'utf-8'), +) as { redirects: Redirect[] } + +const redirects = vercelConfig.redirects.filter((redirect) => !redirect.has) + +function findRedirect(source: string) { + return redirects.find((redirect) => redirect.source === source) +} + +describe('docs routing redirects', () => { + it.each([ + ['/tools', '/docs/tools'], + ['/tools/:path*', '/docs/tools/:path*'], + ['/partners', '/docs/partners'], + ['/api', '/docs/api'], + ['/api/authentication', '/docs/api/authentication'], + ['/api/conventions', '/docs/api/conventions'], + ['/api/errors', '/docs/api/errors'], + ['/api/indexer', '/docs/api/indexer'], + ['/api/indexer-api', '/docs/api/indexer-api'], + ['/api/json-rpc', '/docs/api/json-rpc'], + ['/api/pagination', '/docs/api/pagination'], + ['/api/rate-limits', '/docs/api/rate-limits'], + ['/api/transactions', '/docs/api/transactions'], + ['/api/transactions-and-transfers', '/docs/api/transactions-and-transfers'], + ['/api/transfers', '/docs/api/transfers'], + ['/api/versioning-policy', '/docs/api/versioning-policy'], + ])('redirects %s to %s', (source, destination) => { + expect(findRedirect(source)).toMatchObject({ + source, + destination, + permanent: true, + }) + }) + + it.each([ + ['/api/:path*', 'real API functions such as /api/og must stay routable'], + ['/api/:path(.*)', 'real API functions such as /api/feedback must stay routable'], + ])('does not add broad API docs redirect %s because %s', (source) => { + expect(findRedirect(source)).toBeUndefined() + }) +}) 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 cfa92b03..8ecf6e7d 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -1,6 +1,6 @@ import type { PropsWithChildren } from 'react' -const normalizeProxiedRscFetch = ` +export const normalizeProxiedRscFetch = ` (() => { if (window.__tempoNormalizeProxiedRscFetch) return; window.__tempoNormalizeProxiedRscFetch = true; @@ -11,9 +11,17 @@ const normalizeProxiedRscFetch = ` : input instanceof URL ? input.toString() : input.url; - const rewritten = url - .replace(/\\/RSC\\/R\\/developers\\.txt(?=($|\\?))/, '/RSC/R/_root.txt') - .replace(/\\/RSC\\/R\\/developers\\//, '/RSC/R/'); + 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); diff --git a/src/pages/docs/_layout.tsx b/src/pages/docs/_layout.tsx index 9904cfa4..7378fec6 100644 --- a/src/pages/docs/_layout.tsx +++ b/src/pages/docs/_layout.tsx @@ -5,6 +5,7 @@ import DocsHeader from '../../components/DocsHeader' import DocsSectionNav from '../../components/DocsSectionNav' import DocsSidebarDrawer from '../../components/DocsSidebarDrawer' import { usePageSettled } from '../../lib/pageSettled' +import { normalizeRscFetchUrl } from '../../lib/rsc-route-normalization' const Analytics = lazy(() => import('@vercel/analytics/react').then((module) => ({ default: module.Analytics })), @@ -21,9 +22,7 @@ if (typeof window !== 'undefined') { window.fetch = (input, init) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url - const rewritten = url - .replace(/\/RSC\/R\/developers\.txt(?=($|\?))/, '/RSC/R/_root.txt') - .replace(/\/RSC\/R\/developers\//, '/RSC/R/') + const rewritten = normalizeRscFetchUrl(url, window.location.href, window.location.origin) if (rewritten === url) return originalFetch(input, init) if (typeof input === 'string' || input instanceof URL) return originalFetch(rewritten, init) diff --git a/vercel.json b/vercel.json index 39ef50da..0df716d4 100644 --- a/vercel.json +++ b/vercel.json @@ -54,6 +54,71 @@ "destination": "https://accounts.tempo.xyz/:path*", "permanent": true }, + { + "source": "/api", + "destination": "/docs/api", + "permanent": true + }, + { + "source": "/api/authentication", + "destination": "/docs/api/authentication", + "permanent": true + }, + { + "source": "/api/conventions", + "destination": "/docs/api/conventions", + "permanent": true + }, + { + "source": "/api/errors", + "destination": "/docs/api/errors", + "permanent": true + }, + { + "source": "/api/indexer", + "destination": "/docs/api/indexer", + "permanent": true + }, + { + "source": "/api/indexer-api", + "destination": "/docs/api/indexer-api", + "permanent": true + }, + { + "source": "/api/json-rpc", + "destination": "/docs/api/json-rpc", + "permanent": true + }, + { + "source": "/api/pagination", + "destination": "/docs/api/pagination", + "permanent": true + }, + { + "source": "/api/rate-limits", + "destination": "/docs/api/rate-limits", + "permanent": true + }, + { + "source": "/api/transactions", + "destination": "/docs/api/transactions", + "permanent": true + }, + { + "source": "/api/transactions-and-transfers", + "destination": "/docs/api/transactions-and-transfers", + "permanent": true + }, + { + "source": "/api/transfers", + "destination": "/docs/api/transfers", + "permanent": true + }, + { + "source": "/api/versioning-policy", + "destination": "/docs/api/versioning-policy", + "permanent": true + }, { "source": "/guide/bridge-usdc-stargate", "destination": "/docs/guide/bridge-layerzero", @@ -149,6 +214,16 @@ "destination": "/docs/wallet/:path*", "permanent": true }, + { + "source": "/tools", + "destination": "/docs/tools", + "permanent": true + }, + { + "source": "/tools/:path*", + "destination": "/docs/tools/:path*", + "permanent": true + }, { "source": "/ecosystem", "destination": "/docs/ecosystem", @@ -184,6 +259,11 @@ "destination": "/docs/changelog", "permanent": true }, + { + "source": "/partners", + "destination": "/docs/partners", + "permanent": true + }, { "source": "/learn/partners", "destination": "/docs/partners",