mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-17 08:05:19 +00:00
Prepare @gitbook/proxy to support serving under sub-directory (#2641)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/proxy': minor
|
||||
---
|
||||
|
||||
First version
|
||||
@@ -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<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);
|
||||
}
|
||||
@@ -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(
|
||||
@@ -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
|
||||
@@ -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' ||
|
||||
@@ -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<typeof getVisitorAuthToken>,
|
||||
): Promise<LookupResult> {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
dist/
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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/',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user