Skip to content

Commit 3d1dd8b

Browse files
ralyodioclaude
andcommitted
refactor(autoblog): swap sender to @profullstack/autoblog SDK
The webhook wire shape moves to CloudEvents 1.0 + Standard Webhooks signing, both implemented in @profullstack/autoblog. lib/lx/webhookDeliver.ts now: - maps the lx_article row to the SDK's normalized Post shape - delegates the actual delivery (headers, HMAC, retry policy, fetch) to sendWebhook() from the SDK - keeps DB orchestration around it (atomic ready→publishing claim, terminal published/failed write, keyword status bump) Wire shape change is intentional and (per user) safe to do as a hard cutover — no one has published anything to live receivers yet. Receivers (threatcrush, coinpayportal, sh1pt) get the same SDK dependency + the verifyAndParse helper in follow-up commits. SDK is pinned to git+https://github.com/profullstack/autoblog#73e0b78 until we publish to npm. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 27981f0 commit 3d1dd8b

3 files changed

Lines changed: 81 additions & 126 deletions

File tree

‎lib/lx/webhookDeliver.ts‎

Lines changed: 71 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,19 @@
11
// Webhook delivery for autoblog articles (PRD §7).
22
//
3-
// Reads one lx_article (status='ready') + its owning lx_site, builds the
4-
// lx.publish_article payload, and POSTs to site.webhook_url with the
5-
// site's bearer secret. Retries on transient failure (non-2xx response,
6-
// network error). On 3xx redirects we follow once; on 4xx we do NOT
7-
// retry (the receiver is asking us to stop). On 5xx + network errors
8-
// we retry up to MAX_ATTEMPTS with backoff.
3+
// Reads one lx_article (status='ready') + its owning lx_site, maps it
4+
// to the normalized `Post` shape, and hands the actual HTTP work off
5+
// to @profullstack/autoblog. The SDK emits CloudEvents 1.0 envelopes
6+
// signed with Standard Webhooks headers and handles retry policy
7+
// (0s / 10s / 60s, retry on 5xx/408/429/network, give up on 4xx).
98
//
10-
// Idempotency: the X-Crawlproof-Delivery UUID is stable across retries
11-
// of the same lx_article. Receivers can hash it to detect replays.
9+
// This function still owns the DB orchestration around delivery: the
10+
// atomic ready→publishing claim, the published/failed terminal write,
11+
// and the keyword status bump. The wire shape and transport live in
12+
// the SDK so all four Profullstack consumers share one definition.
1213

13-
import crypto from "node:crypto";
1414
import type { SupabaseClient } from "@supabase/supabase-js";
15-
16-
const UA = "Crawlproof-LinkExchange/1.0";
17-
const REQUEST_TIMEOUT_MS = 10_000;
18-
const MAX_ATTEMPTS = 3;
19-
const RETRY_DELAYS_MS = [0, 10_000, 60_000]; // 0s, 10s, 60s
15+
import { buildEvent, sendWebhook, type Post } from "@profullstack/autoblog";
16+
import { env } from "../env";
2017

2118
type ArticleRow = {
2219
id: string;
@@ -38,6 +35,8 @@ type ArticleRow = {
3835

3936
type SiteRow = {
4037
id: string;
38+
domain: string;
39+
blog_root_url: string;
4140
webhook_url: string | null;
4241
webhook_secret: string | null;
4342
};
@@ -50,65 +49,25 @@ export type DeliveryResult = {
5049
error?: string;
5150
};
5251

53-
function shouldRetry(status: number | null): boolean {
54-
if (status === null) return true; // network / timeout
55-
if (status >= 500) return true;
56-
if (status === 408 || status === 429) return true;
57-
return false;
58-
}
59-
60-
async function postOnce(input: {
61-
url: string;
62-
secret: string;
63-
deliveryId: string;
64-
payload: unknown;
65-
}): Promise<{ status: number | null; error?: string }> {
66-
const { url, secret, deliveryId, payload } = input;
67-
const controller = new AbortController();
68-
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
69-
try {
70-
const res = await fetch(url, {
71-
method: "POST",
72-
signal: controller.signal,
73-
headers: {
74-
"content-type": "application/json",
75-
authorization: `Bearer ${secret}`,
76-
"user-agent": UA,
77-
"x-crawlproof-delivery": deliveryId,
78-
},
79-
body: JSON.stringify(payload),
80-
redirect: "follow",
81-
});
82-
return { status: res.status };
83-
} catch (err) {
84-
return {
85-
status: null,
86-
error: err instanceof Error ? err.message : String(err),
87-
};
88-
} finally {
89-
clearTimeout(timer);
90-
}
91-
}
92-
93-
function buildPayload(article: ArticleRow): unknown {
52+
function articleToPost(article: ArticleRow, site: SiteRow): Post {
53+
const blogRoot = site.blog_root_url.replace(/\/$/, "");
54+
const url = `${blogRoot}/${article.slug}`;
9455
return {
95-
event_type: "lx.publish_article",
96-
timestamp: new Date().toISOString(),
97-
data: {
98-
article: {
99-
id: article.id,
100-
title: article.title,
101-
slug: article.slug,
102-
content_markdown: article.content_markdown,
103-
content_html: article.content_html,
104-
meta_description: article.meta_description,
105-
image_url: article.image_url,
106-
tags: article.tags,
107-
outbound_links: article.outbound_links ?? [],
108-
internal_links: article.internal_links ?? [],
109-
created_at: article.created_at,
110-
},
111-
},
56+
id: article.id,
57+
url,
58+
canonical_url: url,
59+
title: article.title,
60+
slug: article.slug,
61+
excerpt: article.meta_description || null,
62+
html: article.content_html,
63+
markdown: article.content_markdown,
64+
status: "published",
65+
published_at: new Date().toISOString(),
66+
updated_at: new Date().toISOString(),
67+
author: null,
68+
tags: article.tags ?? [],
69+
categories: [],
70+
featured_image: article.image_url ? { url: article.image_url } : null,
11271
};
11372
}
11473

@@ -118,8 +77,7 @@ export async function deliverArticle(
11877
): Promise<DeliveryResult> {
11978
const { supabase } = deps;
12079

121-
// Atomic claim: only flip ready -> publishing once. If the row isn't
122-
// ready (already publishing or published), bail.
80+
// Atomic claim: only flip ready -> publishing once.
12381
const { data: claimed } = await supabase
12482
.from("lx_article")
12583
.update({ status: "publishing" })
@@ -141,7 +99,7 @@ export async function deliverArticle(
14199

142100
const { data: site } = await supabase
143101
.from("lx_site")
144-
.select("id, webhook_url, webhook_secret")
102+
.select("id, domain, blog_root_url, webhook_url, webhook_secret")
145103
.eq("id", claimed.site_id)
146104
.maybeSingle<SiteRow>();
147105
if (!site?.webhook_url || !site?.webhook_secret) {
@@ -161,64 +119,51 @@ export async function deliverArticle(
161119
};
162120
}
163121

164-
const deliveryId = claimed.webhook_delivery_id ?? crypto.randomUUID();
165-
const payload = buildPayload(claimed);
122+
const post = articleToPost(claimed, site);
123+
// Reuse the saved delivery id on retries so receivers idempotently
124+
// dedupe. SDK uses event.id as the webhook-id header.
125+
const event = buildEvent(post, {
126+
source: env.siteUrl,
127+
eventId: claimed.webhook_delivery_id ?? undefined,
128+
});
166129

167-
let attempt = 0;
168-
let lastStatus: number | null = null;
169-
let lastError: string | undefined;
130+
const result = await sendWebhook(site.webhook_url, event, {
131+
secret: site.webhook_secret,
132+
});
170133

171-
while (attempt < MAX_ATTEMPTS) {
172-
if (RETRY_DELAYS_MS[attempt] > 0) {
173-
await new Promise((r) => setTimeout(r, RETRY_DELAYS_MS[attempt]));
174-
}
175-
attempt++;
176-
const r = await postOnce({
177-
url: site.webhook_url,
178-
secret: site.webhook_secret,
179-
deliveryId,
180-
payload,
181-
});
182-
lastStatus = r.status;
183-
lastError = r.error;
184-
if (r.status !== null && r.status >= 200 && r.status < 300) {
185-
// Success.
186-
await supabase
187-
.from("lx_article")
188-
.update({
189-
status: "published",
190-
published_at: new Date().toISOString(),
191-
webhook_delivery_id: deliveryId,
192-
webhook_response_code: r.status,
193-
webhook_attempts: attempt,
194-
webhook_last_error: null,
195-
})
196-
.eq("id", articleId);
197-
// Mark the originating keyword as published.
198-
await supabase
199-
.from("lx_keyword")
200-
.update({ status: "published" })
201-
.eq("article_id", articleId);
202-
return {
203-
ok: true,
134+
if (result.ok) {
135+
await supabase
136+
.from("lx_article")
137+
.update({
204138
status: "published",
205-
responseCode: r.status,
206-
attempts: attempt,
207-
};
208-
}
209-
if (!shouldRetry(r.status)) break;
139+
published_at: new Date().toISOString(),
140+
webhook_delivery_id: event.id,
141+
webhook_response_code: result.status,
142+
webhook_attempts: result.attempts,
143+
webhook_last_error: null,
144+
})
145+
.eq("id", articleId);
146+
await supabase
147+
.from("lx_keyword")
148+
.update({ status: "published" })
149+
.eq("article_id", articleId);
150+
return {
151+
ok: true,
152+
status: "published",
153+
responseCode: result.status,
154+
attempts: result.attempts,
155+
};
210156
}
211157

212-
// All attempts failed.
213158
await supabase
214159
.from("lx_article")
215160
.update({
216161
status: "failed",
217-
webhook_delivery_id: deliveryId,
218-
webhook_response_code: lastStatus,
219-
webhook_attempts: attempt,
162+
webhook_delivery_id: event.id,
163+
webhook_response_code: result.status,
164+
webhook_attempts: result.attempts,
220165
webhook_last_error:
221-
lastError ?? (lastStatus !== null ? `HTTP ${lastStatus}` : "unknown error"),
166+
result.error ?? (result.status !== null ? `HTTP ${result.status}` : "unknown error"),
222167
})
223168
.eq("id", articleId);
224169
await supabase
@@ -229,8 +174,8 @@ export async function deliverArticle(
229174
return {
230175
ok: false,
231176
status: "failed",
232-
responseCode: lastStatus,
233-
attempts: attempt,
234-
error: lastError ?? `HTTP ${lastStatus}`,
177+
responseCode: result.status,
178+
attempts: result.attempts,
179+
error: result.error ?? `HTTP ${result.status}`,
235180
};
236181
}

‎package-lock.json‎

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
},
1818
"dependencies": {
1919
"@anthropic-ai/sdk": "^0.95.2",
20+
"@profullstack/autoblog": "github:profullstack/autoblog#73e0b78",
2021
"@supabase/ssr": "^0.10.3",
2122
"@supabase/supabase-js": "^2.105.4",
2223
"cheerio": "^1.0.0",

0 commit comments

Comments
 (0)