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.
|
* 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> {
|
||||||
|
|||||||
@@ -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