Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(miniapp-utils)!: DOMA-10685 added SSR cookies helper 🍪 #5533

Merged
merged 12 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading