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" ;
1414import 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
2118type ArticleRow = {
2219 id : string ;
@@ -38,6 +35,8 @@ type ArticleRow = {
3835
3936type 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}
0 commit comments