Compare commits

..

3 Commits

Author SHA1 Message Date
Samy Pessé 295a7fab34 Changeset 2025-09-14 18:04:52 +02:00
Samy Pessé 3a23005020 Fix it 2025-09-14 12:58:12 +02:00
Samy Pessé 0b64e40d9f Expose hook useVisitorSession 2025-09-14 12:39:32 +02:00
5 changed files with 160 additions and 109 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Expose a hook useVisitorSession to access the current user/visitor
@@ -8,7 +8,7 @@ import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
import { getAllBrowserCookiesMap } from '@/lib/browser'; import { getAllBrowserCookiesMap } from '@/lib/browser';
import { type CurrentContentContext, useCurrentContent } from '../hooks'; import { type CurrentContentContext, useCurrentContent } from '../hooks';
import { getSession } from './sessions'; import { getSession } from './sessions';
import { getVisitorId } from './visitorId'; import { type SessionResponse, useVisitorSession } from './visitorId';
export type InsightsEventName = api.SiteInsightsEvent['type']; export type InsightsEventName = api.SiteInsightsEvent['type'];
@@ -55,12 +55,6 @@ interface InsightsProviderProps {
/** If true, the events will be sent to the server. */ /** If true, the events will be sent to the server. */
enabled: boolean; enabled: boolean;
/** If true, the visitor cookie tracking will be used */
visitorCookieTrackingEnabled: boolean;
/** The application URL. */
appURL: string;
/** The url of the endpoint to send events to */ /** The url of the endpoint to send events to */
eventUrl: string; eventUrl: string;
@@ -72,10 +66,10 @@ interface InsightsProviderProps {
* Wrap the content of the app with the InsightsProvider to track events. * Wrap the content of the app with the InsightsProvider to track events.
*/ */
export function InsightsProvider(props: InsightsProviderProps) { export function InsightsProvider(props: InsightsProviderProps) {
const { enabled, children, visitorCookieTrackingEnabled, eventUrl, appURL } = props; const { enabled, children, eventUrl } = props;
const visitorSession = useVisitorSession();
const currentContent = useCurrentContent(); const currentContent = useCurrentContent();
const visitorIdRef = React.useRef<string | null>(null);
const eventsRef = React.useRef<{ const eventsRef = React.useRef<{
[pathname: string]: [pathname: string]:
| { | {
@@ -92,9 +86,8 @@ export function InsightsProvider(props: InsightsProviderProps) {
*/ */
const flushEventsSync = useEventCallback(() => { const flushEventsSync = useEventCallback(() => {
const session = getSession(); const session = getSession();
const visitorId = visitorIdRef.current; if (!visitorSession) {
if (!visitorId) { return;
throw new Error('Visitor ID should be set before flushing events');
} }
const allEvents: api.SiteInsightsEvent[] = []; const allEvents: api.SiteInsightsEvent[] = [];
@@ -114,7 +107,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
events: eventsForPathname.events, events: eventsForPathname.events,
context: currentContent, context: currentContent,
pageContext: eventsForPathname.pageContext, pageContext: eventsForPathname.pageContext,
visitorId, visitorSession,
sessionId: session.id, sessionId: session.id,
}) })
); );
@@ -137,14 +130,17 @@ export function InsightsProvider(props: InsightsProviderProps) {
} }
}); });
const flushBatchedEvents = useDebounceCallback(async () => { const flushBatchedEvents = useDebounceCallback(() => {
const visitorId =
visitorIdRef.current ?? (await getVisitorId(appURL, visitorCookieTrackingEnabled));
visitorIdRef.current = visitorId;
flushEventsSync(); flushEventsSync();
}, 1500); }, 1500);
// Flush events once the visitor session is set
React.useEffect(() => {
if (visitorSession) {
flushEventsSync();
}
}, [visitorSession, flushEventsSync]);
const trackEvent: TrackEventCallback = useEventCallback( const trackEvent: TrackEventCallback = useEventCallback(
( (
event: TrackEventInput<InsightsEventName>, event: TrackEventInput<InsightsEventName>,
@@ -169,7 +165,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
if (eventsRef.current[pathname].pageContext !== undefined) { if (eventsRef.current[pathname].pageContext !== undefined) {
// If the pageId is set, we know that the page_view event has been tracked // If the pageId is set, we know that the page_view event has been tracked
// and we can flush the events // and we can flush the events
if (options?.immediate && visitorIdRef.current) { if (options?.immediate) {
flushBatchedEvents.cancel(); flushBatchedEvents.cancel();
flushEventsSync(); flushEventsSync();
} else { } else {
@@ -180,18 +176,14 @@ export function InsightsProvider(props: InsightsProviderProps) {
); );
/** /**
* Get the visitor ID and store it in a ref. * When the page is unloaded, flush all events.
*/ */
React.useEffect(() => { React.useEffect(() => {
getVisitorId(appURL, visitorCookieTrackingEnabled).then((visitorId) => { window.addEventListener('beforeunload', flushEventsSync);
visitorIdRef.current = visitorId;
// When the page is unloaded, flush all events, but only if the visitor ID is set
window.addEventListener('beforeunload', flushEventsSync);
});
return () => { return () => {
window.removeEventListener('beforeunload', flushEventsSync); window.removeEventListener('beforeunload', flushEventsSync);
}; };
}, [flushEventsSync, visitorCookieTrackingEnabled, appURL]); }, [flushEventsSync]);
return ( return (
<InsightsContext.Provider value={trackEvent}> <InsightsContext.Provider value={trackEvent}>
@@ -245,12 +237,12 @@ function transformEvents(input: {
events: TrackEventInput<InsightsEventName>[]; events: TrackEventInput<InsightsEventName>[];
context: CurrentContentContext; context: CurrentContentContext;
pageContext: InsightsEventPageContext; pageContext: InsightsEventPageContext;
visitorId: string; visitorSession: SessionResponse;
sessionId: string; sessionId: string;
}): api.SiteInsightsEvent[] { }): api.SiteInsightsEvent[] {
const session: api.SiteInsightsEventSession = { const session: api.SiteInsightsEventSession = {
sessionId: input.sessionId, sessionId: input.sessionId,
visitorId: input.visitorId, visitorId: input.visitorSession.deviceId,
userAgent: window.navigator.userAgent, userAgent: window.navigator.userAgent,
language: window.navigator.language, language: window.navigator.language,
cookies: getAllBrowserCookiesMap(), cookies: getAllBrowserCookiesMap(),
@@ -1,73 +0,0 @@
'use client';
import { getBrowserCookie } from '@/lib/browser';
import { isCookiesTrackingDisabled } from './cookies';
import { generateRandomId } from './utils';
const VISITORID_COOKIE = '__session';
let visitorId: string | null = null;
let pendingVisitorId: Promise<string> | null = null;
/**
* Return the current visitor identifier.
*/
export async function getVisitorId(
appURL: string,
visitorCookieTrackingEnabled: boolean
): Promise<string> {
if (!visitorId) {
if (!pendingVisitorId) {
pendingVisitorId = fetchVisitorID(appURL, visitorCookieTrackingEnabled).finally(() => {
pendingVisitorId = null;
});
}
visitorId = await pendingVisitorId;
}
return visitorId;
}
/**
* Propose a visitor identifier to the GitBook.com server and get the devideId back.
*/
async function fetchVisitorID(
appURL: string,
visitorCookieTrackingEnabled: boolean
): Promise<string> {
const withoutCookies = isCookiesTrackingDisabled();
if (withoutCookies || !visitorCookieTrackingEnabled) {
return generateRandomId();
}
const existingTrackingCookie = getBrowserCookie(VISITORID_COOKIE);
if (existingTrackingCookie) {
// If the cookie already exists, we'll just use that. Avoids a server request.
return existingTrackingCookie;
}
// No tracking deviceId set, we'll need to consolidate with the server.
const proposed = generateRandomId();
const url = new URL(appURL);
url.pathname = '/__session/2/';
url.searchParams.set('proposed', proposed);
try {
const resp = await fetch(url, {
method: 'GET', // Use GET to play nicely with SameSite cookies.
credentials: 'include', // Make sure to send/receive cookies.
cache: 'no-cache',
mode: 'cors', // Need to use cors as we are on a different domain.
});
const { deviceId } = (await resp.json()) as { deviceId: string };
return deviceId;
} catch (error) {
console.error('Failed to fetch visitor session ID', error);
return proposed;
}
}
@@ -0,0 +1,127 @@
'use client';
import { createStore, useStore } from 'zustand';
import { getBrowserCookie } from '@/lib/browser';
import React from 'react';
import { isCookiesTrackingDisabled } from './cookies';
import { generateRandomId } from './utils';
const VISITORID_COOKIE = '__session';
type SessionVisitorResponse = {
deviceId: string;
};
type SessionUserResponse = SessionVisitorResponse & {
userId: string;
organizationId: string;
};
export type SessionResponse = SessionVisitorResponse | SessionUserResponse;
const visitorSessionStore = createStore<{
session: SessionResponse | null;
pendingSession: Promise<SessionResponse> | null;
}>(() => ({
session: null,
pendingSession: null,
}));
/**
* Fetch and provide the visitor session.
*/
export function VisitorSessionProvider(
props: React.PropsWithChildren<{
appURL: string;
visitorCookieTrackingEnabled: boolean;
}>
) {
const { appURL, visitorCookieTrackingEnabled, children } = props;
React.useEffect(() => {
const state = visitorSessionStore.getState();
if (state.pendingSession || state.session) {
return;
}
const pendingSession = fetchSession({ appURL, visitorCookieTrackingEnabled });
visitorSessionStore.setState({ pendingSession, session: null });
pendingSession.then((session) => {
visitorSessionStore.setState({ pendingSession: null, session });
});
}, [appURL, visitorCookieTrackingEnabled]);
return <>{children}</>;
}
/**
* Hook to get the current visitor session.
*/
export function useVisitorSession() {
return useStore(visitorSessionStore, (state) => state.session);
}
/**
* Propose a visitor identifier to the GitBook.com server and get the devideId back.
*/
async function fetchSession({
appURL,
visitorCookieTrackingEnabled,
}: {
appURL: string;
visitorCookieTrackingEnabled: boolean;
}): Promise<SessionResponse> {
const withoutCookies = isCookiesTrackingDisabled();
if (withoutCookies || !visitorCookieTrackingEnabled) {
return {
deviceId: generateRandomId(),
};
}
const existingTrackingCookie = getSessionCookie();
if (existingTrackingCookie) {
// If the cookie already exists, we'll just use that. Avoids a server request.
return existingTrackingCookie;
}
// No tracking deviceId set, we'll need to consolidate with the server.
const proposed = generateRandomId();
const url = new URL(appURL);
url.pathname = '/__session/2/';
url.searchParams.set('proposed', proposed);
try {
const resp = await fetch(url, {
method: 'GET', // Use GET to play nicely with SameSite cookies.
credentials: 'include', // Make sure to send/receive cookies.
cache: 'no-cache',
mode: 'cors', // Need to use cors as we are on a different domain.
});
const session = (await resp.json()) as SessionResponse;
return session;
} catch (error) {
console.error('Failed to fetch visitor session ID', error);
return {
deviceId: proposed,
};
}
}
function getSessionCookie(): SessionResponse | null {
const value = getBrowserCookie(VISITORID_COOKIE);
if (!(value && typeof value === 'string')) {
return null;
}
try {
const parsed = JSON.parse(value) as SessionResponse;
return parsed;
} catch {
return { deviceId: value };
}
}
@@ -21,7 +21,7 @@ import { AIChat } from '../AIChat';
import { AdaptiveVisitorContextProvider } from '../Adaptive'; import { AdaptiveVisitorContextProvider } from '../Adaptive';
import { Announcement } from '../Announcement'; import { Announcement } from '../Announcement';
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown'; import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
import { InsightsProvider } from '../Insights'; import { InsightsProvider, VisitorSessionProvider } from '../Insights';
import { SearchContainer } from '../Search'; import { SearchContainer } from '../Search';
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections'; import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
import { CurrentContentProvider } from '../hooks'; import { CurrentContentProvider } from '../hooks';
@@ -78,16 +78,16 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
revisionId={context.revisionId} revisionId={context.revisionId}
visitorAuthClaims={visitorAuthClaims} visitorAuthClaims={visitorAuthClaims}
> >
<InsightsProvider <VisitorSessionProvider
enabled={withTracking}
appURL={GITBOOK_APP_URL} appURL={GITBOOK_APP_URL}
eventUrl={eventUrl.toString()}
visitorCookieTrackingEnabled={customization.insights?.trackingCookie} visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
> >
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}> <InsightsProvider enabled={withTracking} eventUrl={eventUrl.toString()}>
{children} <AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
</AIChatProvider> {children}
</InsightsProvider> </AIChatProvider>
</InsightsProvider>
</VisitorSessionProvider>
</CurrentContentProvider> </CurrentContentProvider>
</AdaptiveVisitorContextProvider> </AdaptiveVisitorContextProvider>
</SpaceLayoutContextProvider> </SpaceLayoutContextProvider>