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
26 changes: 24 additions & 2 deletions packages/core/src/api-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ function createRequestContext(request: NextRequest): RequestContext {
return {
headers: request.headers,
cookies: request.cookies,
path: request.nextUrl.pathname,
};
}

Expand All @@ -60,6 +61,19 @@ export async function getUserContext(
}
}

export async function getEventProps(
config: NextlyticsConfigWithDefaults,
ctx: RequestContext,
userContext?: UserContext
): Promise<Record<string, unknown> | 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),
Expand Down Expand Up @@ -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;
Expand All @@ -128,7 +146,7 @@ async function handleClientInit(
serverContext,
clientContext,
userContext,
properties: {},
properties: { ...propsFromCallback },
};

if (isSoftNavigation) {
Expand Down Expand Up @@ -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(),
Expand All @@ -179,7 +201,7 @@ async function handleClientEvent(
serverContext,
clientContext,
userContext,
properties: props || {},
properties: { ...propsFromCallback, ...props },
};

const { clientActions, completion } = dispatchEvent(event, ctx);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 10 additions & 5 deletions packages/core/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { resolveAnonymousUser } from "./anonymous-user";
import {
handleEventPost,
getUserContext,
getEventProps,
type DispatchEvent,
type UpdateEvent,
} from "./api-handler";
Expand All @@ -25,6 +26,7 @@ function createRequestContext(request: NextRequest): RequestContext {
return {
headers: request.headers,
cookies: request.cookies,
path: request.nextUrl.pathname,
};
}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -157,7 +161,8 @@ function createPageViewEvent(
serverContext: ServerEventContext,
isApiPath: boolean,
userContext?: UserContext,
anonymousUserId?: string
anonymousUserId?: string,
extraProps?: Record<string, unknown>
): NextlyticsEvent {
const eventType = isApiPath ? "apiCall" : "pageView";
return {
Expand All @@ -168,6 +173,6 @@ function createPageViewEvent(
anonymousUserId,
serverContext,
userContext,
properties: {},
properties: { ...extraProps },
};
}
129 changes: 129 additions & 0 deletions packages/core/src/path-matcher.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
});
62 changes: 62 additions & 0 deletions packages/core/src/path-matcher.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> | 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<string, string> = {};
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;
}
12 changes: 10 additions & 2 deletions packages/core/src/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -103,6 +104,7 @@ export async function createRequestContext(): Promise<RequestContext> {
return {
cookies: _cookies,
headers: _headers,
path: _headers.get("x-nl-pathname") || "",
};
}

Expand Down Expand Up @@ -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 });
Expand All @@ -277,6 +283,8 @@ export function Nextlytics(userConfig: NextlyticsConfig): NextlyticsResult {
}
}

const propsFromCallback = await getEventProps(config, ctx, userContext);

return {
sendEvent: async (
eventName: string,
Expand All @@ -296,7 +304,7 @@ export function Nextlytics(userConfig: NextlyticsConfig): NextlyticsResult {
anonymousUserId,
serverContext,
userContext,
properties: opts?.props || {},
properties: { ...propsFromCallback, ...opts?.props },
};
await dispatchEventInternal(event, ctx);

Expand Down
Loading