Skip to content

Commit 575924e

Browse files
feat(miniapp-utils): INFRA-1576 embedding context proof of concept (#7416)
* feat(miniapp-utils): embeddingContext proof of concept * feat(miniapp-utils): INFRA-1576 embedding context * feat(miniapp-utils): INFRA-1576 exports, sender and minor improvements * feat(resident-app): INFRA-1576 add embeddingContext * feat(resident-app): INFRA-1576 change casing * feat(resident-app): INFRA-1576 include OS in embedding context
1 parent bdd11d6 commit 575924e

File tree

6 files changed

+276
-17
lines changed

6 files changed

+276
-17
lines changed

apps/resident-app

packages/miniapp-utils/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@
5151
"helpers/cookies": [
5252
"dist/helpers/cookies.d.ts"
5353
],
54+
"helpers/embeddingContext": [
55+
"dist/helpers/embeddingContext.d.ts"
56+
],
5457
"helpers/environment": [
5558
"dist/helpers/environment.d.ts"
5659
],
@@ -126,6 +129,11 @@
126129
"require": "./dist/helpers/cookies.js",
127130
"import": "./dist/helpers/cookies.mjs"
128131
},
132+
"./helpers/embeddingContext": {
133+
"types": "./dist/helpers/embeddingContext.d.ts",
134+
"require": "./dist/helpers/embeddingContext.js",
135+
"import": "./dist/helpers/embeddingContext.mjs"
136+
},
129137
"./helpers/environment": {
130138
"types": "./dist/helpers/environment.d.ts",
131139
"require": "./dist/helpers/environment.js",
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { IncomingMessage, ServerResponse } from 'http'
2+
import type { ComponentType as ReactComponentType } from 'react'
3+
4+
export type Optional<T> = T | undefined
5+
6+
type AppInitialProps<PropsType extends Record<string, unknown>> = { pageProps: PropsType }
7+
8+
type AppContext = {
9+
ctx: {
10+
req: Optional<IncomingMessage>
11+
res: Optional<ServerResponse>
12+
}
13+
}
14+
15+
export type AppType<PropsType extends Record<string, unknown>, ComponentType, RouterType> =
16+
ReactComponentType<{ pageProps: PropsType, Component: ComponentType, router: RouterType }> & {
17+
getInitialProps?: (context: AppContext) => Promise<AppInitialProps<PropsType>> | AppInitialProps<PropsType>
18+
}
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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+
}

packages/miniapp-utils/src/helpers/i18n.tsx

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,9 @@ import { isSSR } from './environment'
55

66
import { useEffectOnce } from '../hooks/useEffectOnce'
77

8+
import type { Optional, AppType } from './common/types'
89
import type { IncomingMessage, ServerResponse } from 'http'
9-
import type { Context, PropsWithChildren, FC, ComponentType as ReactComponentType } from 'react'
10-
11-
12-
type Optional<T> = T | undefined
10+
import type { Context, PropsWithChildren, FC } from 'react'
1311

1412
/**
1513
* Based on RFC5646: https://datatracker.ietf.org/doc/html/rfc5646
@@ -57,19 +55,7 @@ type SSRResultWithI18N<
5755
}
5856
}
5957

60-
type AppInitialProps<PropsType extends Record<string, unknown>> = { pageProps: PropsType }
61-
62-
type AppContext = {
63-
ctx: {
64-
req: Optional<IncomingMessage>
65-
res: Optional<ServerResponse>
66-
}
67-
}
6858

69-
type AppType<PropsType extends Record<string, unknown>, ComponentType, RouterType> =
70-
ReactComponentType<{ pageProps: PropsType, Component: ComponentType, router: RouterType }> & {
71-
getInitialProps?: (context: AppContext) => Promise<AppInitialProps<PropsType>> | AppInitialProps<PropsType>
72-
}
7359

7460
type TranslationsContextType<
7561
AvailableLocale extends string,

packages/miniapp-utils/src/helpers/sender.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getCookie, setCookie } from 'cookies-next'
22

3+
import { getEmbeddingContext } from './embeddingContext'
34
import { generateUUIDv4 } from './uuid'
45

56
type SenderInfo = {
@@ -32,6 +33,12 @@ export function generateFingerprint (): string {
3233
* So consider using it instead
3334
*/
3435
export function getClientSideFingerprint (): string {
36+
// NOTE: if we're inside another application, use its device id as fingerprint, but not persist it in cookie
37+
const embeddingContext = getEmbeddingContext()
38+
if (embeddingContext) {
39+
return embeddingContext.ctx.device.id
40+
}
41+
3542
let fingerprint = getCookie(FINGERPRINT_ID_COOKIE_NAME)
3643
if (!fingerprint) {
3744
fingerprint = generateFingerprint()

0 commit comments

Comments
 (0)