@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.
Files changed (98) hide show
  1. package/LICENSE +299 -0
  2. package/README.md +122 -0
  3. package/dist/app/PostHogProvider.d.ts +62 -0
  4. package/dist/app/PostHogProvider.d.ts.map +1 -0
  5. package/dist/app/PostHogProvider.js +67 -0
  6. package/dist/app/PostHogProvider.js.map +1 -0
  7. package/dist/client/ClientPostHogProvider.d.ts +28 -0
  8. package/dist/client/ClientPostHogProvider.d.ts.map +1 -0
  9. package/dist/client/ClientPostHogProvider.js +34 -0
  10. package/dist/client/ClientPostHogProvider.js.map +1 -0
  11. package/dist/client/PostHogPageView.d.ts +30 -0
  12. package/dist/client/PostHogPageView.d.ts.map +1 -0
  13. package/dist/client/PostHogPageView.js +54 -0
  14. package/dist/client/PostHogPageView.js.map +1 -0
  15. package/dist/client/hooks.d.ts +2 -0
  16. package/dist/client/hooks.d.ts.map +1 -0
  17. package/dist/client/hooks.js +3 -0
  18. package/dist/client/hooks.js.map +1 -0
  19. package/dist/index.client.d.ts +6 -0
  20. package/dist/index.client.d.ts.map +1 -0
  21. package/dist/index.client.js +6 -0
  22. package/dist/index.client.js.map +1 -0
  23. package/dist/index.d.ts +9 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.edge.d.ts +7 -0
  26. package/dist/index.edge.d.ts.map +1 -0
  27. package/dist/index.edge.js +7 -0
  28. package/dist/index.edge.js.map +1 -0
  29. package/dist/index.js +7 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/index.react-server.d.ts +5 -0
  32. package/dist/index.react-server.d.ts.map +1 -0
  33. package/dist/index.react-server.js +6 -0
  34. package/dist/index.react-server.js.map +1 -0
  35. package/dist/middleware/postHogMiddleware.d.ts +95 -0
  36. package/dist/middleware/postHogMiddleware.d.ts.map +1 -0
  37. package/dist/middleware/postHogMiddleware.js +88 -0
  38. package/dist/middleware/postHogMiddleware.js.map +1 -0
  39. package/dist/pages/PostHogPageView.d.ts +25 -0
  40. package/dist/pages/PostHogPageView.d.ts.map +1 -0
  41. package/dist/pages/PostHogPageView.js +38 -0
  42. package/dist/pages/PostHogPageView.js.map +1 -0
  43. package/dist/pages/PostHogProvider.d.ts +16 -0
  44. package/dist/pages/PostHogProvider.d.ts.map +1 -0
  45. package/dist/pages/PostHogProvider.js +38 -0
  46. package/dist/pages/PostHogProvider.js.map +1 -0
  47. package/dist/pages/getServerSidePostHog.d.ts +27 -0
  48. package/dist/pages/getServerSidePostHog.d.ts.map +1 -0
  49. package/dist/pages/getServerSidePostHog.js +40 -0
  50. package/dist/pages/getServerSidePostHog.js.map +1 -0
  51. package/dist/pages.d.ts +9 -0
  52. package/dist/pages.d.ts.map +1 -0
  53. package/dist/pages.js +7 -0
  54. package/dist/pages.js.map +1 -0
  55. package/dist/server/getPostHog.d.ts +29 -0
  56. package/dist/server/getPostHog.d.ts.map +1 -0
  57. package/dist/server/getPostHog.js +61 -0
  58. package/dist/server/getPostHog.js.map +1 -0
  59. package/dist/server/nodeClientCache.d.ts +14 -0
  60. package/dist/server/nodeClientCache.d.ts.map +1 -0
  61. package/dist/server/nodeClientCache.js +33 -0
  62. package/dist/server/nodeClientCache.js.map +1 -0
  63. package/dist/shared/config.d.ts +36 -0
  64. package/dist/shared/config.d.ts.map +1 -0
  65. package/dist/shared/config.js +35 -0
  66. package/dist/shared/config.js.map +1 -0
  67. package/dist/shared/constants.d.ts +6 -0
  68. package/dist/shared/constants.d.ts.map +1 -0
  69. package/dist/shared/constants.js +6 -0
  70. package/dist/shared/constants.js.map +1 -0
  71. package/dist/shared/cookie.d.ts +69 -0
  72. package/dist/shared/cookie.d.ts.map +1 -0
  73. package/dist/shared/cookie.js +126 -0
  74. package/dist/shared/cookie.js.map +1 -0
  75. package/dist/shared/identity.d.ts +6 -0
  76. package/dist/shared/identity.d.ts.map +1 -0
  77. package/dist/shared/identity.js +9 -0
  78. package/dist/shared/identity.js.map +1 -0
  79. package/package.json +89 -0
  80. package/src/app/PostHogProvider.tsx +141 -0
  81. package/src/client/ClientPostHogProvider.tsx +51 -0
  82. package/src/client/PostHogPageView.tsx +63 -0
  83. package/src/client/hooks.ts +8 -0
  84. package/src/index.client.ts +9 -0
  85. package/src/index.edge.ts +10 -0
  86. package/src/index.react-server.ts +7 -0
  87. package/src/index.ts +8 -0
  88. package/src/middleware/postHogMiddleware.ts +170 -0
  89. package/src/pages/PostHogPageView.tsx +41 -0
  90. package/src/pages/PostHogProvider.tsx +61 -0
  91. package/src/pages/getServerSidePostHog.ts +49 -0
  92. package/src/pages.ts +8 -0
  93. package/src/server/getPostHog.ts +66 -0
  94. package/src/server/nodeClientCache.ts +37 -0
  95. package/src/shared/config.ts +52 -0
  96. package/src/shared/constants.ts +5 -0
  97. package/src/shared/cookie.ts +162 -0
  98. 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,5 @@
1
+ export const COOKIE_PREFIX = 'ph_'
2
+ export const COOKIE_SUFFIX = '_posthog'
3
+ export const DEFAULT_API_HOST = 'https://us.i.posthog.com'
4
+ export const COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60
5
+ export const DEFAULT_INGEST_PATH = '/ingest'
@@ -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
+ }
@@ -0,0 +1,9 @@
1
+ import { uuidv7 } from '@posthog/core'
2
+
3
+ /**
4
+ * Generates a random anonymous distinct_id using UUIDv7.
5
+ * Used as a fallback when no PostHog cookie is available.
6
+ */
7
+ export function generateAnonymousId(): string {
8
+ return uuidv7()
9
+ }