@posthog/next 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +299 -0
- package/README.md +122 -0
- package/dist/app/PostHogProvider.d.ts +62 -0
- package/dist/app/PostHogProvider.d.ts.map +1 -0
- package/dist/app/PostHogProvider.js +67 -0
- package/dist/app/PostHogProvider.js.map +1 -0
- package/dist/client/ClientPostHogProvider.d.ts +28 -0
- package/dist/client/ClientPostHogProvider.d.ts.map +1 -0
- package/dist/client/ClientPostHogProvider.js +34 -0
- package/dist/client/ClientPostHogProvider.js.map +1 -0
- package/dist/client/PostHogPageView.d.ts +30 -0
- package/dist/client/PostHogPageView.d.ts.map +1 -0
- package/dist/client/PostHogPageView.js +54 -0
- package/dist/client/PostHogPageView.js.map +1 -0
- package/dist/client/hooks.d.ts +2 -0
- package/dist/client/hooks.d.ts.map +1 -0
- package/dist/client/hooks.js +3 -0
- package/dist/client/hooks.js.map +1 -0
- package/dist/index.client.d.ts +6 -0
- package/dist/index.client.d.ts.map +1 -0
- package/dist/index.client.js +6 -0
- package/dist/index.client.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.edge.d.ts +7 -0
- package/dist/index.edge.d.ts.map +1 -0
- package/dist/index.edge.js +7 -0
- package/dist/index.edge.js.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/index.react-server.d.ts +5 -0
- package/dist/index.react-server.d.ts.map +1 -0
- package/dist/index.react-server.js +6 -0
- package/dist/index.react-server.js.map +1 -0
- package/dist/middleware/postHogMiddleware.d.ts +95 -0
- package/dist/middleware/postHogMiddleware.d.ts.map +1 -0
- package/dist/middleware/postHogMiddleware.js +88 -0
- package/dist/middleware/postHogMiddleware.js.map +1 -0
- package/dist/pages/PostHogPageView.d.ts +25 -0
- package/dist/pages/PostHogPageView.d.ts.map +1 -0
- package/dist/pages/PostHogPageView.js +38 -0
- package/dist/pages/PostHogPageView.js.map +1 -0
- package/dist/pages/PostHogProvider.d.ts +16 -0
- package/dist/pages/PostHogProvider.d.ts.map +1 -0
- package/dist/pages/PostHogProvider.js +38 -0
- package/dist/pages/PostHogProvider.js.map +1 -0
- package/dist/pages/getServerSidePostHog.d.ts +27 -0
- package/dist/pages/getServerSidePostHog.d.ts.map +1 -0
- package/dist/pages/getServerSidePostHog.js +40 -0
- package/dist/pages/getServerSidePostHog.js.map +1 -0
- package/dist/pages.d.ts +9 -0
- package/dist/pages.d.ts.map +1 -0
- package/dist/pages.js +7 -0
- package/dist/pages.js.map +1 -0
- package/dist/server/getPostHog.d.ts +29 -0
- package/dist/server/getPostHog.d.ts.map +1 -0
- package/dist/server/getPostHog.js +61 -0
- package/dist/server/getPostHog.js.map +1 -0
- package/dist/server/nodeClientCache.d.ts +14 -0
- package/dist/server/nodeClientCache.d.ts.map +1 -0
- package/dist/server/nodeClientCache.js +33 -0
- package/dist/server/nodeClientCache.js.map +1 -0
- package/dist/shared/config.d.ts +36 -0
- package/dist/shared/config.d.ts.map +1 -0
- package/dist/shared/config.js +35 -0
- package/dist/shared/config.js.map +1 -0
- package/dist/shared/constants.d.ts +6 -0
- package/dist/shared/constants.d.ts.map +1 -0
- package/dist/shared/constants.js +6 -0
- package/dist/shared/constants.js.map +1 -0
- package/dist/shared/cookie.d.ts +69 -0
- package/dist/shared/cookie.d.ts.map +1 -0
- package/dist/shared/cookie.js +126 -0
- package/dist/shared/cookie.js.map +1 -0
- package/dist/shared/identity.d.ts +6 -0
- package/dist/shared/identity.d.ts.map +1 -0
- package/dist/shared/identity.js +9 -0
- package/dist/shared/identity.js.map +1 -0
- package/package.json +89 -0
- package/src/app/PostHogProvider.tsx +141 -0
- package/src/client/ClientPostHogProvider.tsx +51 -0
- package/src/client/PostHogPageView.tsx +63 -0
- package/src/client/hooks.ts +8 -0
- package/src/index.client.ts +9 -0
- package/src/index.edge.ts +10 -0
- package/src/index.react-server.ts +7 -0
- package/src/index.ts +8 -0
- package/src/middleware/postHogMiddleware.ts +170 -0
- package/src/pages/PostHogPageView.tsx +41 -0
- package/src/pages/PostHogProvider.tsx +61 -0
- package/src/pages/getServerSidePostHog.ts +49 -0
- package/src/pages.ts +8 -0
- package/src/server/getPostHog.ts +66 -0
- package/src/server/nodeClientCache.ts +37 -0
- package/src/shared/config.ts +52 -0
- package/src/shared/constants.ts +5 -0
- package/src/shared/cookie.ts +162 -0
- package/src/shared/identity.ts +9 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { isFunction } from '@posthog/core'
|
|
4
|
+
import type { PostHogOptions, IPostHog } from 'posthog-node'
|
|
5
|
+
import { cookies } from 'next/headers'
|
|
6
|
+
import { getOrCreateNodeClient } from './nodeClientCache'
|
|
7
|
+
import { readPostHogCookie, cookieStateToProperties, isOptedOut } from '../shared/cookie'
|
|
8
|
+
import { resolveApiKey } from '../shared/config'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Returns a PostHog server client scoped to the current request.
|
|
12
|
+
*
|
|
13
|
+
* Reads the user's identity from the PostHog cookie and returns a
|
|
14
|
+
* request-scoped client. Methods like `getAllFlags()`, `getFeatureFlagResult()`,
|
|
15
|
+
* and `capture()` automatically use the current user's identity.
|
|
16
|
+
*
|
|
17
|
+
* Calls `cookies()` internally, which opts the route into dynamic rendering.
|
|
18
|
+
*
|
|
19
|
+
* @param apiKey - PostHog project API key. If omitted, reads from `NEXT_PUBLIC_POSTHOG_KEY`.
|
|
20
|
+
* @param options - Optional `posthog-node` configuration (e.g., `{ host: '...' }`).
|
|
21
|
+
* @returns A `posthog-node` client scoped to the current user.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* import { getPostHog } from '@posthog/next'
|
|
26
|
+
*
|
|
27
|
+
* export default async function Page() {
|
|
28
|
+
* const posthog = await getPostHog()
|
|
29
|
+
* const flags = await posthog.getAllFlags()
|
|
30
|
+
* posthog.capture({ event: 'page_viewed' })
|
|
31
|
+
* return <div>...</div>
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export async function getPostHog(apiKey?: string, options?: Partial<PostHogOptions>): Promise<IPostHog> {
|
|
36
|
+
const resolvedApiKey = resolveApiKey(apiKey)
|
|
37
|
+
const host = options?.host ?? process.env.NEXT_PUBLIC_POSTHOG_HOST
|
|
38
|
+
const resolvedOptions = host ? { ...options, host } : options
|
|
39
|
+
const client = await getOrCreateNodeClient(resolvedApiKey, resolvedOptions)
|
|
40
|
+
const cookieStore = await cookies()
|
|
41
|
+
|
|
42
|
+
if (isOptedOut(cookieStore, resolvedApiKey)) {
|
|
43
|
+
return client
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const state = readPostHogCookie(cookieStore, resolvedApiKey)
|
|
47
|
+
const properties = cookieStateToProperties(state)
|
|
48
|
+
const contextData = { distinctId: state?.distinctId, sessionId: state?.sessionId, properties }
|
|
49
|
+
|
|
50
|
+
// Wrap the shared client in a Proxy that applies request-scoped context
|
|
51
|
+
// to every method call. We can't use enterContext() here because
|
|
52
|
+
// AsyncLocalStorage.enterWith() doesn't propagate back to the caller
|
|
53
|
+
// across the await boundary of this async function.
|
|
54
|
+
return new Proxy(client, {
|
|
55
|
+
get(target, prop, receiver) {
|
|
56
|
+
if (prop === 'withContext') {
|
|
57
|
+
return Reflect.get(target, prop, receiver)
|
|
58
|
+
}
|
|
59
|
+
const value = Reflect.get(target, prop, receiver)
|
|
60
|
+
if (isFunction(value)) {
|
|
61
|
+
return (...args: unknown[]) => target.withContext(contextData, () => value.apply(target, args))
|
|
62
|
+
}
|
|
63
|
+
return value
|
|
64
|
+
},
|
|
65
|
+
}) as IPostHog
|
|
66
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { PostHog } from 'posthog-node'
|
|
2
|
+
import type { PostHogOptions } from 'posthog-node'
|
|
3
|
+
|
|
4
|
+
const cache = new Map<string, PostHog>()
|
|
5
|
+
|
|
6
|
+
// Auto-detect waitUntil from @vercel/functions at module load.
|
|
7
|
+
// Fails gracefully in environments where it's not available.
|
|
8
|
+
const autoDetectedWaitUntil: Promise<((p: Promise<unknown>) => void) | undefined> = import(
|
|
9
|
+
/* webpackIgnore: true */ '@vercel/functions'
|
|
10
|
+
)
|
|
11
|
+
.then((mod) => mod.waitUntil)
|
|
12
|
+
.catch(() => undefined)
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns a cached PostHog node client, creating one if needed.
|
|
16
|
+
*
|
|
17
|
+
* Clients are cached by project key + host. Only the options from the first
|
|
18
|
+
* call for a given key+host pair take effect; subsequent calls with different
|
|
19
|
+
* options (e.g. flushAt, flushInterval) will return the existing client.
|
|
20
|
+
*
|
|
21
|
+
* On first call, awaits auto-detection of @vercel/functions waitUntil
|
|
22
|
+
* and merges it into options. Explicit options.waitUntil takes priority.
|
|
23
|
+
*/
|
|
24
|
+
export async function getOrCreateNodeClient(apiKey: string, options?: Partial<PostHogOptions>): Promise<PostHog> {
|
|
25
|
+
const key = `${apiKey}:${options?.host ?? ''}`
|
|
26
|
+
let client = cache.get(key)
|
|
27
|
+
if (!client) {
|
|
28
|
+
const waitUntil = options?.waitUntil ?? (await autoDetectedWaitUntil)
|
|
29
|
+
const mergedOptions: Partial<PostHogOptions> = {
|
|
30
|
+
...(waitUntil ? { waitUntil } : {}),
|
|
31
|
+
...options,
|
|
32
|
+
}
|
|
33
|
+
client = new PostHog(apiKey, mergedOptions)
|
|
34
|
+
cache.set(key, client)
|
|
35
|
+
}
|
|
36
|
+
return client
|
|
37
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { PostHogConfig } from 'posthog-js'
|
|
2
|
+
import type { PostHogOptions } from 'posthog-node'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Configuration for the client-side PostHog provider.
|
|
6
|
+
* Extends the standard posthog-js config.
|
|
7
|
+
*/
|
|
8
|
+
export type PostHogClientConfig = Partial<PostHogConfig>
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Configuration for the server-side PostHog client.
|
|
12
|
+
* Extends the standard posthog-node options.
|
|
13
|
+
*/
|
|
14
|
+
export type PostHogServerConfig = PostHogOptions
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolves the PostHog API key from an explicit value or the
|
|
18
|
+
* `NEXT_PUBLIC_POSTHOG_KEY` environment variable.
|
|
19
|
+
*
|
|
20
|
+
* Throws if neither is available.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveApiKey(apiKey?: string): string {
|
|
23
|
+
const resolved = apiKey || process.env.NEXT_PUBLIC_POSTHOG_KEY
|
|
24
|
+
if (!resolved) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
'[PostHog Next.js] apiKey is required. Either pass it explicitly or set the NEXT_PUBLIC_POSTHOG_KEY environment variable.'
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
return resolved
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Next.js-specific defaults for the posthog-js client.
|
|
34
|
+
*
|
|
35
|
+
* These ensure the server can read both identity and consent state from cookies:
|
|
36
|
+
* - `capture_pageview: false` — disables posthog-js automatic pageviews so the
|
|
37
|
+
* `PostHogPageView` component can handle them without duplicates
|
|
38
|
+
* - `persistence: 'localStorage+cookie'` — already the posthog-js default, made explicit
|
|
39
|
+
* - `opt_out_capturing_persistence_type: 'cookie'` — writes consent state to a cookie
|
|
40
|
+
* so middleware/server components can read it (posthog-js default is 'localStorage')
|
|
41
|
+
* - `opt_out_persistence_by_default: true` — when opted out, disables persistence
|
|
42
|
+
* so posthog-js does not write cookies or localStorage; the middleware
|
|
43
|
+
* handles deleting the identity cookie separately
|
|
44
|
+
*
|
|
45
|
+
* Users can override any of these via the `options` prop on PostHogProvider.
|
|
46
|
+
*/
|
|
47
|
+
export const NEXTJS_CLIENT_DEFAULTS: Partial<PostHogConfig> = {
|
|
48
|
+
capture_pageview: false,
|
|
49
|
+
persistence: 'localStorage+cookie',
|
|
50
|
+
opt_out_capturing_persistence_type: 'cookie',
|
|
51
|
+
opt_out_persistence_by_default: true,
|
|
52
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { uuidv7, isNoLike, isArray } from '@posthog/core'
|
|
2
|
+
import { COOKIE_PREFIX, COOKIE_SUFFIX } from './constants'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal cookie-reading interface compatible with Next.js `cookies()`,
|
|
6
|
+
* `request.cookies`, and plain objects.
|
|
7
|
+
*/
|
|
8
|
+
export interface CookieStore {
|
|
9
|
+
get(name: string): { value: string } | undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Adapts a raw `Cookie` header string into a {@link CookieStore}.
|
|
14
|
+
*/
|
|
15
|
+
export function cookieStoreFromHeader(cookieHeader: string): CookieStore {
|
|
16
|
+
const cookies: Record<string, string> = {}
|
|
17
|
+
if (cookieHeader) {
|
|
18
|
+
for (const pair of cookieHeader.split(';')) {
|
|
19
|
+
const [key, ...valueParts] = pair.trim().split('=')
|
|
20
|
+
if (key) {
|
|
21
|
+
cookies[key.trim()] = decodeURIComponent(valueParts.join('=').trim())
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { get: (name: string) => (name in cookies ? { value: cookies[name] } : undefined) }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PostHogCookieState {
|
|
29
|
+
distinctId: string
|
|
30
|
+
isIdentified: boolean
|
|
31
|
+
sessionId?: string
|
|
32
|
+
deviceId?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Returns the PostHog cookie name for the given API key.
|
|
37
|
+
*
|
|
38
|
+
* PostHog-js stores state in a cookie named `ph_<sanitized_token>_posthog`.
|
|
39
|
+
* The token is sanitized by replacing `+` with `PL`, `/` with `SL`, `=` with `EQ`.
|
|
40
|
+
*
|
|
41
|
+
* @param apiKey - The PostHog project API key
|
|
42
|
+
* @returns The cookie name string
|
|
43
|
+
*/
|
|
44
|
+
export function getPostHogCookieName(apiKey: string): string {
|
|
45
|
+
const sanitized = apiKey.replace(/\+/g, 'PL').replace(/\//g, 'SL').replace(/=/g, 'EQ')
|
|
46
|
+
return `${COOKIE_PREFIX}${sanitized}${COOKIE_SUFFIX}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Serializes an anonymous ID into the JSON format posthog-js expects.
|
|
51
|
+
*
|
|
52
|
+
* When `distinct_id === $device_id`, posthog-js treats the user as anonymous.
|
|
53
|
+
*
|
|
54
|
+
* @param anonymousId - The anonymous distinct ID to serialize
|
|
55
|
+
* @returns JSON string suitable for the PostHog cookie value
|
|
56
|
+
*/
|
|
57
|
+
export function serializePostHogCookie(anonymousId: string): string {
|
|
58
|
+
const now = Date.now()
|
|
59
|
+
const sessionId = uuidv7()
|
|
60
|
+
return JSON.stringify({
|
|
61
|
+
distinct_id: anonymousId,
|
|
62
|
+
$device_id: anonymousId,
|
|
63
|
+
$user_state: 'anonymous',
|
|
64
|
+
$sesid: [now, sessionId, now],
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reads and parses the PostHog cookie from a cookie store.
|
|
70
|
+
*
|
|
71
|
+
* Compatible with Next.js `cookies()`, `request.cookies`, and any object
|
|
72
|
+
* with a `get(name)` method that returns `{ value: string } | undefined`.
|
|
73
|
+
*/
|
|
74
|
+
export function readPostHogCookie(cookies: CookieStore, apiKey: string): PostHogCookieState | null {
|
|
75
|
+
const cookieName = getPostHogCookieName(apiKey)
|
|
76
|
+
const cookie = cookies.get(cookieName)
|
|
77
|
+
return cookie ? parsePostHogCookie(cookie.value) : null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Converts cookie state into PostHog properties (e.g. `$session_id`, `$device_id`).
|
|
82
|
+
*/
|
|
83
|
+
export function cookieStateToProperties(state: PostHogCookieState | null): Record<string, string> | undefined {
|
|
84
|
+
if (!state) {
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
const props: Record<string, string> = {}
|
|
88
|
+
if (state.sessionId) {
|
|
89
|
+
props.$session_id = state.sessionId
|
|
90
|
+
}
|
|
91
|
+
if (state.deviceId) {
|
|
92
|
+
props.$device_id = state.deviceId
|
|
93
|
+
}
|
|
94
|
+
return Object.keys(props).length > 0 ? props : undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Parses a PostHog cookie value and extracts identity information.
|
|
99
|
+
*
|
|
100
|
+
* The cookie value is a JSON object containing `distinct_id` and `$user_state`.
|
|
101
|
+
* A user is considered identified if `$user_state` is `'identified'`.
|
|
102
|
+
*
|
|
103
|
+
* @param cookieValue - The raw cookie string value
|
|
104
|
+
* @returns Parsed identity state, or null if the cookie is missing/invalid
|
|
105
|
+
*/
|
|
106
|
+
export function parsePostHogCookie(cookieValue: string): PostHogCookieState | null {
|
|
107
|
+
if (!cookieValue) {
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(cookieValue)
|
|
113
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.distinct_id) {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// $sesid is stored as [lastActivityTimestamp, sessionId, sessionStartTimestamp]
|
|
118
|
+
const sesid = isArray(parsed.$sesid) ? parsed.$sesid[1] : undefined
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
distinctId: String(parsed.distinct_id),
|
|
122
|
+
isIdentified: parsed.$user_state === 'identified',
|
|
123
|
+
sessionId: typeof sesid === 'string' ? sesid : undefined,
|
|
124
|
+
deviceId: typeof parsed.$device_id === 'string' ? parsed.$device_id : undefined,
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface ConsentCookieConfig {
|
|
132
|
+
consent_persistence_name?: string | null
|
|
133
|
+
opt_out_capturing_cookie_prefix?: string | null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const CONSENT_PREFIX = '__ph_opt_in_out_'
|
|
137
|
+
|
|
138
|
+
export function getConsentCookieName(apiKey: string, config?: ConsentCookieConfig): string {
|
|
139
|
+
if (config?.consent_persistence_name) {
|
|
140
|
+
return config.consent_persistence_name
|
|
141
|
+
}
|
|
142
|
+
if (config?.opt_out_capturing_cookie_prefix) {
|
|
143
|
+
return config.opt_out_capturing_cookie_prefix + apiKey
|
|
144
|
+
}
|
|
145
|
+
return CONSENT_PREFIX + apiKey
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface ConsentConfig extends ConsentCookieConfig {
|
|
149
|
+
opt_out_capturing_by_default?: boolean
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function isOptedOut(cookies: CookieStore, apiKey: string, config?: ConsentConfig): boolean {
|
|
153
|
+
const cookieName = getConsentCookieName(apiKey, config)
|
|
154
|
+
const cookie = cookies.get(cookieName)
|
|
155
|
+
|
|
156
|
+
if (cookie) {
|
|
157
|
+
return isNoLike(cookie.value)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// No consent cookie means pending — defer to config
|
|
161
|
+
return config?.opt_out_capturing_by_default ?? false
|
|
162
|
+
}
|