Prepare @gitbook/proxy to support serving under sub-directory (#2641)

This commit is contained in:
Samy Pessé
2024-12-18 10:05:31 +01:00
committed by GitHub
parent 75606e4df7
commit 53b9f10ac8
9 changed files with 298 additions and 15 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/proxy': minor
---
First version
Regular → Executable
BIN
View File
Binary file not shown.
+57 -15
View File
@@ -44,6 +44,12 @@ type URLLookupMode =
* This mode is useful when self-hosting a single space. * This mode is useful when self-hosting a single space.
*/ */
| 'single' | '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). * Spaces are located using the incoming URL (using forwarded host headers).
* This mode is the default one when serving on the GitBook infrastructure. * 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: * 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-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 * - 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. * The middleware also takes care of persisting the visitor authentication state.
*/ */
export async function middleware(request: NextRequest) { 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; let apiEndpoint = request.headers.get('x-gitbook-api') ?? DEFAULT_API_ENDPOINT;
const originBasePath = request.headers.get('x-gitbook-basepath') ?? ''; 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( const resolved = await withAPI(
{ {
@@ -117,7 +126,7 @@ export async function middleware(request: NextRequest) {
}), }),
contextId: undefined, contextId: undefined,
}, },
() => lookupSpaceForURL(mode, request, inputURL), () => lookupSiteForURL(mode, request, inputURL),
); );
if ('error' in resolved) { if ('error' in resolved) {
return new NextResponse(resolved.error.message, { 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-mode', mode);
headers.set('x-gitbook-origin-basepath', originBasePath); 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); headers.set('x-gitbook-content-space', resolved.space);
if ('site' in resolved) { if ('site' in resolved) {
headers.set('x-gitbook-content-organization', resolved.organization); 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. * 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); const url = new URL(request.url);
let mode: URLLookupMode = let mode: URLLookupMode =
(process.env.GITBOOK_MODE as URLLookupMode | undefined) ?? 'multi-path'; (process.env.GITBOOK_MODE as URLLookupMode | undefined) ?? 'multi-path';
@@ -332,27 +347,36 @@ function getInputURL(request: NextRequest): { url: URL; mode: URLLookupMode } {
mode = 'multi-id'; 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 }; return { url, mode };
} }
async function lookupSpaceForURL( async function lookupSiteForURL(
mode: URLLookupMode, mode: URLLookupMode,
request: NextRequest, request: NextRequest,
url: URL, url: URL,
): Promise<LookupResult> { ): Promise<LookupResult> {
switch (mode) { switch (mode) {
case 'single': { case 'single': {
return await lookupSpaceInSingleMode(url); return await lookupSiteInSingleMode(url);
} }
case 'multi': { case 'multi': {
return await lookupSpaceInMultiMode(request, url); return await lookupSiteInMultiMode(request, url);
} }
case 'multi-path': { case 'multi-path': {
return await lookupSpaceInMultiPathMode(request, url); return await lookupSiteInMultiPathMode(request, url);
} }
case 'multi-id': { case 'multi-id': {
return await lookupSiteOrSpaceInMultiIdMode(request, url); return await lookupSiteOrSpaceInMultiIdMode(request, url);
} }
case 'proxy':
return await lookupSiteInProxy(request, url);
default: default:
assertNever(mode); assertNever(mode);
} }
@@ -362,7 +386,7 @@ async function lookupSpaceForURL(
* GITBOOK_MODE=single * GITBOOK_MODE=single
* When serving a single space, configured using GITBOOK_SPACE_ID and GITBOOK_TOKEN. * When serving a single space, configured using GITBOOK_SPACE_ID and GITBOOK_TOKEN.
*/ */
async function lookupSpaceInSingleMode(url: URL): Promise<LookupResult> { async function lookupSiteInSingleMode(url: URL): Promise<LookupResult> {
const spaceId = process.env.GITBOOK_SPACE_ID; const spaceId = process.env.GITBOOK_SPACE_ID;
if (!spaceId) { if (!spaceId) {
throw new Error( throw new Error(
@@ -386,13 +410,31 @@ async function lookupSpaceInSingleMode(url: URL): Promise<LookupResult> {
}; };
} }
/**
* GITBOOK_MODE=proxy
* When proxying a site on a different base URL.
*/
async function lookupSiteInProxy(request: NextRequest, url: URL): Promise<LookupResult> {
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 * GITBOOK_MODE=multi
* When serving multi spaces based on the current URL. * When serving multi spaces based on the current URL.
*/ */
async function lookupSpaceInMultiMode(request: NextRequest, url: URL): Promise<LookupResult> { async function lookupSiteInMultiMode(request: NextRequest, url: URL): Promise<LookupResult> {
const visitorAuthToken = getVisitorAuthToken(request, url); const visitorAuthToken = getVisitorAuthToken(request, url);
const lookup = await lookupSpaceByAPI(url, visitorAuthToken); const lookup = await lookupSiteByAPI(url, visitorAuthToken);
return { return {
...lookup, ...lookup,
...('basePath' in lookup && visitorAuthToken ...('basePath' in lookup && visitorAuthToken
@@ -557,7 +599,7 @@ async function lookupSiteOrSpaceInMultiIdMode(
* GITBOOK_MODE=multi-path * GITBOOK_MODE=multi-path
* When serving multi spaces with the url passed in the path. * When serving multi spaces with the url passed in the path.
*/ */
async function lookupSpaceInMultiPathMode(request: NextRequest, url: URL): Promise<LookupResult> { async function lookupSiteInMultiPathMode(request: NextRequest, url: URL): Promise<LookupResult> {
// Skip useless requests // Skip useless requests
if ( if (
url.pathname === '/favicon.ico' || url.pathname === '/favicon.ico' ||
@@ -596,7 +638,7 @@ async function lookupSpaceInMultiPathMode(request: NextRequest, url: URL): Promi
const visitorAuthToken = getVisitorAuthToken(request, target); const visitorAuthToken = getVisitorAuthToken(request, target);
const lookup = await lookupSpaceByAPI(target, visitorAuthToken); const lookup = await lookupSiteByAPI(target, visitorAuthToken);
if ('error' in lookup) { if ('error' in lookup) {
return 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. * 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. * To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*/ */
async function lookupSpaceByAPI( async function lookupSiteByAPI(
lookupURL: URL, lookupURL: URL,
visitorAuthToken: ReturnType<typeof getVisitorAuthToken>, visitorAuthToken: ReturnType<typeof getVisitorAuthToken>,
): Promise<LookupResult> { ): Promise<LookupResult> {
+1
View File
@@ -0,0 +1 @@
dist/
+28
View File
@@ -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,
});
},
};
```
+28
View File
@@ -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"
]
}
+59
View File
@@ -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/',
);
});
});
+97
View File
@@ -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<Response>;
}
/**
* 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;
}
+23
View File
@@ -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"]
}