v2: use static route for VA / adaptive content (#2998)

This commit is contained in:
Samy Pessé
2025-03-20 15:41:17 +01:00
committed by GitHub
parent 96f84b5917
commit 4e5d6c7487
45 changed files with 336 additions and 351 deletions
+5 -2
View File
@@ -136,14 +136,17 @@
"@gitbook/api": "*",
"@gitbook/cache-tags": "workspace:*",
"@sindresorhus/fnv1a": "^3.1.0",
"jwt-decode": "^4.0.0",
"next": "^15.2.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"warn-once": "^0.1.1",
},
"devDependencies": {
"@opennextjs/cloudflare": "^0.5.10",
"@types/rison": "^0.0.9",
"gitbook": "*",
"postcss": "^8",
"tailwindcss": "^3.4.0",
@@ -253,7 +256,7 @@
},
"overrides": {
"@codemirror/state": "6.4.1",
"@gitbook/api": "0.101.0",
"@gitbook/api": "0.102.0",
"react": "18.3.1",
"react-dom": "18.3.1",
},
@@ -604,7 +607,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="],
"@gitbook/api": ["@gitbook/api@0.101.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-DX8CyoXCRyqXW738G9Ik9Pq97L1RjFdVCGxska9tFkrn1+bf60mwpt+6669wrRdlBxb1t2Ru4DsGuUksxCKDag=="],
"@gitbook/api": ["@gitbook/api@0.102.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-9wPr5kyCHhTTwkaCYEMT4q3JjEl1q9mytgvpeusmeREfG4E0RZHYPEL8bmumO7T9wH/0vHjpfW2LSTUUs60AwQ=="],
"@gitbook/cache-do": ["@gitbook/cache-do@workspace:packages/cache-do"],
+1 -1
View File
@@ -12,7 +12,7 @@
"@codemirror/state": "6.4.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"@gitbook/api": "0.101.0"
"@gitbook/api": "0.102.0"
},
"private": true,
"scripts": {
+1
View File
@@ -5,6 +5,7 @@
*/
const nextConfig = {
experimental: {
authInterrupts: true,
useCache: true,
},
+4 -1
View File
@@ -10,11 +10,14 @@
"@gitbook/cache-tags": "workspace:*",
"@sindresorhus/fnv1a": "^3.1.0",
"server-only": "^0.0.1",
"warn-once": "^0.1.1"
"warn-once": "^0.1.1",
"rison": "^0.1.1",
"jwt-decode": "^4.0.0"
},
"devDependencies": {
"gitbook": "*",
"@opennextjs/cloudflare": "^0.5.10",
"@types/rison": "^0.0.9",
"tailwindcss": "^3.4.0",
"postcss": "^8"
},
@@ -13,20 +13,20 @@ type PageProps = {
export default async function Page(props: PageProps) {
const params = await props.params;
const context = await getDynamicSiteContext(params);
const { context } = await getDynamicSiteContext(params);
const pathname = getPagePathFromParams(params);
return <SitePage context={context} pageParams={{ pathname }} />;
}
export async function generateViewport(props: PageProps): Promise<Viewport> {
const context = await getDynamicSiteContext(await props.params);
const { context } = await getDynamicSiteContext(await props.params);
return generateSitePageViewport(context);
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const params = await props.params;
const context = await getDynamicSiteContext(params);
const { context } = await getDynamicSiteContext(params);
const pathname = getPagePathFromParams(params);
return generateSitePageMetadata({
@@ -6,7 +6,7 @@ import {
} from '@/components/SiteLayout';
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
import { getThemeFromMiddleware, getVisitorAuthTokenFromMiddleware } from '@v2/lib/middleware';
import { getThemeFromMiddleware } from '@v2/lib/middleware';
interface SiteDynamicLayoutProps {
params: Promise<RouteLayoutParams>;
@@ -16,9 +16,8 @@ export default async function SiteDynamicLayout({
params,
children,
}: React.PropsWithChildren<SiteDynamicLayoutProps>) {
const context = await getDynamicSiteContext(await params);
const { context, visitorAuthClaims } = await getDynamicSiteContext(await params);
const forcedTheme = await getThemeFromMiddleware();
const visitorAuthToken = await getVisitorAuthTokenFromMiddleware();
return (
<CustomizationRootLayout customization={context.customization}>
@@ -26,7 +25,7 @@ export default async function SiteDynamicLayout({
context={context}
forcedTheme={forcedTheme}
withTracking={!GITBOOK_DISABLE_TRACKING}
visitorAuthToken={visitorAuthToken}
visitorAuthClaims={visitorAuthClaims}
>
{children}
</SiteLayout>
@@ -35,11 +34,11 @@ export default async function SiteDynamicLayout({
}
export async function generateViewport({ params }: SiteDynamicLayoutProps) {
const context = await getDynamicSiteContext(await params);
const { context } = await getDynamicSiteContext(await params);
return generateSiteLayoutViewport(context);
}
export async function generateMetadata({ params }: SiteDynamicLayoutProps) {
const context = await getDynamicSiteContext(await params);
const { context } = await getDynamicSiteContext(await params);
return generateSiteLayoutMetadata(context);
}
@@ -7,6 +7,6 @@ export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getDynamicSiteContext(await params);
const { context } = await getDynamicSiteContext(await params);
return serveLLMsTxt(context);
}
@@ -7,6 +7,6 @@ export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getDynamicSiteContext(await params);
const { context } = await getDynamicSiteContext(await params);
return serveRobotsTxt(context);
}
@@ -7,6 +7,6 @@ export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getDynamicSiteContext(await params);
const { context } = await getDynamicSiteContext(await params);
return servePagesSitemap(context);
}
@@ -7,6 +7,6 @@ export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getDynamicSiteContext(await params);
const { context } = await getDynamicSiteContext(await params);
return serveRootSitemap(context);
}
@@ -7,6 +7,6 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getDynamicSiteContext(await params);
const { context } = await getDynamicSiteContext(await params);
return serveIcon(context, request);
}
@@ -8,6 +8,6 @@ export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams & PageIdParams> }
) {
const context = await getDynamicSiteContext(await params);
const { context } = await getDynamicSiteContext(await params);
return serveOGImage(context, await params);
}
@@ -6,7 +6,7 @@ export default async function RootLayout(props: {
children: React.ReactNode;
}) {
const { params, children } = props;
const context = await getDynamicSiteContext(await params);
const { context } = await getDynamicSiteContext(await params);
return <PDFRootLayout context={context}>{children}</PDFRootLayout>;
}
@@ -6,7 +6,7 @@ export async function generateMetadata({
}: {
params: Promise<RouteLayoutParams>;
}) {
const context = await getDynamicSiteContext(await params);
const { context } = await getDynamicSiteContext(await params);
return generatePDFMetadata(context);
}
@@ -15,6 +15,6 @@ export default async function Page(props: {
searchParams: Promise<{ [key: string]: string }>;
}) {
const { params, searchParams } = props;
const context = await getDynamicSiteContext(await params);
const { context } = await getDynamicSiteContext(await params);
return <PDFPage context={context} searchParams={await searchParams} />;
}
@@ -18,7 +18,7 @@ export default async function Page(props: PageProps) {
'use cache';
const params = await props.params;
const context = await getStaticSiteContext(params);
const { context } = await getStaticSiteContext(params);
const pathname = getPagePathFromParams(params);
cacheTag(
@@ -32,13 +32,13 @@ export default async function Page(props: PageProps) {
}
export async function generateViewport(props: PageProps): Promise<Viewport> {
const context = await getStaticSiteContext(await props.params);
const { context } = await getStaticSiteContext(await props.params);
return generateSitePageViewport(context);
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const params = await props.params;
const context = await getStaticSiteContext(params);
const { context } = await getStaticSiteContext(params);
const pathname = getPagePathFromParams(params);
return generateSitePageMetadata({
@@ -19,7 +19,7 @@ export default async function SiteStaticLayout({
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
'use cache';
const context = await getStaticSiteContext(await params);
const { context, visitorAuthClaims } = await getStaticSiteContext(await params);
cacheTag(
getCacheTag({
@@ -33,7 +33,7 @@ export default async function SiteStaticLayout({
<SiteLayout
context={context}
withTracking={!GITBOOK_DISABLE_TRACKING}
visitorAuthToken={null}
visitorAuthClaims={visitorAuthClaims}
>
{children}
</SiteLayout>
@@ -42,11 +42,11 @@ export default async function SiteStaticLayout({
}
export async function generateViewport({ params }: SiteStaticLayoutProps) {
const context = await getStaticSiteContext(await params);
const { context } = await getStaticSiteContext(await params);
return generateSiteLayoutViewport(context);
}
export async function generateMetadata({ params }: SiteStaticLayoutProps) {
const context = await getStaticSiteContext(await params);
const { context } = await getStaticSiteContext(await params);
return generateSiteLayoutMetadata(context);
}
@@ -9,6 +9,6 @@ export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getStaticSiteContext(await params);
const { context } = await getStaticSiteContext(await params);
return serveLLMsTxt(context);
}
@@ -9,6 +9,6 @@ export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getStaticSiteContext(await params);
const { context } = await getStaticSiteContext(await params);
return serveRobotsTxt(context);
}
@@ -9,6 +9,6 @@ export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getStaticSiteContext(await params);
const { context } = await getStaticSiteContext(await params);
return servePagesSitemap(context);
}
@@ -9,6 +9,6 @@ export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getStaticSiteContext(await params);
const { context } = await getStaticSiteContext(await params);
return serveRootSitemap(context);
}
@@ -9,6 +9,6 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams> }
) {
const context = await getStaticSiteContext(await params);
const { context } = await getStaticSiteContext(await params);
return serveIcon(context, request);
}
@@ -10,6 +10,6 @@ export async function GET(
_request: NextRequest,
{ params }: { params: Promise<RouteLayoutParams & PageIdParams> }
) {
const context = await getStaticSiteContext(await params);
const { context } = await getStaticSiteContext(await params);
return serveOGImage(context, await params);
}
+41 -15
View File
@@ -1,9 +1,9 @@
import {
fetchSiteContextByURL,
fetchSiteContextByURLLookup,
getBaseContext,
} from '@v2/lib/context';
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
import type { PublishedSiteContent, SiteAPIToken } from '@gitbook/api';
import { fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context';
import { jwtDecode } from 'jwt-decode';
import { forbidden } from 'next/navigation';
import rison from 'rison';
export type RouteParamMode = 'url-host' | 'url';
@@ -12,6 +12,9 @@ export type RouteLayoutParams = {
/** URL encoded site URL */
siteURL: string;
/** URL and Rison encoded site data from getPublishedContentByUrl */
siteData: string;
};
export type RouteParams = RouteLayoutParams & {
@@ -21,19 +24,29 @@ export type RouteParams = RouteLayoutParams & {
/**
* Get the static context when rendering statically a site.
*/
export function getStaticSiteContext(params: RouteLayoutParams) {
export async function getStaticSiteContext(params: RouteLayoutParams) {
const siteURL = getSiteURLFromParams(params);
return fetchSiteContextByURL(
const siteURLData = getSiteURLDataFromParams(params);
// For static routes, we check the expiration of the JWT token
// as the route might be revalidated after expiration
const decoded = jwtDecode<SiteAPIToken & { exp: number }>(siteURLData.apiToken);
if (decoded.exp && decoded.exp < Date.now() / 1000 + 120) {
forbidden();
}
const context = await fetchSiteContextByURLLookup(
getBaseContext({
siteURL,
urlMode: getModeFromParams(params.mode),
}),
{
url: siteURL.toString(),
visitorAuthToken: null,
redirectOnError: false,
}
siteURLData
);
return {
context,
visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded),
};
}
/**
@@ -42,15 +55,20 @@ export function getStaticSiteContext(params: RouteLayoutParams) {
*/
export async function getDynamicSiteContext(params: RouteLayoutParams) {
const siteURL = getSiteURLFromParams(params);
const siteURLData = await getSiteURLDataFromMiddleware();
const siteURLData = getSiteURLDataFromParams(params);
return fetchSiteContextByURLLookup(
const context = await fetchSiteContextByURLLookup(
getBaseContext({
siteURL,
urlMode: getModeFromParams(params.mode),
}),
siteURLData
);
return {
context,
visitorAuthClaims: getVisitorAuthClaims(siteURLData),
};
}
/**
@@ -74,3 +92,11 @@ function getModeFromParams(mode: string): RouteParamMode {
return 'url';
}
/**
* Get the decoded site data from the params.
*/
function getSiteURLDataFromParams(params: RouteLayoutParams): PublishedSiteContent {
const decoded = decodeURIComponent(params.siteData);
return rison.decode(decoded);
}
-23
View File
@@ -168,29 +168,6 @@ export function getLinkerForSiteURL(input: {
return linker;
}
/**
* Fetch the context of a site for a given URL and a base context.
*/
export async function fetchSiteContextByURL(
baseContext: GitBookBaseContext,
input: {
url: string;
visitorAuthToken: string | null;
redirectOnError: boolean;
}
): Promise<GitBookSiteContext> {
const { dataFetcher } = baseContext;
const data = await throwIfDataError(
dataFetcher.getPublishedContentByUrl({
url: input.url,
visitorAuthToken: input.visitorAuthToken,
redirectOnError: input.redirectOnError,
})
);
return fetchSiteContextByURLLookup(baseContext, data);
}
/**
* Fetch the context of a site using the resolution of a URL
*/
+1 -53
View File
@@ -4,11 +4,7 @@ import {
GitBookAPI,
type GitBookAPIServiceBinding,
} from '@gitbook/api';
import {
getCacheTag,
getCacheTagForURL,
getComputedContentSourceCacheTags,
} from '@gitbook/cache-tags';
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
import { wrapDataFetcherError } from './errors';
@@ -167,18 +163,6 @@ export function createDataFetcher(
getUserById(userId) {
return trace('getUserById', () => getUserById({ apiToken: null }, { userId }));
},
getPublishedContentByUrl(params) {
return trace('getPublishedContentByUrl', () =>
getPublishedContentByUrl(
{ apiToken: null },
{
url: params.url,
visitorAuthToken: params.visitorAuthToken,
redirectOnError: params.redirectOnError,
}
)
);
},
};
}
@@ -439,42 +423,6 @@ async function getLatestOpenAPISpecVersionContent(
});
}
async function getPublishedContentByUrl(
input: DataFetcherInput,
params: {
url: string;
visitorAuthToken: string | null;
redirectOnError: boolean;
}
) {
'use cache';
const { url, visitorAuthToken, redirectOnError } = params;
cacheTag(getCacheTagForURL(url));
cacheLife('days');
return wrapDataFetcherError(async () => {
const api = await apiClient(input);
const res = await api.urls.getPublishedContentByUrl({
url,
visitorAuthToken: visitorAuthToken ?? undefined,
redirectOnError,
});
if ('site' in res.data) {
cacheTag(
getCacheTag({
tag: 'site',
site: res.data.site,
})
);
}
return res.data;
});
}
async function getPublishedContentSite(
input: DataFetcherInput,
params: {
+5 -2
View File
@@ -94,12 +94,15 @@ export async function getPublishedContentByURL(input: {
* In both cases, the idea is to use the deepest/longest/most inclusive path to resolve the content.
*/
if (alternative.primary || ('site' in data && data.complete)) {
const changeRequest = data.changeRequest ?? lookup.changeRequest;
const revision = data.revision ?? lookup.revision;
const siteResult: PublishedSiteContentLookup = {
...data,
changeRequest: data.changeRequest ?? lookup.changeRequest,
revision: data.revision ?? lookup.revision,
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
pathname: joinPath(data.pathname, alternative.extraPath),
...(changeRequest ? { changeRequest } : {}),
...(revision ? { revision } : {}),
};
return { data: siteResult };
}
@@ -37,15 +37,6 @@ export interface GitBookDataFetcher {
*/
getUserById(userId: string): Promise<DataFetcherResponse<api.User>>;
/**
* Get a published content by its URL.
*/
getPublishedContentByUrl(params: {
url: string;
visitorAuthToken: string | null;
redirectOnError: boolean;
}): Promise<DataFetcherResponse<api.PublishedSiteContentLookup>>;
/**
* Get a published content site by its organization ID and site ID.
*/
-19
View File
@@ -32,11 +32,6 @@ export enum MiddlewareHeaders {
*/
Customization = 'x-gitbook-customization',
/**
* The visitor token used to access this content
*/
VisitorAuthToken = 'x-gitbook-visitor-token',
/**
* The API token used to fetch the content.
* This should only be passed for non-site dynamic routes.
@@ -105,20 +100,6 @@ export async function getThemeFromMiddleware() {
: CustomizationThemeMode.Dark;
}
/**
* Get the visitor auth token from the middleware headers.
* This function should only be called in a dynamic route.
*/
export async function getVisitorAuthTokenFromMiddleware(): Promise<string | null> {
const headersList = await headers();
const visitorAuthToken = headersList.get(MiddlewareHeaders.VisitorAuthToken);
if (!visitorAuthToken) {
return null;
}
return visitorAuthToken;
}
/**
* Get the API token from the middleware headers.
* This function should only be called in a dynamic route.
+17 -13
View File
@@ -1,6 +1,7 @@
import { CustomizationThemeMode } from '@gitbook/api';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import rison from 'rison';
import { getContentSecurityPolicy } from '@/lib/csp';
import { validateSerializedCustomization } from '@/lib/customization';
@@ -83,7 +84,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
// Detect and extract the visitor authentication token from the request
//
// @ts-ignore - request typing
const visitorToken = getVisitorToken(request, siteURL);
const visitorToken = getVisitorToken({
cookies: request.cookies.getAll(),
url: siteURL,
});
const data = await throwIfDataError(
getPublishedContentByURL({
@@ -95,7 +99,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
redirectOnError: visitorToken?.source === 'visitor-auth-cookie',
})
);
let cookies: ResponseCookies = {};
const cookies: ResponseCookies = [];
//
// Handle redirects
@@ -123,10 +127,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
return NextResponse.redirect(data.redirect);
}
cookies = {
...cookies,
...getResponseCookiesForVisitorAuth(data.basePath, visitorToken),
};
cookies.push(...getResponseCookiesForVisitorAuth(data.siteBasePath, visitorToken));
//
// Make sure the URL is clean of any va token after a successful lookup
@@ -144,8 +145,9 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
// Render and serve the content
//
// When visitor has authentication (adaptive content or VA), we serve dynamic routes.
let routeType = visitorToken ? 'dynamic' : 'static';
// The route is static, except when using dynamic parameters from query params
// (customization override, theme, etc)
let routeType: 'dynamic' | 'static' = 'static';
const requestHeaders = new Headers(request.headers);
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
@@ -184,6 +186,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
routeType,
mode,
encodeURIComponent(siteURLWithoutProtocol),
encodeURIComponent(rison.encode(data)),
pathname,
].join('/');
@@ -234,8 +237,9 @@ async function servePreviewRoutes(requestURL: URL, request: NextRequest) {
const queryAPIToken = requestURL.searchParams.get('token');
if (queryAPIToken) {
requestURL.searchParams.delete('token');
return writeResponseCookies(NextResponse.redirect(requestURL.toString()), {
[cookieName]: {
return writeResponseCookies(NextResponse.redirect(requestURL.toString()), [
{
name: cookieName,
value: queryAPIToken,
options: {
httpOnly: true,
@@ -244,7 +248,7 @@ async function servePreviewRoutes(requestURL: URL, request: NextRequest) {
maxAge: 60 * 60, // 1 hour
},
},
});
]);
}
const apiToken = request.cookies.get(cookieName)?.value;
@@ -371,8 +375,8 @@ function appendQueryParams(url: URL, from: URLSearchParams) {
* Write the cookies to a response.
*/
function writeResponseCookies<R extends NextResponse>(response: R, cookies: ResponseCookies): R {
Object.entries(cookies).forEach(([key, { value, options }]) => {
response.cookies.set(key, value, options);
cookies.forEach((cookie) => {
response.cookies.set(cookie.name, cookie.value, cookie.options);
});
return response;
@@ -1,4 +1,4 @@
import { getThemeFromMiddleware, getVisitorAuthTokenFromMiddleware } from '@v2/lib/middleware';
import { getSiteURLDataFromMiddleware, getThemeFromMiddleware } from '@v2/lib/middleware';
import type { Metadata, Viewport } from 'next';
import type React from 'react';
@@ -7,6 +7,7 @@ import {
generateSiteLayoutMetadata,
generateSiteLayoutViewport,
} from '@/components/SiteLayout';
import { getVisitorAuthClaims } from '@/lib/adaptive';
import { getSiteContentPointer } from '@/lib/pointer';
import { shouldTrackEvents } from '@/lib/tracking';
import { fetchV1ContextForSitePointer } from '@/lib/v1';
@@ -22,14 +23,14 @@ export default async function ContentLayout(props: { children: React.ReactNode }
const context = await fetchLayoutData();
const queryStringTheme = await getThemeFromMiddleware();
const visitorAuthToken = await getVisitorAuthTokenFromMiddleware();
const siteData = await getSiteURLDataFromMiddleware();
return (
<SiteLayout
context={context}
forcedTheme={queryStringTheme}
withTracking={await shouldTrackEvents()}
visitorAuthToken={visitorAuthToken}
visitorAuthClaims={getVisitorAuthClaims(siteData)}
>
{children}
</SiteLayout>
@@ -5,8 +5,8 @@ import { OpenAPIOperationContextProvider } from '@gitbook/react-openapi';
import * as React from 'react';
import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
import * as cookies from '@/lib/cookies';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { getAllBrowserCookiesMap } from '@/lib/browser-cookies';
import { getSession } from './sessions';
import { getVisitorId } from './visitorId';
@@ -23,6 +23,7 @@ type InsightsEventContext = {
siteShareKey: string | null;
spaceId: string;
revisionId: string;
visitorAuthClaims: VisitorAuthClaims;
};
/**
@@ -65,7 +66,6 @@ interface InsightsProviderProps extends InsightsEventContext {
enabled: boolean;
appURL: string;
apiHost: string;
visitorAuthToken: string | null;
children: React.ReactNode;
}
@@ -73,7 +73,7 @@ interface InsightsProviderProps extends InsightsEventContext {
* Wrap the content of the app with the InsightsProvider to track events.
*/
export function InsightsProvider(props: InsightsProviderProps) {
const { enabled, appURL, apiHost, visitorAuthToken, children, ...context } = props;
const { enabled, appURL, apiHost, children, ...context } = props;
const visitorIdRef = React.useRef<string | null>(null);
const eventsRef = React.useRef<{
@@ -116,7 +116,6 @@ export function InsightsProvider(props: InsightsProviderProps) {
pageContext: eventsForPathname.pageContext,
visitorId,
sessionId: session.id,
visitorAuthToken,
})
);
@@ -253,16 +252,15 @@ function transformEvents(input: {
pageContext: InsightsEventPageContext;
visitorId: string;
sessionId: string;
visitorAuthToken: string | null;
}): api.SiteInsightsEvent[] {
const session: api.SiteInsightsEventSession = {
sessionId: input.sessionId,
visitorId: input.visitorId,
userAgent: window.navigator.userAgent,
language: window.navigator.language,
cookies: cookies.getAll(),
cookies: getAllBrowserCookiesMap(),
referrer: document.referrer || null,
visitorAuthToken: input.visitorAuthToken ?? null,
visitorAuthClaims: input.context.visitorAuthClaims,
};
const location: api.SiteInsightsEventLocation = {
@@ -1,6 +1,6 @@
'use client';
import * as cookies from '@/lib/cookies';
import { getBrowserCookie, setBrowserCookie } from '@/lib/browser-cookies';
const GRANTED_COOKIE = '__gitbook_cookie_granted';
@@ -8,7 +8,7 @@ const GRANTED_COOKIE = '__gitbook_cookie_granted';
* Accept or reject cookies.
*/
export function setCookiesTracking(enabled: boolean) {
cookies.set(GRANTED_COOKIE, enabled ? 'yes' : 'no', {
setBrowserCookie(GRANTED_COOKIE, enabled ? 'yes' : 'no', {
expires: 365,
sameSite: 'none',
secure: true,
@@ -20,7 +20,7 @@ export function setCookiesTracking(enabled: boolean) {
* Return `undefined` if state is not known.
*/
export function isCookiesTrackingDisabled() {
const state = cookies.get(GRANTED_COOKIE);
const state = getBrowserCookie(GRANTED_COOKIE);
if (state === 'yes') {
return false;
@@ -1,6 +1,6 @@
'use client';
import * as cookies from '@/lib/cookies';
import { getBrowserCookie } from '@/lib/browser-cookies';
import { isCookiesTrackingDisabled } from './cookies';
import { generateRandomId } from './utils';
@@ -37,7 +37,7 @@ async function fetchVisitorID(appURL: string): Promise<string> {
return generateRandomId();
}
const existingTrackingCookie = cookies.get(VISITORID_COOKIE);
const existingTrackingCookie = getBrowserCookie(VISITORID_COOKIE);
if (existingTrackingCookie) {
// If the cookie already exists, we'll just use that. Avoids a server request.
@@ -12,6 +12,7 @@ import { SpaceLayout } from '@/components/SpaceLayout';
import { buildVersion } from '@/lib/build';
import { isSiteIndexable } from '@/lib/seo';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { GITBOOK_API_PUBLIC_URL, GITBOOK_ASSETS_URL, GITBOOK_ICONS_URL } from '@v2/lib/env';
import { getResizedImageURL } from '@v2/lib/images';
import { ClientContexts } from './ClientContexts';
@@ -25,10 +26,10 @@ export async function SiteLayout(props: {
context: GitBookSiteContext;
forcedTheme?: CustomizationThemeMode | null;
withTracking: boolean;
visitorAuthToken: string | null;
visitorAuthClaims: VisitorAuthClaims;
children: React.ReactNode;
}) {
const { context, nonce, forcedTheme, withTracking, visitorAuthToken, children } = props;
const { context, nonce, forcedTheme, withTracking, visitorAuthClaims, children } = props;
const { scripts, customization } = context;
@@ -57,7 +58,7 @@ export async function SiteLayout(props: {
<SpaceLayout
context={context}
withTracking={withTracking}
visitorAuthToken={visitorAuthToken}
visitorAuthClaims={visitorAuthClaims}
>
{children}
</SpaceLayout>
@@ -11,6 +11,7 @@ import { getSpaceLanguage } from '@/intl/server';
import { t } from '@/intl/translate';
import { tcls } from '@/lib/tailwind';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@v2/lib/env';
import { SpacesDropdown } from '../Header/SpacesDropdown';
import { InsightsProvider } from '../Insights';
@@ -26,13 +27,13 @@ export function SpaceLayout(props: {
/** Whether to enable tracking of events into site insights. */
withTracking: boolean;
/** The visitor token used to access this content */
visitorAuthToken: string | null;
/** The visitor auth claims. */
visitorAuthClaims: VisitorAuthClaims;
/** The children of the layout. */
children: React.ReactNode;
}) {
const { context, withTracking, visitorAuthToken, children } = props;
const { context, withTracking, visitorAuthClaims, children } = props;
const { siteSpace, customization, sections, siteSpaces } = context;
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
@@ -52,7 +53,6 @@ export function SpaceLayout(props: {
enabled={withTracking}
appURL={GITBOOK_APP_URL}
apiHost={GITBOOK_API_PUBLIC_URL}
visitorAuthToken={visitorAuthToken}
organizationId={context.organizationId}
siteId={context.site.id}
siteSectionId={context.sections?.current?.id ?? null}
@@ -60,6 +60,7 @@ export function SpaceLayout(props: {
siteShareKey={context.shareKey ?? null}
revisionId={context.revisionId}
spaceId={context.space.id}
visitorAuthClaims={visitorAuthClaims}
>
<Header withTopHeader={withTopHeader} context={context} />
<div className="scroll-nojump">
+23
View File
@@ -0,0 +1,23 @@
import type { PublishedSiteContent, SiteAPIToken } from '@gitbook/api';
import { jwtDecode } from 'jwt-decode';
/**
* Claims about the visitor, stored in the VA and auth token.
*/
export type VisitorAuthClaims = Record<string, any>;
/**
* Get the visitor auth claims from the API response obtained from `getPublishedContentByUrl`.
*/
export function getVisitorAuthClaims(siteData: PublishedSiteContent): VisitorAuthClaims {
const { apiToken } = siteData;
return getVisitorAuthClaimsFromToken(jwtDecode<SiteAPIToken>(apiToken));
}
/**
* Get the visitor auth claims from a decoded API token.
*/
export function getVisitorAuthClaimsFromToken(token: SiteAPIToken): VisitorAuthClaims {
return token.claims ?? {};
}
@@ -0,0 +1,56 @@
'use client';
import cookies from 'js-cookie';
import { checkIsSecurityError } from './security-error';
/**
* Get all cookies.
* @returns A map of cookie names to their values.
*/
export function getAllBrowserCookies(): Array<{
name: string;
value: string;
}> {
try {
const all = cookies.get();
return Object.entries(all).map(([name, value]) => ({ name, value }));
} catch (error) {
if (checkIsSecurityError(error)) {
return [];
}
throw error;
}
}
/**
* Get a map of cookie names to their values.
* @returns A map of cookie names to their values.
*/
export function getAllBrowserCookiesMap(): Record<string, string> {
return Object.fromEntries(getAllBrowserCookies().map(({ name, value }) => [name, value]));
}
/**
* Get a cookie by name.
* @param name - The name of the cookie to get.
* @returns The value of the cookie or undefined if the cookie does not exist.
*/
export function getBrowserCookie(name: string): string | undefined {
try {
return cookies.get(name);
} catch (error) {
if (checkIsSecurityError(error)) {
return undefined;
}
throw error;
}
}
/**
* Set a cookie.
* @param name - The name of the cookie to set.
* @param value - The value of the cookie to set.
* @param options - The options for the cookie to set.
*/
export const setBrowserCookie = cookies.set;
-29
View File
@@ -1,29 +0,0 @@
import cookies from 'js-cookie';
import { checkIsSecurityError } from './security-error';
export function getAll(): {
[key: string]: string;
} {
try {
return cookies.get();
} catch (error) {
if (checkIsSecurityError(error)) {
return {};
}
throw error;
}
}
export function get(name: string): string | undefined {
try {
return cookies.get(name);
} catch (error) {
if (checkIsSecurityError(error)) {
return undefined;
}
throw error;
}
}
export const set = cookies.set;
-16
View File
@@ -18,7 +18,6 @@ import {
getDocument,
getEmbedByUrlInSpace,
getLatestOpenAPISpecVersionContent,
getPublishedContentByUrl,
getPublishedContentSite,
getReusableContent,
getRevision,
@@ -73,11 +72,7 @@ export async function getV1BaseContext(): Promise<GitBookBaseContext> {
* This data fetcher should only be used at the top of the tree.
*/
async function getDataFetcherV1(): Promise<GitBookDataFetcher> {
const apiClient = await api();
const dataFetcher: GitBookDataFetcher = {
apiEndpoint: apiClient.client.endpoint,
async api() {
const result = await api();
return result.client;
@@ -100,17 +95,6 @@ async function getDataFetcherV1(): Promise<GitBookDataFetcher> {
});
},
// @ts-ignore - types are compatible enough, and this will not be called in v1 this way
getPublishedContentByUrl(params) {
return wrapDataFetcherError(async () => {
return getPublishedContentByUrl(
params.url,
params.visitorAuthToken ?? undefined,
params.redirectOnError ? true : undefined
);
});
},
getPublishedContentSite(params) {
return wrapDataFetcherError(async () => {
return getPublishedContentSite(params);
+57 -41
View File
@@ -1,5 +1,4 @@
import { describe, expect, it } from 'bun:test';
import type { NextRequest } from 'next/server';
import type { JwtPayload } from 'jwt-decode';
import {
@@ -11,66 +10,94 @@ import {
describe('getVisitorAuthToken', () => {
it('should return the token from the query parameters', () => {
const request = nextRequest('https://example.com?jwt_token=123');
expect(getVisitorToken(request, request.nextUrl)).toEqual({ source: 'url', token: '123' });
expect(
getVisitorToken({ cookies: [], url: new URL('https://example.com?jwt_token=123') })
).toEqual({ source: 'url', token: '123' });
});
it('should return the token from the cookie root basepath', () => {
const request = nextRequest('https://example.com', {
[getVisitorAuthCookieName('/')]: { value: getVisitorAuthCookieValue('/', '123') },
const visitorAuth = getVisitorToken({
cookies: [
{
name: getVisitorAuthCookieName('/'),
value: getVisitorAuthCookieValue('/', '123'),
},
],
url: new URL('https://example.com'),
});
const visitorAuth = getVisitorToken(request, request.nextUrl);
assertVisitorAuthCookieValue(visitorAuth);
expect(visitorAuth.token).toEqual('123');
});
it('should return the token from the cookie root basepath for a sub-path', () => {
const request = nextRequest('https://example.com/hello/world', {
[getVisitorAuthCookieName('/')]: { value: getVisitorAuthCookieValue('/', '123') },
const visitorAuth = getVisitorToken({
cookies: [
{
name: getVisitorAuthCookieName('/'),
value: getVisitorAuthCookieValue('/', '123'),
},
],
url: new URL('https://example.com/hello/world'),
});
const visitorAuth = getVisitorToken(request, request.nextUrl);
assertVisitorAuthCookieValue(visitorAuth);
expect(visitorAuth.token).toEqual('123');
});
it('should return the closest token from the path', () => {
const request = nextRequest('https://example.com/hello/world', {
[getVisitorAuthCookieName('/')]: { value: getVisitorAuthCookieValue('/', 'no') },
[getVisitorAuthCookieName('/hello/')]: {
value: getVisitorAuthCookieValue('/hello/', '123'),
},
const visitorAuth = getVisitorToken({
cookies: [
{
name: getVisitorAuthCookieName('/'),
value: getVisitorAuthCookieValue('/', 'no'),
},
{
name: getVisitorAuthCookieName('/hello/'),
value: getVisitorAuthCookieValue('/hello/', '123'),
},
],
url: new URL('https://example.com/hello/world'),
});
const visitorAuth = getVisitorToken(request, request.nextUrl);
assertVisitorAuthCookieValue(visitorAuth);
expect(visitorAuth.token).toEqual('123');
});
it('should return the token from the cookie in a collection type url', () => {
const request = nextRequest('https://example.com/hello/v/space1/cool', {
[getVisitorAuthCookieName('/hello/v/space1/')]: {
value: getVisitorAuthCookieValue('/hello/v/space1/', '123'),
},
const visitorAuth = getVisitorToken({
cookies: [
{
name: getVisitorAuthCookieName('/hello/v/space1/'),
value: getVisitorAuthCookieValue('/hello/v/space1/', '123'),
},
],
url: new URL('https://example.com/hello/v/space1/cool'),
});
const visitorAuth = getVisitorToken(request, request.nextUrl);
assertVisitorAuthCookieValue(visitorAuth);
expect(visitorAuth.token).toEqual('123');
});
it('should return undefined if no cookie and no query param', () => {
const request = nextRequest('https://example.com');
expect(getVisitorToken(request, request.nextUrl)).toBeUndefined();
expect(
getVisitorToken({
cookies: [],
url: new URL('https://example.com'),
})
).toBeUndefined();
});
// For backwards compatibility
it('should return the token from the cookie of a /v/ path when the url does not have /v/', () => {
const request = nextRequest('https://example.com/hello/space1/cool', {
[getVisitorAuthCookieName('/')]: { value: getVisitorAuthCookieValue('/', 'no') },
[getVisitorAuthCookieName('/hello/v/space1/')]: {
value: getVisitorAuthCookieValue('/hello/v/space1/', 'gotcha'),
},
const visitorAuth = getVisitorToken({
cookies: [
{
name: getVisitorAuthCookieName('/'),
value: getVisitorAuthCookieValue('/', 'no'),
},
{
name: getVisitorAuthCookieName('/hello/v/space1/'),
value: getVisitorAuthCookieValue('/hello/v/space1/', 'gotcha'),
},
],
url: new URL('https://example.com/hello/space1/cool'),
});
const visitorAuth = getVisitorToken(request, request.nextUrl);
assertVisitorAuthCookieValue(visitorAuth);
expect(visitorAuth.token).toEqual('gotcha');
});
@@ -131,14 +158,3 @@ function assertVisitorAuthCookieValue(
throw new Error('Expected a VisitorAuthCookieValue');
}
function nextRequest(url: string, cookies: Record<string, { value: string }> = {}) {
const nextUrl = new URL(url);
// @ts-ignore
return {
url: nextUrl.toString(),
nextUrl,
headers: new Headers(),
cookies: Object.entries(cookies),
} as NextRequest;
}
+56 -51
View File
@@ -5,7 +5,11 @@ import hash from 'object-hash';
const VISITOR_AUTH_PARAM = 'jwt_token';
export const VISITOR_TOKEN_COOKIE = 'gitbook-visitor-token';
/**
* Typing for a cookie, matching the internal type of Next.js.
*/
export type ResponseCookie = {
name: string;
value: string;
options?: Partial<{
httpOnly: boolean;
@@ -15,7 +19,8 @@ export type ResponseCookie = {
}>;
};
export type ResponseCookies = Record<string, ResponseCookie>;
export type ResponseCookies = ResponseCookie[];
export type RequestCookies = ResponseCookies;
/**
* The contents of the visitor authentication cookie.
@@ -52,10 +57,13 @@ export type VisitorTokenLookup =
* Get the visitor token for the request. This token can either be in the
* query parameters or stored as a cookie.
*/
export function getVisitorToken(
request: NextRequest,
url: URL | NextRequest['nextUrl']
): VisitorTokenLookup {
export function getVisitorToken({
cookies,
url,
}: {
cookies: RequestCookies;
url: URL | NextRequest['nextUrl'];
}): VisitorTokenLookup {
const fromUrl = url.searchParams.get(VISITOR_AUTH_PARAM);
// Allow the empty string to come through
@@ -63,27 +71,26 @@ export function getVisitorToken(
return { source: 'url', token: fromUrl };
}
const visitorAuthToken = getVisitorAuthTokenFromCookies(request, url);
const visitorAuthToken = getVisitorAuthTokenFromCookies(cookies, url);
if (visitorAuthToken) {
return { source: 'visitor-auth-cookie', ...visitorAuthToken };
}
const visitorCustomToken = getVisitorCustomTokenFromCookies(request);
const visitorCustomToken = getVisitorCustomTokenFromCookies(cookies);
if (visitorCustomToken) {
return { source: 'gitbook-visitor-cookie', token: visitorCustomToken };
}
}
/**
* Return the lookup result for content served with visitor auth. It basically disables caching
* and sets a cookie with the visitor auth token.
* Return the lookup result for content served with visitor auth.
*/
export function getResponseCookiesForVisitorAuth(
basePath: string,
visitorTokenLookup: VisitorTokenLookup
): ResponseCookies {
if (!visitorTokenLookup) {
return {};
return [];
}
let decoded: JwtPayload;
@@ -91,32 +98,35 @@ export function getResponseCookiesForVisitorAuth(
decoded = jwtDecode(visitorTokenLookup.token);
} catch (error) {
console.error('Error decoding visitor token', error);
return {};
return [];
}
return {
/**
* If the visitor token has been retrieve from the URL, or if its a VA cookie and the basePath is the same, set it
* as a cookie on the response.
*
* Note that we do not re-store the gitbook-visitor-cookie in another cookie, to maintain a single source of truth.
*/
...(visitorTokenLookup?.source === 'url' ||
/**
* If the visitor token has been retrieve from the URL, or if its a VA cookie and the basePath is the same, set it
* as a cookie on the response.
*
* Note that we do not re-store the gitbook-visitor-cookie in another cookie, to maintain a single source of truth.
*/
if (
visitorTokenLookup?.source === 'url' ||
(visitorTokenLookup?.source === 'visitor-auth-cookie' &&
visitorTokenLookup.basePath === basePath)
? {
[getVisitorAuthCookieName(basePath)]: {
value: getVisitorAuthCookieValue(basePath, visitorTokenLookup.token),
options: {
httpOnly: true,
sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined,
secure: process.env.NODE_ENV === 'production',
maxAge: getVisitorAuthCookieMaxAge(decoded),
},
},
}
: {}),
};
) {
return [
{
name: getVisitorAuthCookieName(basePath),
value: getVisitorAuthCookieValue(basePath, visitorTokenLookup.token),
options: {
httpOnly: true,
sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined,
secure: process.env.NODE_ENV === 'production',
maxAge: getVisitorAuthCookieMaxAge(decoded),
},
},
];
}
return [];
}
/**
@@ -180,7 +190,7 @@ function getUrlBasePathCombinations(url: URL | NextRequest['nextUrl']): string[]
* best possible match for the current URL.
*/
function getVisitorAuthTokenFromCookies(
request: NextRequest,
cookies: RequestCookies,
url: URL | NextRequest['nextUrl']
): VisitorAuthCookieValue | undefined {
const urlBasePaths = getUrlBasePathCombinations(url);
@@ -188,7 +198,7 @@ function getVisitorAuthTokenFromCookies(
// for the content could be hosted on a base path like `/foo/v/bar` or `/foo` or just `/`
// We keep trying to find with each of these base paths until we find a token.
for (const basePath of urlBasePaths) {
const found = findVisitorAuthCookieForBasePath(request, basePath);
const found = findVisitorAuthCookieForBasePath(cookies, basePath);
if (found) {
return found;
}
@@ -205,32 +215,27 @@ function getVisitorAuthTokenFromCookies(
*
* The cookie should contain as value a JWT encoded token that contains the claims of the visitor.
*/
function getVisitorCustomTokenFromCookies(request: NextRequest): string | undefined {
const visitorCustomCookie = Array.from(request.cookies).find(
([, cookie]) => cookie.name === VISITOR_TOKEN_COOKIE
);
return visitorCustomCookie ? visitorCustomCookie[1].value : undefined;
function getVisitorCustomTokenFromCookies(cookies: RequestCookies): string | undefined {
const visitorCustomCookie = cookies.find((cookie) => cookie.name === VISITOR_TOKEN_COOKIE);
return visitorCustomCookie ? visitorCustomCookie.value : undefined;
}
/**
* Loop through all cookies and find the visitor authentication token for a given base path.
*/
function findVisitorAuthCookieForBasePath(
request: NextRequest,
cookies: RequestCookies,
basePath: string
): VisitorAuthCookieValue | undefined {
return Array.from(request.cookies).reduce<VisitorAuthCookieValue | undefined>(
(acc, [name, cookie]) => {
if (name === getVisitorAuthCookieName(basePath)) {
const value = JSON.parse(cookie.value) as VisitorAuthCookieValue;
if (value.basePath === basePath) {
acc = value;
}
return cookies.reduce<VisitorAuthCookieValue | undefined>((acc, cookie) => {
if (cookie.name === getVisitorAuthCookieName(basePath)) {
const value = JSON.parse(cookie.value) as VisitorAuthCookieValue;
if (value.basePath === basePath) {
acc = value;
}
return acc;
},
undefined
);
}
return acc;
}, undefined);
}
/**
+15 -22
View File
@@ -2,7 +2,6 @@ import { type ContentAPITokenPayload, CustomizationThemeMode, GitBookAPI } from
import { getURLLookupAlternatives, normalizeURL } from '@v2/lib/data';
import assertNever from 'assert-never';
import jwt from 'jsonwebtoken';
import type { ResponseCookie } from 'next/dist/compiled/@edge-runtime/cookies';
import { type NextRequest, NextResponse } from 'next/server';
import hash from 'object-hash';
@@ -183,10 +182,6 @@ export async function middleware(request: NextRequest) {
headers.set(MiddlewareHeaders.SiteURLData, JSON.stringify(resolved));
}
if (resolved.visitorToken) {
headers.set(MiddlewareHeaders.VisitorAuthToken, resolved.visitorToken);
}
// For tests, we make it possible to enable search indexation
// using a query parameter.
const xGitBookSearchIndexation =
@@ -395,7 +390,10 @@ async function lookupSiteInProxy(request: NextRequest, url: URL): Promise<Lookup
* When serving multi spaces based on the current URL.
*/
async function lookupSiteInMultiMode(request: NextRequest, url: URL): Promise<LookupResult> {
const visitorAuthToken = getVisitorToken(request, url);
const visitorAuthToken = getVisitorToken({
cookies: request.cookies.getAll(),
url,
});
const lookup = await lookupSiteByAPI(url, visitorAuthToken);
return {
...lookup,
@@ -525,8 +523,9 @@ async function lookupSiteOrSpaceInMultiIdMode(
);
}
const cookies: ResponseCookies = {
[cookieName]: {
const cookies: ResponseCookies = [
{
name: cookieName,
value: encodeGitBookTokenCookie(source.id, apiToken, apiEndpoint),
options: {
httpOnly: true,
@@ -535,7 +534,7 @@ async function lookupSiteOrSpaceInMultiIdMode(
sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined,
},
},
};
];
// Get rid of the token from the URL
if (url.searchParams.has(AUTH_TOKEN_QUERY) || url.searchParams.has(API_ENDPOINT_QUERY)) {
@@ -607,7 +606,10 @@ async function lookupSiteInMultiPathMode(request: NextRequest, url: URL): Promis
const target = new URL(targetStr);
target.search = url.search;
const visitorAuthToken = getVisitorToken(request, target);
const visitorAuthToken = getVisitorToken({
cookies: request.cookies.getAll(),
url: target,
});
const lookup = await lookupSiteByAPI(target, visitorAuthToken);
if ('error' in lookup) {
@@ -859,18 +861,9 @@ function encodeGitBookTokenCookie(
return JSON.stringify({ s: spaceId, t: token, e: apiEndpoint });
}
function writeCookies<R extends NextResponse>(
response: R,
cookies: Record<
string,
{
value: string;
options?: Partial<ResponseCookie>;
}
> = {}
): R {
Object.entries(cookies).forEach(([key, { value, options }]) => {
response.cookies.set(key, value, options);
function writeCookies<R extends NextResponse>(response: R, cookies: ResponseCookies = []): R {
cookies.forEach((cookie) => {
response.cookies.set(cookie.name, cookie.value, cookie.options);
});
return response;