Using Val.town as a webhook middleware
Productive procrastination is one of my favourite pastimes. My latest obsession has been webhook events from Ghost CMS to Netlify. I had event listeners in Ghost that triggered builds whenever the site changed. When this event occurred, and it occurred often, it would trigger a Netlify build by POSTing to a Netlify build hook.
I wanted to optimise these webhook events to run fewer builds on Netlify. This meant using fewer build minutes, which may translate to less money spent. Much productive. You might ask, "how many build minutes are you using?". Well my friend, I'm not even close to the free build minute quota. Nevertheless, we press on.
The first thing I did was listen to different events that fired less often. Instead of site changes, I now listen to post published, updated, and unpublished events. This sends a payload that looks something like this to Netlify:
"post": {
"current": {
"id": "695d72563020fd0001cb96d6",
"uuid": "41af1fc2-40e9-43e8-aa47-fbe4de2fd276",
"title": "Test Newsletter post",
"slug": "test-newsletter-post",
"html": "<h1>Some HTML</h1>",
"tags": [
{
...some fields
}
],
// ...Many other fieldsUnfortunately, if the blog post is long it will cause a Netlify build error. I don't see it documented, but I think there's a 140kb limit on payloads sent to Netlify build hooks. I needed a way to process this payload before sending it to Netlify. Enter Val.town.
In Val.town, you create building blocks called Vals that are used to run little http apps, crons, or email handlers all with Javascript/Typescript. I first heard of Val.town from Sam Rose who uses it to power the ping functionality on his website (see his notify Val). I can use a Val as an intermediate service that will do some processing on a webhook payload before sending it off to Netlify.
Here's the code in its entirety. Let's break it down.
export default async function (req: Request): Promise<Response> {
if (req.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
try {
const payload = await req.json();
console.log("Ghost CMS Webhook Payload:", JSON.stringify(payload, null, 2));
const post = payload.post?.current;
// Don't continue if the post has hash-newsletter
const hasNewsletterTag = post?.tags?.some((tag: any) =>
tag.slug === "hash-newsletter"
);
if (hasNewsletterTag) {
console.log("Post is meant to be a newsletter, skipping");
return new Response("Post has hash-newsletter tag", { status: 200 });
}
const response = await fetch(
"<netlify-build-hook-url>",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
},
);
if (!response.ok) {
return new Response("Failed to trigger Netlify build", {
status: response.status,
});
}
} catch (error) {
return new Response("Something went wrong", { status: 500 });
}
return new Response("OK", { status: 200 });
}Breaking down webhook Val
First off, I created a Val with a HTTP trigger. This gives me a URL that I can use to trigger the Val from Ghost. It looks like:
https://jonoyeong--50a8ba8ef0c611f0a43942dde27851f2.web.val.runNext up is the guard clause. When you open a Val it will immediately run a GET request to the above URL. This results in an error since the request is not in the format we expect. The guard clause below returns a nicer response and is easier to parse in the logs.
if (req.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}Then we need to process the Ghost payload. We parse the payload data and grab the current post object.
const payload = await req.json();
const post = payload.post?.currentRemember, the payload from Ghost is structured below.
"post": {
"current": {
"id": "695d72563020fd0001cb96d6",
"uuid": "41af1fc2-40e9-43e8-aa47-fbe4de2fd276",
"title": "Test Newsletter post",
"slug": "test-newsletter-post",
"html": "<h1>Some HTML</h1>",
"tags": [
{
...some fields
}
],
// ...Many other fieldsNow you might wonder why we even need that data. Well we can do some more pre-processing here. I want Ghost to be the source of my blog and newsletter content. Anything that's tagged with #newsletter shouldn't be pulled into my blog. The code below will check if any posts have a #newsletter tag, represented as hash-newsletter in the payload. And if it does, it will skip triggering a Netlify build.
// Don't continue if the post has hash-newsletter
const hasNewsletterTag = post?.tags?.some((tag: any) =>
tag.slug === "hash-newsletter"
);
if (hasNewsletterTag) {
console.log("Post is meant to be a newsletter, skipping");
return new Response("Post has hash-newsletter tag", { status: 200 });
}Finally, we call the Netlify build hook.
const response = await fetch(
"<netlify-build-hook-url>",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
},
);
if (!response.ok) {
return new Response("Failed to trigger Netlify build", {
status: response.status,
});
}I POST to Netlify, with an empty body to avoid any payload size errors. And in case something errors, I return a Response with a message and status that will show up in my Val logs.
I found Val.town pretty nifty for deploying small services that manipulate data from 3rd party services you don't have full control over. There's other ways we can achieve build filtering as well. Melanie Richards documented a couple of other methods. Funnily enough, I found out recently that Astro is releasing a live content collection feature. Live content collections will fetch content on-demand, which might make all of the work above obsolete. I'm not mad though. It just means I'll have another reason to productively procrastinate in the future.