@merkaly/nuxt 0.10.4 → 0.11.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 CHANGED
@@ -71,7 +71,54 @@ export default defineNuxtConfig({
71
71
  ```
72
72
 
73
73
  The sitemap is only installed for indexable apps and is served at `/sitemap.xml`
74
- (linked from `robots.txt`).
74
+ (linked from `robots.txt`), a single file for every locale. The `x-default`
75
+ alternate points to `merkaly.i18n.defaultLocale`; the sitemap is installed after
76
+ `@nuxtjs/i18n` so it reads those options.
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.
75
122
 
76
123
  ## Money formatting
77
124
 
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.4",
7
+ "version": "0.11.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { defineNuxtModule, useLogger, createResolver, installModule, addPlugin, addServerHandler, addRouteMiddleware, addImportsDir, addComponentsDir, addTypeTemplate, addTemplate } from '@nuxt/kit';
1
+ import { defineNuxtModule, useLogger, createResolver, addPlugin, addServerHandler, addRouteMiddleware, addImportsDir, addComponentsDir, addTypeTemplate, addTemplate } from '@nuxt/kit';
2
2
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { resolve, dirname } from 'node:path';
4
4
  import { defu } from 'defu';
@@ -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
  }
@@ -87,6 +91,12 @@ function buildModuleDependencies(nuxt, options) {
87
91
  dependencies["@nuxtjs/robots"] = {
88
92
  overrides: { disallow: options.seo?.disallow ?? [] }
89
93
  };
94
+ if (options.seo?.indexable && options.seo.sitemap) {
95
+ dependencies["@nuxtjs/sitemap"] = {
96
+ // listed after i18n so it installs later and reads its options
97
+ overrides: configureSitemap(options)
98
+ };
99
+ }
90
100
  const logger = useLogger(MODULE_NAME);
91
101
  Object.keys(dependencies).forEach((dependency) => {
92
102
  logger.info(`Loaded ${dependency} module`);
@@ -134,6 +144,8 @@ function configureI18n(nuxt, options) {
134
144
  // absolute hreflang and canonical links
135
145
  defaultLocale: options.i18n.defaultLocale,
136
146
  detectBrowserLanguage: { useCookie: true },
147
+ locales: options.i18n.locales.map((locale) => ({ code: locale.code, language: locale.language })),
148
+ // for modules reading i18n options (sitemap); i18n loads them from the hook
137
149
  restructureDir: ".",
138
150
  strategy: options.i18n.strategy ?? "no_prefix",
139
151
  vueI18n
@@ -163,7 +175,9 @@ function configureSitemap(options) {
163
175
  })),
164
176
  strategy: options.i18n.strategy
165
177
  },
166
- exclude
178
+ exclude,
179
+ sitemaps: false
180
+ // a single /sitemap.xml with hreflang, not one sitemap per locale
167
181
  };
168
182
  }
169
183
  function configureRuntimeConfig(nuxt, options) {
@@ -179,6 +193,8 @@ function configureRuntimeConfig(nuxt, options) {
179
193
  i18n: options.i18n,
180
194
  money: options.money,
181
195
  plausible: options.plausible,
196
+ posthog: options.posthog,
197
+ // NUXT_PUBLIC_MERKALY_POSTHOG overrides it at runtime
182
198
  sentry: {
183
199
  dsn: options.sentry.dsn,
184
200
  project: options.sentry.project
@@ -237,14 +253,15 @@ function configureFontAwesome(nuxt) {
237
253
  src: FONT_AWESOME_KIT_URL
238
254
  });
239
255
  }
240
- function configureVite(nuxt) {
256
+ function configureVite(nuxt, options) {
241
257
  nuxt.options.vite = defu(nuxt.options.vite || {}, {
242
258
  plugins: [svgLoader()],
243
259
  // Force a single shared notivue instance so consumers don't have to declare
244
260
  // `vite.resolve.dedupe: ['notivue']` themselves (avoids the duplicate-instance
245
261
  // `useNotivueInstance()` error, especially when @merkaly/nuxt is linked locally).
246
262
  resolve: {
247
- dedupe: ["notivue"]
263
+ dedupe: ["notivue", ...hasPosthogConfig(options) ? ["posthog-js"] : []]
264
+ // app imports share the initialized posthog
248
265
  }
249
266
  });
250
267
  }
@@ -256,16 +273,26 @@ function configureNuxtOptions(nuxt, options) {
256
273
  configureSentry(nuxt, options);
257
274
  }
258
275
  configureFontAwesome(nuxt);
259
- configureVite(nuxt);
276
+ configureVite(nuxt, options);
260
277
  }
261
278
  function registerRuntimeFeatures(options, resolver) {
262
279
  addPlugin({ src: resolver.resolve("./runtime/plugins/api.global") });
263
280
  addPlugin({ src: resolver.resolve("./runtime/plugins/auth0.client") });
264
281
  addPlugin({ src: resolver.resolve("./runtime/plugins/sentry.global") });
282
+ if (hasPosthogConfig(options)) {
283
+ addPlugin({ src: resolver.resolve("./runtime/plugins/posthog.client") });
284
+ }
265
285
  addServerHandler({
266
286
  handler: resolver.resolve("./runtime/server/middleware/proxy"),
267
287
  middleware: true
268
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
+ }
269
296
  addRouteMiddleware({
270
297
  global: options.auth0.requiresAuth,
271
298
  name: "auth",
@@ -318,11 +345,10 @@ const module$1 = defineNuxtModule({
318
345
  const resolver = createResolver(import.meta.url);
319
346
  configureNuxtOptions(nuxt, options);
320
347
  await configureExternalIntegrations(nuxt, logger);
321
- if (options.seo?.indexable && options.seo.sitemap) {
322
- await installModule("@nuxtjs/sitemap", configureSitemap(options));
323
- logger.info("Loaded @nuxtjs/sitemap module");
324
- }
325
348
  registerRuntimeFeatures(options, resolver);
349
+ if (hasPosthogConfig(options)) {
350
+ logger.info("Loaded PostHog (proxy /phog)");
351
+ }
326
352
  }
327
353
  });
328
354
 
@@ -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.4",
3
+ "version": "0.11.0",
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",