Skip to content

Commit ac0ad5c

Browse files
authored
fix(speedcurve): vendor LUX snippet and group schema options (#805)
1 parent 87b1342 commit ac0ad5c

12 files changed

Lines changed: 92 additions & 18 deletions

File tree

docs/content/scripts/speedcurve.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,40 @@ The composable comes with the following defaults:
2020
<!-- eslint-disable-next-line harlanzw/ai-deslop-passive-voice -->
2121
- **Trigger: Client** The LUX primer is injected into `<head>`{lang="html"} immediately; `lux.js` loads when Nuxt hydrates.
2222

23+
## Setup
24+
25+
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:
26+
27+
```bash
28+
pnpm add -D @speedcurve/lux
29+
```
30+
31+
```ts [nuxt.config.ts: composable-only (no global load)]
32+
export default defineNuxtConfig({
33+
modules: ['@nuxt/scripts'],
34+
scripts: {
35+
registry: {
36+
// Minimum registration — enables the composable per-page.
37+
// Pass `id` here and you can omit it from each useScriptSpeedCurve() call.
38+
speedcurve: {},
39+
},
40+
},
41+
})
42+
```
43+
44+
```ts [nuxt.config.ts: auto-load globally]
45+
export default defineNuxtConfig({
46+
modules: ['@nuxt/scripts'],
47+
scripts: {
48+
registry: {
49+
speedcurve: { id: 'YOUR_SPEEDCURVE_ID', trigger: 'onNuxtReady' },
50+
},
51+
},
52+
})
53+
```
54+
55+
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.
56+
2357
You can access the `LUX` object as a proxy directly, or await `$script` to get the loaded instance.
2458

2559
::code-group

packages/script/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@
6868
"unimport",
6969
"#nuxt-scripts/types",
7070
"posthog-js",
71-
"@speedcurve/lux",
7271
"@nuxt/devtools-kit",
7372
"sirv"
7473
]

packages/script/src/module.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,24 @@ export default defineNuxtModule<ModuleOptions>({
659659
},
660660
})
661661

662+
// SpeedCurve requires opt-in via `scripts.registry.speedcurve` plus the
663+
// `@speedcurve/lux` peer dep so the user controls the snippet version.
664+
// Template only emitted on registration; non-registered consumers of
665+
// useScriptSpeedCurve hit a build error from the unresolved virtual.
666+
if (config.registry?.speedcurve) {
667+
addTemplate({
668+
filename: 'nuxt-scripts-speedcurve-snippet.mjs',
669+
async getContents() {
670+
const snippetPath = await resolvePath('@speedcurve/lux/dist/lux-snippet.js')
671+
if (!existsSync(snippetPath)) {
672+
throw new Error('[nuxt-scripts] useScriptSpeedCurve requires the @speedcurve/lux package. Install it with: npm i -D @speedcurve/lux')
673+
}
674+
const source = readFileSync(snippetPath, 'utf-8')
675+
return `export const luxSnippetSource = ${JSON.stringify(source)}\n`
676+
},
677+
})
678+
}
679+
662680
logger.debug('[nuxt-scripts] Proxy prefix:', proxyPrefix)
663681

664682
for (const script of scripts) {

packages/script/src/registry-types.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -970,7 +970,7 @@
970970
{
971971
"name": "SpeedCurveOptions",
972972
"kind": "const",
973-
"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})"
973+
"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})"
974974
},
975975
{
976976
"name": "SpeedCurveApi",
@@ -2411,7 +2411,7 @@
24112411
"name": "label",
24122412
"type": "string | Function | false",
24132413
"required": false,
2414-
"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.",
2414+
"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.",
24152415
"defaultValue": "String(to.name ?? to.path)"
24162416
},
24172417
{

packages/script/src/runtime/registry/schemas.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,7 @@ export const InitObjectPropertiesSchema = object({
10081008
})
10091009

10101010
export const SpeedCurveOptions = object({
1011+
// -- Composable-only options (consumed by useScriptSpeedCurve, not forwarded to LUX) --
10111012
/**
10121013
* Your SpeedCurve customer ID.
10131014
* @see https://support.speedcurve.com/docs/add-rum-to-your-site
@@ -1025,10 +1026,15 @@ export const SpeedCurveOptions = object({
10251026
* @default true (when spaMode is true)
10261027
*/
10271028
autoTrackSpaNavigations: optional(boolean()),
1029+
1030+
// -- LUX UserConfig passthrough (forwarded onto window.LUX before lux.js loads) --
1031+
// Property names match upstream LUX UserConfig casing exactly. Keep this list in
1032+
// sync with the LUX_USER_CONFIG_KEYS filter in speedcurve.ts.
10281033
/**
10291034
* Page label shown in the SpeedCurve dashboard.
1030-
* Accepts a static string, a function `(to) => string` for per-navigation labels,
1031-
* or `false` to disable labeling entirely.
1035+
* Accepts a static string, a function `(to) => string | false` for per-navigation labels,
1036+
* or `false` to disable labeling entirely. A callback returning `false` skips updating
1037+
* the label for that navigation.
10321038
* @default String(to.name ?? to.path)
10331039
*/
10341040
label: optional(union([string(), function_(), literal(false)])),

packages/script/src/runtime/registry/speedcurve.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import type { LuxGlobal, UserConfig } from '@speedcurve/lux'
22
import type { RouteLocationNormalized } from 'vue-router'
33
import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types'
4-
import luxSnippetSource from '@speedcurve/lux/dist/lux-snippet.js?raw'
54
import { useHead, useNuxtApp, useRouter } from 'nuxt/app'
5+
// Virtual: emitted by the Nuxt module only when `speedcurve` is registered in
6+
// `scripts.registry`. Contents inline the LUX primer resolved from the
7+
// user-installed `@speedcurve/lux` peer dep at build time. Non-registered
8+
// users hit a build error from the unresolved virtual; registered users
9+
// without the peer dep get an install hint when the export is read.
10+
// @ts-expect-error virtual is only emitted when speedcurve is registered
11+
import { luxSnippetSource } from '#build/nuxt-scripts-speedcurve-snippet'
612
import { useRegistryScript } from '../utils'
713
import { afterNextPaint } from '../utils/after-next-paint'
814
import { SpeedCurveOptions } from './schemas'

playground/nuxt.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export default defineNuxtConfig({
9292
databuddyAnalytics: { clientId: 'demo-client-123', trigger: 'manual' },
9393
segment: { writeKey: 'KBXOGxgqMFjm2mxtJDJg0iDn5AnGYb9C', trigger: 'manual' },
9494
posthog: { apiKey: 'phc_CkMaDU6dr11eJoQdAiSJb1rC324dogk3T952gJ6fD9W', trigger: 'manual' },
95+
speedcurve: { id: 'DEMO_LUX_ID', trigger: 'manual' },
9596

9697
// Pixels — infrastructure only
9798
metaPixel: { id: '3925006', trigger: 'manual' },

test/unit/__mocks__/empty.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export {}

test/unit/speedcurve-auto-tracker.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({
2121
useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })),
2222
}))
2323

24-
vi.mock('@speedcurve/lux/dist/lux-snippet.js?raw', () => ({
25-
default: '/* lux snippet */',
24+
vi.mock('#build/nuxt-scripts-speedcurve-snippet', () => ({
25+
luxSnippetSource: '/* lux snippet */',
2626
}))
2727

2828
describe('installAutoTracker', () => {
@@ -85,12 +85,12 @@ describe('installAutoTracker', () => {
8585
injectHead: vi.fn(),
8686
onNuxtReady: vi.fn(),
8787
}))
88-
vi.doMock('@speedcurve/lux/dist/lux-snippet.js?raw', () => ({
89-
default: '/* lux snippet */',
90-
}))
9188
vi.doMock('../../packages/script/src/runtime/composables/useScript', () => ({
9289
useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })),
9390
}))
91+
vi.doMock('#build/nuxt-scripts-speedcurve-snippet', () => ({
92+
luxSnippetSource: '/* lux snippet */',
93+
}))
9494

9595
vi.clearAllMocks()
9696

@@ -119,12 +119,12 @@ describe('installAutoTracker', () => {
119119
injectHead: vi.fn(),
120120
onNuxtReady: vi.fn(),
121121
}))
122-
vi.doMock('@speedcurve/lux/dist/lux-snippet.js?raw', () => ({
123-
default: '/* lux snippet */',
124-
}))
125122
vi.doMock('../../packages/script/src/runtime/composables/useScript', () => ({
126123
useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })),
127124
}))
125+
vi.doMock('#build/nuxt-scripts-speedcurve-snippet', () => ({
126+
luxSnippetSource: '/* lux snippet */',
127+
}))
128128
})
129129

130130
it('applies default label from route name', async () => {

test/unit/speedcurve-config.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({
1717
useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })),
1818
}))
1919

20-
vi.mock('@speedcurve/lux/dist/lux-snippet.js?raw', () => ({
21-
default: '/* lux snippet */',
20+
vi.mock('#build/nuxt-scripts-speedcurve-snippet', () => ({
21+
luxSnippetSource: '/* lux snippet */',
2222
}))
2323

2424
describe('applyConfig', () => {

0 commit comments

Comments
 (0)