Skip to content

Commit e8ae0b2

Browse files
committed
fix(links): defer OG fetch via after(), share Zod schemas, rename MAX_HOPS
Address review feedback on #4264: - Split applyOgFields into sync applyOgIntent + async backfillOgMetadata. Routes now wrap the create/update with next/server after() so the 5s-bounded OG fetch no longer blocks the response. backfillOgMetadata uses per-field updateMany guarded on url + *Manual:false + deletedAt:null so a slow backfill can't clobber a newer URL, manual override, or soft-deleted row. applyOgIntent also nulls auto-managed OG fields up front on url change so the prior URL's metadata isn't shown in the gap. - Extract duplicated Zod helpers (utmField, ogTitleField, ogDescriptionField, ogImageField, isHttpUrl, isPublicHttpUrl) to src/app/api/links/schemas.ts. - Rename MAX_REDIRECTS to MAX_HOPS for accuracy (semantics unchanged). API semantics note: POST /api/links and POST /api/links/:linkId now return auto OG fields as null initially; values populate within ~3s. UI live-preview already runs client-side via /api/links/og-preview.
1 parent db37b4a commit e8ae0b2

5 files changed

Lines changed: 139 additions & 112 deletions

File tree

src/app/api/links/[linkId]/route.ts

Lines changed: 14 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1+
import { after } from 'next/server';
12
import { z } from 'zod';
2-
import { validateUrl } from '@/lib/og';
33
import { parseRequest } from '@/lib/request';
44
import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
55
import { canDeleteLink, canUpdateLink, canViewLink } from '@/permissions';
6-
import { deleteLink, getLink, updateLink } from '@/queries/prisma';
6+
import { backfillOgMetadata, deleteLink, getLink, updateLink } from '@/queries/prisma';
7+
import {
8+
isHttpUrl,
9+
ogDescriptionField,
10+
ogImageField,
11+
ogTitleField,
12+
utmField,
13+
} from '../schemas';
714

815
export async function GET(request: Request, { params }: { params: Promise<{ linkId: string }> }) {
916
const { auth, error } = await parseRequest(request);
@@ -23,50 +30,6 @@ export async function GET(request: Request, { params }: { params: Promise<{ link
2330
return json(website);
2431
}
2532

26-
const utmField = z
27-
.string()
28-
.max(255)
29-
.transform(v => (v === '' ? null : v))
30-
.nullable()
31-
.optional();
32-
33-
const ogTextField = (max: number) =>
34-
z
35-
.string()
36-
.max(max)
37-
.transform(v => {
38-
const trimmed = v.trim();
39-
return trimmed === '' ? null : trimmed;
40-
})
41-
.nullable()
42-
.optional();
43-
44-
function isHttpUrl(value: string): boolean {
45-
try {
46-
const u = new URL(value);
47-
return (u.protocol === 'http:' || u.protocol === 'https:') && !!u.host;
48-
} catch {
49-
return false;
50-
}
51-
}
52-
53-
function isPublicHttpUrl(value: string): boolean {
54-
return isHttpUrl(value) && validateUrl(value) !== null;
55-
}
56-
57-
const ogImageField = z
58-
.string()
59-
.max(2047)
60-
.transform(v => {
61-
const trimmed = v.trim();
62-
return trimmed === '' ? null : trimmed;
63-
})
64-
.nullable()
65-
.optional()
66-
.refine(v => v == null || isPublicHttpUrl(v), {
67-
message: 'ogImage must be a public http(s) URL',
68-
});
69-
7033
export async function POST(request: Request, { params }: { params: Promise<{ linkId: string }> }) {
7134
const schema = z.object({
7235
name: z.string().max(100).optional(),
@@ -81,8 +44,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ lin
8144
utmCampaign: utmField,
8245
utmTerm: utmField,
8346
utmContent: utmField,
84-
ogTitle: ogTextField(255),
85-
ogDescription: ogTextField(500),
47+
ogTitle: ogTitleField,
48+
ogDescription: ogDescriptionField,
8649
ogImage: ogImageField,
8750
});
8851

@@ -127,7 +90,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ lin
12790
if (ogImage !== undefined) payload.ogImage = ogImage;
12891

12992
try {
130-
const result = await updateLink(linkId, payload);
93+
const { result, before } = await updateLink(linkId, payload);
94+
95+
after(() => backfillOgMetadata(result.id, payload, before));
13196

13297
return Response.json(result);
13398
} catch (e: any) {

src/app/api/links/route.ts

Lines changed: 13 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1+
import { after } from 'next/server';
12
import { z } from 'zod';
23
import { uuid } from '@/lib/crypto';
3-
import { validateUrl } from '@/lib/og';
44
import { getQueryFilters, parseRequest } from '@/lib/request';
55
import { json, unauthorized } from '@/lib/response';
66
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
77
import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
8-
import { createLink, getUserLinks } from '@/queries/prisma';
8+
import { backfillOgMetadata, createLink, getUserLinks } from '@/queries/prisma';
9+
import {
10+
isHttpUrl,
11+
ogDescriptionField,
12+
ogImageField,
13+
ogTitleField,
14+
utmField,
15+
} from './schemas';
916

1017
export async function GET(request: Request) {
1118
const schema = z.object({
@@ -27,51 +34,6 @@ export async function GET(request: Request) {
2734
return json(links);
2835
}
2936

30-
const utmField = z
31-
.string()
32-
.max(255)
33-
.transform(v => (v === '' ? null : v))
34-
.nullable()
35-
.optional();
36-
37-
const ogTextField = (max: number) =>
38-
z
39-
.string()
40-
.max(max)
41-
.transform(v => {
42-
const trimmed = v.trim();
43-
return trimmed === '' ? null : trimmed;
44-
})
45-
.nullable()
46-
.optional();
47-
48-
function isHttpUrl(value: string): boolean {
49-
try {
50-
const u = new URL(value);
51-
return (u.protocol === 'http:' || u.protocol === 'https:') && !!u.host;
52-
} catch {
53-
return false;
54-
}
55-
}
56-
57-
// http(s) URL that doesn't resolve to a private/reserved IP literal.
58-
function isPublicHttpUrl(value: string): boolean {
59-
return isHttpUrl(value) && validateUrl(value) !== null;
60-
}
61-
62-
const ogImageField = z
63-
.string()
64-
.max(2047)
65-
.transform(v => {
66-
const trimmed = v.trim();
67-
return trimmed === '' ? null : trimmed;
68-
})
69-
.nullable()
70-
.optional()
71-
.refine(v => v == null || isPublicHttpUrl(v), {
72-
message: 'ogImage must be a public http(s) URL',
73-
});
74-
7537
export async function POST(request: Request) {
7638
const schema = z.object({
7739
name: z.string().max(100),
@@ -87,8 +49,8 @@ export async function POST(request: Request) {
8749
utmCampaign: utmField,
8850
utmTerm: utmField,
8951
utmContent: utmField,
90-
ogTitle: ogTextField(255),
91-
ogDescription: ogTextField(500),
52+
ogTitle: ogTitleField,
53+
ogDescription: ogDescriptionField,
9254
ogImage: ogImageField,
9355
});
9456

@@ -140,5 +102,7 @@ export async function POST(request: Request) {
140102

141103
const result = await createLink(data);
142104

105+
after(() => backfillOgMetadata(result.id, data, null));
106+
143107
return json(result);
144108
}

src/app/api/links/schemas.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { z } from 'zod';
2+
import { validateUrl } from '@/lib/og';
3+
4+
export const utmField = z
5+
.string()
6+
.max(255)
7+
.transform(v => (v === '' ? null : v))
8+
.nullable()
9+
.optional();
10+
11+
const ogTextField = (max: number) =>
12+
z
13+
.string()
14+
.max(max)
15+
.transform(v => {
16+
const trimmed = v.trim();
17+
return trimmed === '' ? null : trimmed;
18+
})
19+
.nullable()
20+
.optional();
21+
22+
export const ogTitleField = ogTextField(255);
23+
export const ogDescriptionField = ogTextField(500);
24+
25+
export function isHttpUrl(value: string): boolean {
26+
try {
27+
const u = new URL(value);
28+
return (u.protocol === 'http:' || u.protocol === 'https:') && !!u.host;
29+
} catch {
30+
return false;
31+
}
32+
}
33+
34+
// http(s) URL whose host doesn't resolve to a private/reserved IP literal.
35+
export function isPublicHttpUrl(value: string): boolean {
36+
return isHttpUrl(value) && validateUrl(value) !== null;
37+
}
38+
39+
export const ogImageField = z
40+
.string()
41+
.max(2047)
42+
.transform(v => {
43+
const trimmed = v.trim();
44+
return trimmed === '' ? null : trimmed;
45+
})
46+
.nullable()
47+
.optional()
48+
.refine(v => v == null || isPublicHttpUrl(v), {
49+
message: 'ogImage must be a public http(s) URL',
50+
});

src/lib/og.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export interface OgMetadata {
99
}
1010

1111
const FETCH_TIMEOUT_MS = 5000;
12-
const MAX_REDIRECTS = 3;
12+
const MAX_HOPS = 3;
1313
const MAX_BODY_BYTES = 1024 * 1024;
1414
const HEAD_SLICE_FALLBACK_BYTES = 64 * 1024;
1515

@@ -211,7 +211,7 @@ export async function fetchOgMetadata(rawUrl: string): Promise<OgMetadata> {
211211
let currentUrl: URL = initial;
212212
let response: import('undici').Response | null = null;
213213

214-
for (let hop = 0; hop < MAX_REDIRECTS; hop++) {
214+
for (let hop = 0; hop < MAX_HOPS; hop++) {
215215
try {
216216
response = await undiciFetch(currentUrl, {
217217
method: 'GET',

src/queries/prisma/link.ts

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ interface OgPreReadRow {
2929
ogImageManual?: boolean | null;
3030
}
3131

32-
// undefined → preserve, null → clear-to-auto, string → manual override.
33-
async function applyOgFields(data: any, currentRow: OgPreReadRow | null): Promise<void> {
32+
// Sync intent classification + clear stale auto fields on url change; network fetch is deferred to backfillOgMetadata.
33+
function applyOgIntent(data: any, currentRow: OgPreReadRow | null = null): void {
3434
for (const f of OG_FIELDS) {
3535
const flag = flagOf(f);
3636
const v = data[f];
@@ -44,12 +44,32 @@ async function applyOgFields(data: any, currentRow: OgPreReadRow | null): Promis
4444
}
4545
}
4646

47+
// On url change, null out auto-managed fields up front so the user never sees the prior URL's metadata.
48+
if (currentRow && data.url !== undefined && data.url !== currentRow.url) {
49+
for (const f of OG_FIELDS) {
50+
const flag = flagOf(f);
51+
if (data[flag] === true) continue;
52+
if (data[flag] === undefined && currentRow[flag]) continue;
53+
if (data[f] === undefined) {
54+
data[f] = null;
55+
data[flag] = false;
56+
}
57+
}
58+
}
59+
}
60+
61+
// Run via next/server `after`; optimistic write guards against races on rapid edits, manual overrides, and deletes.
62+
export async function backfillOgMetadata(
63+
linkId: string,
64+
data: any,
65+
currentRow: OgPreReadRow | null,
66+
): Promise<void> {
4767
const targetUrl = data.url ?? currentRow?.url;
4868
if (!targetUrl) return;
4969

5070
const urlChanged = currentRow ? data.url !== undefined && data.url !== currentRow.url : true;
5171

52-
const fieldsToFill = OG_FIELDS.filter(f => {
72+
const candidateFields = OG_FIELDS.filter(f => {
5373
const flag = flagOf(f);
5474
const intent = data[flag];
5575
if (intent === true) return false;
@@ -58,12 +78,40 @@ async function applyOgFields(data: any, currentRow: OgPreReadRow | null): Promis
5878
return urlChanged;
5979
});
6080

61-
if (fieldsToFill.length === 0) return;
81+
if (candidateFields.length === 0) return;
6282

63-
const parsed = await fetchOgMetadata(targetUrl);
64-
for (const f of fieldsToFill) {
65-
data[f] = parsed[OG_FIELD_TO_PARSED_KEY[f]] ?? null;
66-
data[flagOf(f)] = false;
83+
let parsed;
84+
try {
85+
parsed = await fetchOgMetadata(targetUrl);
86+
} catch {
87+
return;
88+
}
89+
90+
// updateMany no-ops if url+flag have diverged since fetch started; next edit re-triggers.
91+
for (const f of candidateFields) {
92+
const flag = flagOf(f);
93+
const newValue = parsed[OG_FIELD_TO_PARSED_KEY[f]] ?? null;
94+
await prisma.client.link
95+
.updateMany({
96+
where: {
97+
id: linkId,
98+
url: targetUrl,
99+
[flag]: false,
100+
deletedAt: null,
101+
},
102+
data: { [f]: newValue, [flag]: false },
103+
})
104+
.catch(() => {});
105+
}
106+
107+
// del-only (no prime): a concurrent crawler set could clobber us last-write-wins; bounded 24h staleness is accepted.
108+
if (redis.enabled) {
109+
const fresh = await prisma.client.link
110+
.findUnique({ where: { id: linkId }, select: { slug: true } })
111+
.catch(() => null);
112+
if (fresh?.slug) {
113+
await redis.client.del(`link:${fresh.slug}`).catch(() => {});
114+
}
67115
}
68116
}
69117

@@ -120,7 +168,7 @@ export async function getTeamLinks(teamId: string, filters?: QueryFilters) {
120168
}
121169

122170
export async function createLink(data: Prisma.LinkUncheckedCreateInput) {
123-
await applyOgFields(data, null);
171+
applyOgIntent(data);
124172

125173
const result = await prisma.client.link.create({ data });
126174

@@ -133,7 +181,7 @@ export async function createLink(data: Prisma.LinkUncheckedCreateInput) {
133181
}
134182

135183
export async function updateLink(linkId: string, data: any) {
136-
// Always run: needed for OG manual-flag check + Redis cache invalidation.
184+
// Returned to caller for OG manual-flag check, Redis invalidation, and backfill race guards.
137185
const before = await prisma.client.link.findUnique({
138186
where: { id: linkId },
139187
select: {
@@ -145,7 +193,7 @@ export async function updateLink(linkId: string, data: any) {
145193
},
146194
});
147195

148-
await applyOgFields(data, before);
196+
applyOgIntent(data, before);
149197

150198
const result = await prisma.client.link.update({ where: { id: linkId }, data });
151199

@@ -156,7 +204,7 @@ export async function updateLink(linkId: string, data: any) {
156204
}
157205
}
158206

159-
return result;
207+
return { result, before };
160208
}
161209

162210
export async function deleteLink(linkId: string) {

0 commit comments

Comments
 (0)