Skip to content

Commit 6831abd

Browse files
committed
feat: implement redirect rules (wildcard/pattern matching links)
This adds support for redirect rules that use pattern matching to redirect multiple URLs to a single destination. Closes #529. Changes: - Add isRule and rulePattern fields to Link schema - Create rule-cache.ts for caching rules per domain - Add match-rule.ts with pattern matching logic (wildcards, named params) - Update middleware to fallback to rule matching when exact link not found - Pass aliasLinkId to recordClick for analytics tracking - Add validation in processLink for rule patterns - Update Zod schemas and TypeScript types Pattern syntax: - Wildcards: /blog/* matches /blog/anything - Named params: /docs/:slug matches /docs/intro (captures slug) - Specificity-based matching (more specific patterns match first)
1 parent cee073e commit 6831abd

File tree

12 files changed

+424
-27
lines changed

12 files changed

+424
-27
lines changed

apps/web/lib/api/links/process-link.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { isBlacklistedDomain } from "@/lib/edge-config";
22
import { verifyFolderAccess } from "@/lib/folder/permissions";
3+
import { validatePattern } from "@/lib/middleware/utils/match-rule";
34
import { checkIfUserExists, getRandomKey } from "@/lib/planetscale";
45
import { isNotHostedImage } from "@/lib/storage";
56
import { NewLinkProps, ProcessedLinkProps } from "@/lib/types";
@@ -72,6 +73,8 @@ export async function processLink<T extends Record<string, any>>({
7273
programId,
7374
webhookIds,
7475
testVariants,
76+
isRule,
77+
rulePattern,
7578
} = payload;
7679

7780
let expiresAt: string | Date | null | undefined = payload.expiresAt;
@@ -151,6 +154,36 @@ export async function processLink<T extends Record<string, any>>({
151154
};
152155
}
153156

157+
// Redirect rule validation
158+
if (isRule) {
159+
if (!rulePattern) {
160+
return {
161+
link: payload,
162+
error: "Rule pattern is required when creating a redirect rule.",
163+
code: "bad_request",
164+
};
165+
}
166+
167+
const patternError = validatePattern(rulePattern);
168+
if (patternError) {
169+
return {
170+
link: payload,
171+
error: patternError,
172+
code: "unprocessable_entity",
173+
};
174+
}
175+
176+
// Redirect rules require a Pro plan or higher
177+
if (!workspace || workspace.plan === "free") {
178+
return {
179+
link: payload,
180+
error:
181+
"Redirect rules are only available on Pro plan and above. Upgrade to Pro to use this feature.",
182+
code: "forbidden",
183+
};
184+
}
185+
}
186+
154187
const domains = workspace
155188
? await prisma.domain.findMany({
156189
where: { projectId: workspace.id },
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { RedisLinkProps } from "@/lib/types";
2+
import { formatRedisLink, redis } from "@/lib/upstash";
3+
import { LRUCache } from "lru-cache";
4+
import { ExpandedLink } from "./utils/transform-link";
5+
6+
/**
7+
* Cache for redirect rules (wildcard/pattern-based links).
8+
* Rules are cached per domain to enable efficient pattern matching fallback
9+
* when exact link matches are not found.
10+
*/
11+
12+
// LRU cache to reduce Redis load - max 500 domains with 30-second TTL
13+
const ruleLRUCache = new LRUCache<string, RedisLinkProps[]>({
14+
max: 500,
15+
ttl: 30000, // 30 seconds
16+
});
17+
18+
// Redis cache expiration: 1 hour for rules
19+
export const RULE_CACHE_EXPIRATION = 60 * 60;
20+
21+
export type RedisRuleLink = RedisLinkProps & {
22+
rulePattern: string;
23+
};
24+
25+
class RuleCache {
26+
/**
27+
* Set all rules for a domain in cache
28+
*/
29+
async set(domain: string, rules: ExpandedLink[]) {
30+
if (rules.length === 0) {
31+
return;
32+
}
33+
34+
const redisRules = rules.map((rule) => ({
35+
...formatRedisLink(rule),
36+
rulePattern: rule.rulePattern!,
37+
}));
38+
39+
const cacheKey = this._createKey(domain);
40+
41+
// Update LRU cache
42+
ruleLRUCache.set(cacheKey, redisRules);
43+
44+
// Store in Redis
45+
return await redis.set(cacheKey, redisRules, { ex: RULE_CACHE_EXPIRATION });
46+
}
47+
48+
/**
49+
* Get all rules for a domain
50+
*/
51+
async get(domain: string): Promise<RedisRuleLink[] | null> {
52+
const cacheKey = this._createKey(domain);
53+
54+
// Check LRU cache first
55+
let cachedRules = ruleLRUCache.get(cacheKey) as RedisRuleLink[] | undefined;
56+
57+
if (cachedRules) {
58+
console.log(`[Rule LRU Cache HIT] ${cacheKey}`);
59+
return cachedRules;
60+
}
61+
62+
console.log(`[Rule LRU Cache MISS] ${cacheKey} - Checking Redis...`);
63+
64+
try {
65+
cachedRules = await redis.get<RedisRuleLink[]>(cacheKey);
66+
67+
if (cachedRules) {
68+
console.log(
69+
`[Rule Redis Cache HIT] ${cacheKey} - Populating LRU cache...`,
70+
);
71+
ruleLRUCache.set(cacheKey, cachedRules);
72+
}
73+
74+
return cachedRules || null;
75+
} catch (error) {
76+
console.error("[RuleCache] - Error getting rules from Redis:", error);
77+
return null;
78+
}
79+
}
80+
81+
/**
82+
* Delete all rules for a domain (used when rules are updated)
83+
*/
84+
async delete(domain: string) {
85+
const cacheKey = this._createKey(domain);
86+
ruleLRUCache.delete(cacheKey);
87+
return await redis.del(cacheKey);
88+
}
89+
90+
/**
91+
* Invalidate cache when a rule is added/updated/deleted
92+
*/
93+
async invalidate(domain: string) {
94+
return await this.delete(domain);
95+
}
96+
97+
_createKey(domain: string) {
98+
return `rulecache:${domain.toLowerCase()}`;
99+
}
100+
}
101+
102+
export const ruleCache = new RuleCache();

apps/web/lib/middleware/link.ts

Lines changed: 69 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ import {
2121
import { linkCache } from "../api/links/cache";
2222
import { isCaseSensitiveDomain } from "../api/links/case-sensitivity";
2323
import { recordClickCache } from "../api/links/record-click-cache";
24-
import { getLinkViaEdge } from "../planetscale";
24+
import { ruleCache, RedisRuleLink } from "../api/links/rule-cache";
25+
import { getLinkViaEdge, getRulesViaEdge } from "../planetscale";
2526
import { getPartnerEnrollmentInfo } from "../planetscale/get-partner-enrollment-info";
2627
import { cacheDeepLinkClickData } from "./utils/cache-deeplink-click-data";
2728
import { crawlBitly } from "./utils/crawl-bitly";
@@ -33,6 +34,7 @@ import { handleNotFoundLink } from "./utils/handle-not-found-link";
3334
import { isIosAppStoreUrl } from "./utils/is-ios-app-store-url";
3435
import { isSingularTrackingUrl } from "./utils/is-singular-tracking-url";
3536
import { isSupportedCustomURIScheme } from "./utils/is-supported-custom-uri-scheme";
37+
import { matchRules, MatchResult } from "./utils/match-rule";
3638
import { parse } from "./utils/parse";
3739
import { resolveABTestURL } from "./utils/resolve-ab-test-url";
3840

@@ -76,6 +78,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
7678

7779
let cachedLink = await linkCache.get({ domain, key });
7880
let isPartnerLink = Boolean(cachedLink?.programId && cachedLink?.partnerId);
81+
let ruleMatch: MatchResult | null = null;
7982

8083
if (!cachedLink) {
8184
let linkData = await getLinkViaEdge({
@@ -84,38 +87,71 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
8487
});
8588

8689
if (!linkData) {
87-
if (domain === "buff.ly") {
88-
return await crawlBitly(req);
90+
// Try to match against redirect rules before returning 404
91+
let rules = await ruleCache.get(domain);
92+
93+
if (!rules) {
94+
// Fetch rules from database and cache them
95+
const rulesData = await getRulesViaEdge(domain);
96+
if (rulesData.length > 0) {
97+
const formattedRules = rulesData.map((rule) => ({
98+
...formatRedisLink(rule as any),
99+
rulePattern: rule.rulePattern!,
100+
})) as RedisRuleLink[];
101+
await ruleCache.set(domain, formattedRules as any);
102+
rules = formattedRules;
103+
}
89104
}
90105

91-
return await handleNotFoundLink(req);
92-
}
106+
if (rules && rules.length > 0) {
107+
// Match the path against rules
108+
const pathToMatch = `/${key}`;
109+
ruleMatch = matchRules(pathToMatch, rules);
110+
111+
if (ruleMatch) {
112+
// Use the matched rule as the link
113+
cachedLink = ruleMatch.rule;
114+
isPartnerLink = Boolean(
115+
cachedLink?.programId && cachedLink?.partnerId,
116+
);
117+
}
118+
}
93119

94-
isPartnerLink = Boolean(linkData.programId && linkData.partnerId);
120+
// If still no match, return 404
121+
if (!cachedLink) {
122+
if (domain === "buff.ly") {
123+
return await crawlBitly(req);
124+
}
95125

96-
// format link to fit the RedisLinkProps interface
97-
cachedLink = formatRedisLink(linkData as any);
126+
return await handleNotFoundLink(req);
127+
}
128+
} else {
129+
isPartnerLink = Boolean(linkData.programId && linkData.partnerId);
98130

99-
ev.waitUntil(
100-
(async () => {
101-
if (!isPartnerLink) {
102-
await linkCache.set(linkData as any);
103-
return;
104-
}
131+
// format link to fit the RedisLinkProps interface
132+
cachedLink = formatRedisLink(linkData as any);
105133

106-
const { partner, discount } = await getPartnerEnrollmentInfo({
107-
programId: linkData.programId,
108-
partnerId: linkData.partnerId,
109-
});
110-
111-
// we'll use this data on /track/click
112-
await linkCache.set({
113-
...(linkData as any),
114-
...(partner && { partner }),
115-
...(discount && { discount }),
116-
});
117-
})(),
118-
);
134+
ev.waitUntil(
135+
(async () => {
136+
if (!isPartnerLink) {
137+
await linkCache.set(linkData as any);
138+
return;
139+
}
140+
141+
const { partner, discount } = await getPartnerEnrollmentInfo({
142+
programId: linkData.programId,
143+
partnerId: linkData.partnerId,
144+
});
145+
146+
// we'll use this data on /track/click
147+
await linkCache.set({
148+
...(linkData as any),
149+
...(partner && { partner }),
150+
...(discount && { discount }),
151+
});
152+
})(),
153+
);
154+
}
119155
}
120156

121157
const {
@@ -262,6 +298,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
262298
clickId,
263299
workspaceId,
264300
linkId,
301+
aliasLinkId: ruleMatch?.matchedPath,
265302
domain,
266303
key,
267304
url,
@@ -320,6 +357,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
320357
clickId,
321358
workspaceId,
322359
linkId,
360+
aliasLinkId: ruleMatch?.matchedPath,
323361
domain,
324362
key,
325363
url: finalUrl,
@@ -356,6 +394,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
356394
clickId,
357395
workspaceId,
358396
linkId,
397+
aliasLinkId: ruleMatch?.matchedPath,
359398
domain,
360399
key,
361400
url: finalUrl,
@@ -394,6 +433,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
394433
clickId,
395434
workspaceId,
396435
linkId,
436+
aliasLinkId: ruleMatch?.matchedPath,
397437
domain,
398438
key,
399439
url: finalUrl,
@@ -463,6 +503,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
463503
clickId,
464504
workspaceId,
465505
linkId,
506+
aliasLinkId: ruleMatch?.matchedPath,
466507
domain,
467508
key,
468509
url: finalUrl,
@@ -531,6 +572,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
531572
clickId,
532573
workspaceId,
533574
linkId,
575+
aliasLinkId: ruleMatch?.matchedPath,
534576
domain,
535577
key,
536578
url: finalUrl,

0 commit comments

Comments
 (0)