Skip to content

Commit 53b9f10

Browse files
authored
Prepare @gitbook/proxy to support serving under sub-directory (#2641)
1 parent 75606e4 commit 53b9f10

File tree

9 files changed

+298
-15
lines changed

9 files changed

+298
-15
lines changed

.changeset/silly-bananas-serve.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@gitbook/proxy': minor
3+
---
4+
5+
First version

bun.lockb

100644100755
424 Bytes
Binary file not shown.

packages/gitbook/src/middleware.ts

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ type URLLookupMode =
4444
* This mode is useful when self-hosting a single space.
4545
*/
4646
| 'single'
47+
/**
48+
* Mode when a site is being proxied on a different base URL.
49+
* - x-gitbook-site-url is used to determine the site to serve.
50+
* - host / x-forwarded-host / x-gitbook-host + x-gitbook-basepath is used to determine the base URL.
51+
*/
52+
| 'proxy'
4753
/**
4854
* Spaces are located using the incoming URL (using forwarded host headers).
4955
* This mode is the default one when serving on the GitBook infrastructure.
@@ -77,11 +83,14 @@ export type LookupResult = PublishedContentWithCache & {
7783
};
7884

7985
/**
80-
* Middleware to lookup the space to render.
86+
* Middleware to lookup the site to render.
8187
* It takes as input a request with an URL, and a set of headers:
8288
* - x-gitbook-api: the API endpoint to use, if undefined, the default one is used
8389
* - x-gitbook-basepath: base in the path that should be ignored for routing
8490
*
91+
* Once the site has been looked-up, the middleware passes the info to the rendering
92+
* using a rewrite with a set of headers. This is the only way in next.js to do this (basically similar to AsyncLocalStorage).
93+
*
8594
* The middleware also takes care of persisting the visitor authentication state.
8695
*/
8796
export async function middleware(request: NextRequest) {
@@ -106,7 +115,7 @@ export async function middleware(request: NextRequest) {
106115
let apiEndpoint = request.headers.get('x-gitbook-api') ?? DEFAULT_API_ENDPOINT;
107116
const originBasePath = request.headers.get('x-gitbook-basepath') ?? '';
108117

109-
const inputURL = stripURLBasePath(url, originBasePath);
118+
const inputURL = mode === 'proxy' ? url : stripURLBasePath(url, originBasePath);
110119

111120
const resolved = await withAPI(
112121
{
@@ -117,7 +126,7 @@ export async function middleware(request: NextRequest) {
117126
}),
118127
contextId: undefined,
119128
},
120-
() => lookupSpaceForURL(mode, request, inputURL),
129+
() => lookupSiteForURL(mode, request, inputURL),
121130
);
122131
if ('error' in resolved) {
123132
return new NextResponse(resolved.error.message, {
@@ -211,7 +220,10 @@ export async function middleware(request: NextRequest) {
211220
}
212221
headers.set('x-gitbook-mode', mode);
213222
headers.set('x-gitbook-origin-basepath', originBasePath);
214-
headers.set('x-gitbook-basepath', joinPath(originBasePath, resolved.basePath));
223+
headers.set(
224+
'x-gitbook-basepath',
225+
mode === 'proxy' ? originBasePath : joinPath(originBasePath, resolved.basePath),
226+
);
215227
headers.set('x-gitbook-content-space', resolved.space);
216228
if ('site' in resolved) {
217229
headers.set('x-gitbook-content-organization', resolved.organization);
@@ -302,7 +314,10 @@ export async function middleware(request: NextRequest) {
302314
/**
303315
* Compute the input URL the user is trying to access.
304316
*/
305-
function getInputURL(request: NextRequest): { url: URL; mode: URLLookupMode } {
317+
function getInputURL(request: NextRequest): {
318+
url: URL;
319+
mode: URLLookupMode;
320+
} {
306321
const url = new URL(request.url);
307322
let mode: URLLookupMode =
308323
(process.env.GITBOOK_MODE as URLLookupMode | undefined) ?? 'multi-path';
@@ -332,27 +347,36 @@ function getInputURL(request: NextRequest): { url: URL; mode: URLLookupMode } {
332347
mode = 'multi-id';
333348
}
334349

350+
// When passing a x-gitbook-site-url header, this URL is used instead of the request URL
351+
// to determine the site to serve.
352+
const xGitbookSite = request.headers.get('x-gitbook-site-url');
353+
if (xGitbookSite) {
354+
mode = 'proxy';
355+
}
356+
335357
return { url, mode };
336358
}
337359

338-
async function lookupSpaceForURL(
360+
async function lookupSiteForURL(
339361
mode: URLLookupMode,
340362
request: NextRequest,
341363
url: URL,
342364
): Promise<LookupResult> {
343365
switch (mode) {
344366
case 'single': {
345-
return await lookupSpaceInSingleMode(url);
367+
return await lookupSiteInSingleMode(url);
346368
}
347369
case 'multi': {
348-
return await lookupSpaceInMultiMode(request, url);
370+
return await lookupSiteInMultiMode(request, url);
349371
}
350372
case 'multi-path': {
351-
return await lookupSpaceInMultiPathMode(request, url);
373+
return await lookupSiteInMultiPathMode(request, url);
352374
}
353375
case 'multi-id': {
354376
return await lookupSiteOrSpaceInMultiIdMode(request, url);
355377
}
378+
case 'proxy':
379+
return await lookupSiteInProxy(request, url);
356380
default:
357381
assertNever(mode);
358382
}
@@ -362,7 +386,7 @@ async function lookupSpaceForURL(
362386
* GITBOOK_MODE=single
363387
* When serving a single space, configured using GITBOOK_SPACE_ID and GITBOOK_TOKEN.
364388
*/
365-
async function lookupSpaceInSingleMode(url: URL): Promise<LookupResult> {
389+
async function lookupSiteInSingleMode(url: URL): Promise<LookupResult> {
366390
const spaceId = process.env.GITBOOK_SPACE_ID;
367391
if (!spaceId) {
368392
throw new Error(
@@ -386,13 +410,31 @@ async function lookupSpaceInSingleMode(url: URL): Promise<LookupResult> {
386410
};
387411
}
388412

413+
/**
414+
* GITBOOK_MODE=proxy
415+
* When proxying a site on a different base URL.
416+
*/
417+
async function lookupSiteInProxy(request: NextRequest, url: URL): Promise<LookupResult> {
418+
const rawSiteUrl = request.headers.get('x-gitbook-site-url');
419+
if (!rawSiteUrl) {
420+
throw new Error(
421+
`Missing x-gitbook-site-url header. It should be passed when using GITBOOK_MODE=proxy.`,
422+
);
423+
}
424+
425+
const siteUrl = new URL(rawSiteUrl);
426+
siteUrl.pathname = joinPath(siteUrl.pathname, url.pathname);
427+
428+
return await lookupSiteInMultiMode(request, siteUrl);
429+
}
430+
389431
/**
390432
* GITBOOK_MODE=multi
391433
* When serving multi spaces based on the current URL.
392434
*/
393-
async function lookupSpaceInMultiMode(request: NextRequest, url: URL): Promise<LookupResult> {
435+
async function lookupSiteInMultiMode(request: NextRequest, url: URL): Promise<LookupResult> {
394436
const visitorAuthToken = getVisitorAuthToken(request, url);
395-
const lookup = await lookupSpaceByAPI(url, visitorAuthToken);
437+
const lookup = await lookupSiteByAPI(url, visitorAuthToken);
396438
return {
397439
...lookup,
398440
...('basePath' in lookup && visitorAuthToken
@@ -557,7 +599,7 @@ async function lookupSiteOrSpaceInMultiIdMode(
557599
* GITBOOK_MODE=multi-path
558600
* When serving multi spaces with the url passed in the path.
559601
*/
560-
async function lookupSpaceInMultiPathMode(request: NextRequest, url: URL): Promise<LookupResult> {
602+
async function lookupSiteInMultiPathMode(request: NextRequest, url: URL): Promise<LookupResult> {
561603
// Skip useless requests
562604
if (
563605
url.pathname === '/favicon.ico' ||
@@ -596,7 +638,7 @@ async function lookupSpaceInMultiPathMode(request: NextRequest, url: URL): Promi
596638

597639
const visitorAuthToken = getVisitorAuthToken(request, target);
598640

599-
const lookup = await lookupSpaceByAPI(target, visitorAuthToken);
641+
const lookup = await lookupSiteByAPI(target, visitorAuthToken);
600642
if ('error' in lookup) {
601643
return lookup;
602644
}
@@ -632,7 +674,7 @@ async function lookupSpaceInMultiPathMode(request: NextRequest, url: URL): Promi
632674
* Lookup a space by its URL using the GitBook API.
633675
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
634676
*/
635-
async function lookupSpaceByAPI(
677+
async function lookupSiteByAPI(
636678
lookupURL: URL,
637679
visitorAuthToken: ReturnType<typeof getVisitorAuthToken>,
638680
): Promise<LookupResult> {

packages/proxy/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist/

packages/proxy/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# `@gitbook/proxy`
2+
3+
Host a GitBook site on your own domain as a subpath.
4+
5+
## Usage
6+
7+
```ts
8+
import { proxyToGitBook } from '@gitbook/proxy';
9+
10+
const site = proxyToGitBook(event.request, {
11+
site: 'mycompany.gitbook.io/site/',
12+
basePath: '/docs',
13+
});
14+
15+
export default {
16+
async fetch(request) {
17+
// If the requst matches the basePath /docs, we serve from GitBook
18+
if (site.match(request)) {
19+
return site.fetch(request);
20+
}
21+
22+
// Otherwise we do something else.
23+
return new Response('Not found', {
24+
statusCode: 404,
25+
});
26+
},
27+
};
28+
```

packages/proxy/package.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "@gitbook/proxy",
3+
"description": "Host a GitBook site on your own domain as a subpath",
4+
"version": "0.0.0",
5+
"exports": {
6+
".": {
7+
"types": "./dist/index.d.ts",
8+
"development": "./src/index.ts",
9+
"default": "./dist/index.js"
10+
}
11+
},
12+
"dependencies": {},
13+
"devDependencies": {
14+
"typescript": "^5.5.3"
15+
},
16+
"scripts": {
17+
"build": "tsc",
18+
"typecheck": "tsc --noEmit",
19+
"clean": "rm -rf ./dist",
20+
"unit": "bun test"
21+
},
22+
"files": [
23+
"dist",
24+
"src",
25+
"README.md",
26+
"CHANGELOG.md"
27+
]
28+
}

packages/proxy/src/index.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, expect, it } from 'bun:test';
2+
import { proxyToGitBook } from '.';
3+
4+
describe('.match', () => {
5+
it('should return true if the request is below the base path', () => {
6+
const site = proxyToGitBook({ site: 'https://org.gitbook.io/example/', basePath: '/docs' });
7+
expect(site.match('/docs')).toBe(true);
8+
expect(site.match('/docs/')).toBe(true);
9+
expect(site.match('/docs/hello')).toBe(true);
10+
expect(site.match('/docs/hello/world')).toBe(true);
11+
12+
expect(site.match('/hello/world')).toBe(false);
13+
expect(site.match('/')).toBe(false);
14+
});
15+
});
16+
17+
describe('.request', () => {
18+
it('should compute a proper request for a sub-path', () => {
19+
const site = proxyToGitBook({ site: 'https://org.gitbook.io/example/', basePath: '/docs' });
20+
const request = new Request('https://example.com/docs/hello/world');
21+
22+
const proxiedRequest = site.request(request);
23+
expect(proxiedRequest.url).toBe('https://hosting.gitbook.io/docs/hello/world');
24+
expect(proxiedRequest.headers.get('Host')).toBe('hosting.gitbook.io');
25+
expect(proxiedRequest.headers.get('X-Forwarded-Host')).toBe('example.com');
26+
expect(proxiedRequest.headers.get('X-GitBook-BasePath')).toBe('/docs');
27+
expect(proxiedRequest.headers.get('X-GitBook-Site-URL')).toBe(
28+
'https://org.gitbook.io/example/',
29+
);
30+
});
31+
32+
it('should compute a proper request on the root', () => {
33+
const site = proxyToGitBook({ site: 'https://org.gitbook.io/example/', basePath: '/docs' });
34+
const request = new Request('https://example.com/docs');
35+
36+
const proxiedRequest = site.request(request);
37+
expect(proxiedRequest.url).toBe('https://hosting.gitbook.io/docs');
38+
expect(proxiedRequest.headers.get('Host')).toBe('hosting.gitbook.io');
39+
expect(proxiedRequest.headers.get('X-Forwarded-Host')).toBe('example.com');
40+
expect(proxiedRequest.headers.get('X-GitBook-BasePath')).toBe('/docs');
41+
expect(proxiedRequest.headers.get('X-GitBook-Site-URL')).toBe(
42+
'https://org.gitbook.io/example/',
43+
);
44+
});
45+
46+
it('should normalize the basepath', () => {
47+
const site = proxyToGitBook({ site: 'https://org.gitbook.io/example/', basePath: 'docs/' });
48+
const request = new Request('https://example.com/docs/hello/world');
49+
50+
const proxiedRequest = site.request(request);
51+
expect(proxiedRequest.url).toBe('https://hosting.gitbook.io/docs/hello/world');
52+
expect(proxiedRequest.headers.get('Host')).toBe('hosting.gitbook.io');
53+
expect(proxiedRequest.headers.get('X-Forwarded-Host')).toBe('example.com');
54+
expect(proxiedRequest.headers.get('X-GitBook-BasePath')).toBe('/docs');
55+
expect(proxiedRequest.headers.get('X-GitBook-Site-URL')).toBe(
56+
'https://org.gitbook.io/example/',
57+
);
58+
});
59+
});

0 commit comments

Comments
 (0)