Skip to content

Commit 4427598

Browse files
Merge pull request #32 from jitsucom/worktree-staged-napping-pnueli
feat: add getProps callback and pathMatcher utility
2 parents 6c137d7 + 078b4c5 commit 4427598

7 files changed

Lines changed: 245 additions & 9 deletions

File tree

packages/core/src/api-handler.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ function createRequestContext(request: NextRequest): RequestContext {
4545
return {
4646
headers: request.headers,
4747
cookies: request.cookies,
48+
path: request.nextUrl.pathname,
4849
};
4950
}
5051

@@ -60,6 +61,19 @@ export async function getUserContext(
6061
}
6162
}
6263

64+
export async function getEventProps(
65+
config: NextlyticsConfigWithDefaults,
66+
ctx: RequestContext,
67+
userContext?: UserContext
68+
): Promise<Record<string, unknown> | undefined> {
69+
if (!config.callbacks.getProps) return undefined;
70+
try {
71+
return (await config.callbacks.getProps({ ...ctx, user: userContext })) || undefined;
72+
} catch {
73+
return undefined;
74+
}
75+
}
76+
6377
/**
6478
* Reconstruct proper ServerEventContext from /api/event request + client data.
6579
* The /api/event call has its own server context (pointing to /api/event),
@@ -114,6 +128,10 @@ async function handleClientInit(
114128
config,
115129
});
116130

131+
// Resolve getProps using the real page path (not /api/event)
132+
const pageCtx: RequestContext = { ...ctx, path: serverContext.path };
133+
const propsFromCallback = await getEventProps(config, pageCtx, userContext);
134+
117135
// Soft navigation keeps the same pageRenderId but needs a fresh eventId
118136
// so client-side scripts depending on eventId can re-run per navigation.
119137
const isSoftNavigation = hctx.isSoftNavigation;
@@ -128,7 +146,7 @@ async function handleClientInit(
128146
serverContext,
129147
clientContext,
130148
userContext,
131-
properties: {},
149+
properties: { ...propsFromCallback },
132150
};
133151

134152
if (isSoftNavigation) {
@@ -169,6 +187,10 @@ async function handleClientEvent(
169187
config,
170188
});
171189

190+
// Resolve getProps using the real page path (not /api/event)
191+
const pageCtx: RequestContext = { ...ctx, path: serverContext.path };
192+
const propsFromCallback = await getEventProps(config, pageCtx, userContext);
193+
172194
const event: NextlyticsEvent = {
173195
origin: "client",
174196
eventId: generateId(),
@@ -179,7 +201,7 @@ async function handleClientEvent(
179201
serverContext,
180202
clientContext,
181203
userContext,
182-
properties: props || {},
204+
properties: { ...propsFromCallback, ...props },
183205
};
184206

185207
const { clientActions, completion } = dispatchEvent(event, ctx);

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export { Nextlytics } from "./server";
22
export { getNextlyticsProps } from "./pages-router";
33
export { NextlyticsClient, useNextlytics, type NextlyticsContext } from "./client";
44
export { loggingBackend } from "./backends/logging";
5+
export { pathMatcher, type PathMatcherOptions } from "./path-matcher";
56
export type {
67
NextlyticsConfig,
78
NextlyticsResult,

packages/core/src/middleware.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { resolveAnonymousUser } from "./anonymous-user";
1717
import {
1818
handleEventPost,
1919
getUserContext,
20+
getEventProps,
2021
type DispatchEvent,
2122
type UpdateEvent,
2223
} from "./api-handler";
@@ -25,6 +26,7 @@ function createRequestContext(request: NextRequest): RequestContext {
2526
return {
2627
headers: request.headers,
2728
cookies: request.cookies,
29+
path: request.nextUrl.pathname,
2830
};
2931
}
3032

@@ -78,13 +80,13 @@ export function createNextlyticsMiddleware(
7880

7981
// Skip internal paths, prefetch, and static files
8082
if (reqInfo.isNextjsInternal || reqInfo.isPrefetch || reqInfo.isStaticFile) {
81-
return NextResponse.next();
83+
return undefined;
8284
}
8385

8486
// Skip non-page-navigation, non-API requests (e.g. RSC fetches).
8587
// Soft navigations are tracked via the client /api/event request.
8688
if (!reqInfo.isPageNavigation && !config.isApiPath(pathname)) {
87-
return NextResponse.next();
89+
return undefined;
8890
}
8991

9092
const pageRenderId = generateId();
@@ -120,12 +122,14 @@ export function createNextlyticsMiddleware(
120122
}
121123

122124
const userContext = await getUserContext(config, ctx);
125+
const extraProps = await getEventProps(config, ctx, userContext);
123126
const pageViewEvent = createPageViewEvent(
124127
pageRenderId,
125128
serverContext,
126129
isApiPath,
127130
userContext,
128-
anonId
131+
anonId,
132+
extraProps
129133
);
130134

131135
// Dispatch to "on-request" backends only - "on-page-load" backends dispatch later
@@ -157,7 +161,8 @@ function createPageViewEvent(
157161
serverContext: ServerEventContext,
158162
isApiPath: boolean,
159163
userContext?: UserContext,
160-
anonymousUserId?: string
164+
anonymousUserId?: string,
165+
extraProps?: Record<string, unknown>
161166
): NextlyticsEvent {
162167
const eventType = isApiPath ? "apiCall" : "pageView";
163168
return {
@@ -168,6 +173,6 @@ function createPageViewEvent(
168173
anonymousUserId,
169174
serverContext,
170175
userContext,
171-
properties: {},
176+
properties: { ...extraProps },
172177
};
173178
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { describe, expect, it } from "vitest";
2+
import { pathMatcher } from "./path-matcher";
3+
4+
describe("pathMatcher", () => {
5+
describe("basic matching", () => {
6+
it("extracts a single param", () => {
7+
expect(pathMatcher("/[workspace]", "/acme")).toEqual({ workspace: "acme" });
8+
});
9+
10+
it("extracts multiple params", () => {
11+
expect(pathMatcher("/[workspace]/[project]", "/acme/myproject")).toEqual({
12+
workspace: "acme",
13+
project: "myproject",
14+
});
15+
});
16+
17+
it("matches literal segments", () => {
18+
expect(pathMatcher("/app/settings", "/app/settings")).toEqual({});
19+
});
20+
21+
it("returns null on literal mismatch", () => {
22+
expect(pathMatcher("/app/settings", "/app/profile")).toBeNull();
23+
});
24+
25+
it("returns null on segment count mismatch (too few)", () => {
26+
expect(pathMatcher("/[a]/[b]/[c]", "/x/y")).toBeNull();
27+
});
28+
29+
it("returns null on segment count mismatch (too many)", () => {
30+
expect(pathMatcher("/[a]/[b]", "/x/y/z")).toBeNull();
31+
});
32+
33+
it("matches root path against empty pattern", () => {
34+
expect(pathMatcher("/", "/")).toEqual({});
35+
});
36+
37+
it("handles trailing slashes", () => {
38+
expect(pathMatcher("/[workspace]/", "/acme/")).toEqual({ workspace: "acme" });
39+
});
40+
41+
it("decodes URL-encoded values", () => {
42+
expect(pathMatcher("/[name]", "/hello%20world")).toEqual({ name: "hello world" });
43+
});
44+
45+
it("mixes literal and param segments", () => {
46+
expect(pathMatcher("/app/[workspace]/settings", "/app/acme/settings")).toEqual({
47+
workspace: "acme",
48+
});
49+
});
50+
51+
it("returns null when literal segment in the middle mismatches", () => {
52+
expect(pathMatcher("/app/[workspace]/settings", "/app/acme/profile")).toBeNull();
53+
});
54+
});
55+
56+
describe("prefix mode", () => {
57+
it("matches with fewer segments", () => {
58+
expect(pathMatcher("/[workspace]/[project]/[taskId]", "/acme", { prefix: true })).toEqual({
59+
workspace: "acme",
60+
});
61+
});
62+
63+
it("matches partial prefix", () => {
64+
expect(
65+
pathMatcher("/[workspace]/[project]/[taskId]", "/acme/myproject", { prefix: true })
66+
).toEqual({
67+
workspace: "acme",
68+
project: "myproject",
69+
});
70+
});
71+
72+
it("matches full pattern in prefix mode", () => {
73+
expect(pathMatcher("/[workspace]/[project]", "/acme/myproject", { prefix: true })).toEqual({
74+
workspace: "acme",
75+
project: "myproject",
76+
});
77+
});
78+
79+
it("returns null when path has more segments than pattern", () => {
80+
expect(pathMatcher("/[workspace]", "/acme/extra/stuff", { prefix: true })).toBeNull();
81+
});
82+
83+
it("returns null for root path against non-empty pattern", () => {
84+
expect(pathMatcher("/[workspace]", "/", { prefix: true })).toBeNull();
85+
});
86+
87+
it("validates literal segments in prefix mode", () => {
88+
expect(pathMatcher("/app/[workspace]", "/wrong/acme", { prefix: true })).toBeNull();
89+
});
90+
91+
it("matches literal-only prefix", () => {
92+
expect(pathMatcher("/app/settings", "/app", { prefix: true })).toEqual({});
93+
});
94+
});
95+
96+
describe("not option", () => {
97+
it("excludes exact path", () => {
98+
expect(pathMatcher("/[page]", "/auth", { not: "/auth" })).toBeNull();
99+
});
100+
101+
it("excludes path prefix", () => {
102+
expect(pathMatcher("/[page]/[sub]", "/auth/login", { not: "/auth" })).toBeNull();
103+
});
104+
105+
it("does not do partial string match", () => {
106+
// "/auth" should NOT exclude "/authentication"
107+
expect(pathMatcher("/[page]", "/authentication", { not: "/auth" })).toEqual({
108+
page: "authentication",
109+
});
110+
});
111+
112+
it("supports array of exclusions", () => {
113+
expect(pathMatcher("/[page]", "/admin", { not: ["/auth", "/admin"] })).toBeNull();
114+
expect(pathMatcher("/[page]", "/auth", { not: ["/auth", "/admin"] })).toBeNull();
115+
expect(pathMatcher("/[page]", "/home", { not: ["/auth", "/admin"] })).toEqual({
116+
page: "home",
117+
});
118+
});
119+
120+
it("works combined with prefix mode", () => {
121+
expect(
122+
pathMatcher("/[workspace]/[project]", "/auth/login", { not: "/auth", prefix: true })
123+
).toBeNull();
124+
expect(
125+
pathMatcher("/[workspace]/[project]", "/acme", { not: "/auth", prefix: true })
126+
).toEqual({ workspace: "acme" });
127+
});
128+
});
129+
});

packages/core/src/path-matcher.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
export type PathMatcherOptions = {
2+
/** Paths to exclude. Matches exact path or path prefix (with `/` boundary). */
3+
not?: string | string[];
4+
/** Allow partial matches — path can have fewer segments than pattern. */
5+
prefix?: boolean;
6+
};
7+
8+
/**
9+
* Match a URL path against a Next.js-style `[param]` pattern.
10+
*
11+
* Returns extracted params on match, or `null` on mismatch.
12+
*
13+
* @example
14+
* ```ts
15+
* pathMatcher("/[workspace]/[project]", "/acme/myproject")
16+
* // => { workspace: "acme", project: "myproject" }
17+
* ```
18+
*/
19+
export function pathMatcher(
20+
pattern: string,
21+
path: string,
22+
opts?: PathMatcherOptions
23+
): Record<string, string> | null {
24+
// Check exclusions first
25+
if (opts?.not) {
26+
const exclusions = Array.isArray(opts.not) ? opts.not : [opts.not];
27+
for (const excl of exclusions) {
28+
if (path === excl || path.startsWith(excl + "/")) {
29+
return null;
30+
}
31+
}
32+
}
33+
34+
const patternSegments = pattern.split("/").filter(Boolean);
35+
const pathSegments = path.split("/").filter(Boolean);
36+
37+
if (opts?.prefix) {
38+
// Prefix mode: path can have fewer segments (but not more), at least 1 required
39+
if (pathSegments.length === 0) return null;
40+
if (pathSegments.length > patternSegments.length) return null;
41+
} else {
42+
// Exact mode: segment counts must match
43+
if (pathSegments.length !== patternSegments.length) return null;
44+
}
45+
46+
const params: Record<string, string> = {};
47+
const segmentsToMatch = Math.min(patternSegments.length, pathSegments.length);
48+
49+
for (let i = 0; i < segmentsToMatch; i++) {
50+
const pat = patternSegments[i];
51+
const seg = pathSegments[i];
52+
53+
const paramMatch = pat.match(/^\[(\w+)]$/);
54+
if (paramMatch) {
55+
params[paramMatch[1]] = decodeURIComponent(seg);
56+
} else if (pat !== seg) {
57+
return null;
58+
}
59+
}
60+
61+
return params;
62+
}

packages/core/src/server.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
import { logConfigWarnings, validateConfig, withDefaults } from "./config-helpers";
2323
import { createNextlyticsMiddleware } from "./middleware";
2424
import { generateId } from "./uitils";
25+
import { getEventProps } from "./api-handler";
2526

2627
type ResolvedBackend = {
2728
backend: NextlyticsBackend;
@@ -103,6 +104,7 @@ export async function createRequestContext(): Promise<RequestContext> {
103104
return {
104105
cookies: _cookies,
105106
headers: _headers,
107+
path: _headers.get("x-nl-pathname") || "",
106108
};
107109
}
108110

@@ -262,7 +264,11 @@ export function Nextlytics(userConfig: NextlyticsConfig): NextlyticsResult {
262264
const pageRenderId = headersList.get(headerNames.pageRenderId);
263265

264266
const serverContext = createServerContextFromHeaders(headersList);
265-
const ctx: RequestContext = { headers: headersList, cookies: cookieStore };
267+
const ctx: RequestContext = {
268+
headers: headersList,
269+
cookies: cookieStore,
270+
path: headersList.get(headerNames.pathname) || "",
271+
};
266272

267273
// Resolve anonymous user ID
268274
const { anonId: anonymousUserId } = await resolveAnonymousUser({ ctx, serverContext, config });
@@ -277,6 +283,8 @@ export function Nextlytics(userConfig: NextlyticsConfig): NextlyticsResult {
277283
}
278284
}
279285

286+
const propsFromCallback = await getEventProps(config, ctx, userContext);
287+
280288
return {
281289
sendEvent: async (
282290
eventName: string,
@@ -296,7 +304,7 @@ export function Nextlytics(userConfig: NextlyticsConfig): NextlyticsResult {
296304
anonymousUserId,
297305
serverContext,
298306
userContext,
299-
properties: opts?.props || {},
307+
properties: { ...propsFromCallback, ...opts?.props },
300308
};
301309
await dispatchEventInternal(event, ctx);
302310

0 commit comments

Comments
 (0)