@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 +48 -1
- package/dist/module.d.mts +2 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +35 -9
- 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
|
@@ -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
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineNuxtModule, useLogger, createResolver,
|
|
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,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",
|