Skip to content

Commit

Permalink
Prepare @gitbook/proxy to support serving under sub-directory (#2641)
Browse files Browse the repository at this point in the history
  • Loading branch information
SamyPesse authored Dec 18, 2024
1 parent 75606e4 commit 53b9f10
Show file tree
Hide file tree
Showing 9 changed files with 298 additions and 15 deletions.
5 changes: 5 additions & 0 deletions .changeset/silly-bananas-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gitbook/proxy': minor
---

First version
Binary file modified bun.lockb
100644 → 100755
Binary file not shown.
72 changes: 57 additions & 15 deletions packages/gitbook/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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(
{
Expand All @@ -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, {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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<LookupResult> {
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);
}
Expand All @@ -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<LookupResult> {
async function lookupSiteInSingleMode(url: URL): Promise<LookupResult> {
const spaceId = process.env.GITBOOK_SPACE_ID;
if (!spaceId) {
throw new Error(
Expand All @@ -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
* 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 lookup = await lookupSpaceByAPI(url, visitorAuthToken);
const lookup = await lookupSiteByAPI(url, visitorAuthToken);
return {
...lookup,
...('basePath' in lookup && visitorAuthToken
Expand Down Expand Up @@ -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<LookupResult> {
async function lookupSiteInMultiPathMode(request: NextRequest, url: URL): Promise<LookupResult> {
// Skip useless requests
if (
url.pathname === '/favicon.ico' ||
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<typeof getVisitorAuthToken>,
): Promise<LookupResult> {
Expand Down
1 change: 1 addition & 0 deletions packages/proxy/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist/
28 changes: 28 additions & 0 deletions packages/proxy/README.md
Original file line number Diff line number Diff line change
@@ -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 changes: 28 additions & 0 deletions packages/proxy/package.json
Original file line number Diff line number Diff line change
@@ -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 changes: 59 additions & 0 deletions packages/proxy/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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/',
);
});
});
Loading

0 comments on commit 53b9f10

Please sign in to comment.