diff --git a/apps/resident-app b/apps/resident-app index 8d093340cc3..e707ef684ed 160000 --- a/apps/resident-app +++ b/apps/resident-app @@ -1 +1 @@ -Subproject commit 8d093340cc354644820f3b23ef28d6906931eb0b +Subproject commit e707ef684ed61a6d4fb9c72c5e092e7d32d77cc0 diff --git a/packages/apollo/src/utils/client.ts b/packages/apollo/src/utils/client.ts index c73f8fdd2bb..e43d030747f 100644 --- a/packages/apollo/src/utils/client.ts +++ b/packages/apollo/src/utils/client.ts @@ -155,7 +155,7 @@ export function extractApolloState ( * 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) diff --git a/packages/miniapp-utils/package.json b/packages/miniapp-utils/package.json index fd23b1e3eb2..3fd4ef8baba 100644 --- a/packages/miniapp-utils/package.json +++ b/packages/miniapp-utils/package.json @@ -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" ], @@ -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", diff --git a/packages/miniapp-utils/src/helpers/collections.ts b/packages/miniapp-utils/src/helpers/collections.ts index 306b5555099..5925cbd8578 100644 --- a/packages/miniapp-utils/src/helpers/collections.ts +++ b/packages/miniapp-utils/src/helpers/collections.ts @@ -1,3 +1,10 @@ +/** + * Checks whenever values is NonNullable. + * From es5 docs NonNullable excludes null and undefined from T + * @example + * const collection: Array = [1, null, 3, undefined, 5] + * const filtered = collection.filter(nonNull) // Array, so it's safe to process it + */ export function nonNull (val: TVal): val is NonNullable { return val !== null && val !== undefined } diff --git a/packages/miniapp-utils/src/helpers/cookies.ts b/packages/miniapp-utils/src/helpers/cookies.ts new file mode 100644 index 00000000000..4c6d0a29e7d --- /dev/null +++ b/packages/miniapp-utils/src/helpers/cookies.ts @@ -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> = Record + +type Optional = T | undefined + +type SSRProps> = { + props?: PropsType +} + +type SSRPropsWithCookies< + PropsType extends Record, + CookiesList extends ReadonlyArray, + CookiesPropName extends string = typeof SSR_COOKIES_DEFAULT_PROP_NAME, +> = { + props: PropsType & { + [K in CookiesPropName]?: SSRCookiesContextValues + } +} + +export type UseSSRCookiesExtractor< + CookiesList extends ReadonlyArray, + CookiesPropName extends string = typeof SSR_COOKIES_DEFAULT_PROP_NAME, +> = >(pageParams: SSRPropsWithCookies['props']) => SSRCookiesContextValues + +export type UseSSRCookies> = () => SSRCookiesContextValues + +/** + * 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> + * + * @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 ( + * + * + * + * ) + * } + * + * @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, + CookiesPropName extends string = typeof SSR_COOKIES_DEFAULT_PROP_NAME, +> { + allowedCookies: CookiesList + propName: CookiesPropName + private readonly context: Context> + private readonly defaultValues: SSRCookiesContextValues + + 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 + this.context = createContext>(this.defaultValues) + + this.extractSSRCookies = this.extractSSRCookies.bind(this) + } + + getContext (): Context> { + return this.context + } + + generateUseSSRCookiesExtractorHook (): UseSSRCookiesExtractor { + const defaultValues = this.defaultValues + const propName = this.propName + + return function useSSRCookiesExtractor> ( + pageParams: SSRPropsWithCookies['props'] + ): SSRCookiesContextValues { + return pageParams[propName] || defaultValues + } + } + + generateUseSSRCookiesHook (): UseSSRCookies { + const context = this.context + + return function useSSRCookies (): SSRCookiesContextValues { + return useContext(context) + } + } + + extractSSRCookies> ( + req: Optional, + res: Optional, + pageParams: SSRProps + ): SSRPropsWithCookies { + return { + ...pageParams, + props: { + ...pageParams.props, + [this.propName]: Object.fromEntries( + Object.keys(this.defaultValues).map(key => [ + key, + getCookie(key, { req, res }) || null, + ]) + ), + }, + } as SSRPropsWithCookies + } +} diff --git a/packages/miniapp-utils/src/helpers/sender.ts b/packages/miniapp-utils/src/helpers/sender.ts index e59b4bf4d3e..a9bcc8567b7 100644 --- a/packages/miniapp-utils/src/helpers/sender.ts +++ b/packages/miniapp-utils/src/helpers/sender.ts @@ -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 { @@ -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) { @@ -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, diff --git a/packages/miniapp-utils/src/helpers/uuid.ts b/packages/miniapp-utils/src/helpers/uuid.ts index de8eba4a0d3..94a50596967 100644 --- a/packages/miniapp-utils/src/helpers/uuid.ts +++ b/packages/miniapp-utils/src/helpers/uuid.ts @@ -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 @@ -29,5 +34,3 @@ export function generateUUIDv4 (): string { }) .join('') } - -export default generateUUIDv4 \ No newline at end of file diff --git a/packages/miniapp-utils/src/index.ts b/packages/miniapp-utils/src/index.ts index 2fcc1a4eaa4..5a6ef2d9498 100644 --- a/packages/miniapp-utils/src/index.ts +++ b/packages/miniapp-utils/src/index.ts @@ -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'