@activescott/analytics 0.2.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/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # @activescott/analytics
2
+
3
+ PostHog analytics integration for React Router apps — SPA pageview tracking, user identification, and a reverse proxy helper to avoid ad blockers.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @activescott/analytics posthog-js
9
+ ```
10
+
11
+ `posthog-js` is a peer dependency so consumers control its version. When
12
+ bumping the peer floor, check the
13
+ [posthog-js changelog](https://github.com/PostHog/posthog-js/releases) for
14
+ breaking changes to `PostHogConfig` or the `posthog-js/react` exports, then
15
+ release a matching minor of this package.
16
+
17
+ ## Quick Start
18
+
19
+ ### 1. Add the provider to your app
20
+
21
+ Mount it where loader data is available (in React Router v7, that means `App`,
22
+ not `Layout` — `Layout` renders with undefined loader data on error
23
+ boundaries, and error pages intentionally get no pageview):
24
+
25
+ ```tsx
26
+ // app/root.tsx
27
+ import { PostHogProvider } from "@activescott/analytics"
28
+
29
+ export function App() {
30
+ const { posthogKey, user } = useLoaderData<typeof loader>()
31
+ // Dynamic-import the provider module only when a key is configured so
32
+ // disabled/self-host installs pay zero bundle cost for posthog-js.
33
+ ...
34
+ }
35
+ ```
36
+
37
+ Identify by stable id only — never pass emails, phone numbers, or other PII in
38
+ `properties` unless your privacy policy explicitly covers it:
39
+
40
+ ```tsx
41
+ <PostHogProvider
42
+ apiKey={posthogKey}
43
+ options={{ api_host: "/ph", ui_host: "https://us.posthog.com" }}
44
+ user={user ? { distinctId: user.id } : undefined}
45
+ >
46
+ ...
47
+ </PostHogProvider>
48
+ ```
49
+
50
+ With no `apiKey` the provider renders children with zero PostHog code paths.
51
+
52
+ ### 2. Add the reverse proxy route
53
+
54
+ Create `app/routes/ph.$.ts`:
55
+
56
+ ```ts
57
+ import { createPostHogProxy } from "@activescott/analytics/proxy"
58
+
59
+ export const { loader, action } = createPostHogProxy({
60
+ apiHost: "https://us.i.posthog.com",
61
+ assetsHost: "https://us-assets.i.posthog.com",
62
+ // Opt in only if your privacy policy covers it: forwards the ingress-set
63
+ // X-Forwarded-For chain so PostHog sees client IPs for geoIP.
64
+ forwardIp: true,
65
+ })
66
+ ```
67
+
68
+ Register the route in `app/routes.ts`:
69
+
70
+ ```ts
71
+ route("ph/*", "routes/ph.$.ts"),
72
+ ```
73
+
74
+ The proxy hardens the relay for you: upstream origins are fixed at
75
+ configuration time, `Cookie`/`Authorization` (and everything but content-type +
76
+ user-agent) are stripped, `Set-Cookie` is stripped from responses, request
77
+ bodies are capped (default 1 MiB), the upstream call has a timeout (default
78
+ 10s), and only known PostHog path prefixes (`e`, `batch`, `decide`, `flags`,
79
+ `s`, `surveys`, `array`, `static`) are proxied. IP forwarding is deliberately
80
+ absent — `X-Forwarded-For` passthrough vs `?ip=` is a privacy decision for you
81
+ to make explicitly.
82
+
83
+ That's it! PostHog will now:
84
+
85
+ - Track pageviews on every client-side navigation
86
+ - Identify logged-in users by id
87
+ - Receive events through your own domain (`/ph/*`)
88
+
89
+ ## Versioning
90
+
91
+ Independent plain-npm versions with git tags of the form `analytics@<version>`
92
+ (see repo README). Consumers pin the versioned package normally — lockfile,
93
+ Dependabot, and Docker all work unchanged.
@@ -0,0 +1,4 @@
1
+ export { PostHogProvider } from "./post-hog-provider.js";
2
+ export { PostHogPageviewTracker } from "./post-hog-pageview-tracker.js";
3
+ export { PostHogIdentifier } from "./post-hog-identifier.js";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AACxD,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAA;AACvE,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { PostHogProvider } from "./post-hog-provider.js";
2
+ export { PostHogPageviewTracker } from "./post-hog-pageview-tracker.js";
3
+ export { PostHogIdentifier } from "./post-hog-identifier.js";
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AACxD,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAA;AACvE,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAA"}
@@ -0,0 +1,12 @@
1
+ interface PostHogIdentifierProps {
2
+ distinctId?: string | null;
3
+ properties?: Record<string, unknown>;
4
+ }
5
+ /**
6
+ * Identifies the current user to PostHog for analytics tracking.
7
+ * When `distinctId` is provided, calls `posthog.identify()`.
8
+ * When transitioning from identified to anonymous (distinctId removed), calls `posthog.reset()`.
9
+ */
10
+ export declare function PostHogIdentifier({ distinctId, properties, }: PostHogIdentifierProps): null;
11
+ export {};
12
+ //# sourceMappingURL=post-hog-identifier.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"post-hog-identifier.d.ts","sourceRoot":"","sources":["../src/post-hog-identifier.tsx"],"names":[],"mappings":"AAGA,UAAU,sBAAsB;IAC9B,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACrC;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,UAAU,EACV,UAAU,GACX,EAAE,sBAAsB,QAiBxB"}
@@ -0,0 +1,23 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { usePostHog } from "posthog-js/react";
3
+ /**
4
+ * Identifies the current user to PostHog for analytics tracking.
5
+ * When `distinctId` is provided, calls `posthog.identify()`.
6
+ * When transitioning from identified to anonymous (distinctId removed), calls `posthog.reset()`.
7
+ */
8
+ export function PostHogIdentifier({ distinctId, properties, }) {
9
+ const posthog = usePostHog();
10
+ const previousDistinctIdReference = useRef(distinctId);
11
+ useEffect(() => {
12
+ const previousDistinctId = previousDistinctIdReference.current;
13
+ if (distinctId) {
14
+ posthog.identify(distinctId, properties);
15
+ }
16
+ else if (previousDistinctId && !distinctId) {
17
+ posthog.reset();
18
+ }
19
+ previousDistinctIdReference.current = distinctId;
20
+ }, [distinctId, properties, posthog]);
21
+ return null;
22
+ }
23
+ //# sourceMappingURL=post-hog-identifier.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"post-hog-identifier.js","sourceRoot":"","sources":["../src/post-hog-identifier.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,OAAO,CAAA;AACzC,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAO7C;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,EAChC,UAAU,EACV,UAAU,GACa;IACvB,MAAM,OAAO,GAAG,UAAU,EAAE,CAAA;IAC5B,MAAM,2BAA2B,GAAG,MAAM,CAAC,UAAU,CAAC,CAAA;IAEtD,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,kBAAkB,GAAG,2BAA2B,CAAC,OAAO,CAAA;QAE9D,IAAI,UAAU,EAAE,CAAC;YACf,OAAO,CAAC,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC,CAAA;QAC1C,CAAC;aAAM,IAAI,kBAAkB,IAAI,CAAC,UAAU,EAAE,CAAC;YAC7C,OAAO,CAAC,KAAK,EAAE,CAAA;QACjB,CAAC;QAED,2BAA2B,CAAC,OAAO,GAAG,UAAU,CAAA;IAClD,CAAC,EAAE,CAAC,UAAU,EAAE,UAAU,EAAE,OAAO,CAAC,CAAC,CAAA;IAErC,OAAO,IAAI,CAAA;AACb,CAAC"}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Captures `$pageview` events on client-side route changes.
3
+ * Required because `capture_pageview` is set to `false` in the PostHog config
4
+ * (the built-in capture only fires on initial page load, missing SPA navigations).
5
+ */
6
+ export declare function PostHogPageviewTracker(): null;
7
+ //# sourceMappingURL=post-hog-pageview-tracker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"post-hog-pageview-tracker.d.ts","sourceRoot":"","sources":["../src/post-hog-pageview-tracker.tsx"],"names":[],"mappings":"AAIA;;;;GAIG;AACH,wBAAgB,sBAAsB,SASrC"}
@@ -0,0 +1,17 @@
1
+ import { useEffect } from "react";
2
+ import { useLocation } from "react-router";
3
+ import { usePostHog } from "posthog-js/react";
4
+ /**
5
+ * Captures `$pageview` events on client-side route changes.
6
+ * Required because `capture_pageview` is set to `false` in the PostHog config
7
+ * (the built-in capture only fires on initial page load, missing SPA navigations).
8
+ */
9
+ export function PostHogPageviewTracker() {
10
+ const location = useLocation();
11
+ const posthog = usePostHog();
12
+ useEffect(() => {
13
+ posthog.capture("$pageview");
14
+ }, [location.pathname, location.search, posthog]);
15
+ return null;
16
+ }
17
+ //# sourceMappingURL=post-hog-pageview-tracker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"post-hog-pageview-tracker.js","sourceRoot":"","sources":["../src/post-hog-pageview-tracker.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AACjC,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAE7C;;;;GAIG;AACH,MAAM,UAAU,sBAAsB;IACpC,MAAM,QAAQ,GAAG,WAAW,EAAE,CAAA;IAC9B,MAAM,OAAO,GAAG,UAAU,EAAE,CAAA;IAE5B,SAAS,CAAC,GAAG,EAAE;QACb,OAAO,CAAC,OAAO,CAAC,WAAW,CAAC,CAAA;IAC9B,CAAC,EAAE,CAAC,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;IAEjD,OAAO,IAAI,CAAA;AACb,CAAC"}
@@ -0,0 +1,18 @@
1
+ import type { PostHogConfig } from "posthog-js";
2
+ interface PostHogProviderProps {
3
+ apiKey?: string;
4
+ options?: Partial<PostHogConfig>;
5
+ user?: {
6
+ distinctId: string;
7
+ properties?: Record<string, unknown>;
8
+ };
9
+ children: React.ReactNode;
10
+ }
11
+ /**
12
+ * All-in-one PostHog provider for React Router apps.
13
+ * Wraps children with PostHog context, tracks SPA pageviews, and identifies users.
14
+ * If `apiKey` is omitted, renders children without any PostHog functionality.
15
+ */
16
+ export declare function PostHogProvider({ apiKey, options, user, children, }: PostHogProviderProps): import("react").JSX.Element;
17
+ export {};
18
+ //# sourceMappingURL=post-hog-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"post-hog-provider.d.ts","sourceRoot":"","sources":["../src/post-hog-provider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAK/C,UAAU,oBAAoB;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,OAAO,CAAC,aAAa,CAAC,CAAA;IAChC,IAAI,CAAC,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAA;IACnE,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAC1B;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,EAC9B,MAAM,EACN,OAAO,EACP,IAAI,EACJ,QAAQ,GACT,EAAE,oBAAoB,+BAqBtB"}
@@ -0,0 +1,19 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { PostHogProvider as PHProvider } from "posthog-js/react";
3
+ import { PostHogPageviewTracker } from "./post-hog-pageview-tracker.js";
4
+ import { PostHogIdentifier } from "./post-hog-identifier.js";
5
+ /**
6
+ * All-in-one PostHog provider for React Router apps.
7
+ * Wraps children with PostHog context, tracks SPA pageviews, and identifies users.
8
+ * If `apiKey` is omitted, renders children without any PostHog functionality.
9
+ */
10
+ export function PostHogProvider({ apiKey, options, user, children, }) {
11
+ if (!apiKey) {
12
+ return _jsx(_Fragment, { children: children });
13
+ }
14
+ return (_jsxs(PHProvider, { apiKey: apiKey, options: {
15
+ ...options,
16
+ capture_pageview: false,
17
+ }, children: [_jsx(PostHogPageviewTracker, {}), _jsx(PostHogIdentifier, { distinctId: user?.distinctId, properties: user?.properties }), children] }));
18
+ }
19
+ //# sourceMappingURL=post-hog-provider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"post-hog-provider.js","sourceRoot":"","sources":["../src/post-hog-provider.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,eAAe,IAAI,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAChE,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAA;AACvE,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAA;AAS5D;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAAC,EAC9B,MAAM,EACN,OAAO,EACP,IAAI,EACJ,QAAQ,GACa;IACrB,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,OAAO,4BAAG,QAAQ,GAAI,CAAA;IACxB,CAAC;IAED,OAAO,CACL,MAAC,UAAU,IACT,MAAM,EAAE,MAAM,EACd,OAAO,EAAE;YACP,GAAG,OAAO;YACV,gBAAgB,EAAE,KAAK;SACxB,aAED,KAAC,sBAAsB,KAAG,EAC1B,KAAC,iBAAiB,IAChB,UAAU,EAAE,IAAI,EAAE,UAAU,EAC5B,UAAU,EAAE,IAAI,EAAE,UAAU,GAC5B,EACD,QAAQ,IACE,CACd,CAAA;AACH,CAAC"}
@@ -0,0 +1,76 @@
1
+ interface PostHogProxyOptions {
2
+ /**
3
+ * PostHog API origin, e.g. `"https://us.i.posthog.com"`. Full origin, not a
4
+ * region — the region choice stays explicit at the call site.
5
+ */
6
+ apiHost: string;
7
+ /**
8
+ * PostHog static-assets origin. Defaults to `apiHost`. PostHog cloud serves
9
+ * assets from `<region>-assets.i.posthog.com`; pass it explicitly when the
10
+ * `static/` prefix must not hit the API origin.
11
+ */
12
+ assetsHost?: string;
13
+ /**
14
+ * Path prefix this route is mounted at. Defaults to `"/ph"`.
15
+ */
16
+ mountPath?: string;
17
+ /**
18
+ * Maximum proxied request body in bytes, enforced BEFORE buffering (the
19
+ * Content-Length header is rejected first, then the stream is accumulated
20
+ * with a running cap). Defaults to 1 MiB — events are kilobytes.
21
+ */
22
+ maxBodyBytes?: number;
23
+ /**
24
+ * Upstream timeout in milliseconds. Defaults to 10s; a hung PostHog must
25
+ * never pin server resources.
26
+ */
27
+ timeoutMs?: number;
28
+ /**
29
+ * Forward the incoming `X-Forwarded-For` header unchanged so PostHog sees
30
+ * the client IP chain (set by your ingress, used for geoIP). Defaults to
31
+ * false — no IP signal leaves your infrastructure unless you opt in.
32
+ * Spoofing caveat: clients can prepend entries to the chain; PostHog
33
+ * parses it per its own rules, same as every PostHog proxy setup.
34
+ */
35
+ forwardIp?: boolean;
36
+ }
37
+ /**
38
+ * Creates React Router `loader` and `action` handlers that proxy requests to
39
+ * PostHog. Use this in a splat route (e.g. `ph.$.ts`) to reverse-proxy all
40
+ * PostHog requests through your own domain, preventing CSP/CORS issues and ad
41
+ * blocker interference.
42
+ *
43
+ * Hardening (this route is same-origin, so browsers attach your session
44
+ * cookie to every `/ph` request):
45
+ *
46
+ * - Upstream origins are fixed at configuration time, never derived from
47
+ * request input.
48
+ * - `Cookie` and `Authorization` headers are stripped before proxying; only a
49
+ * deliberate allowlist of headers is forwarded.
50
+ * - Response `Set-Cookie` is stripped so PostHog can never plant cookies on
51
+ * your domain.
52
+ * - Request bodies are capped and the upstream call has a timeout.
53
+ * - Bodies are never logged or echoed back in error responses.
54
+ * - Client IPs are NOT forwarded by default; pass `forwardIp: true` to send
55
+ * the ingress-set `X-Forwarded-For` chain upstream for geoIP.
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * // app/routes/ph.$.ts
60
+ * import { createPostHogProxy } from "@activescott/analytics/proxy"
61
+ * export const { loader, action } = createPostHogProxy({
62
+ * apiHost: "https://us.i.posthog.com",
63
+ * assetsHost: "https://us-assets.i.posthog.com",
64
+ * })
65
+ * ```
66
+ */
67
+ export declare function createPostHogProxy(options: PostHogProxyOptions): {
68
+ loader: (loaderArguments: {
69
+ request: Request;
70
+ }) => Promise<Response>;
71
+ action: (actionArguments: {
72
+ request: Request;
73
+ }) => Promise<Response>;
74
+ };
75
+ export {};
76
+ //# sourceMappingURL=proxy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../src/proxy.ts"],"names":[],"mappings":"AAiCA,UAAU,mBAAmB;IAC3B;;;OAGG;IACH,OAAO,EAAE,MAAM,CAAA;IACf;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;OAEG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,mBAAmB,GAAG;IAChE,MAAM,EAAE,CAAC,eAAe,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;IACpE,MAAM,EAAE,CAAC,eAAe,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;CACrE,CA0HA"}
package/dist/proxy.js ADDED
@@ -0,0 +1,215 @@
1
+ const HTTP_NO_CONTENT = 204;
2
+ const HTTP_NOT_MODIFIED = 304;
3
+ const HTTP_NOT_FOUND = 404;
4
+ const HTTP_METHOD_NOT_ALLOWED = 405;
5
+ const HTTP_PAYLOAD_TOO_LARGE = 413;
6
+ const HTTP_BAD_GATEWAY = 502;
7
+ const HTTP_GATEWAY_TIMEOUT = 504;
8
+ const HTTP_INTERNAL_SERVER_ERROR = 500;
9
+ const DEFAULT_MOUNT_PATH = "/ph";
10
+ const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
11
+ const DEFAULT_TIMEOUT_MS = 10_000;
12
+ /**
13
+ * First path segments (after the mount prefix) that may be proxied. Everything
14
+ * else answers 404 so this route can never become a generic relay. Covers
15
+ * posthog-js capture (`e`, `batch`), flags (`decide`, `flags`), session
16
+ * replay (`s`), surveys (`surveys`), autotrack/toolbar helpers (`array`) and
17
+ * static assets (`static`, routed to the assets host). Widen with care.
18
+ */
19
+ const ALLOWED_PREFIXES = [
20
+ "e",
21
+ "batch",
22
+ "decide",
23
+ "flags",
24
+ "s",
25
+ "surveys",
26
+ "array",
27
+ "static",
28
+ ];
29
+ const ALLOWED_METHODS = ["GET", "POST", "OPTIONS"];
30
+ /**
31
+ * Creates React Router `loader` and `action` handlers that proxy requests to
32
+ * PostHog. Use this in a splat route (e.g. `ph.$.ts`) to reverse-proxy all
33
+ * PostHog requests through your own domain, preventing CSP/CORS issues and ad
34
+ * blocker interference.
35
+ *
36
+ * Hardening (this route is same-origin, so browsers attach your session
37
+ * cookie to every `/ph` request):
38
+ *
39
+ * - Upstream origins are fixed at configuration time, never derived from
40
+ * request input.
41
+ * - `Cookie` and `Authorization` headers are stripped before proxying; only a
42
+ * deliberate allowlist of headers is forwarded.
43
+ * - Response `Set-Cookie` is stripped so PostHog can never plant cookies on
44
+ * your domain.
45
+ * - Request bodies are capped and the upstream call has a timeout.
46
+ * - Bodies are never logged or echoed back in error responses.
47
+ * - Client IPs are NOT forwarded by default; pass `forwardIp: true` to send
48
+ * the ingress-set `X-Forwarded-For` chain upstream for geoIP.
49
+ *
50
+ * @example
51
+ * ```ts
52
+ * // app/routes/ph.$.ts
53
+ * import { createPostHogProxy } from "@activescott/analytics/proxy"
54
+ * export const { loader, action } = createPostHogProxy({
55
+ * apiHost: "https://us.i.posthog.com",
56
+ * assetsHost: "https://us-assets.i.posthog.com",
57
+ * })
58
+ * ```
59
+ */
60
+ export function createPostHogProxy(options) {
61
+ const apiHost = normalizeOrigin(options.apiHost);
62
+ const assetsHost = normalizeOrigin(options.assetsHost ?? options.apiHost);
63
+ const mountPath = (options.mountPath ?? DEFAULT_MOUNT_PATH).replace(/\/$/, "");
64
+ const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
65
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
66
+ const forwardIp = options.forwardIp ?? false;
67
+ async function proxyRequest(request) {
68
+ if (!ALLOWED_METHODS.includes(request.method)) {
69
+ return new Response("Method Not Allowed", {
70
+ status: HTTP_METHOD_NOT_ALLOWED,
71
+ });
72
+ }
73
+ let pathname;
74
+ try {
75
+ const url = new URL(request.url);
76
+ pathname = url.pathname;
77
+ if (!pathname.startsWith(`${mountPath}/`) && pathname !== mountPath) {
78
+ return new Response("Not Found", { status: HTTP_NOT_FOUND });
79
+ }
80
+ const stripped = pathname.slice(mountPath.length).replace(/^\//, "");
81
+ const firstSegment = stripped.split("/", 1)[0] ?? "";
82
+ const isAllowed = ALLOWED_PREFIXES.includes(firstSegment);
83
+ if (!isAllowed) {
84
+ return new Response("Not Found", { status: HTTP_NOT_FOUND });
85
+ }
86
+ const targetHost = firstSegment === "static" ? assetsHost : apiHost;
87
+ const targetUrl = new URL(`${stripped}${url.search}`, `${targetHost}/`);
88
+ const headers = new Headers();
89
+ const contentType = request.headers.get("content-type");
90
+ // Accept whatever content type the client sent (posthog-js uses
91
+ // `text/plain` for sendBeacon fallbacks) — the body is opaque bytes.
92
+ if (contentType) {
93
+ headers.set("content-type", contentType);
94
+ }
95
+ const userAgent = request.headers.get("user-agent");
96
+ if (userAgent) {
97
+ headers.set("user-agent", userAgent);
98
+ }
99
+ // Opt-in only: pass the ingress-set client IP chain through for
100
+ // PostHog geoIP. Forwarded unchanged (never synthesized here) and never
101
+ // logged. See the `forwardIp` option docs for the spoofing caveat.
102
+ if (forwardIp) {
103
+ const forwardedFor = request.headers.get("x-forwarded-for");
104
+ if (forwardedFor) {
105
+ headers.set("x-forwarded-for", forwardedFor);
106
+ }
107
+ }
108
+ // Deliberately NOT forwarded: Cookie, Authorization, and everything
109
+ // else.
110
+ let body;
111
+ if (request.method !== "GET" && request.method !== "HEAD") {
112
+ const contentLength = request.headers.get("content-length");
113
+ if (contentLength && Number(contentLength) > maxBodyBytes) {
114
+ return new Response("Payload Too Large", {
115
+ status: HTTP_PAYLOAD_TOO_LARGE,
116
+ });
117
+ }
118
+ body = await readCappedBody(request.body, maxBodyBytes);
119
+ if (!body) {
120
+ return new Response("Payload Too Large", {
121
+ status: HTTP_PAYLOAD_TOO_LARGE,
122
+ });
123
+ }
124
+ }
125
+ const phResponse = await fetch(targetUrl.toString(), {
126
+ method: request.method,
127
+ headers,
128
+ body,
129
+ signal: AbortSignal.timeout(timeoutMs),
130
+ });
131
+ if (phResponse.status === HTTP_NO_CONTENT ||
132
+ phResponse.status === HTTP_NOT_MODIFIED ||
133
+ !phResponse.body) {
134
+ return new Response(null, {
135
+ status: phResponse.status,
136
+ statusText: phResponse.statusText,
137
+ });
138
+ }
139
+ const responseHeaders = new Headers(phResponse.headers);
140
+ responseHeaders.delete("content-encoding");
141
+ responseHeaders.delete("content-length");
142
+ responseHeaders.delete("set-cookie");
143
+ return new Response(phResponse.body, {
144
+ status: phResponse.status,
145
+ statusText: phResponse.statusText,
146
+ headers: responseHeaders,
147
+ });
148
+ }
149
+ catch (error) {
150
+ if (error instanceof DOMException && error.name === "TimeoutError") {
151
+ return new Response("Gateway Timeout", {
152
+ status: HTTP_GATEWAY_TIMEOUT,
153
+ });
154
+ }
155
+ if (error instanceof Error && error.name === "AbortError") {
156
+ return new Response("Gateway Timeout", {
157
+ status: HTTP_GATEWAY_TIMEOUT,
158
+ });
159
+ }
160
+ return new Response("Bad Gateway", { status: HTTP_BAD_GATEWAY });
161
+ }
162
+ }
163
+ return {
164
+ loader({ request }) {
165
+ return proxyRequest(request);
166
+ },
167
+ action({ request }) {
168
+ return proxyRequest(request);
169
+ },
170
+ };
171
+ }
172
+ function normalizeOrigin(origin) {
173
+ const url = new URL(origin);
174
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
175
+ throw new Error(`PostHog origin must be http(s): received ${url.protocol}`);
176
+ }
177
+ return url.origin;
178
+ }
179
+ /**
180
+ * Accumulates a request stream up to `maxBytes`. Returns undefined when the
181
+ * body exceeds the cap (caller answers 413 without ever buffering it whole).
182
+ */
183
+ async function readCappedBody(stream, maxBytes) {
184
+ if (!stream) {
185
+ return new Uint8Array(0);
186
+ }
187
+ const reader = stream.getReader();
188
+ const chunks = [];
189
+ let total = 0;
190
+ try {
191
+ for (;;) {
192
+ const { done, value } = await reader.read();
193
+ if (done) {
194
+ break;
195
+ }
196
+ total += value.byteLength;
197
+ if (total > maxBytes) {
198
+ await reader.cancel().catch(() => { });
199
+ return undefined;
200
+ }
201
+ chunks.push(value);
202
+ }
203
+ }
204
+ finally {
205
+ reader.releaseLock();
206
+ }
207
+ const merged = new Uint8Array(total);
208
+ let offset = 0;
209
+ for (const chunk of chunks) {
210
+ merged.set(chunk, offset);
211
+ offset += chunk.byteLength;
212
+ }
213
+ return merged;
214
+ }
215
+ //# sourceMappingURL=proxy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxy.js","sourceRoot":"","sources":["../src/proxy.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@activescott/analytics",
3
+ "version": "0.2.0",
4
+ "description": "PostHog analytics integration for React Router apps — provider, SPA pageview tracking, user identification, and reverse proxy helper",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./proxy": {
14
+ "types": "./dist/proxy.d.ts",
15
+ "import": "./dist/proxy.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.build.json",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "vitest run"
25
+ },
26
+ "keywords": [
27
+ "posthog",
28
+ "analytics",
29
+ "react-router",
30
+ "spa",
31
+ "pageview",
32
+ "proxy"
33
+ ],
34
+ "author": "Scott Willeke",
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/activescott/js-utils.git",
39
+ "directory": "packages/analytics"
40
+ },
41
+ "peerDependencies": {
42
+ "posthog-js": "^1.200.0",
43
+ "react": ">=18",
44
+ "react-router": "^7.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^22",
48
+ "@types/react": "^19.2.14",
49
+ "posthog-js": "^1.351.4",
50
+ "react": "^19.2.4",
51
+ "react-router": "^7.10.1",
52
+ "typescript": "^5.9.3",
53
+ "vitest": "4.1.10"
54
+ }
55
+ }