Repository: makeswift/makeswift
Pull request #990: feat: add onPublish callback to MakeswiftApiHandler
Author: login: ferrata
Created: 2025-04-04T21:59:03Z | Merged: 2025-04-08T16:54:00Z

## Summary

This PR adds `onPublish` callback to the `MakeswiftApiHandler` function. It will allow users to implement a custom logic when site content is getting published.

### `onPublish` callback

```ts
onPublish: () => void | Promise<void>;
```

The callback will be called once per a site publish event. The callback can be synchronous or asynchronous.

> [!IMPORTANT]
> The publish event only works for a deployed site. E.g. it won't works for a local development environment.

> [!NOTE]
> Any errors thrown in the callback will be logged and ignored.

### Example

To use the `onPublish` callback, implement it in the `MakeswiftApiHandler` call:

```diff
 const handler = MakeswiftApiHandler(process.env.MAKESWIFT_SITE_API_KEY, {
   runtime,
+  events: {
+    onPublish() {
+      console.log(`Published content`);
+    },
+  }
 });
 ```




Diff:
diff --git a/.changeset/dirty-kiwis-search.md b/.changeset/dirty-kiwis-search.md
new file mode 100644
index 000000000..58d410edf
--- /dev/null
+++ b/.changeset/dirty-kiwis-search.md
@@ -0,0 +1,5 @@
+---
+"@makeswift/runtime": patch
+---
+
+feat: add `onPublish` callback to `MakeswiftApiHandler`
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 25fa6215f..424cded57 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,3 +1,5 @@
 {
-  "typescript.tsdk": "node_modules/typescript/lib"
+  "typescript.tsdk": "node_modules/typescript/lib",
+  "editor.tabSize": 2,
+  "cSpell.words": ["Makeswift"]
 }
diff --git a/packages/runtime/package.json b/packages/runtime/package.json
index 25e6647dc..8bc0ca434 100644
--- a/packages/runtime/package.json
+++ b/packages/runtime/package.json
@@ -206,6 +206,7 @@
     "madge": "^6.0.0",
     "msw": "^2.3.1",
     "next": "15.0.2",
+    "node-mocks-http": "^1.16.2",
     "react": "18.2.0",
     "react-dom": "18.2.0",
     "slate": "^0.91.4",
diff --git a/packages/runtime/src/next/api-handler/handlers/webhook/index.ts b/packages/runtime/src/next/api-handler/handlers/webhook/index.ts
index 7e08c8db0..c3314de25 100644
--- a/packages/runtime/src/next/api-handler/handlers/webhook/index.ts
+++ b/packages/runtime/src/next/api-handler/handlers/webhook/index.ts
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'
 import { P, match } from 'ts-pattern'
 import { handleSitePublished } from './site-published'
 import {
+  OnPublish,
   sitePublishedWebhookPayloadSchema,
   WebhookEventType,
   WebhookPayloadSchema,
@@ -11,9 +12,14 @@ import {
 
 type Context = { params: { [key: string]: string | string[] } }
 
+type WebhookParams = {
+  apiKey: string,
+  events?: { onPublish?: OnPublish },
+}
+
 export type WebhookHandlerArgs =
-  | [request: NextRequest, context: Context, params: { apiKey: string }]
-  | [req: NextApiRequest, res: NextApiResponse<WebhookResponseBody>, params: { apiKey: string }]
+  | [request: NextRequest, context: Context, params: WebhookParams]
+  | [req: NextApiRequest, res: NextApiResponse<WebhookResponseBody>, params: WebhookParams]
 
 const routeHandlerPattern = [P.instanceOf(Request), P.any, P.any] as const
 const apiRoutePattern = [P.any, P.any, P.any] as const
@@ -35,24 +41,24 @@ function getRequestBody(args: WebhookHandlerArgs) {
 function respond(args: WebhookHandlerArgs, response: WebhookResponseBody, status?: number) {
   return match(args)
     .with(routeHandlerPattern, () => NextResponse.json(response, { status }))
-    .with(apiRoutePattern, ([, res]) => res.json(response))
+    .with(apiRoutePattern, ([, res]) => res.status(status ?? 200).json(response))
     .exhaustive()
 }
 
 export default async function handler(
   request: NextRequest,
   context: Context,
-  { apiKey }: { apiKey: string },
+  { apiKey, events }: WebhookParams,
 ): Promise<NextResponse<WebhookResponseBody>>
 export default async function handler(
   req: NextApiRequest,
   res: NextApiResponse<WebhookResponseBody>,
-  { apiKey }: { apiKey: string },
+  { apiKey, events }: WebhookParams,
 ): Promise<void>
 export default async function handler(
   ...args: WebhookHandlerArgs
 ): Promise<NextResponse<WebhookResponseBody> | void> {
-  const [, , { apiKey }] = args
+  const [, , { apiKey, events }] = args
   const secret = getSecret(args)
 
   if (secret !== apiKey) return respond(args, { message: 'Unauthorized' }, 401)
@@ -67,8 +73,8 @@ export default async function handler(
     return respond(args, { message: 'Invalid request body' }, 400)
   }
 
-  const result = match(payload.type)
-    .with(WebhookEventType.SITE_PUBLISHED, () => handleSitePublished(payload))
+  const result = await match(payload.type)
+    .with(WebhookEventType.SITE_PUBLISHED, () => handleSitePublished(payload, { onPublish: events?.onPublish }))
     .exhaustive()
 
   return respond(args, result.body, result.status)
diff --git a/packages/runtime/src/next/api-handler/handlers/webhook/site-published.ts b/packages/runtime/src/next/api-handler/handlers/webhook/site-published.ts
index 40ec7f79b..d13f62925 100644
--- a/packages/runtime/src/next/api-handler/handlers/webhook/site-published.ts
+++ b/packages/runtime/src/next/api-handler/handlers/webhook/site-published.ts
@@ -1,9 +1,23 @@
 import { revalidateTag } from 'next/cache'
-import { SitePublishedWebhookPayload, WebhookHandlerResult } from './types'
+import { OnPublish, SitePublishedWebhookPayload, WebhookHandlerResult } from './types'
 
-export function handleSitePublished(_payload: SitePublishedWebhookPayload): WebhookHandlerResult {
+type SitePublishedParams = {
+  onPublish?: OnPublish
+}
+
+export async function handleSitePublished(
+  _payload: SitePublishedWebhookPayload,
+  { onPublish }: SitePublishedParams
+): Promise<WebhookHandlerResult> {
   revalidateTag(MAKESWIFT_CACHE_TAG)
 
+  try {
+    await onPublish?.()
+  } catch (error) {
+    // log and ignore any error in user-provided onPublish
+    console.error("Unhandled exception in the 'onPublish' callback:", error)
+  }
+
   return { body: { success: true }, status: 200 }
 }
 
diff --git a/packages/runtime/src/next/api-handler/handlers/webhook/types.ts b/packages/runtime/src/next/api-handler/handlers/webhook/types.ts
index df84a1b1b..662bcb911 100644
--- a/packages/runtime/src/next/api-handler/handlers/webhook/types.ts
+++ b/packages/runtime/src/next/api-handler/handlers/webhook/types.ts
@@ -32,3 +32,5 @@ export type WebhookHandlerResult = {
   body: WebhookResponseBody
   status: 200
 }
+
+export type OnPublish = () => void | Promise<void>
diff --git a/packages/runtime/src/next/api-handler/index.ts b/packages/runtime/src/next/api-handler/index.ts
index 0e6cabeae..c0862e7fa 100644
--- a/packages/runtime/src/next/api-handler/index.ts
+++ b/packages/runtime/src/next/api-handler/index.ts
@@ -16,7 +16,7 @@ import { revalidate, RevalidationResponse } from './handlers/revalidate'
 import translatableData, { TranslatableDataResponse } from './handlers/translatable-data'
 import mergeTranslatedData, { TranslatedDataResponse } from './handlers/merge-translated-data'
 import webhook from './handlers/webhook'
-import { WebhookResponseBody } from './handlers/webhook/types'
+import { OnPublish, WebhookResponseBody } from './handlers/webhook/types'
 import { ReactRuntime } from '../../react'
 import { P, match } from 'ts-pattern'
 import {
@@ -29,10 +29,13 @@ export type { Manifest, Font }
 
 type Context = { params: { [key: string]: string | string[] } }
 
+type Events = { onPublish: OnPublish }
+
 type MakeswiftApiHandlerConfig = {
   appOrigin?: string
   apiOrigin?: string
   getFonts?: GetFonts
+  events?: Events
   runtime: ReactRuntime
 }
 
@@ -69,6 +72,7 @@ export function MakeswiftApiHandler(
     appOrigin = 'https://app.makeswift.com',
     apiOrigin = 'https://api.makeswift.com',
     getFonts,
+    events,
     runtime,
   }: MakeswiftApiHandlerConfig,
 ): (...args: MakeswiftApiHandlerArgs) => Promise<NextResponse<MakeswiftApiHandlerResponse> | void> {
@@ -232,8 +236,8 @@ export function MakeswiftApiHandler(
 
     if (matches('/webhook')) {
       return match(args)
-        .with(routeHandlerPattern, args => webhook(...args, { apiKey }))
-        .with(apiRoutePattern, args => webhook(...args, { apiKey }))
+        .with(routeHandlerPattern, args => webhook(...args, { apiKey, events }))
+        .with(apiRoutePattern, args => webhook(...args, { apiKey, events }))
         .exhaustive()
     }
 
diff --git a/packages/runtime/src/next/tests/server.makeswift-api-handler.test.ts b/packages/runtime/src/next/tests/server.makeswift-api-handler.test.ts
new file mode 100644
index 000000000..e74e5f50c
--- /dev/null
+++ b/packages/runtime/src/next/tests/server.makeswift-api-handler.test.ts
@@ -0,0 +1,204 @@
+import { createResponse } from "node-mocks-http"
+import { randomUUID } from "crypto"
+import * as nextCache from 'next/cache'
+
+import { MakeswiftApiHandler } from "../api-handler"
+import { NextApiResponse } from 'next'
+import { ReactRuntime } from "../../react"
+import { createNextApiRequest } from "./test-utils"
+
+const apiKey = 'fake-api-key'
+
+type MakeswiftApiHandlerArgs = Partial<Parameters<typeof MakeswiftApiHandler>[1]>
+
+function createHandler(args: Partial<MakeswiftApiHandlerArgs> = {}) {
+  const runtime = new ReactRuntime()
+  return MakeswiftApiHandler(apiKey, {
+    ...args,
+    runtime,
+    apiOrigin: 'https://api.fakeswift.com',
+    appOrigin: 'https://app.fakeswift.com',
+  })
+}
+
+jest.mock('next/cache', () => ({
+  revalidateTag: jest.fn(),
+}))
+
+afterEach(() => {
+  jest.restoreAllMocks()
+})
+
+describe('MakeswiftApiHandler', () => {
+  let consoleErrorSpy: jest.SpyInstance
+
+  beforeEach(() => {
+    consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
+  });
+
+  describe('/api/makeswift/revalidate', () => {
+    function revalidateResponse({ revalidate }: { revalidate: (path: string) => Promise<void> }) {
+      const response = createResponse<NextApiResponse>()
+      response.revalidate = revalidate
+      return response
+    }
+
+    function createRevalidateFixture(args: MakeswiftApiHandlerArgs = {}) {
+      const handler = createHandler(args)
+      const revalidate = jest.fn()
+      const response = revalidateResponse({ revalidate })
+      return { handler, revalidate, response }
+    }
+
+    test('401s and does not call revalidate when no secret is provided', async () => {
+      // Arrange
+      const { handler, revalidate, response } = createRevalidateFixture()
+
+      // Act
+      await handler(
+        createNextApiRequest({ method: 'GET', path: '/makeswift/revalidate' }),
+        response
+      )
+
+      // Assert
+      expect(response.statusCode).toBe(401)
+      expect(revalidate).not.toHaveBeenCalled()
+    })
+
+    test('400s and does not call revalidate when no path is provided', async () => {
+      // Arrange
+      const { handler, revalidate, response } = createRevalidateFixture()
+
+      // Act
+      await handler(
+        createNextApiRequest({ method: 'GET', path: `/makeswift/revalidate?secret=${apiKey}` }),
+        response,
+      )
+
+      // Assert
+      expect(response.statusCode).toBe(400)
+      expect(revalidate).not.toHaveBeenCalled()
+    })
+
+    test('200s and calls revalidate when revalidating a path', async () => {
+      // Arrange
+      const { handler, revalidate, response } = createRevalidateFixture()
+
+      // Act
+      await handler(
+        createNextApiRequest({ method: 'GET', path: `/makeswift/revalidate?secret=${apiKey}&path=/some-path` }),
+        response,
+      )
+
+      // Assert
+      expect(response.statusCode).toBe(200)
+      expect(revalidate).toHaveBeenCalledWith('/some-path')
+    })
+  })
+
+  describe('/api/makeswift/webhook', () => {
+    const webhookPayload = {
+      type: 'site.published', data: {
+        siteId: randomUUID(),
+        publish: {
+          from: null,
+          to: randomUUID(),
+        },
+        at: 1234567890,
+      }
+    }
+
+    function createWebhookFixture(args: MakeswiftApiHandlerArgs = {}) {
+      const handler = createHandler(args)
+
+      const response = createResponse<NextApiResponse>()
+      return { handler, response }
+    }
+
+    test('401s and does not call revalidateTag when no secret is provided', async () => {
+      // Arrange
+      const { handler, response } = createWebhookFixture()
+
+      // Act
+      await handler(
+        createNextApiRequest({ method: 'POST', path: '/makeswift/webhook', body: webhookPayload }),
+        response
+      )
+
+      // Assert
+      expect(response.statusCode).toBe(401)
+      expect(response._getJSONData()).toEqual({ message: 'Unauthorized' })
+      expect(nextCache.revalidateTag).not.toHaveBeenCalled()
+    })
+
+    test('400s and does not call revalidateTag when payload is invalid', async () => {
+      // Arrange
+      const { handler, response } = createWebhookFixture()
+
+      // Act
+      await handler(
+        createNextApiRequest({
+          method: 'POST',
+          path: `/makeswift/webhook?secret=${apiKey}`,
+          body: { type: 'invalid.type' }
+        }),
+        response,
+      )
+
+      // Assert
+      expect(response.statusCode).toBe(400)
+      expect(response._getJSONData()).toEqual({ message: 'Invalid request body' })
+      expect(nextCache.revalidateTag).not.toHaveBeenCalled()
+
+      expect(consoleErrorSpy).toHaveBeenCalledWith(expect.objectContaining({
+        message: expect.stringContaining('Invalid literal value')
+      }))
+    })
+
+    test('200s and calls user-provided onPublish callback', async () => {
+      // Arrange
+      const onPublish = jest.fn()
+      const { handler, response } = createWebhookFixture({ events: { onPublish } })
+
+      // Act
+      await handler(
+        createNextApiRequest({
+          method: 'POST',
+          path: `/makeswift/webhook?secret=${apiKey}`,
+          body: webhookPayload
+        }),
+        response,
+      )
+
+      // Assert
+      expect(response.statusCode).toBe(200)
+      expect(onPublish).toHaveBeenCalled()
+    })
+
+    test('200s and calls revalidateTag when user-provided onPublish callback fails', async () => {
+      // Arrange
+      const onPublish = jest.fn(() => { throw new Error('Failed in user-provided onPublish') })
+      const { handler, response } = createWebhookFixture({ events: { onPublish } })
+
+      // Act
+      await handler(
+        createNextApiRequest({
+          method: 'POST',
+          path: `/makeswift/webhook?secret=${apiKey}`,
+          body: webhookPayload
+        }),
+        response,
+      )
+
+      // Assert
+      expect(response.statusCode).toBe(200)
+      expect(nextCache.revalidateTag).toHaveBeenCalledWith('@@makeswift')
+
+      expect(consoleErrorSpy).toHaveBeenCalledWith(
+        "Unhandled exception in the 'onPublish' callback:", expect.objectContaining({
+          message: 'Failed in user-provided onPublish',
+        })
+      )
+    })
+  })
+})
diff --git a/packages/runtime/src/next/tests/test-utils.ts b/packages/runtime/src/next/tests/test-utils.ts
new file mode 100644
index 000000000..6eda0235a
--- /dev/null
+++ b/packages/runtime/src/next/tests/test-utils.ts
@@ -0,0 +1,45 @@
+import { NextApiRequest } from "next"
+import { createRequest, RequestMethod } from "node-mocks-http"
+
+type Query = { [key: string]: Query } | string[]
+
+function urlToQuery(url: URL): Query {
+  const segments = url.pathname.split('/').filter(Boolean)
+
+  if (segments.length === 0) return {}
+  if (segments.length === 1) return [segments[0]]
+
+  let structure: { [key: string]: any } = {}
+  let current = structure
+
+  for (let i = 0; i < segments.length - 2; i++) {
+    current[segments[i]] = {}
+    current = current[segments[i]]
+  }
+
+  current[segments[segments.length - 2]] = [segments[segments.length - 1]]
+
+  return {
+    ...structure,
+    ...Object.fromEntries(
+      url.searchParams.entries(),
+    ),
+  }
+}
+
+export function createNextApiRequest({
+  method,
+  path,
+  body
+}: {
+  method: RequestMethod
+  path: string
+  body?: Record<string, unknown>
+}): NextApiRequest {
+  return createRequest<NextApiRequest>({
+    method,
+    url: path,
+    query: urlToQuery(new URL(path, 'http://localhost')),
+    body
+  })
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e005c76c7..441ed987f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -525,6 +525,9 @@ importers:
       next:
         specifier: 15.0.2
         version: 15.0.2(@babel/core@7.24.9)(@playwright/test@1.41.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+      node-mocks-http:
+        specifier: ^1.16.2
+        version: 1.16.2(@types/node@17.0.21)
       react:
         specifier: 18.2.0
         version: 18.2.0
@@ -2748,6 +2751,10 @@ packages:
     resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
     engines: {node: '>=6.5'}
 
+  accepts@1.3.8:
+    resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
+    engines: {node: '>= 0.6'}
+
   acorn-globals@7.0.1:
     resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==}
 
@@ -3359,6 +3366,10 @@ packages:
   constant-case@3.0.4:
     resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==}
 
+  content-disposition@0.5.4:
+    resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
+    engines: {node: '>= 0.6'}
+
   convert-source-map@1.8.0:
     resolution: {integrity: sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==}
 
@@ -3609,6 +3620,10 @@ packages:
     resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
     engines: {node: '>=0.4.0'}
 
+  depd@1.1.2:
+    resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
+    engines: {node: '>= 0.6'}
+
   dependency-graph@0.11.0:
     resolution: {integrity: sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==}
     engines: {node: '>= 0.6.0'}
@@ -4293,6 +4308,10 @@ packages:
   framesync@4.1.0:
     resolution: {integrity: sha512-MmgZ4wCoeVxNbx2xp5hN/zPDCbLSKiDt4BbbslK7j/pM2lg5S0vhTNv1v8BCVb99JPIo6hXBFdwzU7Q4qcAaoQ==}
 
+  fresh@0.5.2:
+    resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
+    engines: {node: '>= 0.6'}
+
   fs-extra@10.1.0:
     resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
     engines: {node: '>=12'}
@@ -5575,6 +5594,10 @@ packages:
     resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==}
     engines: {node: '>=8'}
 
+  media-typer@0.3.0:
+    resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
+    engines: {node: '>= 0.6'}
+
   memoize-one@5.2.1:
     resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
 
@@ -5582,6 +5605,9 @@ packages:
     resolution: {integrity: sha512-3YffViIt2QWgTy6Pale5QpopX/IvU3LPL03jOTqp6pGj3VjesdO/U8CuHMKpnQr4shCNCM5fd5XFFvIIl6JBHg==}
     engines: {node: '>=8'}
 
+  merge-descriptors@1.0.3:
+    resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
+
   merge-stream@2.0.0:
     resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
 
@@ -5598,6 +5624,10 @@ packages:
       '@types/node':
         optional: true
 
+  methods@1.1.2:
+    resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
+    engines: {node: '>= 0.6'}
+
   micromatch@4.0.5:
     resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==}
     engines: {node: '>=8.6'}
@@ -5614,6 +5644,11 @@ packages:
     resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
     engines: {node: '>= 0.6'}
 
+  mime@1.6.0:
+    resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
+    engines: {node: '>=4'}
+    hasBin: true
+
   mimic-fn@1.2.0:
     resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==}
     engines: {node: '>=4'}
@@ -5786,6 +5821,18 @@ packages:
   node-int64@0.4.0:
     resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
 
+  node-mocks-http@1.16.2:
+    resolution: {integrity: sha512-2Sh6YItRp1oqewZNlck3LaFp5vbyW2u51HX2p1VLxQ9U/bG90XV8JY9O7Nk+HDd6OOn/oV3nA5Tx5k4Rki0qlg==}
+    engines: {node: '>=14'}
+    peerDependencies:
+      '@types/express': ^4.17.21 || ^5.0.0
+      '@types/node': '*'
+    peerDependenciesMeta:
+      '@types/express':
+        optional: true
+      '@types/node':
+        optional: true
+
   node-releases@2.0.14:
     resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==}
 
@@ -6005,6 +6052,10 @@ packages:
   parse5@7.2.1:
     resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==}
 
+  parseurl@1.3.3:
+    resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
+    engines: {node: '>= 0.8'}
+
   pascal-case@3.1.2:
     resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==}
 
@@ -6249,6 +6300,10 @@ packages:
   quote-unquote@1.0.0:
     resolution: {integrity: sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg==}
 
+  range-parser@1.2.1:
+    resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
+    engines: {node: '>= 0.6'}
+
   rc@1.2.8:
     resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
     hasBin: true
@@ -7256,6 +7311,10 @@ packages:
     resolution: {integrity: sha512-R6wDsVsoS9xYOpy8vgeBlqpdOyzJ12HNfQhC/aAKWM3YoCV9TtunJzh/QpkMgeDhkoynDcw5f1y+qF9yc/HHyg==}
     engines: {node: '>=16'}
 
+  type-is@1.6.18:
+    resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
+    engines: {node: '>= 0.6'}
+
   typed-array-buffer@1.0.0:
     resolution: {integrity: sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==}
     engines: {node: '>= 0.4'}
@@ -10434,6 +10493,11 @@ snapshots:
     dependencies:
       event-target-shim: 5.0.1
 
+  accepts@1.3.8:
+    dependencies:
+      mime-types: 2.1.35
+      negotiator: 0.6.3
+
   acorn-globals@7.0.1:
     dependencies:
       acorn: 8.11.3
@@ -11215,6 +11279,10 @@ snapshots:
       tslib: 2.6.2
       upper-case: 2.0.2
 
+  content-disposition@0.5.4:
+    dependencies:
+      safe-buffer: 5.2.1
+
   convert-source-map@1.8.0:
     dependencies:
       safe-buffer: 5.1.2
@@ -11471,6 +11539,8 @@ snapshots:
 
   delayed-stream@1.0.0: {}
 
+  depd@1.1.2: {}
+
   dependency-graph@0.11.0: {}
 
   dependency-tree@9.0.0:
@@ -11964,7 +12034,7 @@ snapshots:
       debug: 4.3.5
       enhanced-resolve: 5.14.1
       eslint: 8.56.0
-      eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.20.0(eslint@8.56.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0)
+      eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.20.0(eslint@8.56.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.20.0(eslint@8.56.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.56.0))(eslint@8.56.0))(eslint@8.56.0)
       eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.20.0(eslint@8.56.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0)
       fast-glob: 3.3.2
       get-tsconfig: 4.7.2
@@ -11993,7 +12063,7 @@ snapshots:
       - eslint-import-resolver-webpack
       - supports-color
 
-  eslint-module-utils@2.12.0(@typescript-eslint/parser@6.20.0(eslint@8.56.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0):
+  eslint-module-utils@2.12.0(@typescript-eslint/parser@6.20.0(eslint@8.56.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.20.0(eslint@8.56.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.56.0))(eslint@8.56.0))(eslint@8.56.0):
     dependencies:
       debug: 3.2.7
     optionalDependencies:
@@ -12015,7 +12085,7 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  eslint-module-utils@2.8.0(@typescript-eslint/parser@6.20.0(eslint@8.56.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0):
+  eslint-module-utils@2.8.0(@typescript-eslint/parser@6.20.0(eslint@8.56.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.20.0(eslint@8.56.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.56.0))(eslint@8.56.0))(eslint@8.56.0):
     dependencies:
       debug: 3.2.7
     optionalDependencies:
@@ -12036,7 +12106,7 @@ snapshots:
       doctrine: 2.1.0
       eslint: 8.56.0
       eslint-import-resolver-node: 0.3.9
-      eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.20.0(eslint@8.56.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.56.0)
+      eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.20.0(eslint@8.56.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.20.0(eslint@8.56.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.56.0))(eslint@8.56.0))(eslint@8.56.0)
       hasown: 2.0.0
       is-core-module: 2.13.1
       is-glob: 4.0.3
@@ -12532,6 +12602,8 @@ snapshots:
     dependencies:
       hey-listen: 1.0.8
 
+  fresh@0.5.2: {}
+
   fs-extra@10.1.0:
     dependencies:
       graceful-fs: 4.2.11
@@ -14353,6 +14425,8 @@ snapshots:
 
   map-obj@4.3.0: {}
 
+  media-typer@0.3.0: {}
+
   memoize-one@5.2.1: {}
 
   meow@6.1.1:
@@ -14369,6 +14443,8 @@ snapshots:
       type-fest: 0.13.1
       yargs-parser: 18.1.3
 
+  merge-descriptors@1.0.3: {}
+
   merge-stream@2.0.0: {}
 
   merge2@1.4.1: {}
@@ -14377,6 +14453,8 @@ snapshots:
     optionalDependencies:
       '@types/node': 17.0.21
 
+  methods@1.1.2: {}
+
   micromatch@4.0.5:
     dependencies:
       braces: 3.0.2
@@ -14393,6 +14471,8 @@ snapshots:
     dependencies:
       mime-db: 1.52.0
 
+  mime@1.6.0: {}
+
   mimic-fn@1.2.0: {}
 
   mimic-fn@2.1.0: {}
@@ -14598,6 +14678,21 @@ snapshots:
 
   node-int64@0.4.0: {}
 
+  node-mocks-http@1.16.2(@types/node@17.0.21):
+    dependencies:
+      accepts: 1.3.8
+      content-disposition: 0.5.4
+      depd: 1.1.2
+      fresh: 0.5.2
+      merge-descriptors: 1.0.3
+      methods: 1.1.2
+      mime: 1.6.0
+      parseurl: 1.3.3
+      range-parser: 1.2.1
+      type-is: 1.6.18
+    optionalDependencies:
+      '@types/node': 17.0.21
+
   node-releases@2.0.14: {}
 
   node-releases@2.0.17: {}
@@ -14848,6 +14943,8 @@ snapshots:
     dependencies:
       entities: 4.5.0
 
+  parseurl@1.3.3: {}
+
   pascal-case@3.1.2:
     dependencies:
       no-case: 3.0.4
@@ -15087,6 +15184,8 @@ snapshots:
 
   quote-unquote@1.0.0: {}
 
+  range-parser@1.2.1: {}
+
   rc@1.2.8:
     dependencies:
       deep-extend: 0.6.0
@@ -16232,6 +16331,11 @@ snapshots:
 
   type-fest@4.20.1: {}
 
+  type-is@1.6.18:
+    dependencies:
+      media-typer: 0.3.0
+      mime-types: 2.1.35
+
   typed-array-buffer@1.0.0:
     dependencies:
       call-bind: 1.0.5


Reviews:
user: login: migueloller
body: @ferrata, the structure here looks great! As for the source of the event that calls the callback, I would prefer that we use a "publish event" instead of "hooking into" the revalidate call. 

IIRC, @fikrikarim set up the web hook endpoint for tag-based revalidation in App Router. Perhaps we can use the mechanism instead?

I am reviewing this pull request from a plane so I can't easily check the source without losing progress on this comment. So I will comment back with more details once I find them.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-05T17:10:09Z
user: login: agurtovoy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-06T19:11:59Z
user: login: migueloller
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-06T19:38:58Z
user: login: ferrata
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-06T20:13:23Z
user: login: fikrikarim
body: This is great, @ferrata!

Something that came to mind a few months ago about this feature is whether the `onPublish` hook should override or extend the built-in handler.

I lean towards extending it, since I can’t think of a reason why a user would want to disable the default built-in handler.
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-07T17:19:42Z
user: login: ferrata
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T20:07:27Z
user: login: migueloller
body: Looking good 👍🏻 

Nice work on your first contribution to the runtime!
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-07T23:08:09Z
user: login: agurtovoy
body: 🙏
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T15:51:01Z
user: login: migueloller
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-08T16:52:12Z

Review comments:
diff_hunk: @@ -1,9 +1,23 @@
 import { revalidateTag } from 'next/cache'
-import { SitePublishedWebhookPayload, WebhookHandlerResult } from './types'
+import { OnPublish, SitePublishedWebhookPayload, WebhookHandlerResult } from './types'
 
-export function handleSitePublished(_payload: SitePublishedWebhookPayload): WebhookHandlerResult {
+type SitePublishedParams = {
+  onPublish?: OnPublish
+}
+
+export async function handleSitePublished(
+  _payload: SitePublishedWebhookPayload,
+  { onPublish }: SitePublishedParams
+): Promise<WebhookHandlerResult> {
   revalidateTag(MAKESWIFT_CACHE_TAG)
 
+  try {
+    await onPublish?.()
+  } catch (error) {
+    // log and ignore any error in user-provided onPublish
+    console.error(error)
path: packages/runtime/src/next/api-handler/handlers/webhook/site-published.ts
user: login: agurtovoy
body: I'd add context to the error log here, e.g. `console.error("Unhandled exception in the 'onPublish' callback:", error)`
created_at: 2025-04-06T19:11:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,6 +33,7 @@ type MakeswiftApiHandlerConfig = {
   appOrigin?: string
   apiOrigin?: string
   getFonts?: GetFonts
+  onPublish?: OnPublish
path: packages/runtime/src/next/api-handler/index.ts
user: login: migueloller
body: I wonder if we should put this under a `webhook` field so that we have future webhook callbacks grouped in there 🤔
created_at: 2025-04-06T19:34:57Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,207 @@
+import { createRequest, createResponse } from "node-mocks-http"
+import { randomUUID } from "crypto"
+import * as nextCache from 'next/cache'
+
+import { MakeswiftApiHandler } from "../api-handler"
+import { NextApiRequest, NextApiResponse } from 'next'
+import { ReactRuntime } from "../../react"
+
+const apiKey = 'fake-api-key'
+
+type MakeswiftApiHandlerArgs = Partial<Parameters<typeof MakeswiftApiHandler>[1]>
+
+function createHandler(args: Partial<MakeswiftApiHandlerArgs> = {}) {
+  const runtime = new ReactRuntime()
+  return MakeswiftApiHandler(apiKey, {
+    ...args,
+    runtime,
+    apiOrigin: 'https://api.fakeswift.com',
+    appOrigin: 'https://app.fakeswift.com',
+  })
+}
+
+jest.mock('next/cache', () => ({
+  revalidateTag: jest.fn(),
+}))
+
+afterEach(() => {
+  jest.restoreAllMocks()
+})
+
+describe('MakeswiftApiHandler', () => {
+  let consoleErrorSpy: jest.SpyInstance
+
+  beforeEach(() => {
+    consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
+  });
+
+  describe('/api/makeswift/revalidate', () => {
+    function revalidateRequest(
+      query: Record<string, string | string[]> = {},
+    ) {
+      return createRequest<NextApiRequest>({
+        method: 'GET',
+        query: { makeswift: ['revalidate'], ...query },
path: packages/runtime/src/next/tests/server.makeswift-api-handler.test.ts
user: login: migueloller
body: These should probably be set in the URL not directly in the query? Unless I'm missing how this utility function is intended to work?

What I mean is, the URL of the request should be `/api/makeswift/revalidate`. Passing it in the query makes some assumptions about Next.js routing internals (and honestly probably shouldn't work)
created_at: 2025-04-06T19:38:36Z
author_association: MEMBER
diff_hunk: @@ -33,6 +33,7 @@ type MakeswiftApiHandlerConfig = {
   appOrigin?: string
   apiOrigin?: string
   getFonts?: GetFonts
+  onPublish?: OnPublish
path: packages/runtime/src/next/api-handler/index.ts
user: login: ferrata
body: That's a good idea!
created_at: 2025-04-06T20:13:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,207 @@
+import { createRequest, createResponse } from "node-mocks-http"
+import { randomUUID } from "crypto"
+import * as nextCache from 'next/cache'
+
+import { MakeswiftApiHandler } from "../api-handler"
+import { NextApiRequest, NextApiResponse } from 'next'
+import { ReactRuntime } from "../../react"
+
+const apiKey = 'fake-api-key'
+
+type MakeswiftApiHandlerArgs = Partial<Parameters<typeof MakeswiftApiHandler>[1]>
+
+function createHandler(args: Partial<MakeswiftApiHandlerArgs> = {}) {
+  const runtime = new ReactRuntime()
+  return MakeswiftApiHandler(apiKey, {
+    ...args,
+    runtime,
+    apiOrigin: 'https://api.fakeswift.com',
+    appOrigin: 'https://app.fakeswift.com',
+  })
+}
+
+jest.mock('next/cache', () => ({
+  revalidateTag: jest.fn(),
+}))
+
+afterEach(() => {
+  jest.restoreAllMocks()
+})
+
+describe('MakeswiftApiHandler', () => {
+  let consoleErrorSpy: jest.SpyInstance
+
+  beforeEach(() => {
+    consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
+  });
+
+  describe('/api/makeswift/revalidate', () => {
+    function revalidateRequest(
+      query: Record<string, string | string[]> = {},
+    ) {
+      return createRequest<NextApiRequest>({
+        method: 'GET',
+        query: { makeswift: ['revalidate'], ...query },
path: packages/runtime/src/next/tests/server.makeswift-api-handler.test.ts
user: login: ferrata
body: I could not find a tool that can be used to simulate how Next.js does this thing. I will make a little helper to make it use URL instead of a structure.
created_at: 2025-04-07T20:07:27Z
author_association: CONTRIBUTOR

PR comments:
user: login: linear[bot]
created_at: 2025-04-04T21:59:06Z
body: <p><a href="https://linear.app/makeswift/issue/ENG-8229/expose-onpublish-callback-for-notifying-new-or-changed-makeswift-pages">ENG-8229 Expose `onPublish` callback for notifying new or changed Makeswift pages</a></p>
author_association: NONE
user: login: changeset-bot[bot]
created_at: 2025-04-04T21:59:07Z
body: ###  🦋  Changeset detected

Latest commit: 712b81e5ca9cd7f65639de0397e33c7d65455637

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name               | Type  |
| ------------------ | ----- |
| @makeswift/runtime | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/makeswift/makeswift/new/sasha/eng-8229-expose-onpublish-callback?filename=.changeset/slow-wasps-provide.md&value=---%0A%22%40makeswift%2Fruntime%22%3A%20patch%0A---%0A%0Afeat%3A%20add%20onPublish%20callback%20to%20MakeswiftApiHandler%0A)


author_association: NONE
user: login: vercel[bot]
created_at: 2025-04-04T21:59:08Z
body: [vc]: #cdR1C95Zc5zV9tVX4SkDf0sVfLeg8T1/bl5mc4cNy5U=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJtYWtlc3dpZnQtZXhhbXBsZXMtYmFzaWMtdHlwZXNjcmlwdCIsInJvb3REaXJlY3RvcnkiOiJleGFtcGxlcy9iYXNpYy10eXBlc2NyaXB0IiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL21ha2Vzd2lmdC9tYWtlc3dpZnQtZXhhbXBsZXMtYmFzaWMtdHlwZXNjcmlwdC82Q2lrdlc1WDdNaldSb0RkY0FKS05iZVEyWHZEIiwicHJldmlld1VybCI6Im1ha2Vzd2lmdC1leGFtcGxlcy1iYXNpYy10eXBlc2NyaXB0LWdpdC1zYXNoYS1hYzljODMtbWFrZXN3aWZ0LnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiSUdOT1JFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiIifX0seyJuYW1lIjoibWFrZXN3aWZ0LWV4YW1wbGVzLXRoaXJkd2ViIiwicm9vdERpcmVjdG9yeSI6ImV4YW1wbGVzL3RoaXJkd2ViIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL21ha2Vzd2lmdC9tYWtlc3dpZnQtZXhhbXBsZXMtdGhpcmR3ZWIvOHM3TDJ4Z2tqVmZxMmlmVjU0V3VLTnFRbTh2eiIsInByZXZpZXdVcmwiOiJtYWtlc3dpZnQtZXhhbXBsZXMtdGhpcmR3ZWItZ2l0LXNhc2hhLWVuZy04MjI5LWFmZTJkMC1tYWtlc3dpZnQudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJJR05PUkVEIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6IiJ9fSx7Im5hbWUiOiJtYWtlc3dpZnQtZXhhbXBsZXMtYmlnY29tbWVyY2UiLCJyb290RGlyZWN0b3J5IjoiZXhhbXBsZXMvYmlnY29tbWVyY2UiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vbWFrZXN3aWZ0L21ha2Vzd2lmdC1leGFtcGxlcy1iaWdjb21tZXJjZS9IRDljQnVuUzZYVEZTWEdZdmNjQ3FMU0J5eEhVIiwicHJldmlld1VybCI6Im1ha2Vzd2lmdC1leGFtcGxlcy1iaWdjb21tZXJjZS1naXQtc2FzaGEtZW5nLTgtMjkwYjU5LW1ha2Vzd2lmdC52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6Im1ha2Vzd2lmdC1leGFtcGxlcy1iaWdjb21tZXJjZS1naXQtc2FzaGEtZW5nLTgtMjkwYjU5LW1ha2Vzd2lmdC52ZXJjZWwuYXBwIn19LHsibmFtZSI6Im1ha2Vzd2lmdC1leGFtcGxlcy1zaG9waWZ5Iiwicm9vdERpcmVjdG9yeSI6ImV4YW1wbGVzL3Nob3BpZnkiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vbWFrZXN3aWZ0L21ha2Vzd2lmdC1leGFtcGxlcy1zaG9waWZ5L0RZYmppNEpoVGhER25TaWUxRWlLcUxOeXExck0iLCJwcmV2aWV3VXJsIjoibWFrZXN3aWZ0LWV4YW1wbGVzLXNob3BpZnktZ2l0LXNhc2hhLWVuZy04MjI5LWJhODY3My1tYWtlc3dpZnQudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJJR05PUkVEIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6IiJ9fSx7Im5hbWUiOiJtYWtlc3dpZnQtbmV4dGpzLWFwcC1yb3V0ZXIiLCJyb290RGlyZWN0b3J5IjoiYXBwcy9uZXh0anMtYXBwLXJvdXRlciIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9tYWtlc3dpZnQvbWFrZXN3aWZ0LW5leHRqcy1hcHAtcm91dGVyLzRCTExEbldQVWQ5V1FLVGZ6eWY2QkMybnkyRDIiLCJwcmV2aWV3VXJsIjoibWFrZXN3aWZ0LW5leHRqcy1hcHAtcm91dGVyLWdpdC1zYXNoYS1lbmctODIyOS1hNjI0MGMtbWFrZXN3aWZ0LnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoibWFrZXN3aWZ0LW5leHRqcy1hcHAtcm91dGVyLWdpdC1zYXNoYS1lbmctODIyOS1hNjI0MGMtbWFrZXN3aWZ0LnZlcmNlbC5hcHAifX0seyJuYW1lIjoibWFrZXN3aWZ0LW5leHRqcy1wYWdlcy1yb3V0ZXIiLCJyb290RGlyZWN0b3J5IjoiYXBwcy9uZXh0anMtcGFnZXMtcm91dGVyIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL21ha2Vzd2lmdC9tYWtlc3dpZnQtbmV4dGpzLXBhZ2VzLXJvdXRlci9zU3ZjTEFwMjFhWVk4WktCRUtzUVFFVVZidXpjIiwicHJldmlld1VybCI6Im1ha2Vzd2lmdC1uZXh0anMtcGFnZXMtcm91dGVyLWdpdC1zYXNoYS1lbmctODItYzRiOTk1LW1ha2Vzd2lmdC52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6Im1ha2Vzd2lmdC1uZXh0anMtcGFnZXMtcm91dGVyLWdpdC1zYXNoYS1lbmctODItYzRiOTk1LW1ha2Vzd2lmdC52ZXJjZWwuYXBwIn19LHsibmFtZSI6Im1ha2Vzd2lmdC1leGFtcGxlcy1iaWdjb21tZXJjZS1jYXRhbHlzdCIsInJvb3REaXJlY3RvcnkiOiJleGFtcGxlcy9iaWdjb21tZXJjZS1jYXRhbHlzdCIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9tYWtlc3dpZnQvbWFrZXN3aWZ0LWV4YW1wbGVzLWJpZ2NvbW1lcmNlLWNhdGFseXN0LzliSDM4cFl4RjQxczhYa2FtQU1YcHR0NndSWGkiLCJwcmV2aWV3VXJsIjoibWFrZXN3aWZ0LWV4YW1wbGVzLWJpZ2NvbW1lcmNlLWNhdGFseXN0LWdpdC1zYS1lZWMyMTMtbWFrZXN3aWZ0LnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiSUdOT1JFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiIifX0seyJuYW1lIjoibWFrZXN3aWZ0LWV4YW1wbGVzLWJhc2ljLXR5cGVzY3JpcHQtcGFnZXMiLCJyb290RGlyZWN0b3J5IjoiZXhhbXBsZXMvYmFzaWMtdHlwZXNjcmlwdC1wYWdlcyIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9tYWtlc3dpZnQvbWFrZXN3aWZ0LWV4YW1wbGVzLWJhc2ljLXR5cGVzY3JpcHQtcGFnZXMvc0RHYWJwM015UUt0aHJHV3B1WlBQQlhuNEF6ZyIsInByZXZpZXdVcmwiOiJtYWtlc3dpZnQtZXhhbXBsZXMtYmFzaWMtdHlwZXNjcmlwdC1wYWdlcy1naXQtMWY4Njk5LW1ha2Vzd2lmdC52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IklHTk9SRUQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiIn19XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **makeswift-examples-bigcommerce** | ✅ Ready ([Inspect](https://vercel.com/makeswift/makeswift-examples-bigcommerce/HD9cBunS6XTFSXGYvccCqLSByxHU)) | [Visit Preview](https://makeswift-examples-bigcommerce-git-sasha-eng-8-290b59-makeswift.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/makeswift-examples-bigcommerce-git-sasha-eng-8-290b59-makeswift.vercel.app?via=pr-comment-feedback-link) | Apr 8, 2025 3:56pm |
| **makeswift-nextjs-app-router** | ✅ Ready ([Inspect](https://vercel.com/makeswift/makeswift-nextjs-app-router/4BLLDnWPUd9WQKTfzyf6BC2ny2D2)) | [Visit Preview](https://makeswift-nextjs-app-router-git-sasha-eng-8229-a6240c-makeswift.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/makeswift-nextjs-app-router-git-sasha-eng-8229-a6240c-makeswift.vercel.app?via=pr-comment-feedback-link) | Apr 8, 2025 3:56pm |
| **makeswift-nextjs-pages-router** | ✅ Ready ([Inspect](https://vercel.com/makeswift/makeswift-nextjs-pages-router/sSvcLAp21aYY8ZKBEKsQQEUVbuzc)) | [Visit Preview](https://makeswift-nextjs-pages-router-git-sasha-eng-82-c4b995-makeswift.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/makeswift-nextjs-pages-router-git-sasha-eng-82-c4b995-makeswift.vercel.app?via=pr-comment-feedback-link) | Apr 8, 2025 3:56pm |

<details><summary>5 Skipped Deployments</summary>

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **makeswift-examples-basic-typescript** | ⬜️ Ignored ([Inspect](https://vercel.com/makeswift/makeswift-examples-basic-typescript/6CikvW5X7MjWRoDdcAJKNbeQ2XvD)) | [Visit Preview](https://makeswift-examples-basic-typescript-git-sasha-ac9c83-makeswift.vercel.app) |  | Apr 8, 2025 3:56pm |
| **makeswift-examples-basic-typescript-pages** | ⬜️ Ignored ([Inspect](https://vercel.com/makeswift/makeswift-examples-basic-typescript-pages/sDGabp3MyQKthrGWpuZPPBXn4Azg)) | [Visit Preview](https://makeswift-examples-basic-typescript-pages-git-1f8699-makeswift.vercel.app) |  | Apr 8, 2025 3:56pm |
| **makeswift-examples-bigcommerce-catalyst** | ⬜️ Ignored ([Inspect](https://vercel.com/makeswift/makeswift-examples-bigcommerce-catalyst/9bH38pYxF41s8XkamAMXptt6wRXi)) | [Visit Preview](https://makeswift-examples-bigcommerce-catalyst-git-sa-eec213-makeswift.vercel.app) |  | Apr 8, 2025 3:56pm |
| **makeswift-examples-shopify** | ⬜️ Ignored ([Inspect](https://vercel.com/makeswift/makeswift-examples-shopify/DYbji4JhThDGnSie1EiKqLNyq1rM)) | [Visit Preview](https://makeswift-examples-shopify-git-sasha-eng-8229-ba8673-makeswift.vercel.app) |  | Apr 8, 2025 3:56pm |
| **makeswift-examples-thirdweb** | ⬜️ Ignored ([Inspect](https://vercel.com/makeswift/makeswift-examples-thirdweb/8s7L2xgkjVfq2ifV54WuKNqQm8vz)) | [Visit Preview](https://makeswift-examples-thirdweb-git-sasha-eng-8229-afe2d0-makeswift.vercel.app) |  | Apr 8, 2025 3:56pm |
</details>









author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-04T22:00:25Z
body: <!-- add-pr-comment:add-pr-comment -->

# Snapshot Release
> Updated at April 08, 2025, 15:52:05.

I've published a snapshot release for your commit, 015d6fc. To use it, go to a Next.js app with `@makeswift/runtime` installed and run:
```bash
pnpm up @makeswift/runtime@0.0.0-snapshot-20250408155119
```

author_association: CONTRIBUTOR
user: login: migueloller
created_at: 2025-04-05T17:11:56Z
body: @ferrata, I have found the relevant handler here: https://github.com/makeswift/makeswift/blob/main/packages/runtime/src/next/api-handler/handlers/webhook/site-published.ts
author_association: MEMBER
user: login: migueloller
created_at: 2025-04-05T17:15:41Z
body: One more note. It's ok if the callback has no arguments for now. It can simply be used as a signal for when a publish has happened.

What's problematic about revalidation, by the way, is its coupling to Makeswift-defined pages, which does not cover builtin elements.
author_association: MEMBER
user: login: migueloller
created_at: 2025-04-07T21:00:28Z
body: > The publish event only works for a deployed site. E.g. it won't works for a local development environment.

In the future, I would love to have support for this locally via the Makeswift CLI using a method similar to [Stripe's](https://docs.stripe.com/webhooks#local-listener)
author_association: MEMBER