diff --git a/apps/docs/app/(diffshub)/(view)/[...path]/page.tsx b/apps/docs/app/(diffshub)/(view)/[...path]/page.tsx index 5dff5ee5a..f0518a603 100644 --- a/apps/docs/app/(diffshub)/(view)/[...path]/page.tsx +++ b/apps/docs/app/(diffshub)/(view)/[...path]/page.tsx @@ -2,6 +2,9 @@ import { redirect } from 'next/navigation'; import { ReviewUI } from '../_components/ReviewUI'; import { resolveDiffshubViewerRoute } from '../_components/utils'; +import { loadInitialDiffshubPatchResponse } from '@/lib/diffshubPatchResponse'; + +export const dynamic = 'force-dynamic'; // Viewer route that mirrors the upstream path. GitHub is the public default, // while hidden alternate domains can opt in through the `domain` query param. @@ -19,11 +22,16 @@ export default async function DiffshubViewByPathPage({ if (route.kind === 'redirect') { redirect(route.target); } + const initialPatchResponse = loadInitialDiffshubPatchResponse({ + domain: route.domain, + path: route.upstreamPath, + }); return (
diff --git a/apps/docs/app/(diffshub)/(view)/_components/ReviewUI.tsx b/apps/docs/app/(diffshub)/(view)/_components/ReviewUI.tsx index 697f2191f..391041269 100644 --- a/apps/docs/app/(diffshub)/(view)/_components/ReviewUI.tsx +++ b/apps/docs/app/(diffshub)/(view)/_components/ReviewUI.tsx @@ -26,14 +26,21 @@ import { removeSavedCommentSidebarEntry, upsertSavedCommentSidebarEntry, } from './utils'; +import type { InitialDiffshubPatchResponse } from '@/lib/diffshubPatchTypes'; interface ReviewUIProps { domain?: string; + initialPatchResponse: Promise; initialUrl: string; path: string; } -export function ReviewUI({ domain, initialUrl, path }: ReviewUIProps) { +export function ReviewUI({ + domain, + initialPatchResponse, + initialUrl, + path, +}: ReviewUIProps) { useEffect(preloadAvatars, []); const isWorkerPoolReadyOrDisable = useIsWorkerPoolReadyOrDisabled(); @@ -68,6 +75,7 @@ export function ReviewUI({ domain, initialUrl, path }: ReviewUIProps) { } = usePatchLoader({ collapseMode, domain, + initialPatchResponse, onLoadStart: handlePatchLoadStart, path, viewerRef, diff --git a/apps/docs/app/(diffshub)/(view)/_components/streamGitPatchFiles.ts b/apps/docs/app/(diffshub)/(view)/_components/streamGitPatchFiles.ts index c5872d46d..feda0c809 100644 --- a/apps/docs/app/(diffshub)/(view)/_components/streamGitPatchFiles.ts +++ b/apps/docs/app/(diffshub)/(view)/_components/streamGitPatchFiles.ts @@ -6,22 +6,34 @@ const GIT_FILE_BOUNDARY_SCAN_OVERLAP = GIT_FILE_BOUNDARY_WITH_NEWLINE.length - 1; const NON_WHITESPACE_PATTERN = /\S/; +export type GitPatchStream = ReadableStream; + export async function streamGitPatchFiles( - body: ReadableStream, - onFileText: (fileText: string) => Promise + body: GitPatchStream, + onFileText: (fileText: string) => Promise, + signal?: AbortSignal ): Promise { const reader = body.getReader(); const decoder = new TextDecoder(); const parser = createGitPatchFileStreamParser(); + const abortReader = () => { + void reader.cancel().catch(() => {}); + }; + signal?.addEventListener('abort', abortReader, { once: true }); try { for (;;) { + if (signal?.aborted === true) { + return undefined; + } + const result = await reader.read(); if (result.done) { break; } - if (result.value.byteLength > 0) { - parser.push(decoder.decode(result.value, { stream: true })); + const text = decodeGitPatchStreamChunk(decoder, result.value); + if (text.length > 0) { + parser.push(text); await consumeAvailableStreamedFiles(parser, onFileText); } } @@ -41,10 +53,20 @@ export async function streamGitPatchFiles( } return result.fallbackPatchContent; } finally { + signal?.removeEventListener('abort', abortReader); reader.releaseLock(); } } +function decodeGitPatchStreamChunk( + decoder: TextDecoder, + chunk: string | Uint8Array +): string { + return typeof chunk === 'string' + ? chunk + : decoder.decode(chunk, { stream: true }); +} + export function getStreamedPatchMetadata(fileText: string): string | undefined { const diffBoundaryIndex = findNextGitFileBoundary(fileText, 0); if (diffBoundaryIndex == null || diffBoundaryIndex <= 0) { diff --git a/apps/docs/app/(diffshub)/(view)/_components/usePatchLoader.ts b/apps/docs/app/(diffshub)/(view)/_components/usePatchLoader.ts index a07cee686..b43610cbe 100644 --- a/apps/docs/app/(diffshub)/(view)/_components/usePatchLoader.ts +++ b/apps/docs/app/(diffshub)/(view)/_components/usePatchLoader.ts @@ -34,6 +34,7 @@ import { } from './lineHash'; import { getStreamedPatchMetadata, + type GitPatchStream, streamGitPatchFiles, } from './streamGitPatchFiles'; import type { @@ -44,6 +45,7 @@ import type { CommentMetadata, ViewerLoadState, } from './types'; +import type { InitialDiffshubPatchResponse } from '@/lib/diffshubPatchTypes'; const STREAM_PUBLISH_INTERVAL_MS = 100; const STREAM_INITIAL_PUBLISH_INTERVAL_MS = 500; @@ -56,6 +58,7 @@ const GENERIC_PATCH_LOAD_ERROR_MESSAGE = interface UsePatchLoaderOptions { collapseMode: 'expanded' | 'collapsed'; domain?: string; + initialPatchResponse: Promise; onLoadStart(): void; path: string; viewerRef: RefObject | null>; @@ -80,6 +83,7 @@ interface UsePatchLoaderResult { export function usePatchLoader({ collapseMode, domain, + initialPatchResponse, onLoadStart, path, viewerRef, @@ -269,17 +273,20 @@ export function usePatchLoader({ } console.time('-- request time'); - const response = await fetch(`/api/diff?${patchSearchParams}`, { - cache: 'no-store', - signal: controller.signal, - }); + const response = + loadAttempt === 0 + ? await initialPatchResponse + : await fetch(`/api/diff?${patchSearchParams}`, { + cache: 'no-store', + signal: controller.signal, + }); console.timeEnd('-- request time'); // This only catches route setup errors. GitHub fetch failures are // delivered while consuming the stream so the UI can enter the // streaming state as soon as the local transport opens. if (!response.ok) { - const detail = (await response.text()).trim(); + const detail = (await readPatchResponseText(response)).trim(); throw new Error( detail.length > 0 ? detail : `Request failed (${response.status}).` ); @@ -287,7 +294,7 @@ export function usePatchLoader({ if (response.body == null) { console.time('-- reading patch'); - const patchContent = await response.text(); + const patchContent = await readPatchResponseText(response); console.timeEnd('-- reading patch'); await commitFullPatch(patchContent); return; @@ -402,6 +409,10 @@ export function usePatchLoader({ publishTreeSource(); }; const appendStreamedFile = async (fileText: string) => { + if (!isCurrentRequest()) { + return; + } + if (!hasReceivedFirstStreamedFile) { hasReceivedFirstStreamedFile = true; console.timeEnd('-- first streamed file'); @@ -454,7 +465,8 @@ export function usePatchLoader({ console.time('-- reading patch stream'); const fallbackPatchContent = await streamGitPatchFiles( response.body, - appendStreamedFile + appendStreamedFile, + controller.signal ); console.timeEnd('-- reading patch stream'); if (!isCurrentRequest()) { @@ -488,6 +500,7 @@ export function usePatchLoader({ }; }, [ domain, + initialPatchResponse, loadAttempt, onLoadStart, path, @@ -576,6 +589,43 @@ function getNextItemVersion(item: { version?: string | number }): number { return typeof item.version === 'number' ? item.version + 1 : 1; } +type PatchResponseSource = Response | InitialDiffshubPatchResponse; + +async function readPatchResponseText( + response: PatchResponseSource +): Promise { + if ('bodyText' in response) { + if (response.bodyText != null) { + return response.bodyText; + } + return response.body == null ? '' : await readStreamText(response.body); + } + + return response.text(); +} + +async function readStreamText(body: GitPatchStream): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let text = ''; + try { + for (;;) { + const result = await reader.read(); + if (result.done) { + break; + } + text += + typeof result.value === 'string' + ? result.value + : decoder.decode(result.value, { stream: true }); + } + text += decoder.decode(); + return text; + } finally { + reader.releaseLock(); + } +} + function replaceLocationHash(hash: string | null): void { const { pathname, search } = window.location; const nextHash = hash ?? ''; diff --git a/apps/docs/app/(diffshub)/(view)/_components/utils.ts b/apps/docs/app/(diffshub)/(view)/_components/utils.ts index 16b90bc8a..b0a63cfcf 100644 --- a/apps/docs/app/(diffshub)/(view)/_components/utils.ts +++ b/apps/docs/app/(diffshub)/(view)/_components/utils.ts @@ -93,7 +93,7 @@ export function getPatchViewerHref(input: string): string | undefined { const githubPath = getGitHubPathFromURL(parsedURL); if (githubPath != null) return githubPath; if (parsedURL.pathname !== '/') { - return `${parsedURL.pathname}?domain=${encodeURIComponent(parsedURL.hostname)}`; + return getAlternateDomainViewerHref(parsedURL); } return undefined; } catch { @@ -111,7 +111,7 @@ export function getPatchViewerHref(input: string): string | undefined { const githubPath = getGitHubPathFromURL(parsedURL); if (githubPath != null) return githubPath; if (parsedURL.pathname !== '/') { - return `${parsedURL.pathname}?domain=${encodeURIComponent(parsedURL.hostname)}`; + return getAlternateDomainViewerHref(parsedURL); } } catch { // Not parseable even with https:// prefix. @@ -175,6 +175,10 @@ export function resolveDiffshubViewerRoute( }; } +function getAlternateDomainViewerHref(parsedURL: URL): string { + return `${parsedURL.pathname}?domain=${encodeURIComponent(parsedURL.hostname)}`; +} + function getGitHubPathFromURL(parsedURL: URL): string | undefined { if (parsedURL.hostname === GITHUB_HOST) { if (parsedURL.pathname === '/') { diff --git a/apps/docs/app/api/diff/route.ts b/apps/docs/app/api/diff/route.ts index 93d606b83..797fbf561 100644 --- a/apps/docs/app/api/diff/route.ts +++ b/apps/docs/app/api/diff/route.ts @@ -1,388 +1,18 @@ import { type NextRequest } from 'next/server'; -const CACHE_CONTROL = 'no-store'; -const EMPTY_PATCH_MESSAGE = 'GitHub returned an empty diff.'; -const GITHUB_HOST = 'github.com'; -const GITHUB_RAW_DIFF_HOST = 'patch-diff.githubusercontent.com'; -const NON_DIFF_RESPONSE_MESSAGE = 'GitHub did not return a diff for this URL.'; -const NON_WHITESPACE_PATTERN = /\S/; -const RAW_GITHUB_DIFF_PATH_PATTERN = - /^\/raw\/[^/]+\/[^/]+\/pull\/[^/]+\.(?:diff|patch)$/; -const GITHUB_PULL_TAB_PATH_PATTERN = - /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/(?:changes|files)$/; - -const CACHED_BLOBS = new Map([ - [ - '/nodejs/oven-sh/bun/pull/30412', - 'https://diffshub.pierrecdn.com/patches/30412.diff', - ], - [ - '/nodejs/node/pull/59805', - 'https://diffshub.pierrecdn.com/patches/59805.diff', - ], - [ - '/ghostty-org/ghostty/pull/12291', - 'https://diffshub.pierrecdn.com/patches/12291.diff', - ], - [ - '/pierrecomputer/pierre/commit/0800fb', - 'https://diffshub.pierrecdn.com/patches/0800fb.diff', - ], - [ - '/torvalds/linux/compare/v6.0...v7.0', - 'https://diffshub.pierrecdn.com/patches/v6.0-v7.0.diff', - ], -]); - -const HIDDEN_PATCH_DOMAIN_RULES = [ - { domainRoot: 'tangled.org', defaultExtension: '.patch' }, -] as const; - -interface ResolvedPatchRequest { - patchURL: string; - sourceURL?: string; -} +import { createDiffshubPatchResponse } from '@/lib/diffshubPatchResponse'; // Validates the accepted path or URL, normalizes it to a raw diff URL, and // returns a streaming proxy response so the client can render files as they // arrive instead of waiting for the full patch text. export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; - const path = searchParams.get('path'); - const domain = searchParams.get('domain'); - const url = searchParams.get('url'); - - if (path == null && url == null) { - return createTextResponse('Path or URL parameter is required', { - status: 400, - }); - } - - try { - // The client normally sends only the GitHub-relative path, but GitHub also - // exposes raw PR diffs through patch-diff.githubusercontent.com. Tangled - // paths use an explicit domain query parameter and are normalized to their - // patch endpoint. - const patchRequest = resolvePatchRequest(path, domain, url); - if (patchRequest == null) { - return createTextResponse('Invalid GitHub patch URL format', { - status: 400, - }); - } - - return await createPatchStreamResponse( - patchRequest.patchURL, - request.signal, - { - sourceURL: patchRequest.sourceURL ?? patchRequest.patchURL, - } - ); - } catch (error) { - return createTextResponse( - error instanceof Error ? error.message : 'Unknown error', - { status: 500 } - ); - } -} - -// Resolves the accepted URL shapes to the exact upstream URL to fetch. Most -// callers send a GitHub-relative path, but this also permits GitHub's raw PR -// diff host and Tangled patch URLs without becoming a general URL fetcher. -function resolvePatchRequest( - path: string | null, - domain: string | null, - url: string | null -): ResolvedPatchRequest | undefined { - if (url != null) { - return resolvePatchURLInput(url); - } - - if (path == null) { - return undefined; - } - - if (domain != null) { - const patchURL = resolveDomainPatchURL(domain, path); - return patchURL == null ? undefined : { patchURL }; - } - - return resolvePatchURLInput(path); -} - -function resolvePatchURLInput(input: string): ResolvedPatchRequest | undefined { - if (input.startsWith('/')) { - return resolveGitHubPatchRequest(input); - } - - let parsedURL: URL; - try { - parsedURL = new URL(input); - } catch { - return undefined; - } - - if (!isAllowedHTTPSURL(parsedURL)) { - return undefined; - } - - if (parsedURL.hostname === GITHUB_HOST) { - return resolveGitHubPatchRequest(parsedURL.pathname); - } - - if ( - parsedURL.hostname === GITHUB_RAW_DIFF_HOST && - RAW_GITHUB_DIFF_PATH_PATTERN.test(parsedURL.pathname) - ) { - return { patchURL: parsedURL.href }; - } - - const domainPatchURL = resolveDomainPatchURL( - parsedURL.hostname, - parsedURL.pathname - ); - return domainPatchURL == null ? undefined : { patchURL: domainPatchURL }; -} - -function resolveGitHubPatchRequest( - path: string -): ResolvedPatchRequest | undefined { - const patchURL = resolveGitHubPath(path); - return patchURL == null ? undefined : { patchURL }; -} - -function resolveDomainPatchURL( - domain: string, - path: string -): string | undefined { - const domainRule = getHiddenPatchDomainRule(domain); - if (domainRule == null) { - return undefined; - } - - const pathWithLeadingSlash = path.startsWith('/') ? path : `/${path}`; - const url = new URL(`https://${domainRule.hostname}`); - const normalizedPath = pathWithLeadingSlash.replace(/\/+$/, ''); - url.pathname = normalizedPath === '' ? '/' : normalizedPath; - if (!url.pathname.endsWith(domainRule.defaultExtension)) { - url.pathname += domainRule.defaultExtension; - } - - return url.href; -} - -function getHiddenPatchDomainRule( - domain: string -): { defaultExtension: string; hostname: string } | undefined { - let hostname: string; - try { - hostname = new URL(`https://${domain}`).hostname; - } catch { - return undefined; - } - - for (const domainRule of HIDDEN_PATCH_DOMAIN_RULES) { - if ( - hostname === domainRule.domainRoot || - hostname.endsWith(`.${domainRule.domainRoot}`) - ) { - return { defaultExtension: domainRule.defaultExtension, hostname }; - } - } - - return undefined; -} - -function resolveGitHubPath(path: string): string | undefined { - if (path === '/') { - return undefined; - } - - let patchPath = normalizeGitHubPath(path); - if (patchPath === '') { - return undefined; - } - - const blobPatchURL = CACHED_BLOBS.get(removeDiffExtension(patchPath)); - if (blobPatchURL != null) { - return blobPatchURL; - } - - if (!patchPath.endsWith('.patch') && !patchPath.endsWith('.diff')) { - patchPath += '.diff'; - } - - return `https://${GITHUB_HOST}${patchPath}`; -} - -function removeDiffExtension(path: string): string { - if (path.endsWith('.patch')) { - return path.slice(0, -'.patch'.length); - } - - if (path.endsWith('.diff')) { - return path.slice(0, -'.diff'.length); - } - - return path; -} - -function normalizeGitHubPath(path: string): string { - const trimmedPath = path.replace(/\/+$/, ''); - const pullTabMatch = GITHUB_PULL_TAB_PATH_PATTERN.exec(trimmedPath); - if (pullTabMatch == null) { - return trimmedPath; - } - - return `/${pullTabMatch[1]}/${pullTabMatch[2]}/pull/${pullTabMatch[3]}`; -} - -function isAllowedHTTPSURL(url: URL): boolean { - return ( - url.protocol === 'https:' && - url.port === '' && - url.username === '' && - url.password === '' - ); -} - -interface TextResponseOptions { - status?: number; - sourceURL?: string; -} - -// Serves local patch fixtures through the same response path as GitHub data, -// while rejecting empty files so the viewer does not enter a silent no-op -// state. -function createPatchTextResponse( - patchText: string, - options: Omit -): Response { - if (!NON_WHITESPACE_PATTERN.test(patchText)) { - return createTextResponse(EMPTY_PATCH_MESSAGE, { status: 422 }); - } - - return createTextResponse(patchText, options); -} - -// Validates the upstream response before opening the client-facing stream so -// GitHub HTML pages and redirects become small text errors instead of Next.js -// error documents. -async function createPatchStreamResponse( - patchURL: string, - requestSignal: AbortSignal, - options: Omit -): Promise { - const upstreamController = new AbortController(); - const abortUpstream = () => upstreamController.abort(); - requestSignal.addEventListener('abort', abortUpstream, { once: true }); - - let response: Response; - try { - response = await fetch(patchURL, { - cache: 'no-store', - headers: { 'User-Agent': 'pierre-diffshub' }, - signal: upstreamController.signal, - }); - } catch { - requestSignal.removeEventListener('abort', abortUpstream); - return createTextResponse('Failed to fetch patch.', { status: 502 }); - } - - if (!response.ok) { - const status = response.status >= 400 ? response.status : 502; - requestSignal.removeEventListener('abort', abortUpstream); - return createTextResponse( - `Failed to fetch patch: ${response.status} ${response.statusText}`, - { status } - ); - } - - const contentType = response.headers.get('Content-Type'); - if (contentType == null || !contentType.startsWith('text/plain')) { - requestSignal.removeEventListener('abort', abortUpstream); - return createTextResponse(NON_DIFF_RESPONSE_MESSAGE, { status: 415 }); - } - - if (response.headers.get('Content-Length') === '0') { - requestSignal.removeEventListener('abort', abortUpstream); - return createTextResponse(EMPTY_PATCH_MESSAGE, { status: 422 }); - } - - const responseBody = response.body; - if (responseBody == null) { - try { - const patchText = await response.text(); - return createPatchTextResponse(patchText, options); - } finally { - requestSignal.removeEventListener('abort', abortUpstream); - } - } - - const stream = new ReadableStream({ - start(controller) { - void pumpPatchBody(responseBody, controller).finally(() => { - requestSignal.removeEventListener('abort', abortUpstream); - }); + return createDiffshubPatchResponse( + { + domain: searchParams.get('domain'), + path: searchParams.get('path'), + url: searchParams.get('url'), }, - cancel() { - abortUpstream(); - requestSignal.removeEventListener('abort', abortUpstream); - }, - }); - - return createTextResponse(stream, options); -} - -// Forwards each validated upstream diff chunk into the client stream. -async function pumpPatchBody( - body: ReadableStream, - controller: ReadableStreamDefaultController -): Promise { - try { - const reader = body.getReader(); - let sawContent = false; - try { - for (;;) { - const result = await reader.read(); - if (result.done) { - break; - } - - if (result.value.byteLength > 0) { - sawContent = true; - controller.enqueue(result.value); - } - } - } finally { - reader.releaseLock(); - } - - if (!sawContent) { - throw new Error(EMPTY_PATCH_MESSAGE); - } - - controller.close(); - } catch (error) { - controller.error(error); - } -} - -// Centralizes text response headers for both stream and error bodies. Diff -// responses are intentionally not cached in the browser because cached 100MB+ -// responses can replay poorly and delay the first useful diff bytes. -function createTextResponse( - body: string | ReadableStream, - { status = 200, sourceURL }: TextResponseOptions = {} -): Response { - const headers = new Headers({ - 'Content-Type': 'text/plain; charset=utf-8', - 'Cache-Control': CACHE_CONTROL, - }); - if (sourceURL != null) { - headers.set('X-Patch-Source', sourceURL); - } - return new Response(body, { - status, - headers, - }); + request.signal + ); } diff --git a/apps/docs/lib/diffshubPatchResponse.ts b/apps/docs/lib/diffshubPatchResponse.ts new file mode 100644 index 000000000..64c1e9ded --- /dev/null +++ b/apps/docs/lib/diffshubPatchResponse.ts @@ -0,0 +1,469 @@ +import 'server-only'; +import type { InitialDiffshubPatchResponse } from './diffshubPatchTypes'; + +const CACHE_CONTROL = 'no-store'; +const EMPTY_PATCH_MESSAGE = 'GitHub returned an empty diff.'; +const GITHUB_HOST = 'github.com'; +const GITHUB_RAW_DIFF_HOST = 'patch-diff.githubusercontent.com'; +const NON_DIFF_RESPONSE_MESSAGE = 'GitHub did not return a diff for this URL.'; +const NON_WHITESPACE_PATTERN = /\S/; +const RAW_GITHUB_DIFF_PATH_PATTERN = + /^\/raw\/[^/]+\/[^/]+\/pull\/[^/]+\.(?:diff|patch)$/; +const GITHUB_PULL_TAB_PATH_PATTERN = + /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/(?:changes|files)$/; + +const CACHED_BLOBS = new Map([ + [ + '/nodejs/oven-sh/bun/pull/30412', + 'https://diffshub.pierrecdn.com/patches/30412.diff', + ], + [ + '/nodejs/node/pull/59805', + 'https://diffshub.pierrecdn.com/patches/59805.diff', + ], + [ + '/ghostty-org/ghostty/pull/12291', + 'https://diffshub.pierrecdn.com/patches/12291.diff', + ], + [ + '/pierrecomputer/pierre/commit/0800fb', + 'https://diffshub.pierrecdn.com/patches/0800fb.diff', + ], + [ + '/torvalds/linux/compare/v6.0...v7.0', + 'https://diffshub.pierrecdn.com/patches/v6.0-v7.0.diff', + ], +]); + +const HIDDEN_PATCH_DOMAIN_RULES = [ + { domainRoot: 'tangled.org', defaultExtension: '.patch' }, +] as const; + +interface ResolvedPatchRequest { + patchURL: string; + sourceURL?: string; +} + +export interface DiffshubPatchRequestInput { + domain?: string | null; + path?: string | null; + url?: string | null; +} + +// Shared patch loader used by both the API route and the initial server render. +// The API route returns the Response directly, while the page passes its body +// stream through React Flight so the client can consume bytes earlier. +export async function createDiffshubPatchResponse( + { domain = null, path = null, url = null }: DiffshubPatchRequestInput, + requestSignal: AbortSignal +): Promise { + if (path == null && url == null) { + return createTextResponse('Path or URL parameter is required', { + status: 400, + }); + } + + try { + // The client normally sends only the GitHub-relative path, but GitHub also + // exposes raw PR diffs through patch-diff.githubusercontent.com. Tangled + // paths use an explicit domain query parameter and are normalized to their + // patch endpoint. + const patchRequest = resolvePatchRequest(path, domain, url); + if (patchRequest == null) { + return createTextResponse('Invalid GitHub patch URL format', { + status: 400, + }); + } + + return await createPatchStreamResponse( + patchRequest.patchURL, + requestSignal, + { + sourceURL: patchRequest.sourceURL ?? patchRequest.patchURL, + } + ); + } catch (error) { + return createTextResponse( + error instanceof Error ? error.message : 'Unknown error', + { status: 500 } + ); + } +} + +export async function loadInitialDiffshubPatchResponse( + input: DiffshubPatchRequestInput +): Promise { + try { + const response = await createDiffshubPatchResponse( + input, + new AbortController().signal + ); + + if (!response.ok || response.body == null) { + return { + body: null, + bodyText: await response.text(), + ok: response.ok, + status: response.status, + statusText: response.statusText, + }; + } + + return { + body: decodePatchBody(response.body), + bodyText: null, + ok: response.ok, + status: response.status, + statusText: response.statusText, + }; + } catch (error) { + return { + body: null, + bodyText: error instanceof Error ? error.message : 'Unknown error', + ok: false, + status: 500, + statusText: 'Internal Server Error', + }; + } +} + +// React Flight's binary ReadableStream path currently trips over long patch +// streams in dev. Decode on the server so the client receives text chunks via +// the model stream path while preserving incremental delivery. +function decodePatchBody( + body: ReadableStream +): ReadableStream { + const decoder = new TextDecoder(); + const reader = body.getReader(); + + return new ReadableStream({ + async pull(controller) { + const result = await reader.read(); + if (result.done) { + const finalText = decoder.decode(); + if (finalText.length > 0) { + controller.enqueue(finalText); + } + controller.close(); + reader.releaseLock(); + return; + } + + if (result.value.byteLength > 0) { + const text = decoder.decode(result.value, { stream: true }); + if (text.length > 0) { + controller.enqueue(text); + } + } + }, + async cancel(reason) { + try { + await reader.cancel(reason); + } finally { + reader.releaseLock(); + } + }, + }); +} + +// Resolves the accepted URL shapes to the exact upstream URL to fetch. Most +// callers send a GitHub-relative path, but this also permits GitHub's raw PR +// diff host and Tangled patch URLs without becoming a general URL fetcher. +function resolvePatchRequest( + path: string | null, + domain: string | null, + url: string | null +): ResolvedPatchRequest | undefined { + if (url != null) { + return resolvePatchURLInput(url); + } + + if (path == null) { + return undefined; + } + + if (domain != null) { + const patchURL = resolveDomainPatchURL(domain, path); + return patchURL == null ? undefined : { patchURL }; + } + + return resolvePatchURLInput(path); +} + +function resolvePatchURLInput(input: string): ResolvedPatchRequest | undefined { + if (input.startsWith('/')) { + return resolveGitHubPatchRequest(input); + } + + let parsedURL: URL; + try { + parsedURL = new URL(input); + } catch { + return undefined; + } + + if (!isAllowedHTTPSURL(parsedURL)) { + return undefined; + } + + if (parsedURL.hostname === GITHUB_HOST) { + return resolveGitHubPatchRequest(parsedURL.pathname); + } + + if ( + parsedURL.hostname === GITHUB_RAW_DIFF_HOST && + RAW_GITHUB_DIFF_PATH_PATTERN.test(parsedURL.pathname) + ) { + return { patchURL: parsedURL.href }; + } + + const domainPatchURL = resolveDomainPatchURL( + parsedURL.hostname, + parsedURL.pathname + ); + return domainPatchURL == null ? undefined : { patchURL: domainPatchURL }; +} + +function resolveGitHubPatchRequest( + path: string +): ResolvedPatchRequest | undefined { + const patchURL = resolveGitHubPath(path); + return patchURL == null ? undefined : { patchURL }; +} + +function resolveDomainPatchURL( + domain: string, + path: string +): string | undefined { + const domainRule = getHiddenPatchDomainRule(domain); + if (domainRule == null) { + return undefined; + } + + const pathWithLeadingSlash = path.startsWith('/') ? path : `/${path}`; + const url = new URL(`https://${domainRule.hostname}`); + const normalizedPath = pathWithLeadingSlash.replace(/\/+$/, ''); + url.pathname = normalizedPath === '' ? '/' : normalizedPath; + if (!url.pathname.endsWith(domainRule.defaultExtension)) { + url.pathname += domainRule.defaultExtension; + } + + return url.href; +} + +function getHiddenPatchDomainRule( + domain: string +): { defaultExtension: string; hostname: string } | undefined { + let hostname: string; + try { + hostname = new URL(`https://${domain}`).hostname; + } catch { + return undefined; + } + + for (const domainRule of HIDDEN_PATCH_DOMAIN_RULES) { + if ( + hostname === domainRule.domainRoot || + hostname.endsWith(`.${domainRule.domainRoot}`) + ) { + return { defaultExtension: domainRule.defaultExtension, hostname }; + } + } + + return undefined; +} + +function resolveGitHubPath(path: string): string | undefined { + if (path === '/') { + return undefined; + } + + let patchPath = normalizeGitHubPath(path); + if (patchPath === '') { + return undefined; + } + + const blobPatchURL = CACHED_BLOBS.get(removeDiffExtension(patchPath)); + if (blobPatchURL != null) { + return blobPatchURL; + } + + if (!patchPath.endsWith('.patch') && !patchPath.endsWith('.diff')) { + patchPath += '.diff'; + } + + return `https://${GITHUB_HOST}${patchPath}`; +} + +function removeDiffExtension(path: string): string { + if (path.endsWith('.patch')) { + return path.slice(0, -'.patch'.length); + } + + if (path.endsWith('.diff')) { + return path.slice(0, -'.diff'.length); + } + + return path; +} + +function normalizeGitHubPath(path: string): string { + const trimmedPath = path.replace(/\/+$/, ''); + const pullTabMatch = GITHUB_PULL_TAB_PATH_PATTERN.exec(trimmedPath); + if (pullTabMatch == null) { + return trimmedPath; + } + + return `/${pullTabMatch[1]}/${pullTabMatch[2]}/pull/${pullTabMatch[3]}`; +} + +function isAllowedHTTPSURL(url: URL): boolean { + return ( + url.protocol === 'https:' && + url.port === '' && + url.username === '' && + url.password === '' + ); +} + +interface TextResponseOptions { + status?: number; + sourceURL?: string; +} + +// Serves local patch fixtures through the same response path as GitHub data, +// while rejecting empty files so the viewer does not enter a silent no-op +// state. +function createPatchTextResponse( + patchText: string, + options: Omit +): Response { + if (!NON_WHITESPACE_PATTERN.test(patchText)) { + return createTextResponse(EMPTY_PATCH_MESSAGE, { status: 422 }); + } + + return createTextResponse(patchText, options); +} + +// Validates the upstream response before opening the client-facing stream so +// GitHub HTML pages and redirects become small text errors instead of Next.js +// error documents. +async function createPatchStreamResponse( + patchURL: string, + requestSignal: AbortSignal, + options: Omit +): Promise { + const upstreamController = new AbortController(); + const abortUpstream = () => upstreamController.abort(); + requestSignal.addEventListener('abort', abortUpstream, { once: true }); + + let response: Response; + try { + response = await fetch(patchURL, { + cache: 'no-store', + headers: { 'User-Agent': 'pierre-diffshub' }, + signal: upstreamController.signal, + }); + } catch { + requestSignal.removeEventListener('abort', abortUpstream); + return createTextResponse('Failed to fetch patch.', { status: 502 }); + } + + if (!response.ok) { + const status = response.status >= 400 ? response.status : 502; + requestSignal.removeEventListener('abort', abortUpstream); + return createTextResponse( + `Failed to fetch patch: ${response.status} ${response.statusText}`, + { status } + ); + } + + const contentType = response.headers.get('Content-Type'); + if (contentType == null || !contentType.startsWith('text/plain')) { + requestSignal.removeEventListener('abort', abortUpstream); + return createTextResponse(NON_DIFF_RESPONSE_MESSAGE, { status: 415 }); + } + + if (response.headers.get('Content-Length') === '0') { + requestSignal.removeEventListener('abort', abortUpstream); + return createTextResponse(EMPTY_PATCH_MESSAGE, { status: 422 }); + } + + const responseBody = response.body; + if (responseBody == null) { + try { + const patchText = await response.text(); + return createPatchTextResponse(patchText, options); + } finally { + requestSignal.removeEventListener('abort', abortUpstream); + } + } + + const stream = new ReadableStream({ + start(controller) { + void pumpPatchBody(responseBody, controller).finally(() => { + requestSignal.removeEventListener('abort', abortUpstream); + }); + }, + cancel() { + abortUpstream(); + requestSignal.removeEventListener('abort', abortUpstream); + }, + }); + + return createTextResponse(stream, options); +} + +// Forwards each validated upstream diff chunk into the client stream. +async function pumpPatchBody( + body: ReadableStream, + controller: ReadableStreamDefaultController +): Promise { + try { + const reader = body.getReader(); + let sawContent = false; + try { + for (;;) { + const result = await reader.read(); + if (result.done) { + break; + } + + if (result.value.byteLength > 0) { + sawContent = true; + controller.enqueue(result.value); + } + } + } finally { + reader.releaseLock(); + } + + if (!sawContent) { + throw new Error(EMPTY_PATCH_MESSAGE); + } + + controller.close(); + } catch (error) { + controller.error(error); + } +} + +// Centralizes text response headers for both stream and error bodies. Diff +// responses are intentionally not cached in the browser because cached 100MB+ +// responses can replay poorly and delay the first useful diff bytes. +function createTextResponse( + body: string | ReadableStream, + { status = 200, sourceURL }: TextResponseOptions = {} +): Response { + const headers = new Headers({ + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': CACHE_CONTROL, + }); + if (sourceURL != null) { + headers.set('X-Patch-Source', sourceURL); + } + return new Response(body, { + status, + headers, + }); +} diff --git a/apps/docs/lib/diffshubPatchTypes.ts b/apps/docs/lib/diffshubPatchTypes.ts new file mode 100644 index 000000000..f8545558a --- /dev/null +++ b/apps/docs/lib/diffshubPatchTypes.ts @@ -0,0 +1,7 @@ +export interface InitialDiffshubPatchResponse { + body: ReadableStream | null; + bodyText: string | null; + ok: boolean; + status: number; + statusText: string; +} diff --git a/apps/docs/test/getPatchViewerHref.test.ts b/apps/docs/test/getPatchViewerHref.test.ts index 49eadec74..a7f33bbf2 100644 --- a/apps/docs/test/getPatchViewerHref.test.ts +++ b/apps/docs/test/getPatchViewerHref.test.ts @@ -71,6 +71,20 @@ describe('getPatchViewerHref', () => { getPatchViewerHref('github.com/torvalds/linux/compare/v6.0...v7.0') ).toBe('/torvalds/linux/compare/v6.0...v7.0'); }); + + test('Tangled patch path preserves the host in the domain query', () => { + expect(getPatchViewerHref('tangled.org/@owner/repo/pulls/123')).toBe( + '/@owner/repo/pulls/123?domain=tangled.org' + ); + }); + }); + + describe('alternate domain URLs', () => { + test('Tangled URL preserves the host in the domain query', () => { + expect( + getPatchViewerHref('https://tangled.org/@owner/repo/pulls/123') + ).toBe('/@owner/repo/pulls/123?domain=tangled.org'); + }); }); describe('bare GitHub paths (no domain)', () => { diff --git a/apps/docs/test/resolveDiffshubViewerRoute.test.ts b/apps/docs/test/resolveDiffshubViewerRoute.test.ts index 0aa905139..fa6dbd048 100644 --- a/apps/docs/test/resolveDiffshubViewerRoute.test.ts +++ b/apps/docs/test/resolveDiffshubViewerRoute.test.ts @@ -126,6 +126,20 @@ describe('resolveDiffshubViewerRoute', () => { }); describe('alternate domain', () => { + test('renders Tangled paths against the requested host', () => { + expect( + resolveDiffshubViewerRoute( + ['@owner', 'repo', 'pulls', '123'], + 'tangled.org' + ) + ).toEqual({ + domain: 'tangled.org', + kind: 'render', + upstreamPath: '/@owner/repo/pulls/123', + url: 'https://tangled.org/@owner/repo/pulls/123', + }); + }); + test('renders against the requested host without rewriting', () => { expect( resolveDiffshubViewerRoute(