1+ import { deleteCookie , getCookie , setCookie } from 'cookies-next'
2+ import React , { useEffect , useMemo , useState , createContext , useContext } from 'react'
3+ import { z } from 'zod'
4+
5+ import { generateUUIDv4 } from './uuid'
6+
7+ import type { AppType , Optional } from './common/types'
8+ import type { IncomingMessage , ServerResponse } from 'http'
9+
10+ const EMBEDDING_CONTEXT_COOKIE_NAME = 'embeddingContext'
11+ const EMBEDDING_CONTEXT_QUERY_PARAM = 'embeddingContext'
12+ const EMBEDDING_CONTEXT_PRIMARY_TAB_SESSION_STORAGE_KEY = 'isEmbeddingContextProvider'
13+ const EMBEDDING_CONTEXT_PROP_NAME = '__EMBEDDING_CONTEXT__'
14+ const EMBEDDING_CONTEXT_CLEANUP_POLLING_TIMEOUT_IN_MS = 2_000
15+
16+ const EMBEDDING_CONTEXT_SCHEMA = z . strictObject ( {
17+ dv : z . literal ( 1 ) ,
18+ app : z . strictObject ( {
19+ id : z . string ( ) ,
20+ version : z . string ( ) . optional ( ) ,
21+ build : z . string ( ) . optional ( ) ,
22+ } ) ,
23+ platform : z . enum ( [ 'iOS' , 'Android' , 'web' ] ) ,
24+ os : z . strictObject ( {
25+ name : z . string ( ) ,
26+ version : z . string ( ) . optional ( ) ,
27+ } ) . optional ( ) ,
28+ device : z . strictObject ( {
29+ id : z . string ( ) ,
30+ } ) ,
31+ } )
32+
33+ const EMBEDDING_CONTEXT_WITH_SOURCE_SCHEMA = z . strictObject ( {
34+ ctx : EMBEDDING_CONTEXT_SCHEMA ,
35+ source : z . enum ( [ 'query' , 'cookie' ] ) ,
36+ } )
37+
38+ const IS_PRIMARY_ALIVE_MESSAGE_SCHEMA = z . object ( {
39+ type : z . literal ( 'EmbeddingContextPrimaryPolling' ) ,
40+ data : z . strictObject ( {
41+ requestId : z . string ( ) ,
42+ } ) ,
43+ } )
44+
45+ const IS_PRIMARY_ALIVE_RESPONSE_SCHEMA = z . object ( {
46+ type : z . literal ( 'EmbeddingContextPrimaryPollingResult' ) ,
47+ data : z . strictObject ( {
48+ requestId : z . string ( ) ,
49+ isPrimary : z . boolean ( ) ,
50+ } ) ,
51+ } )
52+
53+ export type EmbeddingContext = z . infer < typeof EMBEDDING_CONTEXT_SCHEMA >
54+ type EmbeddingContextWithSource = z . infer < typeof EMBEDDING_CONTEXT_WITH_SOURCE_SCHEMA >
55+ type IsPrimaryAliveMessage = z . infer < typeof IS_PRIMARY_ALIVE_MESSAGE_SCHEMA >
56+ type IsPrimaryAliveResponse = z . infer < typeof IS_PRIMARY_ALIVE_RESPONSE_SCHEMA >
57+
58+ const ReactEmbeddingContext = createContext < EmbeddingContext | null > ( null )
59+
60+ export function useEmbeddingContext ( ) : EmbeddingContext | null {
61+ return useContext ( ReactEmbeddingContext )
62+ }
63+
64+ function b64toContext ( b64 : string ) : EmbeddingContext | null {
65+ try {
66+ const bytes = Uint8Array . from ( atob ( b64 ) , c => c . charCodeAt ( 0 ) )
67+ const decodedUTFString = new TextDecoder ( ) . decode ( bytes )
68+ const parsedCtx = JSON . parse ( decodedUTFString )
69+ return EMBEDDING_CONTEXT_SCHEMA . parse ( parsedCtx )
70+ } catch {
71+ return null
72+ }
73+ }
74+
75+ function contextToB64 ( ctx : EmbeddingContext ) : string {
76+ const stringCtx = JSON . stringify ( ctx )
77+ const bytes = new TextEncoder ( ) . encode ( stringCtx )
78+ return btoa ( String . fromCharCode ( ...bytes ) )
79+ }
80+
81+ export function getEmbeddingContext ( req ?: Optional < IncomingMessage > , res ?: Optional < ServerResponse > ) : EmbeddingContextWithSource | null {
82+ // NOTE: context can be found in query for primary tab
83+ try {
84+ const queryParamValue = req
85+ ? new URL ( req . url ?? '/' , 'https://_' ) . searchParams . get ( EMBEDDING_CONTEXT_QUERY_PARAM )
86+ : new URLSearchParams ( window . location . search ) . get ( EMBEDDING_CONTEXT_QUERY_PARAM )
87+ if ( queryParamValue ) {
88+ const ctx = b64toContext ( decodeURIComponent ( queryParamValue ) )
89+ if ( ctx ) return { ctx, source : 'query' }
90+ }
91+ } catch {
92+ // NOTE: decodeURIComponent might throw on invalid input, ignore it as non-valid query-param
93+ }
94+
95+ // NOTE: context can be found in cookie for secondary tabs
96+ const cookieValue = getCookie ( EMBEDDING_CONTEXT_COOKIE_NAME , { req, res } )
97+ if ( cookieValue ) {
98+ const ctx = b64toContext ( cookieValue )
99+ if ( ctx ) return { ctx, source : 'cookie' }
100+ }
101+
102+ return null
103+ }
104+
105+ export function withEmbeddingContext <
106+ PropsType extends Record < string , unknown > ,
107+ ComponentType ,
108+ RouterType ,
109+ > ( App : AppType < PropsType , ComponentType , RouterType > ) : AppType < PropsType , ComponentType , RouterType > {
110+ const WithEmbeddingContext : AppType < PropsType , ComponentType , RouterType > = ( props ) => {
111+ const { pageProps } = props
112+
113+ const propsContextWithSource = useMemo ( ( ) => {
114+ const { success, data } = EMBEDDING_CONTEXT_WITH_SOURCE_SCHEMA . safeParse ( pageProps [ EMBEDDING_CONTEXT_PROP_NAME ] )
115+ if ( ! success ) return null
116+ return data
117+ } , [ pageProps ] )
118+
119+ const [ embeddingContext , setEmbeddingContext ] = useState < EmbeddingContext | null > ( propsContextWithSource ?. ctx ?? null )
120+ const [ isPrimaryTab , setIsPrimaryTab ] = useState < boolean | null > ( propsContextWithSource ?. source === 'query' ? true : null )
121+ const [ bcChannel , setBCChannel ] = useState < BroadcastChannel | null > ( null )
122+
123+ useEffect ( ( ) => {
124+ // NOTE: if primary tab, save it in session storage, so it won't be lost on user navigation
125+ if ( isPrimaryTab === true && typeof window !== 'undefined' ) {
126+ window . sessionStorage . setItem ( EMBEDDING_CONTEXT_PRIMARY_TAB_SESSION_STORAGE_KEY , 'true' )
127+ }
128+ // NOTE: restore primary tab status if it was lost on navigation
129+ if ( isPrimaryTab === null && typeof window !== 'undefined' ) {
130+ setIsPrimaryTab ( window . sessionStorage . getItem ( EMBEDDING_CONTEXT_PRIMARY_TAB_SESSION_STORAGE_KEY ) === 'true' )
131+ }
132+ } , [ isPrimaryTab ] )
133+
134+ useEffect ( ( ) => {
135+ if ( typeof window === 'undefined' || ! ( 'BroadcastChannel' in window ) ) return
136+
137+ const bc = new BroadcastChannel ( 'embeddingContext' )
138+ setBCChannel ( bc )
139+
140+ return ( ) => {
141+ bc . close ( )
142+ setBCChannel ( null )
143+ }
144+ } , [ ] )
145+
146+ // NOTE: Embedding context is shared between tabs by browser technology (cookies)
147+ // so each new page can obtain it in initial props in SSR and CSR
148+ // The problem is cookie might not be cleaned up on tab close, so we use 2 tricks:
149+ // 1. save primary tab status in session storage, so it won't be lost on user navigation
150+ // 2. use BroadcastChannel to poll if primary tab is still alive, if not, clean up cookie
151+ useEffect ( ( ) => {
152+ if ( isPrimaryTab === null || ! bcChannel ) return
153+
154+ if ( isPrimaryTab ) {
155+ const primaryListener = ( e : MessageEvent ) => {
156+ const { success, data } = IS_PRIMARY_ALIVE_MESSAGE_SCHEMA . safeParse ( e . data )
157+ if ( ! success || ! data ?. data ?. requestId ) return
158+
159+ const response : IsPrimaryAliveResponse = {
160+ type : 'EmbeddingContextPrimaryPollingResult' ,
161+ data : {
162+ isPrimary : true ,
163+ requestId : data . data . requestId ,
164+ } ,
165+ }
166+
167+ bcChannel . postMessage ( response )
168+ }
169+
170+ bcChannel . addEventListener ( 'message' , primaryListener )
171+
172+ return ( ) => {
173+ bcChannel . removeEventListener ( 'message' , primaryListener )
174+ }
175+ }
176+
177+ const requestId = generateUUIDv4 ( )
178+ const timeout = setTimeout ( ( ) => {
179+ deleteCookie ( EMBEDDING_CONTEXT_COOKIE_NAME )
180+ setEmbeddingContext ( null )
181+ setIsPrimaryTab ( false )
182+ } , EMBEDDING_CONTEXT_CLEANUP_POLLING_TIMEOUT_IN_MS )
183+
184+ const secondaryListener = ( e : MessageEvent ) => {
185+ const { success, data } = IS_PRIMARY_ALIVE_RESPONSE_SCHEMA . safeParse ( e . data )
186+ if ( ! success || data ?. data . requestId !== requestId ) return
187+
188+ clearTimeout ( timeout )
189+ }
190+
191+ bcChannel . addEventListener ( 'message' , secondaryListener )
192+
193+ const pollMessage : IsPrimaryAliveMessage = {
194+ type : 'EmbeddingContextPrimaryPolling' ,
195+ data : {
196+ requestId,
197+ } ,
198+ }
199+ bcChannel . postMessage ( pollMessage )
200+
201+ return ( ) => {
202+ bcChannel . removeEventListener ( 'message' , secondaryListener )
203+ clearTimeout ( timeout )
204+ }
205+
206+ } , [ bcChannel , isPrimaryTab ] )
207+
208+ return (
209+ < ReactEmbeddingContext . Provider value = { embeddingContext } >
210+ < App { ...props } />
211+ </ ReactEmbeddingContext . Provider >
212+ )
213+ }
214+
215+ const appGetInitialProps = App . getInitialProps
216+ if ( appGetInitialProps ) {
217+ WithEmbeddingContext . getInitialProps = async function ( context ) {
218+ const appProps = await appGetInitialProps ( context )
219+ const { ctx } = context
220+ const embeddingContextWithSource = getEmbeddingContext ( ctx . req , ctx . res )
221+ if ( embeddingContextWithSource && embeddingContextWithSource . source === 'query' ) {
222+ // Save context in cookie for new tabs
223+ setCookie ( EMBEDDING_CONTEXT_COOKIE_NAME , contextToB64 ( embeddingContextWithSource . ctx ) , {
224+ req : ctx . req ,
225+ res : ctx . res ,
226+ } )
227+ }
228+
229+ return {
230+ ...appProps ,
231+ pageProps : {
232+ ...appProps . pageProps ,
233+ [ EMBEDDING_CONTEXT_PROP_NAME ] : embeddingContextWithSource ,
234+ } ,
235+ }
236+ }
237+ }
238+
239+ return WithEmbeddingContext
240+ }
0 commit comments