diff --git a/FIRST_PARTY.md b/FIRST_PARTY.md
index 1f98b357..ae01f8ac 100644
--- a/FIRST_PARTY.md
+++ b/FIRST_PARTY.md
@@ -115,7 +115,7 @@ Four presets in `proxy-configs.ts` cover all proxy-enabled scripts:
| `PRIVACY_HEATMAP` | ip, language, hardware | GA, Clarity, Hotjar |
| `PRIVACY_IP_ONLY` | ip only | PostHog, Plausible, Umami, Rybbit, Databuddy, Ahrefs, Fathom, CF Web Analytics, Vercel, Matomo, Carbon Ads, Lemon Squeezy, Intercom, Gravatar, YouTube, Vimeo, Calendly |
-Note: GTM, Segment, Crisp, Mixpanel, and Bing UET are bundle-only (no proxy capability), so no privacy transforms are applied.
+Note: GTM, Segment, Crisp, Mixpanel, Bing UET, and SpeedCurve have no proxy capability, so no privacy transforms are applied.
## Script Support
@@ -150,6 +150,7 @@ Note: GTM, Segment, Crisp, Mixpanel, and Bing UET are bundle-only (no proxy capa
| `googleTagManager` | googleTagManager | n/a | Bundle only |
| `segment` | segment | n/a | Bundle only |
| `crisp` | crisp | n/a | Bundle only |
+| `speedcurve` | speedcurve | n/a | No proxy (ID-parameterized CDN URL) |
### Excluded from first-party mode (`proxy: false`)
diff --git a/docs/content/scripts/speedcurve.md b/docs/content/scripts/speedcurve.md
new file mode 100644
index 00000000..68bd3f83
--- /dev/null
+++ b/docs/content/scripts/speedcurve.md
@@ -0,0 +1,108 @@
+---
+title: SpeedCurve LUX
+description: Use SpeedCurve LUX Real User Monitoring in your Nuxt app to measure performance experienced by real users, with automatic SPA navigation tracking.
+links:
+ - label: Source
+ icon: i-simple-icons-github
+ to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/registry/speedcurve.ts
+ size: xs
+---
+
+[SpeedCurve LUX](https://speedcurve.com/features/lux/) is a Real User Monitoring (RUM) tool that measures the performance your users experience. It tracks Core Web Vitals, custom timing marks, and JavaScript errors.
+
+::script-stats
+::
+
+::script-docs
+::
+
+The composable comes with the following defaults:
+
+- **Trigger: Client** The LUX primer is injected into `
`{lang="html"} immediately; `lux.js` loads when Nuxt hydrates.
+
+You can access the `LUX` object as a proxy directly, or await `$script` to get the loaded instance.
+
+::code-group
+
+```ts [Proxy]
+const { proxy } = useScriptSpeedCurve({ id: 'YOUR_ID' })
+proxy.LUX.label = 'my-page'
+```
+
+```ts [onLoaded]
+const { onLoaded } = useScriptSpeedCurve({ id: 'YOUR_ID' })
+onLoaded(({ LUX }) => {
+ LUX.label = 'my-page'
+})
+```
+
+::
+
+## SPA navigation
+
+Set `spaMode: true` to enable SpeedCurve's SPA tracking mode. The composable wires Vue Router automatically:
+
+- `router.beforeEach` calls `LUX.startSoftNavigation()`{lang="ts"} (closes the previous beacon, starts a new one)
+- `nuxt.hook('page:finish')`{lang="ts"} calls `LUX.markLoadTime()`{lang="ts"} after the next paint (sets the END mark)
+- Cancelled navigations seal the phantom beacon with `addData('luxNavFailed', '1')`{lang="ts"} for easy filtering
+
+```ts [app.vue]
+useScriptSpeedCurve({
+ id: 'YOUR_ID',
+ spaMode: true,
+ autoTrackSpaNavigations: true, // default when spaMode is true
+})
+```
+
+To disable auto-wiring and instrument manually:
+
+```ts
+useScriptSpeedCurve({
+ id: 'YOUR_ID',
+ spaMode: true,
+ autoTrackSpaNavigations: false,
+})
+// Then call LUX.startSoftNavigation() and LUX.markLoadTime() yourself
+```
+
+## Custom page labels
+
+By default the composable uses `String(to.name ?? to.path)`{lang="ts"} as the page label for each navigation. Pass a function to `label` to override it:
+
+```ts
+useScriptSpeedCurve({
+ id: 'YOUR_ID',
+ spaMode: true,
+ label: to => to.meta.title as string ?? to.path,
+})
+```
+
+Set `label: false` to disable labeling entirely. Pass a plain string to set a static label (only meaningful without `spaMode`, since the router hook overwrites it on every navigation).
+
+## CSP
+
+Add these directives to your Content Security Policy:
+
+```text
+script-src cdn.speedcurve.com;
+img-src lux.speedcurve.com;
+connect-src lux.speedcurve.com beacon.speedcurve.com;
+```
+
+Reference: https://support.speedcurve.com/docs/add-rum-to-your-csp
+
+::script-types
+::
+
+## Example
+
+Loading SpeedCurve LUX through `app.vue` with SPA tracking enabled.
+
+```vue [app.vue]
+
+```
diff --git a/package.json b/package.json
index 4a764bd1..c4c43c6c 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,7 @@
"dev": "nuxt dev playground",
"dev:ssl": "nuxt dev playground --https",
"dev:prepare": "pnpm -r dev:prepare && nuxt prepare && nuxt prepare playground && pnpm prepare:fixtures",
- "prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn && nuxt prepare test/fixtures/calendly && nuxt prepare test/fixtures/calendly-cdn && nuxt prepare test/fixtures/ahrefs-analytics && nuxt prepare test/fixtures/ahrefs-analytics-cdn && nuxt prepare test/fixtures/usercentrics",
+ "prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/cdn && nuxt prepare test/fixtures/extend-registry && nuxt prepare test/fixtures/partytown && nuxt prepare test/fixtures/first-party && nuxt prepare test/fixtures/linkedin-insight && nuxt prepare test/fixtures/linkedin-insight-cdn && nuxt prepare test/fixtures/calendly && nuxt prepare test/fixtures/calendly-cdn && nuxt prepare test/fixtures/ahrefs-analytics && nuxt prepare test/fixtures/ahrefs-analytics-cdn && nuxt prepare test/fixtures/usercentrics && nuxt prepare test/fixtures/speedcurve",
"typecheck": "nuxt typecheck",
"release": "pnpm build && bumpp -r --output=CHANGELOG.md",
"lint": "eslint .",
diff --git a/packages/script/package.json b/packages/script/package.json
index 414a4fcb..6f6e7e19 100644
--- a/packages/script/package.json
+++ b/packages/script/package.json
@@ -68,6 +68,7 @@
"unimport",
"#nuxt-scripts/types",
"posthog-js",
+ "@speedcurve/lux",
"@nuxt/devtools-kit",
"sirv"
]
@@ -75,6 +76,7 @@
"peerDependencies": {
"@googlemaps/markerclusterer": "^2.6.2",
"@paypal/paypal-js": "^8.1.2 || ^9.0.0",
+ "@speedcurve/lux": "^4.4.3",
"@stripe/stripe-js": "^7.0.0 || ^8.0.0 || ^9.0.0",
"@types/google.maps": "^3.58.1",
"@types/vimeo__player": "^2.18.3",
@@ -89,6 +91,9 @@
"@paypal/paypal-js": {
"optional": true
},
+ "@speedcurve/lux": {
+ "optional": true
+ },
"@stripe/stripe-js": {
"optional": true
},
@@ -130,6 +135,7 @@
"devDependencies": {
"@nuxt/kit": "catalog:",
"@nuxt/module-builder": "catalog:",
+ "@speedcurve/lux": "catalog:",
"rollup": "catalog:",
"unbuild": "catalog:",
"unimport": "catalog:"
diff --git a/packages/script/src/registry-logos.ts b/packages/script/src/registry-logos.ts
index 92c69125..780d4d93 100644
--- a/packages/script/src/registry-logos.ts
+++ b/packages/script/src/registry-logos.ts
@@ -64,4 +64,8 @@ export const LOGOS = {
usercentrics: ``,
bingUet: ``,
snapchatPixel: ``,
+ speedcurve: {
+ light: ``,
+ dark: ``,
+ },
} satisfies Record
diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json
index 27bc4f7e..e023701b 100644
--- a/packages/script/src/registry-types.json
+++ b/packages/script/src/registry-types.json
@@ -966,6 +966,18 @@
"code": "export interface SnapPixelApi {\n snaptr: SnapTrFns & {\n push: SnapTrFns\n loaded: boolean\n version: string\n queue: any[]\n }\n _snaptr: SnapPixelApi['snaptr']\n handleRequest?: SnapTrFns\n}"
}
],
+ "speedcurve": [
+ {
+ "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})"
+ },
+ {
+ "name": "SpeedCurveApi",
+ "kind": "interface",
+ "code": "export interface SpeedCurveApi {\n LUX: LuxGlobal\n}"
+ }
+ ],
"stripe": [
{
"name": "StripeOptions",
@@ -2360,6 +2372,93 @@
"description": "The user's age."
}
],
+ "SpeedCurveOptions": [
+ {
+ "name": "id",
+ "type": "string",
+ "required": true,
+ "description": "Your SpeedCurve customer ID."
+ },
+ {
+ "name": "spaMode",
+ "type": "boolean",
+ "required": false,
+ "description": "Enable SPA (single-page application) mode. When true, lux.js tracks soft navigations instead of full page loads."
+ },
+ {
+ "name": "autoTrackSpaNavigations",
+ "type": "boolean",
+ "required": false,
+ "description": "Automatically wire Vue Router hooks for SPA tracking when spaMode is true. Set to false to instrument navigations manually.",
+ "defaultValue": "true (when spaMode is true)"
+ },
+ {
+ "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.",
+ "defaultValue": "String(to.name ?? to.path)"
+ },
+ {
+ "name": "samplerate",
+ "type": "number",
+ "required": false,
+ "description": "Sampling rate (0–100). Percentage of sessions that send beacons. Upstream spelling is lowercase — matches LUX UserConfig."
+ },
+ {
+ "name": "sendBeaconOnPageHidden",
+ "type": "boolean",
+ "required": false,
+ "description": "Send the beacon when the page is hidden (pagehide event).",
+ "defaultValue": "true"
+ },
+ {
+ "name": "trackErrors",
+ "type": "boolean",
+ "required": false,
+ "description": "Track JavaScript errors.",
+ "defaultValue": "true"
+ },
+ {
+ "name": "maxErrors",
+ "type": "number",
+ "required": false,
+ "description": "Maximum number of errors to track per page view.",
+ "defaultValue": "5"
+ },
+ {
+ "name": "minMeasureTime",
+ "type": "number",
+ "required": false,
+ "description": "Minimum time (ms) before a beacon can be sent."
+ },
+ {
+ "name": "maxMeasureTime",
+ "type": "number",
+ "required": false,
+ "description": "Maximum time (ms) after which the beacon is sent regardless of load state.",
+ "defaultValue": "60000"
+ },
+ {
+ "name": "newBeaconOnPageShow",
+ "type": "boolean",
+ "required": false,
+ "description": "Start a new beacon when the page becomes visible after being hidden."
+ },
+ {
+ "name": "trackHiddenPages",
+ "type": "boolean",
+ "required": false,
+ "description": "Track pages loaded in background tabs.",
+ "defaultValue": "false"
+ },
+ {
+ "name": "cookieDomain",
+ "type": "string",
+ "required": false,
+ "description": "Cookie domain for cross-subdomain session tracking."
+ }
+ ],
"SnapTrPixelOptions": [
{
"name": "id",
diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts
index 8bb07b44..f6296162 100644
--- a/packages/script/src/registry.ts
+++ b/packages/script/src/registry.ts
@@ -42,6 +42,7 @@ import {
RybbitAnalyticsOptions,
SegmentOptions,
SnapTrPixelOptions,
+ SpeedCurveOptions,
StripeOptions,
TikTokPixelOptions,
UmamiAnalyticsOptions,
@@ -173,6 +174,7 @@ export const registryMeta: RegistryScriptMeta[] = [
// Usercentrics is the consent layer itself: must hit the vendor origin so
// signature/policy lookups succeed. Bundle/proxy are intentionally absent.
m('usercentrics', 'Usercentrics', 'utility', 'useScriptUsercentrics', {}, null),
+ m('speedcurve', 'SpeedCurve LUX', 'analytics', 'useScriptSpeedCurve', {}, null),
]
export const REGISTRY_CATEGORIES = [
@@ -851,6 +853,14 @@ export async function registry(resolve?: (path: string) => Promise): Pro
{ route: '/_scripts/proxy/gravatar', handler: './runtime/server/gravatar-proxy', requiresSigning: true },
],
}),
+ def('speedcurve', {
+ schema: SpeedCurveOptions,
+ label: 'SpeedCurve LUX',
+ src: false,
+ category: 'analytics',
+ envDefaults: { id: '' },
+ composableName: 'useScriptSpeedCurve',
+ }),
])
}
diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts
index d6be8661..4843348b 100644
--- a/packages/script/src/runtime/registry/schemas.ts
+++ b/packages/script/src/runtime/registry/schemas.ts
@@ -1,4 +1,4 @@
-import { any, array, boolean, custom, literal, minLength, number, object, optional, pipe, record, string, union } from 'valibot'
+import { any, array, boolean, custom, function_, literal, maxValue, minLength, minValue, number, object, optional, pipe, record, string, union } from 'valibot'
// Shared GCMv2 consent category value.
const consentCategoryValue = union([literal('granted'), literal('denied')])
@@ -1007,6 +1007,75 @@ export const InitObjectPropertiesSchema = object({
age: optional(string()),
})
+export const SpeedCurveOptions = object({
+ /**
+ * Your SpeedCurve customer ID.
+ * @see https://support.speedcurve.com/docs/add-rum-to-your-site
+ */
+ id: pipe(string(), minLength(1)),
+ /**
+ * Enable SPA (single-page application) mode.
+ * When true, lux.js tracks soft navigations instead of full page loads.
+ * @see https://support.speedcurve.com/docs/single-page-applications
+ */
+ spaMode: optional(boolean()),
+ /**
+ * Automatically wire Vue Router hooks for SPA tracking when spaMode is true.
+ * Set to false to instrument navigations manually.
+ * @default true (when spaMode is true)
+ */
+ autoTrackSpaNavigations: optional(boolean()),
+ /**
+ * 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.
+ * @default String(to.name ?? to.path)
+ */
+ label: optional(union([string(), function_(), literal(false)])),
+ /**
+ * Sampling rate (0–100). Percentage of sessions that send beacons.
+ * Upstream spelling is lowercase — matches LUX UserConfig.
+ */
+ samplerate: optional(pipe(number(), minValue(0), maxValue(100))),
+ /**
+ * Send the beacon when the page is hidden (pagehide event).
+ * @default true
+ */
+ sendBeaconOnPageHidden: optional(boolean()),
+ /**
+ * Track JavaScript errors.
+ * @default true
+ */
+ trackErrors: optional(boolean()),
+ /**
+ * Maximum number of errors to track per page view.
+ * @default 5
+ */
+ maxErrors: optional(number()),
+ /**
+ * Minimum time (ms) before a beacon can be sent.
+ */
+ minMeasureTime: optional(number()),
+ /**
+ * Maximum time (ms) after which the beacon is sent regardless of load state.
+ * @default 60000
+ */
+ maxMeasureTime: optional(number()),
+ /**
+ * Start a new beacon when the page becomes visible after being hidden.
+ */
+ newBeaconOnPageShow: optional(boolean()),
+ /**
+ * Track pages loaded in background tabs.
+ * @default false
+ */
+ trackHiddenPages: optional(boolean()),
+ /**
+ * Cookie domain for cross-subdomain session tracking.
+ */
+ cookieDomain: optional(string()),
+})
+
export const SnapTrPixelOptions = object({
/**
* Your Snapchat Pixel ID.
diff --git a/packages/script/src/runtime/registry/speedcurve.ts b/packages/script/src/runtime/registry/speedcurve.ts
new file mode 100644
index 00000000..18c82db0
--- /dev/null
+++ b/packages/script/src/runtime/registry/speedcurve.ts
@@ -0,0 +1,135 @@
+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 { SpeedCurveOptions } from './schemas'
+
+export { SpeedCurveOptions }
+
+export interface SpeedCurveApi {
+ LUX: LuxGlobal
+}
+
+declare global {
+ interface Window {
+ LUX?: LuxGlobal
+ LUX_ae?: ErrorEvent[]
+ LUX_al?: PerformanceEntry[]
+ }
+}
+
+export type SpeedCurveInput = Omit, 'label'> & Omit & {
+ label?: string | ((to: RouteLocationNormalized) => string | false) | false
+}
+
+// Derived from the schema: all schema keys except the composable-only ones.
+const LUX_USER_CONFIG_KEYS = Object.keys(SpeedCurveOptions.entries).filter(
+ k => k !== 'id' && k !== 'autoTrackSpaNavigations' && k !== 'spaMode',
+) as (keyof UserConfig)[]
+
+let luxWired = false
+
+export function useScriptSpeedCurve(_options?: SpeedCurveInput): UseScriptContext {
+ return useRegistryScript('speedcurve', options => ({
+ scriptInput: {
+ src: `https://cdn.speedcurve.com/js/lux.js?id=${options.id}`,
+ crossorigin: 'anonymous',
+ },
+ schema: import.meta.dev ? SpeedCurveOptions : undefined,
+ scriptOptions: {
+ trigger: 'client',
+ use: () => ({ LUX: window.LUX! } as T),
+ beforeInit: () => {
+ useHead({
+ script: [{
+ key: 'speedcurve-lux-primer',
+ tagPosition: 'head',
+ tagPriority: 'critical',
+ innerHTML: luxSnippetSource,
+ }],
+ })
+ },
+ },
+ clientInit: import.meta.server
+ ? undefined
+ : () => {
+ const input = options as unknown as SpeedCurveInput
+ applyConfig(input)
+ if (options.spaMode && options.autoTrackSpaNavigations !== false)
+ installAutoTracker(input)
+ },
+ }), _options as unknown as RegistryScriptInput) as UseScriptContext
+}
+
+export function applyConfig(options: SpeedCurveInput): void {
+ const lux = window.LUX as Record | undefined
+ if (!lux)
+ return
+ for (const k of LUX_USER_CONFIG_KEYS) {
+ const v = (options as Record)[k as string]
+ if (v === undefined)
+ continue
+ // label may be a function or false (handled by installAutoTracker); only pass strings to LUX directly
+ if (k === 'label' && typeof v !== 'string')
+ continue
+ lux[k as string] = v
+ }
+}
+
+export function installAutoTracker(options?: SpeedCurveInput): void {
+ if (luxWired)
+ return
+ luxWired = true
+
+ const router = useRouter()
+ const nuxt = useNuxtApp()
+
+ // For SPA-only apps (no SSR), the first beforeEach fires for the initial
+ // client-side render — not a user navigation. Skip startSoftNavigation.
+ let pendingInitial = nuxt.payload?.serverRendered === false
+
+ const label = options?.label === false
+ ? null
+ : typeof options?.label === 'function'
+ ? options.label
+ : (to: RouteLocationNormalized) => String(to.name ?? to.path)
+
+ router.beforeEach((to) => {
+ const lux = window.LUX
+ if (!lux)
+ return
+ if (pendingInitial) {
+ pendingInitial = false
+ if (label) {
+ const nextLabel = label(to)
+ if (nextLabel !== false)
+ lux.label = nextLabel
+ }
+ return
+ }
+ lux.startSoftNavigation()
+ if (label) {
+ const nextLabel = label(to)
+ if (nextLabel !== false)
+ lux.label = nextLabel
+ }
+ })
+
+ // If a guard cancels navigation, seal the phantom beacon with a filterable tag.
+ router.afterEach((_to, _from, failure) => {
+ if (!failure)
+ return
+ const lux = window.LUX
+ if (!lux)
+ return
+ lux.markLoadTime()
+ lux.addData('luxNavFailed', '1')
+ })
+
+ nuxt.hook('page:finish', () => {
+ afterNextPaint(() => window.LUX?.markLoadTime())
+ })
+}
diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts
index 8419fc27..3bbd1bfa 100644
--- a/packages/script/src/runtime/types.ts
+++ b/packages/script/src/runtime/types.ts
@@ -37,6 +37,7 @@ import type { RedditPixelInput } from './registry/reddit-pixel'
import type { RybbitAnalyticsInput } from './registry/rybbit-analytics'
import type { SegmentInput } from './registry/segment'
import type { SnapTrPixelInput } from './registry/snapchat-pixel'
+import type { SpeedCurveInput } from './registry/speedcurve'
import type { StripeInput } from './registry/stripe'
import type { TikTokPixelInput } from './registry/tiktok-pixel'
import type { UmamiAnalyticsInput } from './registry/umami-analytics'
@@ -275,6 +276,7 @@ export interface ScriptRegistry {
rybbitAnalytics?: RybbitAnalyticsInput
redditPixel?: RedditPixelInput
segment?: SegmentInput
+ speedcurve?: SpeedCurveInput
stripe?: StripeInput
tiktokPixel?: TikTokPixelInput
xEmbed?: XEmbedInput
@@ -301,7 +303,7 @@ export type BuiltInRegistryScriptKey
| 'googleRecaptcha' | 'googleSignIn' | 'lemonSqueezy' | 'googleTagManager'
| 'hotjar' | 'intercom' | 'linkedinInsight' | 'paypal' | 'posthog' | 'matomoAnalytics'
| 'mixpanelAnalytics' | 'rybbitAnalytics' | 'redditPixel' | 'segment' | 'stripe' | 'tiktokPixel'
- | 'xEmbed' | 'xPixel' | 'snapchatPixel' | 'youtubePlayer' | 'vercelAnalytics'
+ | 'xEmbed' | 'xPixel' | 'snapchatPixel' | 'speedcurve' | 'youtubePlayer' | 'vercelAnalytics'
| 'vimeoPlayer' | 'umamiAnalytics' | 'usercentrics' | 'gravatar' | 'npm'
/**
diff --git a/packages/script/src/runtime/utils/after-next-paint.ts b/packages/script/src/runtime/utils/after-next-paint.ts
new file mode 100644
index 00000000..e2633f87
--- /dev/null
+++ b/packages/script/src/runtime/utils/after-next-paint.ts
@@ -0,0 +1,5 @@
+// One rAF fires before the next paint; two fires after it has committed to screen.
+// Same pattern used by web-vitals.js — no Nuxt/Vue equivalent exists.
+export function afterNextPaint(callback: () => void): void {
+ requestAnimationFrame(() => requestAnimationFrame(callback))
+}
diff --git a/packages/script/src/script-meta.ts b/packages/script/src/script-meta.ts
index d0cb7445..5148ebe1 100644
--- a/packages/script/src/script-meta.ts
+++ b/packages/script/src/script-meta.ts
@@ -5,7 +5,7 @@ export type TrackedDataType
| 'session-replay' | 'heatmaps' | 'clicks' | 'scrolls'
| 'retargeting' | 'audiences' | 'form-submissions'
| 'video-engagement' | 'transactions' | 'errors'
- | 'tag-injection' | 'ab-testing'
+ | 'tag-injection' | 'ab-testing' | 'performance-timings'
export interface ScriptMeta {
/** Canonical script URL(s) to fetch for size measurement */
@@ -223,4 +223,10 @@ export const scriptMeta = {
urls: ['https://secure.gravatar.com/js/gprofiles.js'],
trackedData: [],
},
+
+ // Performance monitoring
+ speedcurve: {
+ urls: ['https://cdn.speedcurve.com/js/lux.js'],
+ trackedData: ['page-views', 'performance-timings', 'errors'],
+ },
} satisfies Record
diff --git a/playground/pages/third-parties/speed-curve/manual-spa/a.vue b/playground/pages/third-parties/speed-curve/manual-spa/a.vue
new file mode 100644
index 00000000..b7a839ec
--- /dev/null
+++ b/playground/pages/third-parties/speed-curve/manual-spa/a.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
+ Manual SPA / Page A — spaMode: true, autoTrackSpaNavigations: false
+ Router hooks are wired but auto-tracking is disabled. Call LUX methods yourself to control when beacons fire.
+
+ No SPA / Page A — spaMode: false
+ No router hooks installed. LUX only tracks the initial hard load. Navigating between pages
+ produces no soft-navigation beacons — each navigation is a full page reload from LUX's perspective.
+
+ SPA Auto / Page A — spaMode: true, autoTrackSpaNavigations: true
+ LUX fires startSoftNavigation + markLoadTime automatically on every route change.
+ The beacon for this page fires when you navigate away.
+
+ SPA Auto / Page C — beacon for Page B was sent when you arrived here.
+ Navigate back to Page A to complete the loop and send the beacon for this page.
+