@contentai/next 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +56 -0
- package/dist/client.d.ts +49 -0
- package/dist/client.js +136 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/init.d.ts +26 -0
- package/dist/init.js +84 -0
- package/dist/metadata.d.ts +6 -0
- package/dist/metadata.js +25 -0
- package/dist/tags.d.ts +12 -0
- package/dist/tags.js +28 -0
- package/dist/types.d.ts +114 -0
- package/dist/types.js +5 -0
- package/dist/webhook.d.ts +49 -0
- package/dist/webhook.js +117 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Digiboffins
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# @contentai/next
|
|
2
|
+
|
|
3
|
+
Connect any Next.js website (App Router, Next.js 14.2+) to ContentAI: read
|
|
4
|
+
published posts from the Delivery API and refresh pages when ContentAI sends a
|
|
5
|
+
signed event. No database is needed on the website.
|
|
6
|
+
|
|
7
|
+
You need a ContentAI account; the values below come from your ContentAI.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @contentai/next
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Configure
|
|
16
|
+
|
|
17
|
+
Create a Next.js connection in ContentAI (Connections → Next.js). It shows these
|
|
18
|
+
values once:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# .env.local on the website — server-side only, never NEXT_PUBLIC_
|
|
22
|
+
CONTENTAI_API_URL=https://your-contentai-url
|
|
23
|
+
CONTENTAI_SITE_KEY=cai_site_…
|
|
24
|
+
CONTENTAI_WEBHOOK_SECRET=whsec_…
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Use
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// lib/contentai.ts
|
|
31
|
+
import "server-only";
|
|
32
|
+
import { createClient } from "@contentai/next";
|
|
33
|
+
|
|
34
|
+
export const contentai = createClient({
|
|
35
|
+
apiUrl: process.env.CONTENTAI_API_URL!,
|
|
36
|
+
siteKey: process.env.CONTENTAI_SITE_KEY!,
|
|
37
|
+
revalidate: 600, // fallback refresh, in seconds
|
|
38
|
+
timeoutMs: 5000, // give up on an unreachable ContentAI instead of hanging the page
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```tsx
|
|
43
|
+
// app/blog/[slug]/page.tsx
|
|
44
|
+
import { toMetadata } from "@contentai/next";
|
|
45
|
+
import { contentai } from "@/lib/contentai";
|
|
46
|
+
|
|
47
|
+
export async function generateMetadata({ params }) {
|
|
48
|
+
const result = await contentai.posts.get((await params).slug);
|
|
49
|
+
return result?.type === "post" ? toMetadata(result.post) : {};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default async function Page({ params }) {
|
|
53
|
+
// Returns the post, calls notFound(), or permanently redirects a renamed slug.
|
|
54
|
+
const post = await contentai.posts.resolve((await params).slug, (s) => `/blog/${s}`);
|
|
55
|
+
return <article dangerouslySetInnerHTML={{ __html: post.html }} />;
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Create the webhook route with one command, run in your website's folder:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npx @contentai/next init
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
It writes `app/api/contentai/webhook/route.ts` (or `src/app/…`, `route.js` without
|
|
66
|
+
TypeScript), never overwrites an existing route unless you pass `--force`, and
|
|
67
|
+
accepts `--path` for a different route path. The minimal equivalent by hand:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
// app/api/contentai/webhook/route.ts
|
|
71
|
+
import { createWebhookHandler } from "@contentai/next/webhook";
|
|
72
|
+
|
|
73
|
+
export const POST = createWebhookHandler({
|
|
74
|
+
secret: process.env.CONTENTAI_WEBHOOK_SECRET!,
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
// app/sitemap.ts
|
|
80
|
+
import { toSitemap } from "@contentai/next";
|
|
81
|
+
import { contentai } from "@/lib/contentai";
|
|
82
|
+
|
|
83
|
+
export default async function sitemap() {
|
|
84
|
+
return toSitemap(await contentai.sitemap());
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
If posts include images, allow ContentAI's media host in `next.config`
|
|
89
|
+
`images.remotePatterns`.
|
|
90
|
+
|
|
91
|
+
## Connect your own website
|
|
92
|
+
|
|
93
|
+
Requires Next.js 14.2+ with the App Router, Node.js 18.18+, and a ContentAI
|
|
94
|
+
account.
|
|
95
|
+
|
|
96
|
+
1. **Sign in to ContentAI** at your ContentAI URL.
|
|
97
|
+
2. **Install the SDK** in your website: `npm install @contentai/next`.
|
|
98
|
+
3. **Create the webhook route:** `npx @contentai/next init` (see Webhook setup).
|
|
99
|
+
4. **Create the connection** in ContentAI (Connections → Next.js):
|
|
100
|
+
- Website URL: your site, e.g. `http://localhost:3001`.
|
|
101
|
+
- Post URL pattern: must match your blog route, e.g. `/blog/{slug}` for
|
|
102
|
+
`app/blog/[slug]/page.tsx`.
|
|
103
|
+
- Delivery: Webhook with `{your site}/api/contentai/webhook`, or None to
|
|
104
|
+
rely on the `revalidate` interval.
|
|
105
|
+
5. **Copy the credentials** ContentAI shows once into `.env.local` (see
|
|
106
|
+
Configure), then restart the dev server.
|
|
107
|
+
6. **Add the files** from Use: `lib/contentai.ts`, a post list and post page
|
|
108
|
+
under `app/blog/`, and optionally `app/sitemap.ts`.
|
|
109
|
+
7. **Test:** click Test Connection in ContentAI, publish an article, and open
|
|
110
|
+
your blog page.
|
|
111
|
+
|
|
112
|
+
**Deployed websites:**
|
|
113
|
+
|
|
114
|
+
- Set the three variables in the host's environment variables (e.g. Vercel →
|
|
115
|
+
Settings → Environment Variables), then redeploy.
|
|
116
|
+
- The webhook URL must be your site's public URL, so ContentAI can reach it.
|
|
117
|
+
- Allow ContentAI's media host in `images.remotePatterns` so post images load.
|
|
118
|
+
|
|
119
|
+
### Webhook setup
|
|
120
|
+
|
|
121
|
+
**1. Create the webhook route.** In your website's folder:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
npx @contentai/next init
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
- Creates `app/api/contentai/webhook/route.ts`. Uses `src/app/` if your project
|
|
128
|
+
has it, and `route.js` without TypeScript.
|
|
129
|
+
- Never replaces an existing route; `--force` does. `--path hooks/cms` puts the
|
|
130
|
+
route at a different path.
|
|
131
|
+
- Next.js only serves routes that exist as files in your `app/` folder, so a
|
|
132
|
+
package cannot add one by itself: the command writes the file once, and it
|
|
133
|
+
is then part of your website.
|
|
134
|
+
- The route answers 503 until `CONTENTAI_WEBHOOK_SECRET` is set.
|
|
135
|
+
|
|
136
|
+
**2. Work out the webhook URL.** Your website's URL plus the route path:
|
|
137
|
+
`http://localhost:3001/api/contentai/webhook` locally, or
|
|
138
|
+
`https://yoursite.com/api/contentai/webhook` when deployed.
|
|
139
|
+
|
|
140
|
+
**3. Enter it in ContentAI.** Connections → Next.js → Delivery: **Webhook**.
|
|
141
|
+
|
|
142
|
+
- The Webhook URL field suggests `{Website URL}/api/contentai/webhook`. When
|
|
143
|
+
creating the connection, leave it blank to use that; type a URL only if you
|
|
144
|
+
used `--path`.
|
|
145
|
+
- In production ContentAI only accepts public `https` URLs; `http` and
|
|
146
|
+
localhost are allowed in development.
|
|
147
|
+
- To change it later, open the connection, edit the URL and click **Save
|
|
148
|
+
Settings**.
|
|
149
|
+
|
|
150
|
+
**4. Add the webhook secret.** ContentAI generates `CONTENTAI_WEBHOOK_SECRET`
|
|
151
|
+
when the connection is created and shows it **only once**, together with the
|
|
152
|
+
site key.
|
|
153
|
+
|
|
154
|
+
- Copy it into `.env.local` (or the host's environment variables), never with
|
|
155
|
+
`NEXT_PUBLIC_`, then restart or redeploy.
|
|
156
|
+
- Lost it? Open the connection and click **Rotate webhook secret**. The new
|
|
157
|
+
secret is shown once, and for 7 days events are signed with both the old and
|
|
158
|
+
new secret, so the website can switch without missing events.
|
|
159
|
+
|
|
160
|
+
**5. Click Test Connection.** It appears once the connection is created.
|
|
161
|
+
|
|
162
|
+
- **Webhook:** ContentAI sends a signed `connection.ping` to the URL and waits
|
|
163
|
+
up to 10 seconds. Success means the route exists, is reachable and has the
|
|
164
|
+
right secret; the connection becomes **Active**.
|
|
165
|
+
- **Deploy hook:** checks the URL is allowed without calling it (calling it
|
|
166
|
+
would start a build).
|
|
167
|
+
- **None:** always succeeds.
|
|
168
|
+
- It does **not** check the site key. A wrong `CONTENTAI_SITE_KEY` passes the
|
|
169
|
+
test and shows up later as a 401 when the website fetches posts.
|
|
170
|
+
|
|
171
|
+
| Test Connection says | Usually means | Fix |
|
|
172
|
+
|---|---|---|
|
|
173
|
+
| `answered HTTP 503` | Secret not set on the website | Add `CONTENTAI_WEBHOOK_SECRET`, restart |
|
|
174
|
+
| `answered HTTP 401` | Wrong secret | Copy the secret again, or rotate it |
|
|
175
|
+
| `answered HTTP 404` | No route at that URL | Run `npx @contentai/next init`; check the URL and `--path` |
|
|
176
|
+
| `answered HTTP 3xx` | The URL redirects (e.g. http → https) | Enter the final URL; redirects are not followed |
|
|
177
|
+
| `Could not reach the webhook` / `Timed out after 10s` | Website not running or not reachable from ContentAI | Start the site; ContentAI cannot reach a website on your own `localhost` |
|
|
178
|
+
|
|
179
|
+
Every attempt is listed under **Recent deliveries** in the connection. If the
|
|
180
|
+
website answers `410 Gone`, ContentAI stops delivering until the webhook URL or
|
|
181
|
+
delivery mode is saved again.
|
|
182
|
+
|
|
183
|
+
**Not built yet:** a command for the other files (`lib/contentai.ts`, blog
|
|
184
|
+
pages), a site key check in Test Connection, and an automatic test when the
|
|
185
|
+
connection is first created.
|
|
186
|
+
|
|
187
|
+
## API
|
|
188
|
+
|
|
189
|
+
| Export | Purpose |
|
|
190
|
+
|---|---|
|
|
191
|
+
| `createClient(options)` | `posts.list(params)`, `posts.get(slug)`, `posts.resolve(slug, pathFor)`, `categories.list()`, `tags.list()`, `sitemap()`, `site()` |
|
|
192
|
+
| `createWebhookHandler(options)` | A `POST` route handler: verifies the signature, ignores replays, revalidates cache tags, then calls `onEvent` |
|
|
193
|
+
| `verifyWebhook(headers, rawBody, { secret })` | Verification only, for custom handlers |
|
|
194
|
+
| `toMetadata(post)` / `toSitemap(entries)` | Next.js `Metadata` and sitemap entries |
|
|
195
|
+
| `TAGS`, `tagsForEvent(event)` | The cache tags the client and webhook handler use |
|
|
196
|
+
| `ContentAIError` | Thrown for API errors; `code` is ContentAI's error code |
|
|
197
|
+
|
|
198
|
+
## Keeping your own copy of posts
|
|
199
|
+
|
|
200
|
+
Websites with a database can copy posts in `onEvent`:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
export const POST = createWebhookHandler({
|
|
204
|
+
secret: process.env.CONTENTAI_WEBHOOK_SECRET!,
|
|
205
|
+
onEvent: async (event) => {
|
|
206
|
+
if (event.type === "post.published" || event.type === "post.updated") {
|
|
207
|
+
// Upsert by event.data.post.id, only when event.data.post.version is newer.
|
|
208
|
+
}
|
|
209
|
+
if (event.type === "post.unpublished" || event.type === "post.deleted") {
|
|
210
|
+
// Remove by event.data.id, only when event.data.version is newer.
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Retries can arrive out of order; always compare `version`.
|
|
217
|
+
|
|
218
|
+
## Notes
|
|
219
|
+
|
|
220
|
+
- **Multiple server instances (self-hosted):** Next.js's built-in cache is
|
|
221
|
+
per instance, so only the instance that receives the webhook refreshes at
|
|
222
|
+
once; others refresh on the `revalidate` interval. Configure a shared
|
|
223
|
+
`cacheHandler` if every instance must update immediately. Vercel handles this.
|
|
224
|
+
- **Local development on WSL2:** WSL2 periodically steps the system clock, and
|
|
225
|
+
Next.js 15 compares cache timestamps taken from two different clocks, so a
|
|
226
|
+
page cached within the last few seconds before an event can stay stale until
|
|
227
|
+
the `revalidate` interval. This does not happen on normal servers.
|
|
228
|
+
- **Static exports** (`output: 'export'`) cannot receive webhooks; use the
|
|
229
|
+
deploy hook delivery mode in ContentAI instead.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { DEFAULT_WEBHOOK_PATH, init, InitError } from "./init.js";
|
|
3
|
+
const HELP = `Usage: npx @contentai/next init [--path <route path>] [--force]
|
|
4
|
+
|
|
5
|
+
Creates the ContentAI webhook route in your Next.js website.
|
|
6
|
+
|
|
7
|
+
--path Route path under app/ (default ${DEFAULT_WEBHOOK_PATH})
|
|
8
|
+
--force Replace the route file if it already exists
|
|
9
|
+
`;
|
|
10
|
+
function main(argv) {
|
|
11
|
+
const [command, ...rest] = argv;
|
|
12
|
+
if (!command || command === "--help" || command === "-h") {
|
|
13
|
+
console.log(HELP);
|
|
14
|
+
return command ? 0 : 1;
|
|
15
|
+
}
|
|
16
|
+
if (command !== "init") {
|
|
17
|
+
console.error(`Unknown command: ${command}\n\n${HELP}`);
|
|
18
|
+
return 1;
|
|
19
|
+
}
|
|
20
|
+
let path;
|
|
21
|
+
let force = false;
|
|
22
|
+
for (let i = 0; i < rest.length; i++) {
|
|
23
|
+
if (rest[i] === "--force")
|
|
24
|
+
force = true;
|
|
25
|
+
else if (rest[i] === "--path" && rest[i + 1])
|
|
26
|
+
path = rest[++i];
|
|
27
|
+
else {
|
|
28
|
+
console.error(`Unknown option: ${rest[i]}\n\n${HELP}`);
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const result = init({ cwd: process.cwd(), path, force });
|
|
34
|
+
if (result.status === "exists") {
|
|
35
|
+
console.log(`The webhook route already exists: ${result.file}\nNothing was changed. Use --force to replace it.`);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
console.log(`${result.status === "created" ? "Created" : "Replaced"} ${result.file}
|
|
39
|
+
|
|
40
|
+
Next steps:
|
|
41
|
+
1. Put CONTENTAI_WEBHOOK_SECRET (shown by ContentAI) in .env.local, then restart the dev server.
|
|
42
|
+
2. In ContentAI, set Delivery to Webhook with the URL:
|
|
43
|
+
{your website URL}${result.urlPath}
|
|
44
|
+
3. Click Test Connection.`);
|
|
45
|
+
}
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error instanceof InitError) {
|
|
50
|
+
console.error(error.message);
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
process.exitCode = main(process.argv.slice(2));
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { DeliveryErrorCode, ListPostsParams, Post, PostList, PostLookup, SiteInfo, SitemapEntry, TermWithCount } from "./types.js";
|
|
2
|
+
export interface ClientOptions {
|
|
3
|
+
/** ContentAI's base URL, e.g. process.env.CONTENTAI_API_URL. */
|
|
4
|
+
apiUrl: string;
|
|
5
|
+
/** Server-only. Never expose it through a NEXT_PUBLIC_ variable. */
|
|
6
|
+
siteKey: string;
|
|
7
|
+
/**
|
|
8
|
+
* Seconds before cached data is refetched even without an event. This is
|
|
9
|
+
* what keeps a website correct when an event is lost. Default 600.
|
|
10
|
+
*/
|
|
11
|
+
revalidate?: number;
|
|
12
|
+
/** For tests or custom runtimes. Defaults to the global fetch. */
|
|
13
|
+
fetch?: typeof fetch;
|
|
14
|
+
/** Retries for 429, 5xx and connection failures. Default 2. */
|
|
15
|
+
maxRetries?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Per-request time limit in milliseconds. Default 5000. A request that
|
|
18
|
+
* times out is not retried: an unreachable ContentAI must not hold a page
|
|
19
|
+
* render for the network's own connect timeout, several times over.
|
|
20
|
+
*/
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
}
|
|
23
|
+
export declare class ContentAIError extends Error {
|
|
24
|
+
readonly status: number;
|
|
25
|
+
readonly code: DeliveryErrorCode | "network_error";
|
|
26
|
+
constructor(status: number, code: DeliveryErrorCode | "network_error", message: string);
|
|
27
|
+
}
|
|
28
|
+
export declare function createClient(options: ClientOptions): {
|
|
29
|
+
posts: {
|
|
30
|
+
list: (params?: ListPostsParams) => Promise<PostList>;
|
|
31
|
+
/** A post, a redirect for a renamed slug, or null. */
|
|
32
|
+
get: (slug: string) => Promise<PostLookup | null>;
|
|
33
|
+
/**
|
|
34
|
+
* For a post page: returns the post, calls notFound() when there is
|
|
35
|
+
* none, and permanentRedirect() when the slug was renamed. `pathFor`
|
|
36
|
+
* builds the new URL path; it defaults to /blog/{slug}.
|
|
37
|
+
*/
|
|
38
|
+
resolve(slug: string, pathFor?: (slug: string) => string): Promise<Post>;
|
|
39
|
+
};
|
|
40
|
+
categories: {
|
|
41
|
+
list: () => Promise<TermWithCount[]>;
|
|
42
|
+
};
|
|
43
|
+
tags: {
|
|
44
|
+
list: () => Promise<TermWithCount[]>;
|
|
45
|
+
};
|
|
46
|
+
sitemap: () => Promise<SitemapEntry[]>;
|
|
47
|
+
site: () => Promise<SiteInfo>;
|
|
48
|
+
};
|
|
49
|
+
export type ContentAIClient = ReturnType<typeof createClient>;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { TAGS } from "./tags.js";
|
|
2
|
+
export class ContentAIError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
code;
|
|
5
|
+
constructor(status, code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = "ContentAIError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const MAX_RETRY_WAIT_MS = 10_000;
|
|
13
|
+
function sleep(ms) {
|
|
14
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
|
+
}
|
|
16
|
+
export function createClient(options) {
|
|
17
|
+
if (typeof window !== "undefined") {
|
|
18
|
+
throw new Error("@contentai/next: createClient must only run on the server; the site key is secret.");
|
|
19
|
+
}
|
|
20
|
+
if (!options.apiUrl)
|
|
21
|
+
throw new Error("@contentai/next: apiUrl is required (CONTENTAI_API_URL)");
|
|
22
|
+
if (!options.siteKey)
|
|
23
|
+
throw new Error("@contentai/next: siteKey is required (CONTENTAI_SITE_KEY)");
|
|
24
|
+
const base = `${options.apiUrl.replace(/\/+$/, "")}/api/delivery/v1`;
|
|
25
|
+
const revalidate = options.revalidate ?? 600;
|
|
26
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
27
|
+
const maxRetries = options.maxRetries ?? 2;
|
|
28
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
29
|
+
async function request(path, tags, allowNotFound = false) {
|
|
30
|
+
const init = {
|
|
31
|
+
headers: { Authorization: `Bearer ${options.siteKey}`, Accept: "application/json" },
|
|
32
|
+
next: { revalidate, tags: [TAGS.all, ...tags] },
|
|
33
|
+
};
|
|
34
|
+
for (let attempt = 0;; attempt++) {
|
|
35
|
+
// A referenced timer (not AbortSignal.timeout, whose timer does not keep
|
|
36
|
+
// the process alive), cleared once the body has been read.
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
let timedOut = false;
|
|
39
|
+
const timer = setTimeout(() => {
|
|
40
|
+
timedOut = true;
|
|
41
|
+
controller.abort();
|
|
42
|
+
}, timeoutMs);
|
|
43
|
+
let waitBeforeRetry = null;
|
|
44
|
+
try {
|
|
45
|
+
let response;
|
|
46
|
+
try {
|
|
47
|
+
response = await fetchImpl(`${base}${path}`, { ...init, signal: controller.signal });
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (timedOut) {
|
|
51
|
+
throw new ContentAIError(0, "network_error", `ContentAI did not respond within ${timeoutMs}ms`);
|
|
52
|
+
}
|
|
53
|
+
if (attempt < maxRetries) {
|
|
54
|
+
waitBeforeRetry = 500 * 2 ** attempt;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
throw new ContentAIError(0, "network_error", `Could not reach ContentAI: ${error.message}`);
|
|
58
|
+
}
|
|
59
|
+
if (response.ok)
|
|
60
|
+
return (await response.json());
|
|
61
|
+
if (response.status === 404 && allowNotFound)
|
|
62
|
+
return null;
|
|
63
|
+
const retryable = response.status === 429 || response.status >= 500;
|
|
64
|
+
if (retryable && attempt < maxRetries) {
|
|
65
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
66
|
+
waitBeforeRetry = Math.min(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt, MAX_RETRY_WAIT_MS);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
let code = response.status >= 500 ? "server_error" : "invalid_request";
|
|
70
|
+
let message = `ContentAI responded ${response.status}`;
|
|
71
|
+
try {
|
|
72
|
+
const body = (await response.json());
|
|
73
|
+
if (body.error?.code)
|
|
74
|
+
code = body.error.code;
|
|
75
|
+
if (body.error?.message)
|
|
76
|
+
message = body.error.message;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Not the contract's error body; keep the generic message.
|
|
80
|
+
}
|
|
81
|
+
throw new ContentAIError(response.status, code, message);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
// A timeout while reading the body surfaces here as an abort.
|
|
85
|
+
if (timedOut && !(error instanceof ContentAIError)) {
|
|
86
|
+
throw new ContentAIError(0, "network_error", `ContentAI did not respond within ${timeoutMs}ms`);
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
if (waitBeforeRetry !== null)
|
|
93
|
+
await sleep(waitBeforeRetry);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function query(params) {
|
|
98
|
+
const search = new URLSearchParams();
|
|
99
|
+
for (const [key, value] of Object.entries(params)) {
|
|
100
|
+
if (value !== undefined && value !== "")
|
|
101
|
+
search.set(key, String(value));
|
|
102
|
+
}
|
|
103
|
+
const text = search.toString();
|
|
104
|
+
return text ? `?${text}` : "";
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
posts: {
|
|
108
|
+
list: (params = {}) => request(`/posts${query({ ...params })}`, [TAGS.posts]),
|
|
109
|
+
/** A post, a redirect for a renamed slug, or null. */
|
|
110
|
+
get: (slug) => request(`/posts/${encodeURIComponent(slug)}`, [TAGS.slug(slug)], true),
|
|
111
|
+
/**
|
|
112
|
+
* For a post page: returns the post, calls notFound() when there is
|
|
113
|
+
* none, and permanentRedirect() when the slug was renamed. `pathFor`
|
|
114
|
+
* builds the new URL path; it defaults to /blog/{slug}.
|
|
115
|
+
*/
|
|
116
|
+
async resolve(slug, pathFor = (s) => `/blog/${s}`) {
|
|
117
|
+
const result = await request(`/posts/${encodeURIComponent(slug)}`, [TAGS.slug(slug)], true);
|
|
118
|
+
const navigation = await import("next/navigation");
|
|
119
|
+
// Both throw Next's control-flow errors; they never return.
|
|
120
|
+
if (!result)
|
|
121
|
+
return navigation.notFound();
|
|
122
|
+
if (result.type === "redirect")
|
|
123
|
+
return navigation.permanentRedirect(pathFor(result.slug));
|
|
124
|
+
return result.post;
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
categories: {
|
|
128
|
+
list: () => request("/categories", [TAGS.posts]).then((r) => r.data),
|
|
129
|
+
},
|
|
130
|
+
tags: {
|
|
131
|
+
list: () => request("/tags", [TAGS.posts]).then((r) => r.data),
|
|
132
|
+
},
|
|
133
|
+
sitemap: () => request("/sitemap", [TAGS.posts]).then((r) => r.data),
|
|
134
|
+
site: () => request("/site", []),
|
|
135
|
+
};
|
|
136
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npx @contentai/next init`: writes the webhook route into a Next.js website, so
|
|
3
|
+
* nobody has to copy it by hand. Next.js only serves routes that exist as
|
|
4
|
+
* files in the website's own app/ folder; a package cannot add one, so the
|
|
5
|
+
* file is generated once and then belongs to the website.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_WEBHOOK_PATH = "api/contentai/webhook";
|
|
8
|
+
export declare class InitError extends Error {
|
|
9
|
+
constructor(message: string);
|
|
10
|
+
}
|
|
11
|
+
export interface InitOptions {
|
|
12
|
+
/** The website's root folder, where its package.json is. */
|
|
13
|
+
cwd: string;
|
|
14
|
+
/** Route path under app/. Default api/contentai/webhook. */
|
|
15
|
+
path?: string;
|
|
16
|
+
/** Replace an existing route file. */
|
|
17
|
+
force?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface InitResult {
|
|
20
|
+
status: "created" | "overwritten" | "exists";
|
|
21
|
+
/** The route file, relative to cwd. */
|
|
22
|
+
file: string;
|
|
23
|
+
/** The URL path the route answers, e.g. /api/contentai/webhook. */
|
|
24
|
+
urlPath: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function init(options: InitOptions): InitResult;
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* `npx @contentai/next init`: writes the webhook route into a Next.js website, so
|
|
5
|
+
* nobody has to copy it by hand. Next.js only serves routes that exist as
|
|
6
|
+
* files in the website's own app/ folder; a package cannot add one, so the
|
|
7
|
+
* file is generated once and then belongs to the website.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_WEBHOOK_PATH = "api/contentai/webhook";
|
|
10
|
+
export class InitError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "InitError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const ROUTE_FILES = ["route.ts", "route.js", "route.tsx", "route.jsx", "route.mjs"];
|
|
17
|
+
function routeSource(typescript) {
|
|
18
|
+
const handlerType = typescript ? ": Request" : "";
|
|
19
|
+
return `// POST: signed events from ContentAI (created by \`npx @contentai/next init\`).
|
|
20
|
+
// Verifies each event with CONTENTAI_WEBHOOK_SECRET and refreshes the
|
|
21
|
+
// affected pages. Enter this route's full URL as the Webhook URL in ContentAI.
|
|
22
|
+
import { createWebhookHandler } from "@contentai/next/webhook";
|
|
23
|
+
|
|
24
|
+
const secret = process.env.CONTENTAI_WEBHOOK_SECRET;
|
|
25
|
+
|
|
26
|
+
const handler = secret ? createWebhookHandler({ secret }) : null;
|
|
27
|
+
|
|
28
|
+
export async function POST(request${handlerType}) {
|
|
29
|
+
if (!handler) {
|
|
30
|
+
// Without the secret no event can be verified; say so instead of 401.
|
|
31
|
+
return Response.json({ error: "CONTENTAI_WEBHOOK_SECRET is not configured" }, { status: 503 });
|
|
32
|
+
}
|
|
33
|
+
return handler(request);
|
|
34
|
+
}
|
|
35
|
+
`;
|
|
36
|
+
}
|
|
37
|
+
function normalizePath(raw) {
|
|
38
|
+
const segments = raw.split(/[\\/]+/).filter(Boolean);
|
|
39
|
+
if (segments.length === 0 || segments.some((segment) => segment === "." || segment === "..")) {
|
|
40
|
+
throw new InitError(`Invalid route path: ${raw}`);
|
|
41
|
+
}
|
|
42
|
+
return segments.join("/");
|
|
43
|
+
}
|
|
44
|
+
function readPackageJson(cwd) {
|
|
45
|
+
const file = join(cwd, "package.json");
|
|
46
|
+
if (!existsSync(file)) {
|
|
47
|
+
throw new InitError("No package.json here. Run this command in your Next.js website's folder.");
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new InitError("package.json could not be read.");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function findAppDir(cwd) {
|
|
57
|
+
for (const candidate of ["src/app", "app"]) {
|
|
58
|
+
if (existsSync(join(cwd, candidate)))
|
|
59
|
+
return join(cwd, candidate);
|
|
60
|
+
}
|
|
61
|
+
if (existsSync(join(cwd, "pages")) || existsSync(join(cwd, "src/pages"))) {
|
|
62
|
+
throw new InitError("Only a pages/ folder was found. The ContentAI webhook needs the App Router (an app/ folder).");
|
|
63
|
+
}
|
|
64
|
+
throw new InitError("No app/ or src/app/ folder found. The ContentAI webhook needs the App Router.");
|
|
65
|
+
}
|
|
66
|
+
export function init(options) {
|
|
67
|
+
const { cwd, force = false } = options;
|
|
68
|
+
const pkg = readPackageJson(cwd);
|
|
69
|
+
if (!pkg.dependencies?.next && !pkg.devDependencies?.next) {
|
|
70
|
+
throw new InitError("This package.json does not list next. Run this command in your Next.js website's folder.");
|
|
71
|
+
}
|
|
72
|
+
const routePath = normalizePath(options.path ?? DEFAULT_WEBHOOK_PATH);
|
|
73
|
+
const routeDir = join(findAppDir(cwd), ...routePath.split("/"));
|
|
74
|
+
const urlPath = `/${routePath}`;
|
|
75
|
+
const existing = ROUTE_FILES.map((name) => join(routeDir, name)).find((file) => existsSync(file));
|
|
76
|
+
if (existing && !force) {
|
|
77
|
+
return { status: "exists", file: relative(cwd, existing), urlPath };
|
|
78
|
+
}
|
|
79
|
+
const typescript = existsSync(join(cwd, "tsconfig.json"));
|
|
80
|
+
const file = existing ?? join(routeDir, typescript ? "route.ts" : "route.js");
|
|
81
|
+
mkdirSync(routeDir, { recursive: true });
|
|
82
|
+
writeFileSync(file, routeSource(typescript));
|
|
83
|
+
return { status: existing ? "overwritten" : "created", file: relative(cwd, file), urlPath };
|
|
84
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Metadata, MetadataRoute } from "next";
|
|
2
|
+
import type { Post, SitemapEntry } from "./types.js";
|
|
3
|
+
/** generateMetadata() output for a post page, from the post's SEO fields. */
|
|
4
|
+
export declare function toMetadata(post: Post): Metadata;
|
|
5
|
+
/** app/sitemap.ts entries from client.sitemap(). */
|
|
6
|
+
export declare function toSitemap(entries: SitemapEntry[]): MetadataRoute.Sitemap;
|
package/dist/metadata.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** generateMetadata() output for a post page, from the post's SEO fields. */
|
|
2
|
+
export function toMetadata(post) {
|
|
3
|
+
const image = post.seo.ogImage;
|
|
4
|
+
return {
|
|
5
|
+
title: post.seo.title,
|
|
6
|
+
description: post.seo.description,
|
|
7
|
+
keywords: post.seo.keywords.length ? post.seo.keywords : undefined,
|
|
8
|
+
alternates: { canonical: post.seo.canonicalUrl },
|
|
9
|
+
robots: post.seo.noindex ? { index: false, follow: true } : undefined,
|
|
10
|
+
openGraph: {
|
|
11
|
+
type: "article",
|
|
12
|
+
title: post.seo.title,
|
|
13
|
+
description: post.seo.description,
|
|
14
|
+
url: post.seo.canonicalUrl,
|
|
15
|
+
publishedTime: post.publishedAt,
|
|
16
|
+
modifiedTime: post.updatedAt,
|
|
17
|
+
tags: post.tags.map((t) => t.name),
|
|
18
|
+
images: image ? [{ url: image.url, width: image.width, height: image.height, alt: image.alt }] : undefined,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** app/sitemap.ts entries from client.sitemap(). */
|
|
23
|
+
export function toSitemap(entries) {
|
|
24
|
+
return entries.map((entry) => ({ url: entry.url, lastModified: entry.updatedAt }));
|
|
25
|
+
}
|
package/dist/tags.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ContentAIEvent } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Cache tags, per the contract §7. Every Delivery API fetch is tagged, and
|
|
4
|
+
* events revalidate exactly the tags whose data they change.
|
|
5
|
+
*/
|
|
6
|
+
export declare const TAGS: {
|
|
7
|
+
readonly all: "contentai";
|
|
8
|
+
readonly posts: "contentai:posts";
|
|
9
|
+
readonly post: (id: string) => string;
|
|
10
|
+
readonly slug: (slug: string) => string;
|
|
11
|
+
};
|
|
12
|
+
export declare function tagsForEvent(event: ContentAIEvent): string[];
|
package/dist/tags.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache tags, per the contract §7. Every Delivery API fetch is tagged, and
|
|
3
|
+
* events revalidate exactly the tags whose data they change.
|
|
4
|
+
*/
|
|
5
|
+
export const TAGS = {
|
|
6
|
+
all: "contentai",
|
|
7
|
+
posts: "contentai:posts",
|
|
8
|
+
post: (id) => `contentai:post:${id}`,
|
|
9
|
+
slug: (slug) => `contentai:slug:${slug}`,
|
|
10
|
+
};
|
|
11
|
+
export function tagsForEvent(event) {
|
|
12
|
+
switch (event.type) {
|
|
13
|
+
case "post.published":
|
|
14
|
+
return [TAGS.posts, TAGS.slug(event.data.post.slug)];
|
|
15
|
+
case "post.updated":
|
|
16
|
+
return [
|
|
17
|
+
TAGS.posts,
|
|
18
|
+
TAGS.post(event.data.post.id),
|
|
19
|
+
TAGS.slug(event.data.post.slug),
|
|
20
|
+
...event.data.previousSlugs.map(TAGS.slug),
|
|
21
|
+
];
|
|
22
|
+
case "post.unpublished":
|
|
23
|
+
case "post.deleted":
|
|
24
|
+
return [TAGS.posts, TAGS.post(event.data.id), TAGS.slug(event.data.slug)];
|
|
25
|
+
default:
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ContentAI Delivery API contract, v1.
|
|
3
|
+
* Source of truth: docs/NEXTJS_CONTRACT.md in the ContentAI repository.
|
|
4
|
+
*/
|
|
5
|
+
export interface Image {
|
|
6
|
+
url: string;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
alt: string;
|
|
10
|
+
}
|
|
11
|
+
export interface Term {
|
|
12
|
+
name: string;
|
|
13
|
+
slug: string;
|
|
14
|
+
}
|
|
15
|
+
export interface Heading {
|
|
16
|
+
id: string;
|
|
17
|
+
level: number;
|
|
18
|
+
text: string;
|
|
19
|
+
}
|
|
20
|
+
export interface Seo {
|
|
21
|
+
title: string;
|
|
22
|
+
description: string;
|
|
23
|
+
keywords: string[];
|
|
24
|
+
canonicalUrl: string;
|
|
25
|
+
noindex: boolean;
|
|
26
|
+
ogImage: Image | null;
|
|
27
|
+
}
|
|
28
|
+
export interface PostSummary {
|
|
29
|
+
id: string;
|
|
30
|
+
version: number;
|
|
31
|
+
slug: string;
|
|
32
|
+
url: string;
|
|
33
|
+
title: string;
|
|
34
|
+
excerpt: string;
|
|
35
|
+
readingTimeMinutes: number;
|
|
36
|
+
wordCount: number;
|
|
37
|
+
featuredImage: Image | null;
|
|
38
|
+
categories: Term[];
|
|
39
|
+
tags: Term[];
|
|
40
|
+
/** Always null in v1. */
|
|
41
|
+
author: {
|
|
42
|
+
name: string;
|
|
43
|
+
url: string | null;
|
|
44
|
+
} | null;
|
|
45
|
+
seo: Seo;
|
|
46
|
+
publishedAt: string;
|
|
47
|
+
updatedAt: string;
|
|
48
|
+
}
|
|
49
|
+
export interface Post extends PostSummary {
|
|
50
|
+
/** Sanitized by ContentAI. Does not include the title. */
|
|
51
|
+
html: string;
|
|
52
|
+
headings: Heading[];
|
|
53
|
+
}
|
|
54
|
+
export interface Pagination {
|
|
55
|
+
page: number;
|
|
56
|
+
perPage: number;
|
|
57
|
+
total: number;
|
|
58
|
+
totalPages: number;
|
|
59
|
+
}
|
|
60
|
+
export interface PostList {
|
|
61
|
+
data: PostSummary[];
|
|
62
|
+
pagination: Pagination;
|
|
63
|
+
}
|
|
64
|
+
export type PostLookup = {
|
|
65
|
+
type: "post";
|
|
66
|
+
post: Post;
|
|
67
|
+
} | {
|
|
68
|
+
type: "redirect";
|
|
69
|
+
slug: string;
|
|
70
|
+
permanent: true;
|
|
71
|
+
};
|
|
72
|
+
export interface TermWithCount extends Term {
|
|
73
|
+
postCount: number;
|
|
74
|
+
}
|
|
75
|
+
export interface SitemapEntry {
|
|
76
|
+
slug: string;
|
|
77
|
+
url: string;
|
|
78
|
+
updatedAt: string;
|
|
79
|
+
}
|
|
80
|
+
export interface SiteInfo {
|
|
81
|
+
connectionId: string;
|
|
82
|
+
name: string;
|
|
83
|
+
siteUrl: string;
|
|
84
|
+
postUrlPattern: string;
|
|
85
|
+
delivery: "webhook" | "deploy_hook" | "none";
|
|
86
|
+
}
|
|
87
|
+
export interface ListPostsParams {
|
|
88
|
+
page?: number;
|
|
89
|
+
perPage?: number;
|
|
90
|
+
category?: string;
|
|
91
|
+
tag?: string;
|
|
92
|
+
sort?: "newest" | "oldest";
|
|
93
|
+
}
|
|
94
|
+
interface EventBase<T extends string, D> {
|
|
95
|
+
id: string;
|
|
96
|
+
type: T;
|
|
97
|
+
createdAt: string;
|
|
98
|
+
connectionId: string;
|
|
99
|
+
data: D;
|
|
100
|
+
}
|
|
101
|
+
export interface PostReference {
|
|
102
|
+
id: string;
|
|
103
|
+
slug: string;
|
|
104
|
+
version: number;
|
|
105
|
+
}
|
|
106
|
+
export type ContentAIEvent = EventBase<"post.published", {
|
|
107
|
+
post: Post;
|
|
108
|
+
}> | EventBase<"post.updated", {
|
|
109
|
+
post: Post;
|
|
110
|
+
previousSlugs: string[];
|
|
111
|
+
}> | EventBase<"post.unpublished", PostReference> | EventBase<"post.deleted", PostReference> | EventBase<"connection.ping", Record<string, never>>;
|
|
112
|
+
export type ContentAIEventType = ContentAIEvent["type"];
|
|
113
|
+
export type DeliveryErrorCode = "invalid_request" | "unauthorized" | "key_revoked" | "not_found" | "rate_limited" | "server_error";
|
|
114
|
+
export {};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ContentAIEvent } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Receiving ContentAI events, per the contract §5. Signatures follow the
|
|
4
|
+
* Standard Webhooks spec. Uses Web Crypto only, so it runs in Node.js and
|
|
5
|
+
* Edge route handlers alike.
|
|
6
|
+
*/
|
|
7
|
+
export declare class WebhookVerificationError extends Error {
|
|
8
|
+
constructor(message: string);
|
|
9
|
+
}
|
|
10
|
+
export interface VerifyOptions {
|
|
11
|
+
/** whsec_… secret, or several during rotation. */
|
|
12
|
+
secret: string | string[];
|
|
13
|
+
/** Maximum clock difference accepted, in seconds. Default 300. */
|
|
14
|
+
toleranceSeconds?: number;
|
|
15
|
+
/** For tests. */
|
|
16
|
+
now?: () => number;
|
|
17
|
+
}
|
|
18
|
+
export declare function sign(secret: string, id: string, timestamp: number, body: string): Promise<string>;
|
|
19
|
+
/**
|
|
20
|
+
* Verifies a request's signature against its raw body, then parses it.
|
|
21
|
+
* Throws WebhookVerificationError on any mismatch.
|
|
22
|
+
*/
|
|
23
|
+
export declare function verifyWebhook(headers: Headers, rawBody: string, options: VerifyOptions): Promise<ContentAIEvent>;
|
|
24
|
+
export interface WebhookHandlerOptions extends VerifyOptions {
|
|
25
|
+
/** Revalidate the event's cache tags (§7). Default true. */
|
|
26
|
+
revalidate?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Called once per verified event, after revalidation. Copy posts into your
|
|
29
|
+
* own database here if you keep one; apply a post only when its version is
|
|
30
|
+
* higher than the one you hold, since retries can arrive out of order.
|
|
31
|
+
*/
|
|
32
|
+
onEvent?: (event: ContentAIEvent) => void | Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Durable duplicate check across server instances. Return true if this
|
|
35
|
+
* event id was already processed. The handler also remembers recent ids in
|
|
36
|
+
* memory, which covers retries hitting the same instance.
|
|
37
|
+
*/
|
|
38
|
+
isDuplicate?: (eventId: string) => boolean | Promise<boolean>;
|
|
39
|
+
/** For tests. Defaults to next/cache's revalidateTag. */
|
|
40
|
+
revalidateTag?: (tag: string, profile?: {
|
|
41
|
+
expire: number;
|
|
42
|
+
}) => void;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A ready-made POST route handler:
|
|
46
|
+
*
|
|
47
|
+
* export const POST = createWebhookHandler({ secret: process.env.CONTENTAI_WEBHOOK_SECRET! });
|
|
48
|
+
*/
|
|
49
|
+
export declare function createWebhookHandler(options: WebhookHandlerOptions): (request: Request) => Promise<Response>;
|
package/dist/webhook.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { tagsForEvent } from "./tags.js";
|
|
2
|
+
/**
|
|
3
|
+
* Receiving ContentAI events, per the contract §5. Signatures follow the
|
|
4
|
+
* Standard Webhooks spec. Uses Web Crypto only, so it runs in Node.js and
|
|
5
|
+
* Edge route handlers alike.
|
|
6
|
+
*/
|
|
7
|
+
export class WebhookVerificationError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "WebhookVerificationError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const encoder = new TextEncoder();
|
|
14
|
+
function base64ToBytes(value) {
|
|
15
|
+
const binary = atob(value);
|
|
16
|
+
const bytes = new Uint8Array(binary.length);
|
|
17
|
+
for (let i = 0; i < binary.length; i++)
|
|
18
|
+
bytes[i] = binary.charCodeAt(i);
|
|
19
|
+
return bytes;
|
|
20
|
+
}
|
|
21
|
+
function bytesToBase64(bytes) {
|
|
22
|
+
let binary = "";
|
|
23
|
+
for (const byte of new Uint8Array(bytes))
|
|
24
|
+
binary += String.fromCharCode(byte);
|
|
25
|
+
return btoa(binary);
|
|
26
|
+
}
|
|
27
|
+
function constantTimeEqual(a, b) {
|
|
28
|
+
if (a.length !== b.length)
|
|
29
|
+
return false;
|
|
30
|
+
let diff = 0;
|
|
31
|
+
for (let i = 0; i < a.length; i++)
|
|
32
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
33
|
+
return diff === 0;
|
|
34
|
+
}
|
|
35
|
+
export async function sign(secret, id, timestamp, body) {
|
|
36
|
+
if (!secret.startsWith("whsec_")) {
|
|
37
|
+
throw new WebhookVerificationError("The webhook secret must start with whsec_");
|
|
38
|
+
}
|
|
39
|
+
const key = await crypto.subtle.importKey("raw", base64ToBytes(secret.slice("whsec_".length)), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
40
|
+
const mac = await crypto.subtle.sign("HMAC", key, encoder.encode(`${id}.${timestamp}.${body}`));
|
|
41
|
+
return `v1,${bytesToBase64(mac)}`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Verifies a request's signature against its raw body, then parses it.
|
|
45
|
+
* Throws WebhookVerificationError on any mismatch.
|
|
46
|
+
*/
|
|
47
|
+
export async function verifyWebhook(headers, rawBody, options) {
|
|
48
|
+
const id = headers.get("webhook-id");
|
|
49
|
+
const timestampHeader = headers.get("webhook-timestamp");
|
|
50
|
+
const signatureHeader = headers.get("webhook-signature");
|
|
51
|
+
if (!id || !timestampHeader || !signatureHeader) {
|
|
52
|
+
throw new WebhookVerificationError("Missing webhook-id, webhook-timestamp or webhook-signature");
|
|
53
|
+
}
|
|
54
|
+
const timestamp = Number(timestampHeader);
|
|
55
|
+
const now = Math.floor((options.now ?? Date.now)() / 1000);
|
|
56
|
+
const tolerance = options.toleranceSeconds ?? 300;
|
|
57
|
+
if (!Number.isInteger(timestamp) || Math.abs(now - timestamp) > tolerance) {
|
|
58
|
+
throw new WebhookVerificationError("Timestamp outside the allowed window");
|
|
59
|
+
}
|
|
60
|
+
const secrets = Array.isArray(options.secret) ? options.secret : [options.secret];
|
|
61
|
+
const given = signatureHeader.split(" ").filter(Boolean);
|
|
62
|
+
for (const secret of secrets.filter(Boolean)) {
|
|
63
|
+
const expected = await sign(secret, id, timestamp, rawBody);
|
|
64
|
+
if (given.some((candidate) => constantTimeEqual(candidate, expected))) {
|
|
65
|
+
const event = JSON.parse(rawBody);
|
|
66
|
+
if (event.id !== id)
|
|
67
|
+
throw new WebhookVerificationError("Body id does not match webhook-id");
|
|
68
|
+
return event;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
throw new WebhookVerificationError("No matching signature");
|
|
72
|
+
}
|
|
73
|
+
const RECENT_LIMIT = 1000;
|
|
74
|
+
function json(status, body) {
|
|
75
|
+
return new Response(JSON.stringify(body), {
|
|
76
|
+
status,
|
|
77
|
+
headers: { "Content-Type": "application/json" },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* A ready-made POST route handler:
|
|
82
|
+
*
|
|
83
|
+
* export const POST = createWebhookHandler({ secret: process.env.CONTENTAI_WEBHOOK_SECRET! });
|
|
84
|
+
*/
|
|
85
|
+
export function createWebhookHandler(options) {
|
|
86
|
+
const recent = new Set();
|
|
87
|
+
return async function POST(request) {
|
|
88
|
+
// The signature covers the exact bytes, so read the raw body before parsing.
|
|
89
|
+
const rawBody = await request.text();
|
|
90
|
+
let event;
|
|
91
|
+
try {
|
|
92
|
+
event = await verifyWebhook(request.headers, rawBody, options);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
const message = error instanceof WebhookVerificationError ? error.message : "Invalid payload";
|
|
96
|
+
return json(401, { error: message });
|
|
97
|
+
}
|
|
98
|
+
if (recent.has(event.id) || (await options.isDuplicate?.(event.id))) {
|
|
99
|
+
return json(200, { received: true, duplicate: true });
|
|
100
|
+
}
|
|
101
|
+
if (options.revalidate !== false) {
|
|
102
|
+
const revalidateTag = options.revalidateTag ??
|
|
103
|
+
(await import("next/cache")).revalidateTag;
|
|
104
|
+
// { expire: 0 } expires immediately, which a webhook needs: Next.js 16
|
|
105
|
+
// otherwise serves stale content once (and warns about the one-argument
|
|
106
|
+
// form). Next.js 14 and 15 ignore the second argument.
|
|
107
|
+
for (const tag of tagsForEvent(event))
|
|
108
|
+
revalidateTag(tag, { expire: 0 });
|
|
109
|
+
}
|
|
110
|
+
// Unknown event types are acknowledged and ignored (contract §10).
|
|
111
|
+
await options.onEvent?.(event);
|
|
112
|
+
recent.add(event.id);
|
|
113
|
+
if (recent.size > RECENT_LIMIT)
|
|
114
|
+
recent.delete(recent.values().next().value);
|
|
115
|
+
return json(200, { received: true });
|
|
116
|
+
};
|
|
117
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@contentai/next",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Connect any Next.js website to ContentAI: fetch published posts and handle signed webhook events.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"contentai",
|
|
8
|
+
"nextjs",
|
|
9
|
+
"next",
|
|
10
|
+
"cms",
|
|
11
|
+
"headless-cms",
|
|
12
|
+
"webhook"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"bin": {
|
|
17
|
+
"contentai-next": "./dist/cli.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./webhook": {
|
|
30
|
+
"types": "./dist/webhook.d.ts",
|
|
31
|
+
"default": "./dist/webhook.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18.18"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
|
|
42
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
43
|
+
"test": "tsx --test \"src/__tests__/*.test.ts\"",
|
|
44
|
+
"prepack": "npm run build",
|
|
45
|
+
"prepublishOnly": "npm run typecheck && npm test"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"next": ">=14.2.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^20",
|
|
52
|
+
"next": "15.5.14",
|
|
53
|
+
"tsx": "^4.23.13",
|
|
54
|
+
"typescript": "^5"
|
|
55
|
+
}
|
|
56
|
+
}
|