From f837af7b0faab94768197c13d2276c19a7c72f8d Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 27 May 2026 18:06:41 +1000 Subject: [PATCH 1/7] fix(speedcurve): vendor LUX snippet and group schema options - Inline the LUX 2.0.0 snippet as a string const in `runtime/utils/speedcurve-snippet.ts` so `@speedcurve/lux` is no longer required at user build time (still kept as an optional peer dep for types). - Drop `@speedcurve/lux` from `build.externals`; remove obsolete `?raw` mocks from the unit tests. - Split `SpeedCurveOptions` into composable-only options (`id`, `spaMode`, `autoTrackSpaNavigations`) and LUX `UserConfig` passthrough, with a note pointing to `LUX_USER_CONFIG_KEYS`. - Regenerate `registry-types.json`. Addresses unresolved review comments from #782. --- packages/script/package.json | 1 - packages/script/src/registry-types.json | 4 ++-- packages/script/src/runtime/registry/schemas.ts | 10 ++++++++-- packages/script/src/runtime/registry/speedcurve.ts | 2 +- .../script/src/runtime/utils/speedcurve-snippet.ts | 5 +++++ test/unit/speedcurve-auto-tracker.test.ts | 10 ---------- test/unit/speedcurve-config.test.ts | 4 ---- test/unit/speedcurve-primer.test.ts | 4 ---- 8 files changed, 16 insertions(+), 24 deletions(-) create mode 100644 packages/script/src/runtime/utils/speedcurve-snippet.ts 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/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..92da710c7 100644 --- a/packages/script/src/runtime/registry/speedcurve.ts +++ b/packages/script/src/runtime/registry/speedcurve.ts @@ -1,10 +1,10 @@ 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' import { useRegistryScript } from '../utils' import { afterNextPaint } from '../utils/after-next-paint' +import { luxSnippetSource } from '../utils/speedcurve-snippet' import { SpeedCurveOptions } from './schemas' export { SpeedCurveOptions } diff --git a/packages/script/src/runtime/utils/speedcurve-snippet.ts b/packages/script/src/runtime/utils/speedcurve-snippet.ts new file mode 100644 index 000000000..bb2f1d17e --- /dev/null +++ b/packages/script/src/runtime/utils/speedcurve-snippet.ts @@ -0,0 +1,5 @@ +// Vendored verbatim from @speedcurve/lux/dist/lux-snippet.js (RUM Snippet v2.0.0). +// Inlined here so users don't need @speedcurve/lux installed at runtime; +// the package is only used for TypeScript types. +// On upstream snippet bumps, replace this string with the new dist contents. +export const luxSnippetSource = `/* SpeedCurve RUM Snippet v2.0.0 */LUX=function(){var n=Math.floor;function t(){return Date.now?Date.now():+new Date}var r,a=t(),e=window.performance||{},i=e.timing||{activationStart:0,navigationStart:(null===(r=window.LUX)||void 0===r?void 0:r.ns)||a};function o(){return e.now?n(e.now()):t()-i.navigationStart}(LUX=window.LUX||{}).ac=[],LUX.addData=function(n,t){return LUX.cmd(["addData",n,t])},LUX.cmd=function(n){return LUX.ac.push(n)},LUX.getDebug=function(){return[[a,0,[]]]},LUX.init=function(n){return LUX.cmd(["init",n||o()])},LUX.mark=function(){for(var n=[],t=0;t ({ useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })), })) -vi.mock('@speedcurve/lux/dist/lux-snippet.js?raw', () => ({ - default: '/* lux snippet */', -})) - describe('installAutoTracker', () => { beforeEach(() => { vi.clearAllMocks() @@ -85,9 +81,6 @@ 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' })), })) @@ -119,9 +112,6 @@ 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' })), })) diff --git a/test/unit/speedcurve-config.test.ts b/test/unit/speedcurve-config.test.ts index 3e66c980e..bcccce08d 100644 --- a/test/unit/speedcurve-config.test.ts +++ b/test/unit/speedcurve-config.test.ts @@ -17,10 +17,6 @@ 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 */', -})) - describe('applyConfig', () => { beforeEach(() => { Object.defineProperty(window, 'LUX', { value: {}, writable: true, configurable: true }) diff --git a/test/unit/speedcurve-primer.test.ts b/test/unit/speedcurve-primer.test.ts index d03152ee3..dfe74340b 100644 --- a/test/unit/speedcurve-primer.test.ts +++ b/test/unit/speedcurve-primer.test.ts @@ -19,10 +19,6 @@ 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";})()', -})) - describe('useScriptSpeedCurve primer injection', () => { beforeEach(() => { useHeadCalls.length = 0 From 7638dfe5d70674614dc7bcbf8e94db9f9cb6e892 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 27 May 2026 18:48:22 +1000 Subject: [PATCH 2/7] refactor(speedcurve): emit primer via virtual template on opt-in registration - Replace the vendored snippet with a virtual `#build/nuxt-scripts-speedcurve-snippet` template. - The module only emits the template when `scripts.registry.speedcurve` is set; missing registration produces a clear build error. - The template resolves the snippet from the user-installed `@speedcurve/lux` peer dep so users control the snippet version; missing dep throws at import with an install hint. - Adds a setup section to the docs documenting the opt-in registration + peer dep install. --- docs/content/scripts/speedcurve.md | 21 +++++++++++++++++++ packages/script/src/module.ts | 18 ++++++++++++++++ .../script/src/runtime/registry/speedcurve.ts | 7 ++++++- .../src/runtime/utils/speedcurve-snippet.ts | 5 ----- test/unit/__mocks__/empty.ts | 1 + test/unit/speedcurve-auto-tracker.test.ts | 10 +++++++++ test/unit/speedcurve-config.test.ts | 4 ++++ test/unit/speedcurve-primer.test.ts | 4 ++++ vitest.config.ts | 9 ++++++++ 9 files changed, 73 insertions(+), 6 deletions(-) delete mode 100644 packages/script/src/runtime/utils/speedcurve-snippet.ts create mode 100644 test/unit/__mocks__/empty.ts diff --git a/docs/content/scripts/speedcurve.md b/docs/content/scripts/speedcurve.md index 68bd3f831..108658199 100644 --- a/docs/content/scripts/speedcurve.md +++ b/docs/content/scripts/speedcurve.md @@ -20,6 +20,27 @@ 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. Register it in your Nuxt config so the module resolves the primer snippet at build time, then install the `@speedcurve/lux` peer dep: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + scripts: { + registry: { + speedcurve: { id: 'YOUR_SPEEDCURVE_ID' }, + }, + }, +}) +``` + +```bash +npm i -D @speedcurve/lux +``` + +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/src/module.ts b/packages/script/src/module.ts index e3ae9fd18..4f9c6ce3c 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -659,6 +659,24 @@ export default defineNuxtModule({ }, }) + // SpeedCurve requires opt-in via `scripts.registry.speedcurve` plus the + // `@speedcurve/lux` peer dep so the user controls the snippet version. + // Only emit the snippet template when the user has registered the script; + // otherwise the virtual import in speedcurve.ts errors at build, pointing + // at the missing registration. + 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) + if (!snippetPath || !existsSync(snippetPath)) + return `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/runtime/registry/speedcurve.ts b/packages/script/src/runtime/registry/speedcurve.ts index 92da710c7..3eca603e0 100644 --- a/packages/script/src/runtime/registry/speedcurve.ts +++ b/packages/script/src/runtime/registry/speedcurve.ts @@ -4,7 +4,12 @@ import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types' import { useHead, useNuxtApp, useRouter } from 'nuxt/app' import { useRegistryScript } from '../utils' import { afterNextPaint } from '../utils/after-next-paint' -import { luxSnippetSource } from '../utils/speedcurve-snippet' +// Virtual: emitted by the Nuxt module when `speedcurve` is registered in +// `scripts.registry`. The module resolves the primer snippet from the +// user-installed `@speedcurve/lux` peer dep at build time. Calling +// useScriptSpeedCurve without registering speedcurve in nuxt.config raises a +// build error pointing at the missing registration. +import { luxSnippetSource } from '#build/nuxt-scripts-speedcurve-snippet' import { SpeedCurveOptions } from './schemas' export { SpeedCurveOptions } diff --git a/packages/script/src/runtime/utils/speedcurve-snippet.ts b/packages/script/src/runtime/utils/speedcurve-snippet.ts deleted file mode 100644 index bb2f1d17e..000000000 --- a/packages/script/src/runtime/utils/speedcurve-snippet.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Vendored verbatim from @speedcurve/lux/dist/lux-snippet.js (RUM Snippet v2.0.0). -// Inlined here so users don't need @speedcurve/lux installed at runtime; -// the package is only used for TypeScript types. -// On upstream snippet bumps, replace this string with the new dist contents. -export const luxSnippetSource = `/* SpeedCurve RUM Snippet v2.0.0 */LUX=function(){var n=Math.floor;function t(){return Date.now?Date.now():+new Date}var r,a=t(),e=window.performance||{},i=e.timing||{activationStart:0,navigationStart:(null===(r=window.LUX)||void 0===r?void 0:r.ns)||a};function o(){return e.now?n(e.now()):t()-i.navigationStart}(LUX=window.LUX||{}).ac=[],LUX.addData=function(n,t){return LUX.cmd(["addData",n,t])},LUX.cmd=function(n){return LUX.ac.push(n)},LUX.getDebug=function(){return[[a,0,[]]]},LUX.init=function(n){return LUX.cmd(["init",n||o()])},LUX.mark=function(){for(var n=[],t=0;t ({ useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })), })) +vi.mock('#build/nuxt-scripts-speedcurve-snippet', () => ({ + luxSnippetSource: '/* lux snippet */', +})) + describe('installAutoTracker', () => { beforeEach(() => { vi.clearAllMocks() @@ -84,6 +88,9 @@ describe('installAutoTracker', () => { 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() @@ -115,6 +122,9 @@ describe('installAutoTracker', () => { 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 bcccce08d..5b71a0b05 100644 --- a/test/unit/speedcurve-config.test.ts +++ b/test/unit/speedcurve-config.test.ts @@ -17,6 +17,10 @@ vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({ useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })), })) +vi.mock('#build/nuxt-scripts-speedcurve-snippet', () => ({ + luxSnippetSource: '/* lux snippet */', +})) + describe('applyConfig', () => { beforeEach(() => { Object.defineProperty(window, 'LUX', { value: {}, writable: true, configurable: true }) diff --git a/test/unit/speedcurve-primer.test.ts b/test/unit/speedcurve-primer.test.ts index dfe74340b..0590382a2 100644 --- a/test/unit/speedcurve-primer.test.ts +++ b/test/unit/speedcurve-primer.test.ts @@ -19,6 +19,10 @@ vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({ useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })), })) +vi.mock('#build/nuxt-scripts-speedcurve-snippet', () => ({ + luxSnippetSource: '(function(){window.LUX=window.LUX||{};window.LUX.snippetVersion="2.0.0";})()', +})) + describe('useScriptSpeedCurve primer injection', () => { beforeEach(() => { useHeadCalls.length = 0 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', From 3315d136288dc1469a1c8b93dea982bad92332b2 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 27 May 2026 19:20:03 +1000 Subject: [PATCH 3/7] fix(speedcurve): export luxSnippetSource in missing-dep fallback A bare `throw` in the virtual template body produces an ESM module with no exports, so `import { luxSnippetSource }` fails at instantiation with "does not provide an export named 'luxSnippetSource'" before the install hint can surface. Wrap the throw inside the export initializer so the error fires only when the snippet is read. Also reorders the virtual import in speedcurve.ts ahead of the relative imports to satisfy import-order lint. --- packages/script/src/module.ts | 5 ++++- packages/script/src/runtime/registry/speedcurve.ts | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 4f9c6ce3c..dddca642b 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -669,8 +669,11 @@ export default defineNuxtModule({ filename: 'nuxt-scripts-speedcurve-snippet.mjs', async getContents() { const snippetPath = await resolvePath('@speedcurve/lux/dist/lux-snippet.js').catch(() => null) + // The named export must exist or ESM instantiation fails before the + // install hint is ever read. Wrap the throw in the export's + // initializer so the error surfaces only when the snippet is read. if (!snippetPath || !existsSync(snippetPath)) - return `throw new Error('[nuxt-scripts] useScriptSpeedCurve requires the @speedcurve/lux package. Install it with: npm i -D @speedcurve/lux')\n` + 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` }, diff --git a/packages/script/src/runtime/registry/speedcurve.ts b/packages/script/src/runtime/registry/speedcurve.ts index 3eca603e0..bec95a3a2 100644 --- a/packages/script/src/runtime/registry/speedcurve.ts +++ b/packages/script/src/runtime/registry/speedcurve.ts @@ -2,14 +2,14 @@ import type { LuxGlobal, UserConfig } from '@speedcurve/lux' import type { RouteLocationNormalized } from 'vue-router' import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types' import { useHead, useNuxtApp, useRouter } from 'nuxt/app' -import { useRegistryScript } from '../utils' -import { afterNextPaint } from '../utils/after-next-paint' // Virtual: emitted by the Nuxt module when `speedcurve` is registered in // `scripts.registry`. The module resolves the primer snippet from the // user-installed `@speedcurve/lux` peer dep at build time. Calling // useScriptSpeedCurve without registering speedcurve in nuxt.config raises a // build error pointing at the missing registration. import { luxSnippetSource } from '#build/nuxt-scripts-speedcurve-snippet' +import { useRegistryScript } from '../utils' +import { afterNextPaint } from '../utils/after-next-paint' import { SpeedCurveOptions } from './schemas' export { SpeedCurveOptions } From ed759e8b5fe6dc14cd48dd7cdfd1be50d267ba53 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 27 May 2026 19:43:37 +1000 Subject: [PATCH 4/7] fix(speedcurve): emit virtual snippet .d.ts unconditionally for typecheck The Nuxt `#build/*` tsconfig path resolves to a real file on disk; without an emitted .d.ts the typecheck fails for any consumer of useScriptSpeedCurve regardless of registration. Always emit a tiny declaration template so types resolve, keep the runtime .mjs emission gated on `scripts.registry.speedcurve` so non-users carry no LUX primer code. --- packages/script/src/module.ts | 16 ++++++++++------ .../script/src/runtime/registry/speedcurve.ts | 11 ++++++----- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index dddca642b..c3857dd09 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -661,17 +661,21 @@ export default defineNuxtModule({ // SpeedCurve requires opt-in via `scripts.registry.speedcurve` plus the // `@speedcurve/lux` peer dep so the user controls the snippet version. - // Only emit the snippet template when the user has registered the script; - // otherwise the virtual import in speedcurve.ts errors at build, pointing - // at the missing registration. + // Types are always declared so typecheck stays green; the runtime .mjs is + // only emitted on registration. Non-registered consumers of + // useScriptSpeedCurve hit a build error from the unresolved virtual. + addTemplate({ + filename: 'nuxt-scripts-speedcurve-snippet.d.ts', + write: true, + getContents: () => `export declare const luxSnippetSource: string\n`, + }) 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) - // The named export must exist or ESM instantiation fails before the - // install hint is ever read. Wrap the throw in the export's - // initializer so the error surfaces only when the snippet is read. + // 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') diff --git a/packages/script/src/runtime/registry/speedcurve.ts b/packages/script/src/runtime/registry/speedcurve.ts index bec95a3a2..632fb40af 100644 --- a/packages/script/src/runtime/registry/speedcurve.ts +++ b/packages/script/src/runtime/registry/speedcurve.ts @@ -2,11 +2,12 @@ import type { LuxGlobal, UserConfig } from '@speedcurve/lux' import type { RouteLocationNormalized } from 'vue-router' import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types' import { useHead, useNuxtApp, useRouter } from 'nuxt/app' -// Virtual: emitted by the Nuxt module when `speedcurve` is registered in -// `scripts.registry`. The module resolves the primer snippet from the -// user-installed `@speedcurve/lux` peer dep at build time. Calling -// useScriptSpeedCurve without registering speedcurve in nuxt.config raises a -// build error pointing at the missing registration. +// 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. If the user is not +// registered, the import errors at build (pointing at the missing +// registration); if registered without the peer dep, reading the export +// throws an install hint. import { luxSnippetSource } from '#build/nuxt-scripts-speedcurve-snippet' import { useRegistryScript } from '../utils' import { afterNextPaint } from '../utils/after-next-paint' From e8b886e973c53e43356d5fbace4729f906d222b9 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 27 May 2026 19:45:37 +1000 Subject: [PATCH 5/7] fix(speedcurve): ts-expect-error virtual import instead of stub .d.ts Drop the unconditional .d.ts template; the virtual is only emitted on registration, so just suppress the type error on the import line. Non-registered consumers still hit the unresolved-virtual build error at user build time. --- packages/script/src/module.ts | 8 +------- packages/script/src/runtime/registry/speedcurve.ts | 8 ++++---- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index c3857dd09..4cab62285 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -661,14 +661,8 @@ export default defineNuxtModule({ // SpeedCurve requires opt-in via `scripts.registry.speedcurve` plus the // `@speedcurve/lux` peer dep so the user controls the snippet version. - // Types are always declared so typecheck stays green; the runtime .mjs is - // only emitted on registration. Non-registered consumers of + // Template only emitted on registration; non-registered consumers of // useScriptSpeedCurve hit a build error from the unresolved virtual. - addTemplate({ - filename: 'nuxt-scripts-speedcurve-snippet.d.ts', - write: true, - getContents: () => `export declare const luxSnippetSource: string\n`, - }) if (config.registry?.speedcurve) { addTemplate({ filename: 'nuxt-scripts-speedcurve-snippet.mjs', diff --git a/packages/script/src/runtime/registry/speedcurve.ts b/packages/script/src/runtime/registry/speedcurve.ts index 632fb40af..1013075e7 100644 --- a/packages/script/src/runtime/registry/speedcurve.ts +++ b/packages/script/src/runtime/registry/speedcurve.ts @@ -4,10 +4,10 @@ import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types' 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. If the user is not -// registered, the import errors at build (pointing at the missing -// registration); if registered without the peer dep, reading the export -// throws an install hint. +// 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' From 51c7ceafe463d78da2f0d95f42ff861329a3bce5 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 27 May 2026 20:23:05 +1000 Subject: [PATCH 6/7] chore(playground): register speedcurve so demo pages resolve the virtual snippet The playground has speed-curve demo pages but speedcurve wasn't registered, so the gated `#build/nuxt-scripts-speedcurve-snippet` virtual didn't emit and the Vercel build failed at module resolution. --- playground/nuxt.config.ts | 1 + 1 file changed, 1 insertion(+) 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' }, From 763b10ae240fe610050f20af84c15b305142d9dc Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 27 May 2026 20:24:10 +1000 Subject: [PATCH 7/7] docs(speedcurve): clarify registration is required even without global load Make explicit that `scripts.registry.speedcurve` registration is what triggers the build-time primer resolution, not optional. Show the minimum `speedcurve: {}` shape for composable-only usage alongside the global-load variant, plus the two failure modes (missing registration vs missing peer dep) so users can diagnose without spelunking. --- docs/content/scripts/speedcurve.md | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/content/scripts/speedcurve.md b/docs/content/scripts/speedcurve.md index 108658199..0c18d7013 100644 --- a/docs/content/scripts/speedcurve.md +++ b/docs/content/scripts/speedcurve.md @@ -22,24 +22,37 @@ The composable comes with the following defaults: ## Setup -SpeedCurve LUX is opt-in. Register it in your Nuxt config so the module resolves the primer snippet at build time, then install the `@speedcurve/lux` peer dep: +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: -```ts [nuxt.config.ts] +```bash +npm i -D @speedcurve/lux +``` + +```ts [nuxt.config.ts: composable-only (no global load)] export default defineNuxtConfig({ modules: ['@nuxt/scripts'], scripts: { registry: { - speedcurve: { id: 'YOUR_SPEEDCURVE_ID' }, + // Minimum registration — enables the composable per-page. + // Pass `id` here and you can omit it from each useScriptSpeedCurve() call. + speedcurve: {}, }, }, }) ``` -```bash -npm i -D @speedcurve/lux +```ts [nuxt.config.ts: auto-load globally] +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + scripts: { + registry: { + speedcurve: { id: 'YOUR_SPEEDCURVE_ID', trigger: 'onNuxtReady' }, + }, + }, +}) ``` -Pinning your own `@speedcurve/lux` version means you control when the primer snippet updates. +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.