@merkaly/nuxt 0.10.5 → 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 +45 -0
- package/dist/module.d.mts +2 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +23 -3
- package/dist/runtime/plugins/posthog.client.d.ts +2 -0
- package/dist/runtime/plugins/posthog.client.js +22 -0
- package/dist/runtime/server/routes/posthog.d.ts +2 -0
- package/dist/runtime/server/routes/posthog.js +61 -0
- package/package.json +2 -1
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
package/dist/module.json
CHANGED
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
|
|
|
@@ -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,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.
|
|
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",
|