Skip to content

Commit

Permalink
feat(miniapp-utils)!: DOMA-10685 added SSR cookies helper 🍪 (#5533)
Browse files Browse the repository at this point in the history
* docs(miniapp-utils): DOMA-10685 added docs to nonNull util

* feat(miniapp-utils): DOMA-10685 added SSR cookies helper

* feat(miniapp-utils): DOMA-10685 added SSR cookies helper

* chore(miniapp-utils): DOMA-10685 types exports added

* refactor(resident-app): DOMA-10685 migrate ssr utils

* feat(miniapp-utils)!: DOMA-10685 fixed generateUUIDv4 exports

BREAKING-CHANGE: removed default export for generateUUIDv4

* feat(miniapp-utils): DOMA-10685 added collections and cookies to default exports

* docs(apollo): DOMA-10685 JSDocs fixed

* docs(miniapp-utils): DOMA-10685 added missing JSDocs to all helpers

* feat(miniapp-utils): DOMA-10685 make extractSSRCookies work on client-side (getInitialProps)

* feat(miniapp-utils): DOMA-10685 make extractSSRCookies work on client-side (getInitialProps)

* chore(resident-app): DOMA-10685 sync submodule to main
  • Loading branch information
SavelevMatthew authored Nov 25, 2024
1 parent 4f276f6 commit 4f4fb78
Show file tree
Hide file tree
Showing 8 changed files with 216 additions and 4 deletions.
2 changes: 1 addition & 1 deletion apps/resident-app
2 changes: 1 addition & 1 deletion packages/apollo/src/utils/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export function extractApolloState<PropsType> (
* export { extractApolloState } from '@open-condo/apollo'
*
* @example Use in SSR
* import { initializeApollo, extractApolloState } from '@/lib/apollo'
* import { initializeApollo, extractApolloState } from '@/domains/common/utils/apollo'
*
* export const getServerSideProps = async ({ req }) => {
* const headers = extractHeaders(req)
Expand Down
8 changes: 8 additions & 0 deletions packages/miniapp-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
"helpers/collections": [
"dist/types/helpers/collections.d.ts"
],
"helpers/cookies": [
"dist/types/helpers/cookies.d.ts"
],
"helpers/environment": [
"dist/types/helpers/environment.d.ts"
],
Expand Down Expand Up @@ -81,6 +84,11 @@
"require": "./dist/cjs/helpers/collections.js",
"import": "./dist/esm/helpers/collections.js"
},
"./helpers/cookies": {
"types": "./dist/types/helpers/cookies.d.ts",
"require": "./dist/cjs/helpers/cookies.js",
"import": "./dist/esm/helpers/cookies.js"
},
"./helpers/environment": {
"types": "./dist/types/helpers/environment.d.ts",
"require": "./dist/cjs/helpers/environment.js",
Expand Down
7 changes: 7 additions & 0 deletions packages/miniapp-utils/src/helpers/collections.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/**
* Checks whenever values is NonNullable.
* From es5 docs NonNullable excludes null and undefined from T
* @example
* const collection: Array<number | null | undefined> = [1, null, 3, undefined, 5]
* const filtered = collection.filter(nonNull) // Array<number>, so it's safe to process it
*/
export function nonNull<TVal> (val: TVal): val is NonNullable<TVal> {
return val !== null && val !== undefined
}
155 changes: 155 additions & 0 deletions packages/miniapp-utils/src/helpers/cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { getCookie } from 'cookies-next'
import { createContext, useContext } from 'react'

import type { IncomingMessage, ServerResponse } from 'http'
import type { Context } from 'react'

const SSR_COOKIES_DEFAULT_PROP_NAME = '__SSR_COOKIES__'

export type SSRCookiesContextValues<CookiesList extends ReadonlyArray<string>> = Record<CookiesList[number], string | null>

type Optional<T> = T | undefined

type SSRProps<PropsType extends Record<string, unknown>> = {
props?: PropsType
}

type SSRPropsWithCookies<
PropsType extends Record<string, unknown>,
CookiesList extends ReadonlyArray<string>,
CookiesPropName extends string = typeof SSR_COOKIES_DEFAULT_PROP_NAME,
> = {
props: PropsType & {
[K in CookiesPropName]?: SSRCookiesContextValues<CookiesList>
}
}

export type UseSSRCookiesExtractor<
CookiesList extends ReadonlyArray<string>,
CookiesPropName extends string = typeof SSR_COOKIES_DEFAULT_PROP_NAME,
> = <PropsType extends Record<string, unknown>>(pageParams: SSRPropsWithCookies<PropsType, CookiesList, CookiesPropName>['props']) => SSRCookiesContextValues<CookiesList>

export type UseSSRCookies<CookiesList extends ReadonlyArray<string>> = () => SSRCookiesContextValues<CookiesList>

/**
* Helper that allows you to pass cookies from the request directly to the SSR,
* thus avoiding layout shifts and loading states.
*
* NOTE: You should not use this tool to pass secure http-only cookies to the client,
* that's why each application must define the list of allowed cookies itself.
*
* @example Init helper and export utils for app
* import { SSRCookiesHelper } from '@open-condo/miniapp-utils/helpers/cookies'
* import type { SSRCookiesContextValues } from '@open-condo/miniapp-utils/helpers/cookies'
*
* import type { Context } from 'react'
*
* // NOTE: put here only cookies needed in SRR (hydration), does not put http-only cookies here
* const VITAL_COOKIES = ['residentId', 'isLayoutMinified'] as const
*
* const cookieHelper = new SSRCookiesHelper(VITAL_COOKIES)
*
* export const extractSSRCookies = cookieHelper.extractSSRCookies
* export const useSSRCookiesExtractor = cookieHelper.generateUseSSRCookiesExtractorHook()
* export const useSSRCookies = cookieHelper.generateUseSSRCookiesHook()
* export const SSRCookiesContext = cookieHelper.getContext() as Context<SSRCookiesContextValues<typeof VITAL_COOKIES>>
*
* @example Extract cookies in getServerSideProps / getInitialProps
* import { extractSSRCookies } from '@/domains/common/utils/ssr'
*
* export const getServerSideProps = async ({ req, res }) => {
* return extractSSRCookies(req, res, {
* props: { ... }
* })
* }
*
* @example Pass extracted cookies to React context in your _app.ts
* import { SSRCookiesContext } from '@/domains/common/utils/ssr'
*
* export default function App ({ Component, pageProps }: AppProps): ReactNode {
* const ssrCookies = useSSRCookiesExtractor(pageProps)
*
* return (
* <SSRCookiesContext.Provider value={ssrCookies}>
* <Component {...pageProps} />
* </SSRCookiesContext.Provider>
* )
* }
*
* @example Use extracted cookies anywhere in your app.
* // /domains/common/components/Layout.tsx
* import { useState } from 'react'
* import { useSSRCookies } from '@/domains/common/utils/ssr'
*
* import type { FC } from 'react'
*
* export const Layout: FC = () => {
* const { isLayoutMinified } = useSSRCookies()
*
* const [layoutMinified, setLayoutMinified] = useState(isLayoutMinified === 'true')
*
* return {
* // ...
* }
* }
*/
export class SSRCookiesHelper<
CookiesList extends ReadonlyArray<string>,
CookiesPropName extends string = typeof SSR_COOKIES_DEFAULT_PROP_NAME,
> {
allowedCookies: CookiesList
propName: CookiesPropName
private readonly context: Context<SSRCookiesContextValues<CookiesList>>
private readonly defaultValues: SSRCookiesContextValues<CookiesList>

constructor (allowedCookies: CookiesList, propName?: CookiesPropName) {
this.allowedCookies = allowedCookies
this.propName = propName || SSR_COOKIES_DEFAULT_PROP_NAME as CookiesPropName
this.defaultValues = Object.fromEntries(allowedCookies.map(key => [key, null])) as SSRCookiesContextValues<CookiesList>
this.context = createContext<SSRCookiesContextValues<CookiesList>>(this.defaultValues)

this.extractSSRCookies = this.extractSSRCookies.bind(this)
}

getContext (): Context<SSRCookiesContextValues<CookiesList>> {
return this.context
}

generateUseSSRCookiesExtractorHook (): UseSSRCookiesExtractor<CookiesList, CookiesPropName> {
const defaultValues = this.defaultValues
const propName = this.propName

return function useSSRCookiesExtractor<PropsType extends Record<string, unknown>> (
pageParams: SSRPropsWithCookies<PropsType, CookiesList, CookiesPropName>['props']
): SSRCookiesContextValues<CookiesList> {
return pageParams[propName] || defaultValues
}
}

generateUseSSRCookiesHook (): UseSSRCookies<CookiesList> {
const context = this.context

return function useSSRCookies (): SSRCookiesContextValues<CookiesList> {
return useContext(context)
}
}

extractSSRCookies<PropsType extends Record<string, unknown>> (
req: Optional<IncomingMessage>,
res: Optional<ServerResponse>,
pageParams: SSRProps<PropsType>
): SSRPropsWithCookies<PropsType, CookiesList, CookiesPropName> {
return {
...pageParams,
props: {
...pageParams.props,
[this.propName]: Object.fromEntries(
Object.keys(this.defaultValues).map(key => [
key,
getCookie(key, { req, res }) || null,
])
),
},
} as SSRPropsWithCookies<PropsType, CookiesList, CookiesPropName>
}
}
26 changes: 26 additions & 0 deletions packages/miniapp-utils/src/helpers/sender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ type SenderInfo = {
fingerprint: string
}

/** Name of the cookie in which the fingerprint will be stored */
export const FINGERPRINT_ID_COOKIE_NAME = 'fingerprint'
/** Default fingerprint length */
export const FINGERPRINT_ID_LENGTH = 12

function makeId (length: number): string {
Expand All @@ -20,6 +22,13 @@ export function generateFingerprint (): string {
return makeId(FINGERPRINT_ID_LENGTH)
}

/**
* Creates a device fingerprint in the browser environment
* that can be used to send mutations in open-condo applications,
* uses cookies for storage between sessions.
* Mostly used to generate the sender field in getClientSideSenderInfo.
* So consider using it instead
*/
export function getClientSideFingerprint (): string {
let fingerprint = getCookie(FINGERPRINT_ID_COOKIE_NAME)
if (!fingerprint) {
Expand All @@ -30,6 +39,23 @@ export function getClientSideFingerprint (): string {
return fingerprint
}

/**
* Creates a device fingerprint in the browser environment
* that can be used to send mutations in open-condo applications.
* Uses cookies for storage between sessions
* @example
* submitReadingsMutation({
* variables: {
* data: {
* ...values,
* dv: 1,
* sender: getClientSideSenderInfo(),
* meter: { connect: { id: meter.id } },
* source: { connect: { id: METER_READING_MOBILE_APP_SOURCE_ID } },
* },
* },
* })
*/
export function getClientSideSenderInfo (): SenderInfo {
return {
dv: 1,
Expand Down
7 changes: 5 additions & 2 deletions packages/miniapp-utils/src/helpers/uuid.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { randomBytes } from 'crypto'

/**
* Generates v4 UUIDs in both browser and Node environments
* @example
* const uuid = generateUUIDv4()
*/
export function generateUUIDv4 (): string {
let randomValues: Uint8Array

Expand Down Expand Up @@ -29,5 +34,3 @@ export function generateUUIDv4 (): string {
})
.join('')
}

export default generateUUIDv4
13 changes: 13 additions & 0 deletions packages/miniapp-utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
/**
* Helpers
*/

export { prepareSSRContext, getTracingMiddleware } from './helpers/apollo'
export type { TracingMiddlewareOptions } from './helpers/apollo'

export { nonNull } from './helpers/collections'

export { SSRCookiesHelper } from './helpers/cookies'
export type { UseSSRCookies, UseSSRCookiesExtractor, SSRCookiesContextValues } from './helpers/cookies'

export { isDebug, isSSR } from './helpers/environment'


export { FINGERPRINT_ID_COOKIE_NAME, FINGERPRINT_ID_LENGTH, generateFingerprint, getClientSideFingerprint, getClientSideSenderInfo } from './helpers/sender'

export { generateUUIDv4 } from './helpers/uuid'

/**
* Hooks
*/

export { useEffectOnce } from './hooks/useEffectOnce'

Expand Down

0 comments on commit 4f4fb78

Please sign in to comment.