Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ The composable comes with the following defaults:
<!-- 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`, including for per-page usage. Registration triggers the module to resolve and inline the LUX primer at build time. Install the `@speedcurve/lux` peer dep alongside:

```bash
pnpm add -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, the build fails with an install hint. 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
18 changes: 18 additions & 0 deletions packages/script/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,24 @@ 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')
if (!existsSync(snippetPath)) {
throw new Error('[nuxt-scripts] useScriptSpeedCurve requires the @speedcurve/lux package. Install it with: npm i -D @speedcurve/lux')
}
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) {
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