@merkaly/nuxt 0.10.5 → 0.11.1

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 CHANGED
@@ -75,6 +75,51 @@ The sitemap is only installed for indexable apps and is served at `/sitemap.xml`
75
75
  alternate points to `merkaly.i18n.defaultLocale`; the sitemap is installed after
76
76
  `@nuxtjs/i18n` so it reads those options.
77
77
 
78
+ ## PostHog
79
+
80
+ Product analytics with [PostHog](https://posthog.com), **US region**, behind a
81
+ first-party proxy. Set the project key and the module does the rest:
82
+
83
+ ```ts
84
+ export default defineNuxtConfig({
85
+ merkaly: {
86
+ posthog: process.env.POSTHOG_KEY, // phc_...
87
+ },
88
+ });
89
+ ```
90
+
91
+ Without a key nothing is installed: no plugin, no proxy route and no
92
+ `posthog-js` in the bundle. With a key it lands in
93
+ `runtimeConfig.public.merkaly.posthog`, so `NUXT_PUBLIC_MERKALY_POSTHOG`
94
+ overrides it at runtime.
95
+
96
+ **Client plugin.** Initializes `posthog-js` **only in production builds**
97
+ (`nuxt dev` never sends events) with `api_host: '/phog'` and
98
+ `ui_host: 'https://us.posthog.com'`. It identifies the Auth0 user from
99
+ `useAuth()` (`identify(sub, { email, name })`) and calls `posthog.reset()` on
100
+ logout. The app can keep using `import posthog from 'posthog-js'` for
101
+ `capture()`: the module dedupes `posthog-js` so the app and the plugin share
102
+ the initialized instance (keep `posthog-js` as an app dependency).
103
+
104
+ **Proxy `/phog/**`.** A Nitro server route, so apps must run as a Node server
105
+ (`node .output/server`), not as static files.
106
+
107
+ | Path | Upstream |
108
+ |---|---|
109
+ | `/phog/static/*`, `/phog/array/*` | `https://us-assets.i.posthog.com` |
110
+ | everything else (`/e/`, `/batch/`, `/s/`, `/i/v1/*`, `/flags/`, `/decide/`...) | `https://us.i.posthog.com` |
111
+
112
+ - Path and query string are kept; `Host` is the upstream host (PostHog answers 401 otherwise).
113
+ - Forwarded: `PostHog-*` headers, `Content-Type`, `Content-Encoding`, `User-Agent`, `Accept`, and `Authorization` only when it is a PostHog token (`Bearer phc_`, `phs_` or `phx_`).
114
+ - `X-Forwarded-For` gets the client IP appended, so PostHog geolocates the visitor and not the server.
115
+ - **Cookies are never forwarded**, and PostHog `Set-Cookie` headers never reach the app domain.
116
+ - The request body is streamed, up to **64 MB** (session recordings); larger bodies get `413`.
117
+ - The response is streamed back with its status and cache headers.
118
+
119
+ The path is `/phog` because ad blockers drop requests to paths such as
120
+ `/posthog`, `/analytics`, `/tracking` or `/telemetry`; a first-party, neutral
121
+ path keeps events flowing.
122
+
78
123
  ## Money formatting
79
124
 
80
125
  Money in Merkaly is stored as **integer cents**. `MKFormatMoney`, `MKInputMoney`,
package/dist/module.d.mts CHANGED
@@ -52,6 +52,8 @@ interface MerkalyModuleOptions {
52
52
  domain: string;
53
53
  localhost: string;
54
54
  };
55
+ /** PostHog project key (`phc_...`, US region). Empty installs nothing. */
56
+ posthog?: string;
55
57
  sentry: {
56
58
  dsn: string;
57
59
  project: string;
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.14.0"
6
6
  },
7
- "version": "0.10.5",
7
+ "version": "0.11.1",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -38,6 +38,7 @@ const defaultOptions = {
38
38
  domain: "",
39
39
  localhost: ""
40
40
  },
41
+ posthog: "",
41
42
  sentry: {
42
43
  dsn: "",
43
44
  project: "",
@@ -59,6 +60,9 @@ function hasI18nConfig(options) {
59
60
  function hasPlausibleConfig(options) {
60
61
  return Boolean(options.plausible?.domain);
61
62
  }
63
+ function hasPosthogConfig(options) {
64
+ return Boolean(options.posthog);
65
+ }
62
66
  function hasSentryBuildConfig(options) {
63
67
  return Boolean(options.sentry.project && options.sentry.token);
64
68
  }
@@ -189,6 +193,8 @@ function configureRuntimeConfig(nuxt, options) {
189
193
  i18n: options.i18n,
190
194
  money: options.money,
191
195
  plausible: options.plausible,
196
+ posthog: options.posthog,
197
+ // NUXT_PUBLIC_MERKALY_POSTHOG overrides it at runtime
192
198
  sentry: {
193
199
  dsn: options.sentry.dsn,
194
200
  project: options.sentry.project
@@ -247,14 +253,15 @@ function configureFontAwesome(nuxt) {
247
253
  src: FONT_AWESOME_KIT_URL
248
254
  });
249
255
  }
250
- function configureVite(nuxt) {
256
+ function configureVite(nuxt, options) {
251
257
  nuxt.options.vite = defu(nuxt.options.vite || {}, {
252
258
  plugins: [svgLoader()],
253
259
  // Force a single shared notivue instance so consumers don't have to declare
254
260
  // `vite.resolve.dedupe: ['notivue']` themselves (avoids the duplicate-instance
255
261
  // `useNotivueInstance()` error, especially when @merkaly/nuxt is linked locally).
256
262
  resolve: {
257
- dedupe: ["notivue"]
263
+ dedupe: ["notivue", ...hasPosthogConfig(options) ? ["posthog-js"] : []]
264
+ // app imports share the initialized posthog
258
265
  }
259
266
  });
260
267
  }
@@ -266,16 +273,26 @@ function configureNuxtOptions(nuxt, options) {
266
273
  configureSentry(nuxt, options);
267
274
  }
268
275
  configureFontAwesome(nuxt);
269
- configureVite(nuxt);
276
+ configureVite(nuxt, options);
270
277
  }
271
278
  function registerRuntimeFeatures(options, resolver) {
272
279
  addPlugin({ src: resolver.resolve("./runtime/plugins/api.global") });
273
280
  addPlugin({ src: resolver.resolve("./runtime/plugins/auth0.client") });
274
281
  addPlugin({ src: resolver.resolve("./runtime/plugins/sentry.global") });
282
+ if (hasPosthogConfig(options)) {
283
+ addPlugin({ src: resolver.resolve("./runtime/plugins/posthog.client") });
284
+ }
275
285
  addServerHandler({
276
286
  handler: resolver.resolve("./runtime/server/middleware/proxy"),
277
287
  middleware: true
278
288
  });
289
+ if (hasPosthogConfig(options)) {
290
+ addServerHandler({
291
+ handler: resolver.resolve("./runtime/server/routes/posthog"),
292
+ route: "/phog/**"
293
+ // neutral name, ad blockers match /posthog and /analytics
294
+ });
295
+ }
279
296
  addRouteMiddleware({
280
297
  global: options.auth0.requiresAuth,
281
298
  name: "auth",
@@ -329,6 +346,9 @@ const module$1 = defineNuxtModule({
329
346
  configureNuxtOptions(nuxt, options);
330
347
  await configureExternalIntegrations(nuxt, logger);
331
348
  registerRuntimeFeatures(options, resolver);
349
+ if (hasPosthogConfig(options)) {
350
+ logger.info("Loaded PostHog (proxy /phog)");
351
+ }
332
352
  }
333
353
  });
334
354
 
@@ -44,3 +44,7 @@ hook("page:finish", () => regenerate());
44
44
  </BApp>
45
45
  </main>
46
46
  </template>
47
+
48
+ <style>
49
+ :root{--nv-z:1090}
50
+ </style>
@@ -0,0 +1,2 @@
1
+ declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
2
+ export default _default;
@@ -0,0 +1,22 @@
1
+ import posthog from "posthog-js";
2
+ import { watchImmediate } from "@vueuse/core";
3
+ import { defineNuxtPlugin, useRuntimeConfig } from "#app";
4
+ import { useAuth } from "../composables/useAuth.js";
5
+ export default defineNuxtPlugin(() => {
6
+ if (import.meta.dev) return;
7
+ const { public: $config } = useRuntimeConfig();
8
+ if (!$config.merkaly.posthog) {
9
+ console.warn("[posthog] merkaly.posthog is empty, analytics disabled");
10
+ return;
11
+ }
12
+ posthog.init($config.merkaly.posthog, {
13
+ api_host: "/phog",
14
+ // first-party proxy
15
+ ui_host: "https://us.posthog.com"
16
+ });
17
+ const { user } = useAuth();
18
+ watchImmediate(() => user.value?.sub, (sub, previous) => {
19
+ if (sub) return posthog.identify(sub, { email: user.value?.email, name: user.value?.name });
20
+ if (previous) posthog.reset();
21
+ });
22
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<void>>;
2
+ export default _default;
@@ -0,0 +1,61 @@
1
+ import { createError, defineEventHandler, getRequestHeader, getRequestWebStream, send, sendStream, setResponseHeaders, setResponseStatus } from "h3";
2
+ const PROXY_PREFIX = "/phog";
3
+ const INGEST_ORIGIN = "https://us.i.posthog.com";
4
+ const ASSETS_ORIGIN = "https://us-assets.i.posthog.com";
5
+ const ASSETS_PATH_PATTERN = /^\/(static|array)\//;
6
+ const POSTHOG_TOKEN_PATTERN = /^Bearer ph[csx]_/;
7
+ const MAX_BODY_BYTES = 64 * 1024 * 1024;
8
+ const REQUEST_HEADERS = ["accept", "content-encoding", "content-type", "user-agent"];
9
+ const RESPONSE_HEADERS = ["cache-control", "content-type", "etag", "expires", "last-modified", "location", "retry-after", "vary"];
10
+ function resolveTarget(event) {
11
+ const path = event.path.slice(PROXY_PREFIX.length) || "/";
12
+ const origin = ASSETS_PATH_PATTERN.test(path) ? ASSETS_ORIGIN : INGEST_ORIGIN;
13
+ return new URL(path, origin);
14
+ }
15
+ function buildHeaders(event, target) {
16
+ const incoming = event.node.req.headers;
17
+ const headers = new Headers({ host: target.host });
18
+ Object.keys(incoming).filter((name) => name.startsWith("posthog-") || REQUEST_HEADERS.includes(name)).forEach((name) => headers.set(name, String(incoming[name])));
19
+ const authorization = getRequestHeader(event, "authorization");
20
+ if (authorization && POSTHOG_TOKEN_PATTERN.test(authorization)) headers.set("authorization", authorization);
21
+ const chain = getRequestHeader(event, "x-forwarded-for");
22
+ const client = event.node.req.socket.remoteAddress;
23
+ headers.set("x-forwarded-for", [chain, client].filter(Boolean).join(", "));
24
+ return headers;
25
+ }
26
+ function limitBody(event, state) {
27
+ const stream = getRequestWebStream(event);
28
+ if (!stream) return void 0;
29
+ let size = 0;
30
+ return stream.pipeThrough(new TransformStream({
31
+ transform(chunk, controller) {
32
+ size += chunk.byteLength;
33
+ if (size <= MAX_BODY_BYTES) return controller.enqueue(chunk);
34
+ state.tooLarge = true;
35
+ controller.error(new Error("PostHog body too large"));
36
+ }
37
+ }));
38
+ }
39
+ export default defineEventHandler((event) => {
40
+ if (Number(getRequestHeader(event, "content-length")) > MAX_BODY_BYTES) {
41
+ throw createError({ statusCode: 413, statusMessage: "Payload Too Large" });
42
+ }
43
+ const target = resolveTarget(event);
44
+ const state = { tooLarge: false };
45
+ const body = limitBody(event, state);
46
+ return fetch(target, {
47
+ body,
48
+ duplex: body ? "half" : void 0,
49
+ // streamed request body
50
+ headers: buildHeaders(event, target),
51
+ method: event.method,
52
+ redirect: "manual"
53
+ }).catch((reason) => {
54
+ if (state.tooLarge) throw createError({ statusCode: 413, statusMessage: "Payload Too Large" });
55
+ throw createError({ cause: reason, statusCode: 502, statusMessage: "PostHog unreachable" });
56
+ }).then((response) => {
57
+ setResponseStatus(event, response.status, response.statusText);
58
+ setResponseHeaders(event, Object.fromEntries(RESPONSE_HEADERS.filter((name) => response.headers.has(name)).map((name) => [name, response.headers.get(name)])));
59
+ return response.body ? sendStream(event, response.body) : send(event, "");
60
+ });
61
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@merkaly/nuxt",
3
- "version": "0.10.5",
3
+ "version": "0.11.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/merkaly-io/nuxt.git"
@@ -57,6 +57,7 @@
57
57
  "gsap": "^3.15.0",
58
58
  "notivue": "^2.4.5",
59
59
  "nuxt": "^4.6.3",
60
+ "posthog-js": "^1.446.0",
60
61
  "reflect-metadata": "^0.2.2",
61
62
  "sass": "^1.107.1",
62
63
  "typescript": "~5.9.3",