Skip to content
Open
34 changes: 34 additions & 0 deletions docs/content/scripts/speedcurve.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,40 @@
<!-- eslint-disable-next-line harlanzw/ai-deslop-passive-voice -->
- **Trigger: Client** The LUX primer is injected into `<head>`{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:

Check warning on line 25 in docs/content/scripts/speedcurve.md

View workflow job for this annotation

GitHub Actions / lint

"It's not X β€” it's Y" is a common AI writing pattern. Rewrite to state the point directly

```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
Expand Down
1 change: 0 additions & 1 deletion packages/script/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@
"unimport",
"#nuxt-scripts/types",
"posthog-js",
"@speedcurve/lux",
"@nuxt/devtools-kit",
"sirv"
]
Expand Down
19 changes: 19 additions & 0 deletions packages/script/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,25 @@ export default defineNuxtModule<ModuleOptions>({
},
})

// 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`
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
})
}

logger.debug('[nuxt-scripts] Proxy prefix:', proxyPrefix)

for (const script of scripts) {
Expand Down
4 changes: 2 additions & 2 deletions packages/script/src/registry-types.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)"
},
{
Expand Down
10 changes: 8 additions & 2 deletions packages/script/src/runtime/registry/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)])),
Expand Down
8 changes: 7 additions & 1 deletion packages/script/src/runtime/registry/speedcurve.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
1 change: 1 addition & 0 deletions playground/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
1 change: 1 addition & 0 deletions test/unit/__mocks__/empty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {}
16 changes: 8 additions & 8 deletions test/unit/speedcurve-auto-tracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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 () => {
Expand Down
4 changes: 2 additions & 2 deletions test/unit/speedcurve-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
4 changes: 2 additions & 2 deletions test/unit/speedcurve-primer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
9 changes: 9 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading