diff --git a/packages/core/src/api-handler.ts b/packages/core/src/api-handler.ts index 01256b7..f51f91d 100644 --- a/packages/core/src/api-handler.ts +++ b/packages/core/src/api-handler.ts @@ -45,6 +45,7 @@ function createRequestContext(request: NextRequest): RequestContext { return { headers: request.headers, cookies: request.cookies, + path: request.nextUrl.pathname, }; } @@ -60,6 +61,19 @@ export async function getUserContext( } } +export async function getEventProps( + config: NextlyticsConfigWithDefaults, + ctx: RequestContext, + userContext?: UserContext +): Promise | undefined> { + if (!config.callbacks.getProps) return undefined; + try { + return (await config.callbacks.getProps({ ...ctx, user: userContext })) || undefined; + } catch { + return undefined; + } +} + /** * Reconstruct proper ServerEventContext from /api/event request + client data. * The /api/event call has its own server context (pointing to /api/event), @@ -114,6 +128,10 @@ async function handleClientInit( config, }); + // Resolve getProps using the real page path (not /api/event) + const pageCtx: RequestContext = { ...ctx, path: serverContext.path }; + const propsFromCallback = await getEventProps(config, pageCtx, userContext); + // Soft navigation keeps the same pageRenderId but needs a fresh eventId // so client-side scripts depending on eventId can re-run per navigation. const isSoftNavigation = hctx.isSoftNavigation; @@ -128,7 +146,7 @@ async function handleClientInit( serverContext, clientContext, userContext, - properties: {}, + properties: { ...propsFromCallback }, }; if (isSoftNavigation) { @@ -169,6 +187,10 @@ async function handleClientEvent( config, }); + // Resolve getProps using the real page path (not /api/event) + const pageCtx: RequestContext = { ...ctx, path: serverContext.path }; + const propsFromCallback = await getEventProps(config, pageCtx, userContext); + const event: NextlyticsEvent = { origin: "client", eventId: generateId(), @@ -179,7 +201,7 @@ async function handleClientEvent( serverContext, clientContext, userContext, - properties: props || {}, + properties: { ...propsFromCallback, ...props }, }; const { clientActions, completion } = dispatchEvent(event, ctx); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7e438f7..d2ccd77 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ export { Nextlytics } from "./server"; export { getNextlyticsProps } from "./pages-router"; export { NextlyticsClient, useNextlytics, type NextlyticsContext } from "./client"; export { loggingBackend } from "./backends/logging"; +export { pathMatcher, type PathMatcherOptions } from "./path-matcher"; export type { NextlyticsConfig, NextlyticsResult, diff --git a/packages/core/src/middleware.ts b/packages/core/src/middleware.ts index 00e85bb..792dc4d 100644 --- a/packages/core/src/middleware.ts +++ b/packages/core/src/middleware.ts @@ -17,6 +17,7 @@ import { resolveAnonymousUser } from "./anonymous-user"; import { handleEventPost, getUserContext, + getEventProps, type DispatchEvent, type UpdateEvent, } from "./api-handler"; @@ -25,6 +26,7 @@ function createRequestContext(request: NextRequest): RequestContext { return { headers: request.headers, cookies: request.cookies, + path: request.nextUrl.pathname, }; } @@ -78,13 +80,13 @@ export function createNextlyticsMiddleware( // Skip internal paths, prefetch, and static files if (reqInfo.isNextjsInternal || reqInfo.isPrefetch || reqInfo.isStaticFile) { - return NextResponse.next(); + return undefined; } // Skip non-page-navigation, non-API requests (e.g. RSC fetches). // Soft navigations are tracked via the client /api/event request. if (!reqInfo.isPageNavigation && !config.isApiPath(pathname)) { - return NextResponse.next(); + return undefined; } const pageRenderId = generateId(); @@ -120,12 +122,14 @@ export function createNextlyticsMiddleware( } const userContext = await getUserContext(config, ctx); + const extraProps = await getEventProps(config, ctx, userContext); const pageViewEvent = createPageViewEvent( pageRenderId, serverContext, isApiPath, userContext, - anonId + anonId, + extraProps ); // Dispatch to "on-request" backends only - "on-page-load" backends dispatch later @@ -157,7 +161,8 @@ function createPageViewEvent( serverContext: ServerEventContext, isApiPath: boolean, userContext?: UserContext, - anonymousUserId?: string + anonymousUserId?: string, + extraProps?: Record ): NextlyticsEvent { const eventType = isApiPath ? "apiCall" : "pageView"; return { @@ -168,6 +173,6 @@ function createPageViewEvent( anonymousUserId, serverContext, userContext, - properties: {}, + properties: { ...extraProps }, }; } diff --git a/packages/core/src/path-matcher.test.ts b/packages/core/src/path-matcher.test.ts new file mode 100644 index 0000000..f488be7 --- /dev/null +++ b/packages/core/src/path-matcher.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { pathMatcher } from "./path-matcher"; + +describe("pathMatcher", () => { + describe("basic matching", () => { + it("extracts a single param", () => { + expect(pathMatcher("/[workspace]", "/acme")).toEqual({ workspace: "acme" }); + }); + + it("extracts multiple params", () => { + expect(pathMatcher("/[workspace]/[project]", "/acme/myproject")).toEqual({ + workspace: "acme", + project: "myproject", + }); + }); + + it("matches literal segments", () => { + expect(pathMatcher("/app/settings", "/app/settings")).toEqual({}); + }); + + it("returns null on literal mismatch", () => { + expect(pathMatcher("/app/settings", "/app/profile")).toBeNull(); + }); + + it("returns null on segment count mismatch (too few)", () => { + expect(pathMatcher("/[a]/[b]/[c]", "/x/y")).toBeNull(); + }); + + it("returns null on segment count mismatch (too many)", () => { + expect(pathMatcher("/[a]/[b]", "/x/y/z")).toBeNull(); + }); + + it("matches root path against empty pattern", () => { + expect(pathMatcher("/", "/")).toEqual({}); + }); + + it("handles trailing slashes", () => { + expect(pathMatcher("/[workspace]/", "/acme/")).toEqual({ workspace: "acme" }); + }); + + it("decodes URL-encoded values", () => { + expect(pathMatcher("/[name]", "/hello%20world")).toEqual({ name: "hello world" }); + }); + + it("mixes literal and param segments", () => { + expect(pathMatcher("/app/[workspace]/settings", "/app/acme/settings")).toEqual({ + workspace: "acme", + }); + }); + + it("returns null when literal segment in the middle mismatches", () => { + expect(pathMatcher("/app/[workspace]/settings", "/app/acme/profile")).toBeNull(); + }); + }); + + describe("prefix mode", () => { + it("matches with fewer segments", () => { + expect(pathMatcher("/[workspace]/[project]/[taskId]", "/acme", { prefix: true })).toEqual({ + workspace: "acme", + }); + }); + + it("matches partial prefix", () => { + expect( + pathMatcher("/[workspace]/[project]/[taskId]", "/acme/myproject", { prefix: true }) + ).toEqual({ + workspace: "acme", + project: "myproject", + }); + }); + + it("matches full pattern in prefix mode", () => { + expect(pathMatcher("/[workspace]/[project]", "/acme/myproject", { prefix: true })).toEqual({ + workspace: "acme", + project: "myproject", + }); + }); + + it("returns null when path has more segments than pattern", () => { + expect(pathMatcher("/[workspace]", "/acme/extra/stuff", { prefix: true })).toBeNull(); + }); + + it("returns null for root path against non-empty pattern", () => { + expect(pathMatcher("/[workspace]", "/", { prefix: true })).toBeNull(); + }); + + it("validates literal segments in prefix mode", () => { + expect(pathMatcher("/app/[workspace]", "/wrong/acme", { prefix: true })).toBeNull(); + }); + + it("matches literal-only prefix", () => { + expect(pathMatcher("/app/settings", "/app", { prefix: true })).toEqual({}); + }); + }); + + describe("not option", () => { + it("excludes exact path", () => { + expect(pathMatcher("/[page]", "/auth", { not: "/auth" })).toBeNull(); + }); + + it("excludes path prefix", () => { + expect(pathMatcher("/[page]/[sub]", "/auth/login", { not: "/auth" })).toBeNull(); + }); + + it("does not do partial string match", () => { + // "/auth" should NOT exclude "/authentication" + expect(pathMatcher("/[page]", "/authentication", { not: "/auth" })).toEqual({ + page: "authentication", + }); + }); + + it("supports array of exclusions", () => { + expect(pathMatcher("/[page]", "/admin", { not: ["/auth", "/admin"] })).toBeNull(); + expect(pathMatcher("/[page]", "/auth", { not: ["/auth", "/admin"] })).toBeNull(); + expect(pathMatcher("/[page]", "/home", { not: ["/auth", "/admin"] })).toEqual({ + page: "home", + }); + }); + + it("works combined with prefix mode", () => { + expect( + pathMatcher("/[workspace]/[project]", "/auth/login", { not: "/auth", prefix: true }) + ).toBeNull(); + expect( + pathMatcher("/[workspace]/[project]", "/acme", { not: "/auth", prefix: true }) + ).toEqual({ workspace: "acme" }); + }); + }); +}); diff --git a/packages/core/src/path-matcher.ts b/packages/core/src/path-matcher.ts new file mode 100644 index 0000000..1f7097a --- /dev/null +++ b/packages/core/src/path-matcher.ts @@ -0,0 +1,62 @@ +export type PathMatcherOptions = { + /** Paths to exclude. Matches exact path or path prefix (with `/` boundary). */ + not?: string | string[]; + /** Allow partial matches — path can have fewer segments than pattern. */ + prefix?: boolean; +}; + +/** + * Match a URL path against a Next.js-style `[param]` pattern. + * + * Returns extracted params on match, or `null` on mismatch. + * + * @example + * ```ts + * pathMatcher("/[workspace]/[project]", "/acme/myproject") + * // => { workspace: "acme", project: "myproject" } + * ``` + */ +export function pathMatcher( + pattern: string, + path: string, + opts?: PathMatcherOptions +): Record | null { + // Check exclusions first + if (opts?.not) { + const exclusions = Array.isArray(opts.not) ? opts.not : [opts.not]; + for (const excl of exclusions) { + if (path === excl || path.startsWith(excl + "/")) { + return null; + } + } + } + + const patternSegments = pattern.split("/").filter(Boolean); + const pathSegments = path.split("/").filter(Boolean); + + if (opts?.prefix) { + // Prefix mode: path can have fewer segments (but not more), at least 1 required + if (pathSegments.length === 0) return null; + if (pathSegments.length > patternSegments.length) return null; + } else { + // Exact mode: segment counts must match + if (pathSegments.length !== patternSegments.length) return null; + } + + const params: Record = {}; + const segmentsToMatch = Math.min(patternSegments.length, pathSegments.length); + + for (let i = 0; i < segmentsToMatch; i++) { + const pat = patternSegments[i]; + const seg = pathSegments[i]; + + const paramMatch = pat.match(/^\[(\w+)]$/); + if (paramMatch) { + params[paramMatch[1]] = decodeURIComponent(seg); + } else if (pat !== seg) { + return null; + } + } + + return params; +} diff --git a/packages/core/src/server.tsx b/packages/core/src/server.tsx index b66b840..6dea17c 100644 --- a/packages/core/src/server.tsx +++ b/packages/core/src/server.tsx @@ -22,6 +22,7 @@ import type { import { logConfigWarnings, validateConfig, withDefaults } from "./config-helpers"; import { createNextlyticsMiddleware } from "./middleware"; import { generateId } from "./uitils"; +import { getEventProps } from "./api-handler"; type ResolvedBackend = { backend: NextlyticsBackend; @@ -103,6 +104,7 @@ export async function createRequestContext(): Promise { return { cookies: _cookies, headers: _headers, + path: _headers.get("x-nl-pathname") || "", }; } @@ -262,7 +264,11 @@ export function Nextlytics(userConfig: NextlyticsConfig): NextlyticsResult { const pageRenderId = headersList.get(headerNames.pageRenderId); const serverContext = createServerContextFromHeaders(headersList); - const ctx: RequestContext = { headers: headersList, cookies: cookieStore }; + const ctx: RequestContext = { + headers: headersList, + cookies: cookieStore, + path: headersList.get(headerNames.pathname) || "", + }; // Resolve anonymous user ID const { anonId: anonymousUserId } = await resolveAnonymousUser({ ctx, serverContext, config }); @@ -277,6 +283,8 @@ export function Nextlytics(userConfig: NextlyticsConfig): NextlyticsResult { } } + const propsFromCallback = await getEventProps(config, ctx, userContext); + return { sendEvent: async ( eventName: string, @@ -296,7 +304,7 @@ export function Nextlytics(userConfig: NextlyticsConfig): NextlyticsResult { anonymousUserId, serverContext, userContext, - properties: opts?.props || {}, + properties: { ...propsFromCallback, ...opts?.props }, }; await dispatchEventInternal(event, ctx); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ef51645..0632fa3 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -109,6 +109,7 @@ export type AnonymousUserResult = { export type RequestContext = { headers: Headers; cookies: Pick; + path: string; }; export type NextlyticsPlugin = { @@ -171,6 +172,14 @@ export type NextlyticsConfig = { ctx: RequestContext; originalAnonymousUserId?: string; }) => Promise; + /** + * Derive extra event properties from the request context. + * Called on every request; the returned object is merged into `event.properties`. + * User-provided properties (e.g. from `sendEvent` or client custom events) take priority. + */ + getProps?: ( + ctx: RequestContext & { user?: UserContext } + ) => Record | undefined | Promise | undefined>; }; /** Analytics backends to send events to */ backends?: BackendConfigEntry[];