diff --git a/.changeset/silly-bananas-serve.md b/.changeset/silly-bananas-serve.md new file mode 100644 index 000000000..e2a70ade5 --- /dev/null +++ b/.changeset/silly-bananas-serve.md @@ -0,0 +1,5 @@ +--- +'@gitbook/proxy': minor +--- + +First version diff --git a/bun.lockb b/bun.lockb old mode 100644 new mode 100755 index 6c00827f0..dc1b41c81 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index 8c00e71ea..2dfa807f2 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -44,6 +44,12 @@ type URLLookupMode = * This mode is useful when self-hosting a single space. */ | 'single' + /** + * Mode when a site is being proxied on a different base URL. + * - x-gitbook-site-url is used to determine the site to serve. + * - host / x-forwarded-host / x-gitbook-host + x-gitbook-basepath is used to determine the base URL. + */ + | 'proxy' /** * Spaces are located using the incoming URL (using forwarded host headers). * This mode is the default one when serving on the GitBook infrastructure. @@ -77,11 +83,14 @@ export type LookupResult = PublishedContentWithCache & { }; /** - * Middleware to lookup the space to render. + * Middleware to lookup the site to render. * It takes as input a request with an URL, and a set of headers: * - x-gitbook-api: the API endpoint to use, if undefined, the default one is used * - x-gitbook-basepath: base in the path that should be ignored for routing * + * Once the site has been looked-up, the middleware passes the info to the rendering + * using a rewrite with a set of headers. This is the only way in next.js to do this (basically similar to AsyncLocalStorage). + * * The middleware also takes care of persisting the visitor authentication state. */ export async function middleware(request: NextRequest) { @@ -106,7 +115,7 @@ export async function middleware(request: NextRequest) { let apiEndpoint = request.headers.get('x-gitbook-api') ?? DEFAULT_API_ENDPOINT; const originBasePath = request.headers.get('x-gitbook-basepath') ?? ''; - const inputURL = stripURLBasePath(url, originBasePath); + const inputURL = mode === 'proxy' ? url : stripURLBasePath(url, originBasePath); const resolved = await withAPI( { @@ -117,7 +126,7 @@ export async function middleware(request: NextRequest) { }), contextId: undefined, }, - () => lookupSpaceForURL(mode, request, inputURL), + () => lookupSiteForURL(mode, request, inputURL), ); if ('error' in resolved) { return new NextResponse(resolved.error.message, { @@ -211,7 +220,10 @@ export async function middleware(request: NextRequest) { } headers.set('x-gitbook-mode', mode); headers.set('x-gitbook-origin-basepath', originBasePath); - headers.set('x-gitbook-basepath', joinPath(originBasePath, resolved.basePath)); + headers.set( + 'x-gitbook-basepath', + mode === 'proxy' ? originBasePath : joinPath(originBasePath, resolved.basePath), + ); headers.set('x-gitbook-content-space', resolved.space); if ('site' in resolved) { headers.set('x-gitbook-content-organization', resolved.organization); @@ -302,7 +314,10 @@ export async function middleware(request: NextRequest) { /** * Compute the input URL the user is trying to access. */ -function getInputURL(request: NextRequest): { url: URL; mode: URLLookupMode } { +function getInputURL(request: NextRequest): { + url: URL; + mode: URLLookupMode; +} { const url = new URL(request.url); let mode: URLLookupMode = (process.env.GITBOOK_MODE as URLLookupMode | undefined) ?? 'multi-path'; @@ -332,27 +347,36 @@ function getInputURL(request: NextRequest): { url: URL; mode: URLLookupMode } { mode = 'multi-id'; } + // When passing a x-gitbook-site-url header, this URL is used instead of the request URL + // to determine the site to serve. + const xGitbookSite = request.headers.get('x-gitbook-site-url'); + if (xGitbookSite) { + mode = 'proxy'; + } + return { url, mode }; } -async function lookupSpaceForURL( +async function lookupSiteForURL( mode: URLLookupMode, request: NextRequest, url: URL, ): Promise { switch (mode) { case 'single': { - return await lookupSpaceInSingleMode(url); + return await lookupSiteInSingleMode(url); } case 'multi': { - return await lookupSpaceInMultiMode(request, url); + return await lookupSiteInMultiMode(request, url); } case 'multi-path': { - return await lookupSpaceInMultiPathMode(request, url); + return await lookupSiteInMultiPathMode(request, url); } case 'multi-id': { return await lookupSiteOrSpaceInMultiIdMode(request, url); } + case 'proxy': + return await lookupSiteInProxy(request, url); default: assertNever(mode); } @@ -362,7 +386,7 @@ async function lookupSpaceForURL( * GITBOOK_MODE=single * When serving a single space, configured using GITBOOK_SPACE_ID and GITBOOK_TOKEN. */ -async function lookupSpaceInSingleMode(url: URL): Promise { +async function lookupSiteInSingleMode(url: URL): Promise { const spaceId = process.env.GITBOOK_SPACE_ID; if (!spaceId) { throw new Error( @@ -386,13 +410,31 @@ async function lookupSpaceInSingleMode(url: URL): Promise { }; } +/** + * GITBOOK_MODE=proxy + * When proxying a site on a different base URL. + */ +async function lookupSiteInProxy(request: NextRequest, url: URL): Promise { + const rawSiteUrl = request.headers.get('x-gitbook-site-url'); + if (!rawSiteUrl) { + throw new Error( + `Missing x-gitbook-site-url header. It should be passed when using GITBOOK_MODE=proxy.`, + ); + } + + const siteUrl = new URL(rawSiteUrl); + siteUrl.pathname = joinPath(siteUrl.pathname, url.pathname); + + return await lookupSiteInMultiMode(request, siteUrl); +} + /** * GITBOOK_MODE=multi * When serving multi spaces based on the current URL. */ -async function lookupSpaceInMultiMode(request: NextRequest, url: URL): Promise { +async function lookupSiteInMultiMode(request: NextRequest, url: URL): Promise { const visitorAuthToken = getVisitorAuthToken(request, url); - const lookup = await lookupSpaceByAPI(url, visitorAuthToken); + const lookup = await lookupSiteByAPI(url, visitorAuthToken); return { ...lookup, ...('basePath' in lookup && visitorAuthToken @@ -557,7 +599,7 @@ async function lookupSiteOrSpaceInMultiIdMode( * GITBOOK_MODE=multi-path * When serving multi spaces with the url passed in the path. */ -async function lookupSpaceInMultiPathMode(request: NextRequest, url: URL): Promise { +async function lookupSiteInMultiPathMode(request: NextRequest, url: URL): Promise { // Skip useless requests if ( url.pathname === '/favicon.ico' || @@ -596,7 +638,7 @@ async function lookupSpaceInMultiPathMode(request: NextRequest, url: URL): Promi const visitorAuthToken = getVisitorAuthToken(request, target); - const lookup = await lookupSpaceByAPI(target, visitorAuthToken); + const lookup = await lookupSiteByAPI(target, visitorAuthToken); if ('error' in lookup) { return lookup; } @@ -632,7 +674,7 @@ async function lookupSpaceInMultiPathMode(request: NextRequest, url: URL): Promi * Lookup a space by its URL using the GitBook API. * To optimize caching, we try multiple lookup alternatives and return the first one that matches. */ -async function lookupSpaceByAPI( +async function lookupSiteByAPI( lookupURL: URL, visitorAuthToken: ReturnType, ): Promise { diff --git a/packages/proxy/.gitignore b/packages/proxy/.gitignore new file mode 100644 index 000000000..849ddff3b --- /dev/null +++ b/packages/proxy/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/proxy/README.md b/packages/proxy/README.md new file mode 100644 index 000000000..e9e609601 --- /dev/null +++ b/packages/proxy/README.md @@ -0,0 +1,28 @@ +# `@gitbook/proxy` + +Host a GitBook site on your own domain as a subpath. + +## Usage + +```ts +import { proxyToGitBook } from '@gitbook/proxy'; + +const site = proxyToGitBook(event.request, { + site: 'mycompany.gitbook.io/site/', + basePath: '/docs', +}); + +export default { + async fetch(request) { + // If the requst matches the basePath /docs, we serve from GitBook + if (site.match(request)) { + return site.fetch(request); + } + + // Otherwise we do something else. + return new Response('Not found', { + statusCode: 404, + }); + }, +}; +``` diff --git a/packages/proxy/package.json b/packages/proxy/package.json new file mode 100644 index 000000000..6fcee7d5d --- /dev/null +++ b/packages/proxy/package.json @@ -0,0 +1,28 @@ +{ + "name": "@gitbook/proxy", + "description": "Host a GitBook site on your own domain as a subpath", + "version": "0.0.0", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "development": "./src/index.ts", + "default": "./dist/index.js" + } + }, + "dependencies": {}, + "devDependencies": { + "typescript": "^5.5.3" + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "clean": "rm -rf ./dist", + "unit": "bun test" + }, + "files": [ + "dist", + "src", + "README.md", + "CHANGELOG.md" + ] +} diff --git a/packages/proxy/src/index.test.ts b/packages/proxy/src/index.test.ts new file mode 100644 index 000000000..b645106e2 --- /dev/null +++ b/packages/proxy/src/index.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'bun:test'; +import { proxyToGitBook } from '.'; + +describe('.match', () => { + it('should return true if the request is below the base path', () => { + const site = proxyToGitBook({ site: 'https://org.gitbook.io/example/', basePath: '/docs' }); + expect(site.match('/docs')).toBe(true); + expect(site.match('/docs/')).toBe(true); + expect(site.match('/docs/hello')).toBe(true); + expect(site.match('/docs/hello/world')).toBe(true); + + expect(site.match('/hello/world')).toBe(false); + expect(site.match('/')).toBe(false); + }); +}); + +describe('.request', () => { + it('should compute a proper request for a sub-path', () => { + const site = proxyToGitBook({ site: 'https://org.gitbook.io/example/', basePath: '/docs' }); + const request = new Request('https://example.com/docs/hello/world'); + + const proxiedRequest = site.request(request); + expect(proxiedRequest.url).toBe('https://hosting.gitbook.io/docs/hello/world'); + expect(proxiedRequest.headers.get('Host')).toBe('hosting.gitbook.io'); + expect(proxiedRequest.headers.get('X-Forwarded-Host')).toBe('example.com'); + expect(proxiedRequest.headers.get('X-GitBook-BasePath')).toBe('/docs'); + expect(proxiedRequest.headers.get('X-GitBook-Site-URL')).toBe( + 'https://org.gitbook.io/example/', + ); + }); + + it('should compute a proper request on the root', () => { + const site = proxyToGitBook({ site: 'https://org.gitbook.io/example/', basePath: '/docs' }); + const request = new Request('https://example.com/docs'); + + const proxiedRequest = site.request(request); + expect(proxiedRequest.url).toBe('https://hosting.gitbook.io/docs'); + expect(proxiedRequest.headers.get('Host')).toBe('hosting.gitbook.io'); + expect(proxiedRequest.headers.get('X-Forwarded-Host')).toBe('example.com'); + expect(proxiedRequest.headers.get('X-GitBook-BasePath')).toBe('/docs'); + expect(proxiedRequest.headers.get('X-GitBook-Site-URL')).toBe( + 'https://org.gitbook.io/example/', + ); + }); + + it('should normalize the basepath', () => { + const site = proxyToGitBook({ site: 'https://org.gitbook.io/example/', basePath: 'docs/' }); + const request = new Request('https://example.com/docs/hello/world'); + + const proxiedRequest = site.request(request); + expect(proxiedRequest.url).toBe('https://hosting.gitbook.io/docs/hello/world'); + expect(proxiedRequest.headers.get('Host')).toBe('hosting.gitbook.io'); + expect(proxiedRequest.headers.get('X-Forwarded-Host')).toBe('example.com'); + expect(proxiedRequest.headers.get('X-GitBook-BasePath')).toBe('/docs'); + expect(proxiedRequest.headers.get('X-GitBook-Site-URL')).toBe( + 'https://org.gitbook.io/example/', + ); + }); +}); diff --git a/packages/proxy/src/index.ts b/packages/proxy/src/index.ts new file mode 100644 index 000000000..7d2d50dc0 --- /dev/null +++ b/packages/proxy/src/index.ts @@ -0,0 +1,97 @@ +export interface ProxyToGitBookOptions { + /** + * The URL of the published site. + * @example "https://mycompany.gitbook.io/docs" + */ + site: string; + + /** + * Base path to serve the site on. + * @example "/docs" + */ + basePath: string; + + /** + * Hostname used by GitBook to serve content. + * Do not set this option unless you know what you are doing. + */ + gitbookHost?: string; +} + +export interface ProxySite { + /** + * Test if the request should be proxied to this site. + */ + match(request: Request | string): boolean; + + /** + * Get the proxied request for a given request. + */ + request(request: Request): Request; + + /** + * Fetch the request from the site. + */ + fetch(request: Request): Promise; +} + +/** + * Proxies requests to a GitBook site. + */ +export function proxyToGitBook(options: ProxyToGitBookOptions): ProxySite { + const { gitbookHost = 'hosting.gitbook.io' } = options; + + const siteUrl = new URL(options.site); + const rawSiteUrl = siteUrl.toString(); + + const basePath = normalizeBasePath(options.basePath); + + const site: ProxySite = { + match: (request) => { + const pathname = typeof request === 'string' ? request : new URL(request.url).pathname; + return pathname === basePath || pathname.startsWith(basePath + '/'); + }, + + request: (originRequest) => { + const originUrl = new URL(originRequest.url); + + const url = new URL(originUrl); + url.hostname = gitbookHost; + + const proxyRequest = new Request(url, originRequest); + proxyRequest.headers.set('Host', gitbookHost); + + // Pass the original host and protocol + proxyRequest.headers.set('X-Forwarded-Host', originUrl.hostname); + proxyRequest.headers.set('X-Forwarded-Proto', 'https'); + + // Pass the basepath on the original URL + proxyRequest.headers.set('X-GitBook-BasePath', basePath); + + // Pass the site URL + proxyRequest.headers.set('X-GitBook-Site-URL', rawSiteUrl); + + return proxyRequest; + }, + + fetch: async (originRequest) => { + return fetch(site.request(originRequest)); + }, + }; + + return site; +} + +function normalizeBasePath(basePath: string): string { + let result = withLeadingSlash(basePath); + result = withoutTrailingSlash(result); + return result; +} + +function withLeadingSlash(path: string): string { + return path.startsWith('/') ? path : '/' + path; +} + +function withoutTrailingSlash(path: string): string { + return path.endsWith('/') ? path.slice(0, -1) : path; +} diff --git a/packages/proxy/tsconfig.json b/packages/proxy/tsconfig.json new file mode 100644 index 000000000..aa63c0ef4 --- /dev/null +++ b/packages/proxy/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": false, + "declaration": true, + "outDir": "dist", + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "incremental": true, + "types": [ + "bun-types" // add Bun global + ] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +}