diff --git a/docs/content/scripts/speedcurve.md b/docs/content/scripts/speedcurve.md index 68bd3f831..0c18d7013 100644 --- a/docs/content/scripts/speedcurve.md +++ b/docs/content/scripts/speedcurve.md @@ -20,6 +20,40 @@ The composable comes with the following defaults: - **Trigger: Client** The LUX primer is injected into ``{lang="html"} immediately; `lux.js` loads when Nuxt hydrates. +## Setup + +SpeedCurve LUX is opt-in. You **must** register it in `scripts.registry.speedcurve` before calling `useScriptSpeedCurve`, even if you're not auto-loading globally; registration is what triggers the module to resolve and inline the LUX primer at build time. Install the `@speedcurve/lux` peer dep alongside: + +```bash +npm i -D @speedcurve/lux +``` + +```ts [nuxt.config.ts: composable-only (no global load)] +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + scripts: { + registry: { + // Minimum registration — enables the composable per-page. + // Pass `id` here and you can omit it from each useScriptSpeedCurve() call. + speedcurve: {}, + }, + }, +}) +``` + +```ts [nuxt.config.ts: auto-load globally] +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + scripts: { + registry: { + speedcurve: { id: 'YOUR_SPEEDCURVE_ID', trigger: 'onNuxtReady' }, + }, + }, +}) +``` + +If `speedcurve` isn't registered, builds fail with an unresolved `#build/nuxt-scripts-speedcurve-snippet` import. If it's registered but `@speedcurve/lux` is missing, you'll see an install hint at runtime when LUX initialises. Pinning your own `@speedcurve/lux` version means you control when the primer snippet updates. + You can access the `LUX` object as a proxy directly, or await `$script` to get the loaded instance. ::code-group diff --git a/packages/script/package.json b/packages/script/package.json index f8bedf358..c4bbbf356 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -68,7 +68,6 @@ "unimport", "#nuxt-scripts/types", "posthog-js", - "@speedcurve/lux", "@nuxt/devtools-kit", "sirv" ] diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index e3ae9fd18..4cab62285 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -659,6 +659,25 @@ export default defineNuxtModule({ }, }) + // SpeedCurve requires opt-in via `scripts.registry.speedcurve` plus the + // `@speedcurve/lux` peer dep so the user controls the snippet version. + // Template only emitted on registration; non-registered consumers of + // useScriptSpeedCurve hit a build error from the unresolved virtual. + if (config.registry?.speedcurve) { + addTemplate({ + filename: 'nuxt-scripts-speedcurve-snippet.mjs', + async getContents() { + const snippetPath = await resolvePath('@speedcurve/lux/dist/lux-snippet.js').catch(() => null) + // Named export must exist or ESM instantiation fails before the + // install hint is ever read, hence the IIFE initializer pattern. + if (!snippetPath || !existsSync(snippetPath)) + return `export const luxSnippetSource = (() => { throw new Error('[nuxt-scripts] useScriptSpeedCurve requires the @speedcurve/lux package. Install it with: npm i -D @speedcurve/lux') })()\n` + const source = readFileSync(snippetPath, 'utf-8') + return `export const luxSnippetSource = ${JSON.stringify(source)}\n` + }, + }) + } + logger.debug('[nuxt-scripts] Proxy prefix:', proxyPrefix) for (const script of scripts) { diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 8aec6c6f5..a23513d73 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -970,7 +970,7 @@ { "name": "SpeedCurveOptions", "kind": "const", - "code": "export const SpeedCurveOptions = object({\n /**\n * Your SpeedCurve customer ID.\n * @see https://support.speedcurve.com/docs/add-rum-to-your-site\n */\n id: pipe(string(), minLength(1)),\n /**\n * Enable SPA (single-page application) mode.\n * When true, lux.js tracks soft navigations instead of full page loads.\n * @see https://support.speedcurve.com/docs/single-page-applications\n */\n spaMode: optional(boolean()),\n /**\n * Automatically wire Vue Router hooks for SPA tracking when spaMode is true.\n * Set to false to instrument navigations manually.\n * @default true (when spaMode is true)\n */\n autoTrackSpaNavigations: optional(boolean()),\n /**\n * Page label shown in the SpeedCurve dashboard.\n * Accepts a static string, a function `(to) => string` for per-navigation labels,\n * or `false` to disable labeling entirely.\n * @default String(to.name ?? to.path)\n */\n label: optional(union([string(), function_(), literal(false)])),\n /**\n * Sampling rate (0–100). Percentage of sessions that send beacons.\n * Upstream spelling is lowercase — matches LUX UserConfig.\n */\n samplerate: optional(pipe(number(), minValue(0), maxValue(100))),\n /**\n * Send the beacon when the page is hidden (pagehide event).\n * @default true\n */\n sendBeaconOnPageHidden: optional(boolean()),\n /**\n * Track JavaScript errors.\n * @default true\n */\n trackErrors: optional(boolean()),\n /**\n * Maximum number of errors to track per page view.\n * @default 5\n */\n maxErrors: optional(number()),\n /**\n * Minimum time (ms) before a beacon can be sent.\n */\n minMeasureTime: optional(number()),\n /**\n * Maximum time (ms) after which the beacon is sent regardless of load state.\n * @default 60000\n */\n maxMeasureTime: optional(number()),\n /**\n * Start a new beacon when the page becomes visible after being hidden.\n */\n newBeaconOnPageShow: optional(boolean()),\n /**\n * Track pages loaded in background tabs.\n * @default false\n */\n trackHiddenPages: optional(boolean()),\n /**\n * Cookie domain for cross-subdomain session tracking.\n */\n cookieDomain: optional(string()),\n})" + "code": "export const SpeedCurveOptions = object({\n // -- Composable-only options (consumed by useScriptSpeedCurve, not forwarded to LUX) --\n /**\n * Your SpeedCurve customer ID.\n * @see https://support.speedcurve.com/docs/add-rum-to-your-site\n */\n id: pipe(string(), minLength(1)),\n /**\n * Enable SPA (single-page application) mode.\n * When true, lux.js tracks soft navigations instead of full page loads.\n * @see https://support.speedcurve.com/docs/single-page-applications\n */\n spaMode: optional(boolean()),\n /**\n * Automatically wire Vue Router hooks for SPA tracking when spaMode is true.\n * Set to false to instrument navigations manually.\n * @default true (when spaMode is true)\n */\n autoTrackSpaNavigations: optional(boolean()),\n\n // -- LUX UserConfig passthrough (forwarded onto window.LUX before lux.js loads) --\n // Property names match upstream LUX UserConfig casing exactly. Keep this list in\n // sync with the LUX_USER_CONFIG_KEYS filter in speedcurve.ts.\n /**\n * Page label shown in the SpeedCurve dashboard.\n * Accepts a static string, a function `(to) => string | false` for per-navigation labels,\n * or `false` to disable labeling entirely. A callback returning `false` skips updating\n * the label for that navigation.\n * @default String(to.name ?? to.path)\n */\n label: optional(union([string(), function_(), literal(false)])),\n /**\n * Sampling rate (0–100). Percentage of sessions that send beacons.\n * Upstream spelling is lowercase — matches LUX UserConfig.\n */\n samplerate: optional(pipe(number(), minValue(0), maxValue(100))),\n /**\n * Send the beacon when the page is hidden (pagehide event).\n * @default true\n */\n sendBeaconOnPageHidden: optional(boolean()),\n /**\n * Track JavaScript errors.\n * @default true\n */\n trackErrors: optional(boolean()),\n /**\n * Maximum number of errors to track per page view.\n * @default 5\n */\n maxErrors: optional(number()),\n /**\n * Minimum time (ms) before a beacon can be sent.\n */\n minMeasureTime: optional(number()),\n /**\n * Maximum time (ms) after which the beacon is sent regardless of load state.\n * @default 60000\n */\n maxMeasureTime: optional(number()),\n /**\n * Start a new beacon when the page becomes visible after being hidden.\n */\n newBeaconOnPageShow: optional(boolean()),\n /**\n * Track pages loaded in background tabs.\n * @default false\n */\n trackHiddenPages: optional(boolean()),\n /**\n * Cookie domain for cross-subdomain session tracking.\n */\n cookieDomain: optional(string()),\n})" }, { "name": "SpeedCurveApi", @@ -2411,7 +2411,7 @@ "name": "label", "type": "string | Function | false", "required": false, - "description": "Page label shown in the SpeedCurve dashboard. Accepts a static string, a function `(to) => string` for per-navigation labels, or `false` to disable labeling entirely.", + "description": "Page label shown in the SpeedCurve dashboard. Accepts a static string, a function `(to) => string | false` for per-navigation labels, or `false` to disable labeling entirely. A callback returning `false` skips updating the label for that navigation.", "defaultValue": "String(to.name ?? to.path)" }, { diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 3e3452077..d5dd19ff2 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -1008,6 +1008,7 @@ export const InitObjectPropertiesSchema = object({ }) export const SpeedCurveOptions = object({ + // -- Composable-only options (consumed by useScriptSpeedCurve, not forwarded to LUX) -- /** * Your SpeedCurve customer ID. * @see https://support.speedcurve.com/docs/add-rum-to-your-site @@ -1025,10 +1026,15 @@ export const SpeedCurveOptions = object({ * @default true (when spaMode is true) */ autoTrackSpaNavigations: optional(boolean()), + + // -- LUX UserConfig passthrough (forwarded onto window.LUX before lux.js loads) -- + // Property names match upstream LUX UserConfig casing exactly. Keep this list in + // sync with the LUX_USER_CONFIG_KEYS filter in speedcurve.ts. /** * Page label shown in the SpeedCurve dashboard. - * Accepts a static string, a function `(to) => string` for per-navigation labels, - * or `false` to disable labeling entirely. + * Accepts a static string, a function `(to) => string | false` for per-navigation labels, + * or `false` to disable labeling entirely. A callback returning `false` skips updating + * the label for that navigation. * @default String(to.name ?? to.path) */ label: optional(union([string(), function_(), literal(false)])), diff --git a/packages/script/src/runtime/registry/speedcurve.ts b/packages/script/src/runtime/registry/speedcurve.ts index 18c82db07..1013075e7 100644 --- a/packages/script/src/runtime/registry/speedcurve.ts +++ b/packages/script/src/runtime/registry/speedcurve.ts @@ -1,8 +1,14 @@ import type { LuxGlobal, UserConfig } from '@speedcurve/lux' import type { RouteLocationNormalized } from 'vue-router' import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types' -import luxSnippetSource from '@speedcurve/lux/dist/lux-snippet.js?raw' import { useHead, useNuxtApp, useRouter } from 'nuxt/app' +// Virtual: emitted by the Nuxt module only when `speedcurve` is registered in +// `scripts.registry`. Contents inline the LUX primer resolved from the +// user-installed `@speedcurve/lux` peer dep at build time. Non-registered +// users hit a build error from the unresolved virtual; registered users +// without the peer dep get an install hint when the export is read. +// @ts-expect-error virtual is only emitted when speedcurve is registered +import { luxSnippetSource } from '#build/nuxt-scripts-speedcurve-snippet' import { useRegistryScript } from '../utils' import { afterNextPaint } from '../utils/after-next-paint' import { SpeedCurveOptions } from './schemas' diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 8869aa6f4..f65edd794 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -92,6 +92,7 @@ export default defineNuxtConfig({ databuddyAnalytics: { clientId: 'demo-client-123', trigger: 'manual' }, segment: { writeKey: 'KBXOGxgqMFjm2mxtJDJg0iDn5AnGYb9C', trigger: 'manual' }, posthog: { apiKey: 'phc_CkMaDU6dr11eJoQdAiSJb1rC324dogk3T952gJ6fD9W', trigger: 'manual' }, + speedcurve: { id: 'DEMO_LUX_ID', trigger: 'manual' }, // Pixels — infrastructure only metaPixel: { id: '3925006', trigger: 'manual' }, diff --git a/test/unit/__mocks__/empty.ts b/test/unit/__mocks__/empty.ts new file mode 100644 index 000000000..336ce12bb --- /dev/null +++ b/test/unit/__mocks__/empty.ts @@ -0,0 +1 @@ +export {} diff --git a/test/unit/speedcurve-auto-tracker.test.ts b/test/unit/speedcurve-auto-tracker.test.ts index c7fdef102..16ddf4cb6 100644 --- a/test/unit/speedcurve-auto-tracker.test.ts +++ b/test/unit/speedcurve-auto-tracker.test.ts @@ -21,8 +21,8 @@ vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({ useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })), })) -vi.mock('@speedcurve/lux/dist/lux-snippet.js?raw', () => ({ - default: '/* lux snippet */', +vi.mock('#build/nuxt-scripts-speedcurve-snippet', () => ({ + luxSnippetSource: '/* lux snippet */', })) describe('installAutoTracker', () => { @@ -85,12 +85,12 @@ describe('installAutoTracker', () => { injectHead: vi.fn(), onNuxtReady: vi.fn(), })) - vi.doMock('@speedcurve/lux/dist/lux-snippet.js?raw', () => ({ - default: '/* lux snippet */', - })) vi.doMock('../../packages/script/src/runtime/composables/useScript', () => ({ useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })), })) + vi.doMock('#build/nuxt-scripts-speedcurve-snippet', () => ({ + luxSnippetSource: '/* lux snippet */', + })) vi.clearAllMocks() @@ -119,12 +119,12 @@ describe('installAutoTracker', () => { injectHead: vi.fn(), onNuxtReady: vi.fn(), })) - vi.doMock('@speedcurve/lux/dist/lux-snippet.js?raw', () => ({ - default: '/* lux snippet */', - })) vi.doMock('../../packages/script/src/runtime/composables/useScript', () => ({ useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })), })) + vi.doMock('#build/nuxt-scripts-speedcurve-snippet', () => ({ + luxSnippetSource: '/* lux snippet */', + })) }) it('applies default label from route name', async () => { diff --git a/test/unit/speedcurve-config.test.ts b/test/unit/speedcurve-config.test.ts index 3e66c980e..5b71a0b05 100644 --- a/test/unit/speedcurve-config.test.ts +++ b/test/unit/speedcurve-config.test.ts @@ -17,8 +17,8 @@ vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({ useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })), })) -vi.mock('@speedcurve/lux/dist/lux-snippet.js?raw', () => ({ - default: '/* lux snippet */', +vi.mock('#build/nuxt-scripts-speedcurve-snippet', () => ({ + luxSnippetSource: '/* lux snippet */', })) describe('applyConfig', () => { diff --git a/test/unit/speedcurve-primer.test.ts b/test/unit/speedcurve-primer.test.ts index d03152ee3..0590382a2 100644 --- a/test/unit/speedcurve-primer.test.ts +++ b/test/unit/speedcurve-primer.test.ts @@ -19,8 +19,8 @@ vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({ useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })), })) -vi.mock('@speedcurve/lux/dist/lux-snippet.js?raw', () => ({ - default: '(function(){window.LUX=window.LUX||{};window.LUX.snippetVersion="2.0.0";})()', +vi.mock('#build/nuxt-scripts-speedcurve-snippet', () => ({ + luxSnippetSource: '(function(){window.LUX=window.LUX||{};window.LUX.snippetVersion="2.0.0";})()', })) describe('useScriptSpeedCurve primer injection', () => { diff --git a/vitest.config.ts b/vitest.config.ts index 2ceb90e56..0e84acbfc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,6 +21,15 @@ export default defineConfig({ }), // utils folders as *.test.ts in either test/unit or in src/**/*.test.ts defineProject({ + resolve: { + alias: { + // Virtual emitted by the Nuxt module at build time; unit tests + // mock it via `vi.mock('#build/nuxt-scripts-speedcurve-snippet')`, + // but the import must first resolve to *something* the bundler + // accepts. The alias points at an empty placeholder. + '#build/nuxt-scripts-speedcurve-snippet': new URL('./test/unit/__mocks__/empty.ts', import.meta.url).pathname, + }, + }, test: { name: 'unit', environment: 'node',