diff --git a/packages/app/next.config.ts b/packages/app/next.config.ts index 8942a52..d1e332f 100644 --- a/packages/app/next.config.ts +++ b/packages/app/next.config.ts @@ -1,6 +1,12 @@ import type { NextConfig } from 'vinext' +import { STATIC_EXPORT_DEPLOYMENT_ID } from './src/lib/static-export-rsc-transport' + +const isPagesDemo = process.env.VITE_YEOLLIN_DEMO === 'true' + const nextConfig: NextConfig = { + assetPrefix: isPagesDemo ? process.env.VITE_YEOLLIN_BASE_PATH : undefined, + deploymentId: isPagesDemo ? STATIC_EXPORT_DEPLOYMENT_ID : undefined, output: 'export', } diff --git a/packages/app/src/app/layout.tsx b/packages/app/src/app/layout.tsx index ac684fd..4556bb1 100644 --- a/packages/app/src/app/layout.tsx +++ b/packages/app/src/app/layout.tsx @@ -3,6 +3,22 @@ import { resetCss } from '@devup-ui/reset-css' import type { Metadata } from 'next' import { Providers } from '@/components/providers' +import { + installStaticExportRscTransport, + STATIC_EXPORT_DEPLOYMENT_ID, +} from '@/lib/static-export-rsc-transport' + +function escapeScriptClosingTag(script: string): string { + return script.replace(/<\/script/giu, '\\u003c/script') +} + +const staticExportRscTransport = escapeScriptClosingTag( + `(${installStaticExportRscTransport.toString()})(${JSON.stringify(STATIC_EXPORT_DEPLOYMENT_ID)}, ${JSON.stringify(process.env.VITE_YEOLLIN_BASE_PATH ?? '')})`, +) + +const needsStaticExportRscTransport = + process.env.NODE_ENV === 'production' && + process.env.VITE_YEOLLIN_DEMO === 'true' export const metadata: Metadata = { title: 'Yeollin CMS', @@ -19,6 +35,11 @@ export default function RootLayout({ return ( + {needsStaticExportRscTransport && ( + + )} diff --git a/packages/app/src/lib/mock-api.ts b/packages/app/src/lib/mock-api.ts index 8add8a4..295a308 100644 --- a/packages/app/src/lib/mock-api.ts +++ b/packages/app/src/lib/mock-api.ts @@ -658,59 +658,6 @@ function mockResponse(url: URL, method: string, init?: RequestInit): Response { ) } -function staticRscRequestUrl( - input: RequestInfo | URL, - init: RequestInit | undefined, - url: URL, -): URL | null { - if ( - url.origin !== window.location.origin || - (DEMO_BASE_PATH !== '' && - url.pathname !== DEMO_BASE_PATH && - !url.pathname.startsWith(`${DEMO_BASE_PATH}/`)) - ) { - return null - } - - const headers = new Headers(input instanceof Request ? input.headers : {}) - new Headers(init?.headers).forEach((value, name) => headers.set(name, value)) - if ( - headers.get('RSC') !== '1' && - !headers.get('Accept')?.includes('text/x-component') - ) { - return null - } - - const staticUrl = new URL(url) - if (!staticUrl.pathname.endsWith('.rsc')) { - staticUrl.pathname = staticUrl.pathname.endsWith('/') - ? `${staticUrl.pathname}index.rsc` - : `${staticUrl.pathname}.rsc` - } - return staticUrl -} - -function normalizeStaticRscResponse(response: Response): Response { - if ( - !response.ok || - response.headers.get('Content-Type')?.startsWith('text/x-component') - ) { - return response - } - - const headers = new Headers(response.headers) - headers.set('Content-Type', 'text/x-component') - - // GitHub Pages serves unknown extensions as application/octet-stream. - // Shadowing the immutable header collection preserves the fetched response's - // URL and body, which vinext uses when deciding whether to navigate in-app. - Object.defineProperty(response, 'headers', { - configurable: true, - value: headers, - }) - return response -} - /** Installs the browser-only API simulator used by the public GitHub Pages demo. */ export function installMockApi(): void { if (installed || typeof window === 'undefined') return @@ -729,12 +676,7 @@ export function installMockApi(): void { ) { return mockResponse(url, method, init) } - const staticRscUrl = staticRscRequestUrl(input, init, url) - if (staticRscUrl === null) return nativeFetch(input, init) - - const staticInput = - input instanceof Request ? new Request(staticRscUrl, input) : staticRscUrl - return normalizeStaticRscResponse(await nativeFetch(staticInput, init)) + return nativeFetch(input, init) } } diff --git a/packages/app/src/lib/static-export-rsc-transport.ts b/packages/app/src/lib/static-export-rsc-transport.ts new file mode 100644 index 0000000..e30a8d1 --- /dev/null +++ b/packages/app/src/lib/static-export-rsc-transport.ts @@ -0,0 +1,65 @@ +export const STATIC_EXPORT_DEPLOYMENT_ID = 'yeollin-cms-pages-demo' + +/** + * Adapts vinext RSC requests to artifacts emitted by `output: 'export'` on a + * plain static host such as GitHub Pages. + */ +export function installStaticExportRscTransport( + deploymentId: string, + basePath: string, +): void { + const nativeFetch = globalThis.fetch.bind(globalThis) + const normalizedBasePath = basePath.replace(/\/$/u, '') + + globalThis.fetch = async (input, init) => { + const request = new Request(input, init) + if (request.method !== 'GET' || request.headers.get('RSC') !== '1') { + return nativeFetch(input, init) + } + + const visibleUrl = new URL(request.url) + if ( + visibleUrl.origin !== globalThis.location.origin || + (normalizedBasePath !== '' && + visibleUrl.pathname !== normalizedBasePath && + !visibleUrl.pathname.startsWith(`${normalizedBasePath}/`)) + ) { + return nativeFetch(input, init) + } + + const artifactUrl = new URL(visibleUrl) + if (!artifactUrl.pathname.endsWith('.rsc')) { + const rootPath = + normalizedBasePath === '' ? '/' : `${normalizedBasePath}/` + artifactUrl.pathname = + artifactUrl.pathname === rootPath + ? `${rootPath}index.rsc` + : `${artifactUrl.pathname.replace(/\/$/u, '')}.rsc` + } + artifactUrl.searchParams.delete('_rsc') + + const artifactResponse = await nativeFetch(artifactUrl, { + credentials: request.credentials, + headers: request.headers, + signal: request.signal, + }) + if (!artifactResponse.ok) { + await artifactResponse.body?.cancel() + return nativeFetch(input, init) + } + + const headers = new Headers(artifactResponse.headers) + headers.set('Content-Type', 'text/x-component') + headers.set('X-Vinext-RSC-Compatibility-Id', deploymentId) + + const response = new Response(artifactResponse.body, { + headers, + status: artifactResponse.status, + statusText: artifactResponse.statusText, + }) + + // The `.rsc` artifact is a transport detail, not a redirect destination. + Object.defineProperty(response, 'url', { value: visibleUrl.href }) + return response + } +}