@soloworks/smking-next 0.9.1 → 0.12.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/CHANGELOG.md +83 -0
- package/README.md +7 -47
- package/package.json +17 -4
- package/src/components/smking-cms.tsx +163 -0
- package/src/index.ts +6 -0
- package/src/lib/client.ts +6 -3
- package/src/lib/cms-client.ts +88 -0
- package/src/lib/crawlers.ts +136 -0
- package/src/lib/path.ts +1 -1
- package/src/lib/proxy.ts +147 -0
- package/src/lib/webhook-route.ts +126 -0
- package/src/types.ts +118 -0
- package/src/route.ts +0 -112
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,88 @@
|
|
|
1
1
|
# @soloworks/smking-next
|
|
2
2
|
|
|
3
|
+
## 0.12.0 — 2026-05-15
|
|
4
|
+
|
|
5
|
+
**AI crawler + AI referral telemetry — `smkingProxy` Next.js middleware.**
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`@soloworks/smking-next/proxy` subpath export.** New `smkingProxy({ apiKey, baseUrl? })` middleware factory + `composeProxy([...handlers])` helper. Detects AI bot UAs (17 patterns covering GPTBot / ClaudeBot / PerplexityBot / Google-Extended / Applebot / CCBot / Bytespider / meta-externalagent / Amazonbot / Cohere / Diffbot) and AI-referrer hostnames (chatgpt.com / perplexity.ai / claude.ai / gemini.google.com / copilot.microsoft.com / bing.com), then fire-and-forgets a `POST /api/v1/crawler-hit` ingest via Vercel `after()` so the customer's response is never delayed.
|
|
10
|
+
|
|
11
|
+
Install:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// proxy.ts
|
|
15
|
+
import { smkingProxy } from "@soloworks/smking-next/proxy";
|
|
16
|
+
|
|
17
|
+
export const proxy = smkingProxy({ apiKey: process.env.SMKING_API_KEY! });
|
|
18
|
+
|
|
19
|
+
export const config = {
|
|
20
|
+
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
21
|
+
};
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Composing with an existing proxy:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { composeProxy, smkingProxy } from "@soloworks/smking-next/proxy";
|
|
28
|
+
import { customerProxy } from "./their-proxy";
|
|
29
|
+
|
|
30
|
+
export const proxy = composeProxy([
|
|
31
|
+
smkingProxy({ apiKey: process.env.SMKING_API_KEY! }),
|
|
32
|
+
customerProxy,
|
|
33
|
+
]);
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Fail-open: missing `SMKING_API_KEY` / `SMKING_BASE_URL` → no-op (matches `getAeoContent` convention). Ingestion endpoint down / 4xx / 5xx → silent skip. Customer site never breaks because of telemetry.
|
|
37
|
+
|
|
38
|
+
### Why
|
|
39
|
+
|
|
40
|
+
Crawler telemetry feeds the new 4-pillar AEO Scorecard (visibility / bot engagement / content readiness / traffic impact). Without per-customer-site detection, the scorecard's Bot Engagement and Traffic Impact pillars stay at zero forever. SDK ships the detection so customers don't need to roll their own — `pnpm update @soloworks/smking-next` + add 5-line `proxy.ts` = done.
|
|
41
|
+
|
|
42
|
+
### Customer migration
|
|
43
|
+
|
|
44
|
+
- **Caret rule reminder**: `^0.11` resolves to `>=0.11.0 <0.12.0`, so `pnpm update` will NOT pull 0.12 automatically. Bump constraint to `^0.12` in `package.json` to receive this release.
|
|
45
|
+
- No other breaking changes vs 0.11. AEO / CMS / webhook surfaces unchanged.
|
|
46
|
+
|
|
47
|
+
## 0.11.0 — 2026-05-15
|
|
48
|
+
|
|
49
|
+
**Substrate pivot: unified webhook channel.**
|
|
50
|
+
|
|
51
|
+
### BREAKING
|
|
52
|
+
|
|
53
|
+
- **Unified webhook endpoint.** `@soloworks/smking-next/webhook` replaces the previous split: `/route` (AEO Bearer-authed) AND `/cms-webhook` (CMS HMAC). Single HMAC-signed endpoint, payload `.kind` (`"aeo" | "cms_page" | ...`) dispatches which revalidate tag namespace to use.
|
|
54
|
+
|
|
55
|
+
Customer migration:
|
|
56
|
+
1. **Env**: `SMKING_WEBHOOK_TOKEN` → `SMKING_WEBHOOK_SECRET` (HMAC key — same secret you got from the install prompt for CMS, now used for everything).
|
|
57
|
+
2. **Route shim**: replace both `app/api/smking-revalidate/route.ts` (AEO) AND `app/api/smking/webhook/route.ts` (CMS) with a single `app/api/smking/webhook/route.ts` exporting `POST` from `@soloworks/smking-next/webhook`.
|
|
58
|
+
3. **Dashboard webhook URL** field should point at the single `/api/smking/webhook` path.
|
|
59
|
+
|
|
60
|
+
- **Revalidate tag namespaces changed.** Customer code calling `revalidateTag` directly (rare — most customers use `<SmkingAEO />` / `<SmkingCms />` which handle this internally) must update:
|
|
61
|
+
- `smking:path:*` → `smking:aeo:*`
|
|
62
|
+
- `smking:cms:*` → `smking:cms_page:*`
|
|
63
|
+
|
|
64
|
+
- **`/route` and `/cms-webhook` exports removed.** Importing either at v0.11 fails with a "Module not found" error pointing customers at the migration. Old source files deleted from the package (git history preserves them).
|
|
65
|
+
|
|
66
|
+
### Why
|
|
67
|
+
|
|
68
|
+
PostHog architectural pattern study (`docs/cms-tech-suggestions-posthog.md`) flagged the split webhook as substrate-discipline violation. One channel for the substrate; payload `kind` distinguishes projection. Future product surfaces (widget config, crawler analytics, Shopify connector) extend the discriminator without growing the SDK.
|
|
69
|
+
|
|
70
|
+
## 0.10.0 — 2026-05-14
|
|
71
|
+
|
|
72
|
+
**`SmkingCms` typing fix + CMS revalidate default aligned to 5 min.**
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
|
|
76
|
+
- `SeoMeta.metaDescription` was missing from the TypeScript interface, but the SaaS public-page endpoint and `smking/laravel` both emit this field inside the seo block. Customers pulling v0.9.1 with `<SmkingCms />` hit a `TS2551` compile error on `seo.metaDescription`. Added as optional since the AEO endpoint emits `metaDescription` at `AeoResponse` top level instead — AEO callers still see `undefined`, CMS callers get the value.
|
|
77
|
+
|
|
78
|
+
### Behavioral change
|
|
79
|
+
|
|
80
|
+
- `getCmsPage` / `<SmkingCms />` `revalidate` default reduced from `3600` (1h) → `300` (5min). CMS content is hand-edited (frequent updates) vs AEO which is SaaS-generated (more stable), so a shorter ISR backstop is the correct fallback. Matches `smking/laravel`'s `cms_ttl` default. Override via the `revalidate` prop if you need the previous 1h behaviour. Customers with the CMS publish webhook wired (`SMKING_WEBHOOK_SECRET` + `@soloworks/smking-next/cms-webhook` handler) are unaffected — push invalidation bypasses ISR entirely.
|
|
81
|
+
|
|
82
|
+
### README
|
|
83
|
+
|
|
84
|
+
- Install section trimmed to a pointer at the dashboard install prompt / `npx @soloworks/smking-wizard`. Reduces drift between the SDK README and the per-site install prompt that's the source of truth.
|
|
85
|
+
|
|
3
86
|
## 0.9.1 — 2026-05-13
|
|
4
87
|
|
|
5
88
|
**`smking-next doctor` subcommand for self-check + machine-readable output.** The CLI bin now accepts two subcommands: `install` (existing one-shot scaffold) and the new `doctor` (self-check). With `--json`, doctor emits structured output for the new `@smking/wizard` install agent's `run_doctor` tool.
|
package/README.md
CHANGED
|
@@ -8,59 +8,19 @@ AI-native SEO (AEO) for Next.js. One server component injects JSON-LD, OG tags,
|
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
pnpm add @soloworks/smking-next
|
|
13
|
-
```
|
|
11
|
+
**Don't follow this README to install.** Your smking dashboard generates a per-site install prompt with the real `SMKING_API_KEY`, `SMKING_BASE_URL`, and (if you use CMS) `SMKING_WEBHOOK_SECRET` baked in, plus copy-pasteable layout / route shims. The prompt is the source of truth and stays in sync with the SDK version.
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
Two ways to get it:
|
|
16
14
|
|
|
17
15
|
```bash
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
### 1. Drop the component into your root layout
|
|
23
|
-
|
|
24
|
-
```tsx
|
|
25
|
-
// app/layout.tsx
|
|
26
|
-
import { SmkingAEO } from '@soloworks/smking-next';
|
|
27
|
-
|
|
28
|
-
export default function RootLayout({
|
|
29
|
-
children,
|
|
30
|
-
}: {
|
|
31
|
-
children: React.ReactNode;
|
|
32
|
-
}) {
|
|
33
|
-
return (
|
|
34
|
-
<html lang="en">
|
|
35
|
-
<body>
|
|
36
|
-
<SmkingAEO apiKey={process.env.SMKING_API_KEY!} />
|
|
37
|
-
{children}
|
|
38
|
-
</body>
|
|
39
|
-
</html>
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
That's it for AEO injection. Every request to any URL fetches that URL's AEO content from smking and emits:
|
|
16
|
+
# Option 1 — one-shot wizard (installs deps + writes env + runs doctor)
|
|
17
|
+
npx @soloworks/smking-wizard
|
|
45
18
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
- sr-only `<div>` containing FAQ, AI summary, and product image (visually hidden, in-DOM for crawlers)
|
|
49
|
-
|
|
50
|
-
### 2. Wire the webhook for instant cache invalidation
|
|
51
|
-
|
|
52
|
-
```ts
|
|
53
|
-
// app/api/smking-revalidate/route.ts
|
|
54
|
-
export { POST, GET } from '@soloworks/smking-next/route';
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
Add to environment:
|
|
58
|
-
|
|
59
|
-
```bash
|
|
60
|
-
SMKING_WEBHOOK_TOKEN=... # any random string; share with smking SaaS
|
|
19
|
+
# Option 2 — copy the prompt manually from your smking dashboard's
|
|
20
|
+
# install panel into your editor / coding agent.
|
|
61
21
|
```
|
|
62
22
|
|
|
63
|
-
|
|
23
|
+
The wizard owns: `pnpm add @soloworks/smking-next`, `<SmkingAEO />` mount in `app/layout.tsx`, env writes, `app/api/smking/webhook/route.ts` shim, and doctor verification.
|
|
64
24
|
|
|
65
25
|
## How metadata wins / loses
|
|
66
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soloworks/smking-next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "AI-native SEO (AEO) for Next.js — auto-inject JSON-LD, FAQ, AI summary, and SEO metadata so AI crawlers (ChatGPT, Perplexity, Google AI) can cite your pages.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/sillyleo/smking/tree/main/packages/smking-next",
|
|
@@ -15,9 +15,13 @@
|
|
|
15
15
|
"types": "./src/index.ts",
|
|
16
16
|
"default": "./src/index.ts"
|
|
17
17
|
},
|
|
18
|
-
"./
|
|
19
|
-
"types": "./src/
|
|
20
|
-
"default": "./src/
|
|
18
|
+
"./cms": {
|
|
19
|
+
"types": "./src/components/smking-cms.tsx",
|
|
20
|
+
"default": "./src/components/smking-cms.tsx"
|
|
21
|
+
},
|
|
22
|
+
"./webhook": {
|
|
23
|
+
"types": "./src/lib/webhook-route.ts",
|
|
24
|
+
"default": "./src/lib/webhook-route.ts"
|
|
21
25
|
},
|
|
22
26
|
"./robots": {
|
|
23
27
|
"types": "./src/lib/robots.ts",
|
|
@@ -30,6 +34,10 @@
|
|
|
30
34
|
"./llms-txt": {
|
|
31
35
|
"types": "./src/lib/llms-txt-route.ts",
|
|
32
36
|
"default": "./src/lib/llms-txt-route.ts"
|
|
37
|
+
},
|
|
38
|
+
"./proxy": {
|
|
39
|
+
"types": "./src/lib/proxy.ts",
|
|
40
|
+
"default": "./src/lib/proxy.ts"
|
|
33
41
|
}
|
|
34
42
|
},
|
|
35
43
|
"bin": {
|
|
@@ -60,6 +68,11 @@
|
|
|
60
68
|
"devDependencies": {
|
|
61
69
|
"@testing-library/jest-dom": "^6.9.1",
|
|
62
70
|
"@testing-library/react": "^16.3.2",
|
|
71
|
+
"@tiptap/core": "^3.23.4",
|
|
72
|
+
"@tiptap/extension-image": "^3.23.4",
|
|
73
|
+
"@tiptap/extension-link": "^3.23.4",
|
|
74
|
+
"@tiptap/starter-kit": "^3.23.4",
|
|
75
|
+
"@tiptap/static-renderer": "^3.23.4",
|
|
63
76
|
"@types/react": "^19.0.0",
|
|
64
77
|
"@vitejs/plugin-react": "^6.0.1",
|
|
65
78
|
"jsdom": "^29.1.0",
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { getCmsPage } from "../lib/cms-client";
|
|
2
|
+
import type { Block, CmsParams } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Server Component that renders a published smking CMS page.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```tsx
|
|
9
|
+
* import { SmkingCms } from '@soloworks/smking-next/cms';
|
|
10
|
+
*
|
|
11
|
+
* export default function Page() {
|
|
12
|
+
* return (
|
|
13
|
+
* <SmkingCms
|
|
14
|
+
* apiKey={process.env.SMKING_API_KEY!}
|
|
15
|
+
* slug="hello"
|
|
16
|
+
* />
|
|
17
|
+
* );
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* v0.11+ substrate pivot: fetches a Block[] array and dispatches each
|
|
22
|
+
* block by `component`. Article block carries pre-rendered HTML
|
|
23
|
+
* (server-rendered at write time via tiptap-html, so the customer SDK
|
|
24
|
+
* needs zero Tiptap dependencies). Hero / nav-* blocks render inline;
|
|
25
|
+
* full server-side data fetch for nav-recent-posts / nav-taxonomy-list
|
|
26
|
+
* / nav-search arrives in M2+ — they emit pending markers for now so
|
|
27
|
+
* customer pages don't 500 if the page contains them.
|
|
28
|
+
*
|
|
29
|
+
* Returns null when the response isn't ready (pending / not_found /
|
|
30
|
+
* unreachable / mis-configured) — fail-open by design.
|
|
31
|
+
*
|
|
32
|
+
* Emits `<title>` / `<meta>` / `og:*` / canonical inline; React 19
|
|
33
|
+
* hoists them into `<head>` automatically.
|
|
34
|
+
*/
|
|
35
|
+
export async function SmkingCms(props: CmsParams) {
|
|
36
|
+
const data = await getCmsPage(props);
|
|
37
|
+
if (!data || data.status !== "ready" || !data.page) return null;
|
|
38
|
+
|
|
39
|
+
// v0.11+ — emit SEO head tags inline. React 19 hoists `<title>` and
|
|
40
|
+
// `<meta>` tags found anywhere in the tree into `<head>` automatically
|
|
41
|
+
// (last write wins on duplicate tags), so a deeper layout / page that
|
|
42
|
+
// also sets these still overrides ours where present.
|
|
43
|
+
const seo = data.seo;
|
|
44
|
+
const blocks = data.page.blocks ?? [];
|
|
45
|
+
return (
|
|
46
|
+
<>
|
|
47
|
+
{seo?.title && <title data-smking="cms">{seo.title}</title>}
|
|
48
|
+
{seo?.metaDescription && (
|
|
49
|
+
<meta
|
|
50
|
+
name="description"
|
|
51
|
+
content={seo.metaDescription}
|
|
52
|
+
data-smking="cms"
|
|
53
|
+
/>
|
|
54
|
+
)}
|
|
55
|
+
{seo?.ogTitle && (
|
|
56
|
+
<meta property="og:title" content={seo.ogTitle} data-smking="cms" />
|
|
57
|
+
)}
|
|
58
|
+
{seo?.ogDescription && (
|
|
59
|
+
<meta
|
|
60
|
+
property="og:description"
|
|
61
|
+
content={seo.ogDescription}
|
|
62
|
+
data-smking="cms"
|
|
63
|
+
/>
|
|
64
|
+
)}
|
|
65
|
+
{seo?.ogImageUrl && (
|
|
66
|
+
<meta property="og:image" content={seo.ogImageUrl} data-smking="cms" />
|
|
67
|
+
)}
|
|
68
|
+
{seo?.canonicalUrl && (
|
|
69
|
+
<link rel="canonical" href={seo.canonicalUrl} data-smking="cms" />
|
|
70
|
+
)}
|
|
71
|
+
|
|
72
|
+
<article
|
|
73
|
+
className="smk-cms"
|
|
74
|
+
data-smking="cms"
|
|
75
|
+
data-content-type={data.page.contentType}
|
|
76
|
+
>
|
|
77
|
+
{data.page.title && (
|
|
78
|
+
<h1 className="smk-cms__title">{data.page.title}</h1>
|
|
79
|
+
)}
|
|
80
|
+
{blocks.map((block) => renderBlock(block))}
|
|
81
|
+
</article>
|
|
82
|
+
</>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Dispatch a single block to its rendered React element.
|
|
88
|
+
*
|
|
89
|
+
* Trust boundary for `article` blocks: HTML is rendered server-side at
|
|
90
|
+
* write time by `@tiptap/html/server` inside the SaaS (Tiptap's renderer
|
|
91
|
+
* only emits HTML for known nodes per the configured schema, so author
|
|
92
|
+
* input can't smuggle arbitrary tags through). Customer site renders
|
|
93
|
+
* that HTML as-is via React's raw-HTML escape hatch. If customer wants
|
|
94
|
+
* defence-in-depth beyond the SaaS sanitisation tier, layer CSP on
|
|
95
|
+
* their host page (smking SDK does not ship a customer-side sanitiser
|
|
96
|
+
* to keep zero-dep customer install).
|
|
97
|
+
*/
|
|
98
|
+
function renderBlock(block: Block) {
|
|
99
|
+
switch (block.component) {
|
|
100
|
+
case "article": {
|
|
101
|
+
const articleHtml: { __html: string } = { __html: block.props.html };
|
|
102
|
+
return (
|
|
103
|
+
<div
|
|
104
|
+
key={block.id}
|
|
105
|
+
className="smk-block smk-block--article"
|
|
106
|
+
data-block-id={block.id}
|
|
107
|
+
// eslint-disable-next-line react/no-danger -- HTML pre-rendered server-side via @tiptap/html/server; trust boundary = SaaS schema. See function-level doc-comment above.
|
|
108
|
+
dangerouslySetInnerHTML={articleHtml}
|
|
109
|
+
/>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
case "hero":
|
|
113
|
+
return (
|
|
114
|
+
<section
|
|
115
|
+
key={block.id}
|
|
116
|
+
className="smk-block smk-block--hero smk-hero"
|
|
117
|
+
data-block-id={block.id}
|
|
118
|
+
>
|
|
119
|
+
{block.props.image && (
|
|
120
|
+
<img
|
|
121
|
+
src={block.props.image.url}
|
|
122
|
+
alt={block.props.image.alt}
|
|
123
|
+
className="smk-hero__image"
|
|
124
|
+
/>
|
|
125
|
+
)}
|
|
126
|
+
<h2 className="smk-hero__title">{block.props.title}</h2>
|
|
127
|
+
{block.props.subtitle && (
|
|
128
|
+
<p className="smk-hero__subtitle">{block.props.subtitle}</p>
|
|
129
|
+
)}
|
|
130
|
+
{block.props.cta && (
|
|
131
|
+
<a className="smk-hero__cta" href={block.props.cta.href}>
|
|
132
|
+
{block.props.cta.label}
|
|
133
|
+
</a>
|
|
134
|
+
)}
|
|
135
|
+
</section>
|
|
136
|
+
);
|
|
137
|
+
case "nav-recent-posts":
|
|
138
|
+
case "nav-taxonomy-list":
|
|
139
|
+
case "nav-search":
|
|
140
|
+
// Server-side data fetch + render lands in M2+ alongside the
|
|
141
|
+
// public posts / taxonomy / search endpoints. Emit a marker so
|
|
142
|
+
// customer pages don't 500 if an editor publishes a page with
|
|
143
|
+
// one of these blocks before M2 ships.
|
|
144
|
+
return (
|
|
145
|
+
<section
|
|
146
|
+
key={block.id}
|
|
147
|
+
className={`smk-block smk-block--${block.component} smk-nav-pending`}
|
|
148
|
+
data-block-id={block.id}
|
|
149
|
+
data-block-component={block.component}
|
|
150
|
+
/>
|
|
151
|
+
);
|
|
152
|
+
default:
|
|
153
|
+
// Forward-compat: unknown block component — emit empty marker
|
|
154
|
+
// so the page renders. Better than throwing.
|
|
155
|
+
return (
|
|
156
|
+
<section
|
|
157
|
+
key={(block as { id: string }).id}
|
|
158
|
+
className="smk-block smk-block--unknown"
|
|
159
|
+
data-block-component={(block as { component: string }).component}
|
|
160
|
+
/>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
export { SmkingAEO } from "./components/smking-aeo";
|
|
2
|
+
export { SmkingCms } from "./components/smking-cms";
|
|
2
3
|
export { getAeoContent } from "./lib/client";
|
|
4
|
+
export { getCmsPage } from "./lib/cms-client";
|
|
3
5
|
export type {
|
|
4
6
|
AeoResponse,
|
|
5
7
|
AeoStatus,
|
|
6
8
|
ChatLinks,
|
|
9
|
+
CmsPage,
|
|
10
|
+
CmsParams,
|
|
11
|
+
CmsResponse,
|
|
12
|
+
CmsStatus,
|
|
7
13
|
DiscoverParams,
|
|
8
14
|
FaqItem,
|
|
9
15
|
SeoMeta,
|
package/src/lib/client.ts
CHANGED
|
@@ -26,8 +26,11 @@ function warnOnce(key: string, message: string): void {
|
|
|
26
26
|
* - JSON parse failure
|
|
27
27
|
*
|
|
28
28
|
* On success, cached by Next.js data cache for 1h (ISR backstop). Tagged
|
|
29
|
-
* with `smking:
|
|
30
|
-
* can `revalidateTag` to invalidate
|
|
29
|
+
* with `smking:aeo:<path>` so the unified webhook handler at
|
|
30
|
+
* `@soloworks/smking-next/webhook` can `revalidateTag` to invalidate
|
|
31
|
+
* instantly when SaaS pushes an update. Namespace changed from
|
|
32
|
+
* `smking:path:*` in v0.11 — unified channel uses payload.kind to
|
|
33
|
+
* scope tag prefix.
|
|
31
34
|
*
|
|
32
35
|
* Sends `{ key, path, url }` — `url` is required for first-sight
|
|
33
36
|
* registration: when the SaaS hasn't seen this path before it queues a
|
|
@@ -82,7 +85,7 @@ export async function getAeoContent(
|
|
|
82
85
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
83
86
|
next: {
|
|
84
87
|
revalidate: params.revalidate ?? DEFAULT_REVALIDATE_SECONDS,
|
|
85
|
-
tags: [`smking:
|
|
88
|
+
tags: [`smking:aeo:${path}`],
|
|
86
89
|
},
|
|
87
90
|
});
|
|
88
91
|
if (!res.ok) return null;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Type-only import loads Next.js's RequestInit augmentation so the
|
|
2
|
+
// `next: { revalidate, tags }` property on fetch options typechecks.
|
|
3
|
+
import type {} from "next";
|
|
4
|
+
|
|
5
|
+
import type { CmsParams, CmsResponse } from "../types";
|
|
6
|
+
|
|
7
|
+
// CMS-specific TTL — shorter than AEO's 1h because CMS body is
|
|
8
|
+
// hand-edited (frequent updates) vs AEO which is SaaS-generated
|
|
9
|
+
// (more stable). Matches smking/laravel `cms_ttl` default for
|
|
10
|
+
// cross-SDK behavioral consistency. Customer can override via the
|
|
11
|
+
// `revalidate` prop. Webhook delivery (when SMKING_WEBHOOK_SECRET
|
|
12
|
+
// is wired) bypasses this entirely via revalidateTag.
|
|
13
|
+
const DEFAULT_REVALIDATE_SECONDS = 300;
|
|
14
|
+
const FETCH_TIMEOUT_MS = 2000;
|
|
15
|
+
|
|
16
|
+
const _warnedKeys = new Set<string>();
|
|
17
|
+
function warnOnce(key: string, message: string): void {
|
|
18
|
+
if (process.env.NODE_ENV === "production") return;
|
|
19
|
+
if (_warnedKeys.has(key)) return;
|
|
20
|
+
_warnedKeys.add(key);
|
|
21
|
+
console.warn(message);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fetch a published CMS page from the smking public API.
|
|
26
|
+
*
|
|
27
|
+
* Mirrors `getAeoContent` shape — same fail-open posture, same Next.js
|
|
28
|
+
* data-cache integration. Returns null when:
|
|
29
|
+
* - apiKey or baseUrl missing (one-time dev warning)
|
|
30
|
+
* - network failure / 2s timeout
|
|
31
|
+
* - 4xx / 5xx response
|
|
32
|
+
* - JSON parse failure
|
|
33
|
+
*
|
|
34
|
+
* On success, cached by Next.js data cache for 5min by default
|
|
35
|
+
* (ISR backstop — see DEFAULT_REVALIDATE_SECONDS rationale).
|
|
36
|
+
* Tagged with `smking:cms_page:<slug>` so the unified `/webhook` handler
|
|
37
|
+
* can `revalidateTag` to invalidate instantly when SaaS publishes an
|
|
38
|
+
* update. Namespace changed from `smking:cms:*` in v0.11 — unified
|
|
39
|
+
* channel uses payload.kind to scope tag prefix.
|
|
40
|
+
*/
|
|
41
|
+
export async function getCmsPage(
|
|
42
|
+
params: CmsParams,
|
|
43
|
+
): Promise<CmsResponse | null> {
|
|
44
|
+
if (!params.apiKey) {
|
|
45
|
+
warnOnce(
|
|
46
|
+
"missing-api-key",
|
|
47
|
+
"[@soloworks/smking-next/cms] apiKey is empty — skipping CMS render. Set SMKING_API_KEY or pass apiKey prop.",
|
|
48
|
+
);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const baseUrl = (params.baseUrl ?? process.env.SMKING_BASE_URL)?.replace(
|
|
53
|
+
/\/$/,
|
|
54
|
+
"",
|
|
55
|
+
);
|
|
56
|
+
if (!baseUrl) {
|
|
57
|
+
warnOnce(
|
|
58
|
+
"missing-base-url",
|
|
59
|
+
"[@soloworks/smking-next/cms] SMKING_BASE_URL is not configured — skipping CMS render. Set the env var or pass baseUrl prop.",
|
|
60
|
+
);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!params.slug) {
|
|
65
|
+
warnOnce(
|
|
66
|
+
"missing-slug",
|
|
67
|
+
"[@soloworks/smking-next/cms] slug prop is required.",
|
|
68
|
+
);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const url = `${baseUrl}/api/v1/public/page?key=${encodeURIComponent(
|
|
74
|
+
params.apiKey,
|
|
75
|
+
)}&slug=${encodeURIComponent(params.slug)}`;
|
|
76
|
+
const res = await fetch(url, {
|
|
77
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
78
|
+
next: {
|
|
79
|
+
revalidate: params.revalidate ?? DEFAULT_REVALIDATE_SECONDS,
|
|
80
|
+
tags: [`smking:cms_page:${params.slug}`],
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
if (!res.ok) return null;
|
|
84
|
+
return (await res.json()) as CmsResponse;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI bot + referral detection. Shared by `smkingProxy` (Next.js middleware)
|
|
3
|
+
* and any caller that wants to classify a request before forwarding it to
|
|
4
|
+
* the smking ingestion endpoint.
|
|
5
|
+
*
|
|
6
|
+
* Patterns are 2026-04 active list — see
|
|
7
|
+
* docs/ai-tracking-implementation.md A.2 for sourcing notes. Quarterly
|
|
8
|
+
* refresh from Cloudflare Radar / Dark Visitors / each vendor's docs.
|
|
9
|
+
*
|
|
10
|
+
* Detection precedence:
|
|
11
|
+
* 1. Bot UA match → `purpose` derived from bot category
|
|
12
|
+
* 2. No bot, but referer is an AI search surface → `purpose = ai_referral`
|
|
13
|
+
* 3. Otherwise → no record (we don't store noise)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type AiBotCategory = "training" | "search" | "user_triggered";
|
|
17
|
+
|
|
18
|
+
export type AiHitPurpose =
|
|
19
|
+
| "training"
|
|
20
|
+
| "realtime_citation"
|
|
21
|
+
| "ai_referral"
|
|
22
|
+
| "unknown";
|
|
23
|
+
|
|
24
|
+
interface CrawlerPattern {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly regex: RegExp;
|
|
27
|
+
readonly category: AiBotCategory;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const PATTERNS: readonly CrawlerPattern[] = [
|
|
31
|
+
// OpenAI
|
|
32
|
+
{ name: "GPTBot", regex: /GPTBot\/[\d.]+/, category: "training" },
|
|
33
|
+
{ name: "OAI-SearchBot", regex: /OAI-SearchBot\/[\d.]+/, category: "search" },
|
|
34
|
+
{ name: "ChatGPT-User", regex: /ChatGPT-User\/[\d.]+/, category: "user_triggered" },
|
|
35
|
+
// Anthropic
|
|
36
|
+
{ name: "ClaudeBot", regex: /ClaudeBot\/[\d.]+/, category: "training" },
|
|
37
|
+
{ name: "Claude-Web", regex: /Claude-Web\/[\d.]+/, category: "user_triggered" },
|
|
38
|
+
{ name: "anthropic-ai", regex: /anthropic-ai/, category: "training" },
|
|
39
|
+
// Perplexity
|
|
40
|
+
{ name: "PerplexityBot", regex: /PerplexityBot\/[\d.]+/, category: "training" },
|
|
41
|
+
{ name: "Perplexity-User", regex: /Perplexity-User\/[\d.]+/, category: "user_triggered" },
|
|
42
|
+
// Google
|
|
43
|
+
{ name: "Google-Extended", regex: /Google-Extended/, category: "training" },
|
|
44
|
+
{ name: "GoogleOther", regex: /GoogleOther/, category: "training" },
|
|
45
|
+
// Apple
|
|
46
|
+
{ name: "Applebot-Extended", regex: /Applebot-Extended\/[\d.]+/, category: "training" },
|
|
47
|
+
// Common Crawl (shared corpus for many LLMs)
|
|
48
|
+
{ name: "CCBot", regex: /CCBot\/[\d.]+/, category: "training" },
|
|
49
|
+
// ByteDance / Doubao
|
|
50
|
+
{ name: "Bytespider", regex: /Bytespider/, category: "training" },
|
|
51
|
+
// Meta AI
|
|
52
|
+
{ name: "meta-externalagent", regex: /meta-externalagent\/[\d.]+/, category: "training" },
|
|
53
|
+
// Amazon Alexa+
|
|
54
|
+
{ name: "Amazonbot", regex: /Amazonbot\/[\d.]+/, category: "training" },
|
|
55
|
+
// Cohere
|
|
56
|
+
{ name: "cohere-ai", regex: /cohere-ai/, category: "training" },
|
|
57
|
+
// Diffbot
|
|
58
|
+
{ name: "Diffbot", regex: /Diffbot\/[\d.]+/, category: "training" },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Hostnames that indicate the user came from an AI answer surface. Used to
|
|
63
|
+
* tag `ai_referral` traffic (no bot UA, real human session bounced from
|
|
64
|
+
* ChatGPT et al.).
|
|
65
|
+
*/
|
|
66
|
+
const AI_REFERRER_HOSTS: ReadonlySet<string> = new Set([
|
|
67
|
+
"chatgpt.com",
|
|
68
|
+
"chat.openai.com",
|
|
69
|
+
"perplexity.ai",
|
|
70
|
+
"www.perplexity.ai",
|
|
71
|
+
"claude.ai",
|
|
72
|
+
"gemini.google.com",
|
|
73
|
+
"copilot.microsoft.com",
|
|
74
|
+
"bing.com",
|
|
75
|
+
"www.bing.com",
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
export interface AiBotInfo {
|
|
79
|
+
readonly name: string;
|
|
80
|
+
readonly category: AiBotCategory;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Returns bot info if the UA matches a known AI crawler pattern. Linear
|
|
85
|
+
* scan of ~17 regexes — sub-millisecond per call, fine for a hot path.
|
|
86
|
+
*/
|
|
87
|
+
export function detectAiBot(userAgent: string | null | undefined): AiBotInfo | null {
|
|
88
|
+
if (!userAgent) return null;
|
|
89
|
+
for (const p of PATTERNS) {
|
|
90
|
+
if (p.regex.test(userAgent)) {
|
|
91
|
+
return { name: p.name, category: p.category };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Returns true when the referer header points to an AI answer surface.
|
|
99
|
+
* NULL-safe and tolerant of malformed URLs.
|
|
100
|
+
*/
|
|
101
|
+
export function detectAiReferral(referer: string | null | undefined): boolean {
|
|
102
|
+
if (!referer) return false;
|
|
103
|
+
try {
|
|
104
|
+
const host = new URL(referer).hostname.toLowerCase();
|
|
105
|
+
return AI_REFERRER_HOSTS.has(host);
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface AiHitClassification {
|
|
112
|
+
readonly purpose: AiHitPurpose;
|
|
113
|
+
readonly bot: string | null;
|
|
114
|
+
readonly botCategory: AiBotCategory | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Classify a request from its UA + referer. Returns `null` when it's
|
|
119
|
+
* neither a known AI bot nor an AI-referred human session — the caller
|
|
120
|
+
* should skip recording these to keep ingestion focused.
|
|
121
|
+
*/
|
|
122
|
+
export function classifyAiHit(
|
|
123
|
+
userAgent: string | null | undefined,
|
|
124
|
+
referer: string | null | undefined,
|
|
125
|
+
): AiHitClassification | null {
|
|
126
|
+
const bot = detectAiBot(userAgent);
|
|
127
|
+
if (bot) {
|
|
128
|
+
const purpose: AiHitPurpose =
|
|
129
|
+
bot.category === "training" ? "training" : "realtime_citation";
|
|
130
|
+
return { purpose, bot: bot.name, botCategory: bot.category };
|
|
131
|
+
}
|
|
132
|
+
if (detectAiReferral(referer)) {
|
|
133
|
+
return { purpose: "ai_referral", bot: null, botCategory: null };
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
package/src/lib/path.ts
CHANGED
|
@@ -43,7 +43,7 @@ export async function resolveRequestPath(): Promise<{
|
|
|
43
43
|
* Normalize a path so cache tags align across hand-written and
|
|
44
44
|
* auto-resolved callers. `<SmkingAEO path="products/abc" />` and
|
|
45
45
|
* `<SmkingAEO path="/products/abc" />` must hit the same Next.js cache
|
|
46
|
-
* entry — otherwise the webhook handler's `revalidateTag('smking:
|
|
46
|
+
* entry — otherwise the webhook handler's `revalidateTag('smking:aeo:/products/abc')`
|
|
47
47
|
* misses the version of the entry that lacks the leading slash.
|
|
48
48
|
*/
|
|
49
49
|
export function normalizePath(path: string): string {
|
package/src/lib/proxy.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest, after } from "next/server";
|
|
2
|
+
import { classifyAiHit } from "./crawlers";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SmKing AI traffic ingestion proxy (v0.12+, Next.js 16 `proxy.ts`).
|
|
6
|
+
*
|
|
7
|
+
* Drop-in install:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* // proxy.ts (project root)
|
|
11
|
+
* import { smkingProxy } from "@soloworks/smking-next/proxy";
|
|
12
|
+
*
|
|
13
|
+
* export const proxy = smkingProxy({
|
|
14
|
+
* apiKey: process.env.SMKING_API_KEY!,
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* export const config = {
|
|
18
|
+
* matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
19
|
+
* };
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* What it does:
|
|
23
|
+
* - Inspects each request's UA + referer
|
|
24
|
+
* - If it's an AI bot or AI-referred human session, batches one
|
|
25
|
+
* ingestion call via Vercel `after()` (post-response background)
|
|
26
|
+
* - Otherwise: passes through with zero overhead
|
|
27
|
+
*
|
|
28
|
+
* Composing with an existing proxy:
|
|
29
|
+
*
|
|
30
|
+
* ```ts
|
|
31
|
+
* import { composeProxy, smkingProxy } from "@soloworks/smking-next/proxy";
|
|
32
|
+
* import { customerProxy } from "./their-proxy";
|
|
33
|
+
*
|
|
34
|
+
* export const proxy = composeProxy([
|
|
35
|
+
* smkingProxy({ apiKey: process.env.SMKING_API_KEY! }),
|
|
36
|
+
* customerProxy,
|
|
37
|
+
* ]);
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* Fail-open posture (matches `getAeoContent`):
|
|
41
|
+
* - No `apiKey` → no-op, customer site keeps running
|
|
42
|
+
* - No `baseUrl` / `SMKING_BASE_URL` → no-op
|
|
43
|
+
* - Ingestion endpoint down / 4xx / 5xx → silent skip, next request retries
|
|
44
|
+
*
|
|
45
|
+
* Why `after()` (not fire-and-forget `fetch`):
|
|
46
|
+
* - Vercel serverless kills the function once the response flushes,
|
|
47
|
+
* which can drop a naked fetch before it's sent
|
|
48
|
+
* - `after()` keeps the function alive within the existing lifetime
|
|
49
|
+
* for background work — designed exactly for this case
|
|
50
|
+
*/
|
|
51
|
+
export interface SmkingProxyConfig {
|
|
52
|
+
/** Site's public API key (`pk_*`). Falls back to `SMKING_API_KEY` env. */
|
|
53
|
+
apiKey?: string;
|
|
54
|
+
/** Ingestion base URL. Falls back to `SMKING_BASE_URL` env. */
|
|
55
|
+
baseUrl?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type ProxyMiddleware = (
|
|
59
|
+
request: NextRequest,
|
|
60
|
+
) => NextResponse | Promise<NextResponse>;
|
|
61
|
+
|
|
62
|
+
const INGEST_TIMEOUT_MS = 2000;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the proxy middleware. Returns a no-op middleware when the
|
|
66
|
+
* required env / config is missing so dev / staging installs without
|
|
67
|
+
* env never crash.
|
|
68
|
+
*/
|
|
69
|
+
export function smkingProxy(config: SmkingProxyConfig = {}): ProxyMiddleware {
|
|
70
|
+
return (request: NextRequest) => {
|
|
71
|
+
const apiKey = config.apiKey ?? process.env.SMKING_API_KEY;
|
|
72
|
+
const baseUrl = (config.baseUrl ?? process.env.SMKING_BASE_URL)?.replace(
|
|
73
|
+
/\/$/,
|
|
74
|
+
"",
|
|
75
|
+
);
|
|
76
|
+
if (!apiKey || !baseUrl) {
|
|
77
|
+
return NextResponse.next();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ua = request.headers.get("user-agent");
|
|
81
|
+
const referer = request.headers.get("referer");
|
|
82
|
+
const hit = classifyAiHit(ua, referer);
|
|
83
|
+
if (!hit) return NextResponse.next();
|
|
84
|
+
|
|
85
|
+
const path = request.nextUrl.pathname;
|
|
86
|
+
const pageUrl = request.nextUrl.toString();
|
|
87
|
+
|
|
88
|
+
after(async () => {
|
|
89
|
+
try {
|
|
90
|
+
await fetch(`${baseUrl}/api/v1/crawler-hit`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: {
|
|
93
|
+
"content-type": "application/json",
|
|
94
|
+
"x-public-key": apiKey,
|
|
95
|
+
},
|
|
96
|
+
body: JSON.stringify({
|
|
97
|
+
hits: [
|
|
98
|
+
{
|
|
99
|
+
bot: hit.bot,
|
|
100
|
+
bot_category: hit.botCategory,
|
|
101
|
+
purpose: hit.purpose,
|
|
102
|
+
path,
|
|
103
|
+
page_url: pageUrl,
|
|
104
|
+
user_agent: ua,
|
|
105
|
+
referer,
|
|
106
|
+
timestamp: new Date().toISOString(),
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
}),
|
|
110
|
+
signal: AbortSignal.timeout(INGEST_TIMEOUT_MS),
|
|
111
|
+
});
|
|
112
|
+
} catch {
|
|
113
|
+
// Ingestion failure must NEVER bubble to the customer site.
|
|
114
|
+
// Next request will retry — losing a hit is acceptable.
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return NextResponse.next();
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Run middlewares in sequence. The first one to return a non-`next()`
|
|
124
|
+
* response (redirect / rewrite / abort) short-circuits the chain.
|
|
125
|
+
*
|
|
126
|
+
* NOTE: this assumes each middleware in the chain only adds headers or
|
|
127
|
+
* passes through — it does NOT compose response bodies. Use cases:
|
|
128
|
+
* - smkingProxy (telemetry only) + customer auth proxy
|
|
129
|
+
* - smkingProxy + customer feature flag proxy
|
|
130
|
+
*/
|
|
131
|
+
export function composeProxy(handlers: ProxyMiddleware[]): ProxyMiddleware {
|
|
132
|
+
return async (request: NextRequest) => {
|
|
133
|
+
for (const h of handlers) {
|
|
134
|
+
const result = await h(request);
|
|
135
|
+
if (!isPassThrough(result)) return result;
|
|
136
|
+
}
|
|
137
|
+
return NextResponse.next();
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isPassThrough(res: NextResponse): boolean {
|
|
142
|
+
// `NextResponse.next()` carries `x-middleware-next: 1` internally.
|
|
143
|
+
// Public surface: `res.headers.get('x-middleware-next')` returns "1"
|
|
144
|
+
// for a pass-through. Anything else (redirect, rewrite, body) is a
|
|
145
|
+
// terminal response.
|
|
146
|
+
return res.headers.get("x-middleware-next") === "1";
|
|
147
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { revalidateTag } from "next/cache";
|
|
2
|
+
import { Buffer } from "node:buffer";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
|
|
5
|
+
interface WebhookPayload {
|
|
6
|
+
kind?: string;
|
|
7
|
+
paths?: string[];
|
|
8
|
+
slugs?: string[];
|
|
9
|
+
deliveredAt?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* SmKing unified webhook handler (v0.11+).
|
|
14
|
+
*
|
|
15
|
+
* Drop-in install:
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* // app/api/smking/webhook/route.ts
|
|
19
|
+
* export { POST } from "@soloworks/smking-next/webhook";
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Replaces v0.10's split handlers:
|
|
23
|
+
* - `/route` AEO, Bearer-authed (SMKING_WEBHOOK_TOKEN) — GONE
|
|
24
|
+
* - `/cms-webhook` CMS, HMAC-signed (SMKING_WEBHOOK_SECRET) — GONE
|
|
25
|
+
*
|
|
26
|
+
* Single HMAC-SHA256 endpoint. Payload `.kind` (`"aeo" | "cms_page" |
|
|
27
|
+
* future kinds`) dispatches which revalidate tag namespace to use:
|
|
28
|
+
*
|
|
29
|
+
* smking:{kind}:{identifier}
|
|
30
|
+
*
|
|
31
|
+
* Where identifier is either:
|
|
32
|
+
* - path (AEO: `["/products/foo"]`) — `smking:aeo:/products/foo`
|
|
33
|
+
* - slug (CMS: `["hello"]`) — `smking:cms_page:hello`
|
|
34
|
+
*
|
|
35
|
+
* Customer migration from v0.10:
|
|
36
|
+
* 1. env: SMKING_WEBHOOK_TOKEN → SMKING_WEBHOOK_SECRET (HMAC key)
|
|
37
|
+
* 2. route path: /api/smking-revalidate + /api/smking/webhook
|
|
38
|
+
* → /api/smking/webhook (single)
|
|
39
|
+
* 3. revalidateTag callers update tag prefix
|
|
40
|
+
*
|
|
41
|
+
* Auth: HMAC-SHA256 only. Bearer dropped — single auth model means one
|
|
42
|
+
* env var, one shim, fewer customer mis-config paths (the disambiguation
|
|
43
|
+
* problem the v0.10 dual-handler shipped with).
|
|
44
|
+
*/
|
|
45
|
+
export async function POST(request: Request): Promise<Response> {
|
|
46
|
+
const secret = process.env.SMKING_WEBHOOK_SECRET;
|
|
47
|
+
if (!secret) {
|
|
48
|
+
return Response.json(
|
|
49
|
+
{ error: "webhook_secret_missing" },
|
|
50
|
+
{ status: 503 },
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Raw body for HMAC verify — re-encoding via JSON.parse + stringify
|
|
55
|
+
// would change byte order / spacing and invalidate the signature.
|
|
56
|
+
const rawBody = await request.text();
|
|
57
|
+
const providedSig = request.headers.get("x-smking-signature");
|
|
58
|
+
if (!verifySignature(rawBody, providedSig, secret)) {
|
|
59
|
+
return Response.json({ error: "invalid_signature" }, { status: 401 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let payload: WebhookPayload;
|
|
63
|
+
try {
|
|
64
|
+
payload = JSON.parse(rawBody) as WebhookPayload;
|
|
65
|
+
} catch {
|
|
66
|
+
return Response.json({ error: "invalid_payload" }, { status: 400 });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const kind = payload.kind;
|
|
70
|
+
if (typeof kind !== "string" || kind.length === 0) {
|
|
71
|
+
// Forward-compat: SaaS may emit kinds we haven't taught the SDK
|
|
72
|
+
// yet — acknowledge (200) without action so SaaS doesn't retry.
|
|
73
|
+
return Response.json({ ok: true, note: "no_action_taken" });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let revalidated = 0;
|
|
77
|
+
let errors = 0;
|
|
78
|
+
for (const path of payload.paths ?? []) {
|
|
79
|
+
try {
|
|
80
|
+
revalidateTag(`smking:${kind}:${path}`, "default");
|
|
81
|
+
revalidated++;
|
|
82
|
+
} catch (err) {
|
|
83
|
+
errors++;
|
|
84
|
+
console.warn(
|
|
85
|
+
`[@soloworks/smking-next/webhook] revalidateTag failed for ${kind}/${path}:`,
|
|
86
|
+
err,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const slug of payload.slugs ?? []) {
|
|
91
|
+
try {
|
|
92
|
+
revalidateTag(`smking:${kind}:${slug}`, "default");
|
|
93
|
+
revalidated++;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
errors++;
|
|
96
|
+
console.warn(
|
|
97
|
+
`[@soloworks/smking-next/webhook] revalidateTag failed for ${kind}/${slug}:`,
|
|
98
|
+
err,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return Response.json({ ok: true, kind, revalidated, errors });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function verifySignature(
|
|
107
|
+
rawBody: string,
|
|
108
|
+
signatureHeader: string | null,
|
|
109
|
+
secret: string,
|
|
110
|
+
): boolean {
|
|
111
|
+
if (!signatureHeader || !signatureHeader.startsWith("sha256=")) return false;
|
|
112
|
+
const provided = signatureHeader.slice("sha256=".length);
|
|
113
|
+
const expected = crypto
|
|
114
|
+
.createHmac("sha256", secret)
|
|
115
|
+
.update(rawBody)
|
|
116
|
+
.digest("hex");
|
|
117
|
+
if (expected.length !== provided.length) return false;
|
|
118
|
+
try {
|
|
119
|
+
return crypto.timingSafeEqual(
|
|
120
|
+
Buffer.from(expected, "hex"),
|
|
121
|
+
Buffer.from(provided, "hex"),
|
|
122
|
+
);
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -21,9 +21,19 @@ export interface ChatLinks {
|
|
|
21
21
|
* Server-resolved SEO metadata. Public API does fallback chains
|
|
22
22
|
* server-side (ogTitle → title, ogDescription → metaDescription,
|
|
23
23
|
* ogImageUrl → imageUrl, canonicalUrl → pageUrl).
|
|
24
|
+
*
|
|
25
|
+
* `metaDescription` is emitted by the CMS public endpoint (per-page
|
|
26
|
+
* search-snippet override). The AEO public endpoint emits its own
|
|
27
|
+
* metaDescription at the top level of `AeoResponse` instead — keep both
|
|
28
|
+
* paths since they cover different surfaces.
|
|
24
29
|
*/
|
|
25
30
|
export interface SeoMeta {
|
|
26
31
|
title: string | null;
|
|
32
|
+
/**
|
|
33
|
+
* Optional — only present on the CMS surface. AEO emits its own
|
|
34
|
+
* metaDescription at `AeoResponse.metaDescription` (top level).
|
|
35
|
+
*/
|
|
36
|
+
metaDescription?: string | null;
|
|
27
37
|
ogTitle: string | null;
|
|
28
38
|
ogDescription: string | null;
|
|
29
39
|
ogImageUrl: string | null;
|
|
@@ -42,6 +52,114 @@ export interface AeoResponse {
|
|
|
42
52
|
seo?: SeoMeta | null;
|
|
43
53
|
}
|
|
44
54
|
|
|
55
|
+
// ── CMS body content (mirrors SaaS /api/v1/public/page) ───────────────
|
|
56
|
+
|
|
57
|
+
export type CmsStatus = "ready" | "pending" | "not_found";
|
|
58
|
+
|
|
59
|
+
export type CmsContentType = "article" | "landing" | "listing";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* v2 substrate block primitives. Mirrors `apps/web/src/features/cms/types.ts`
|
|
63
|
+
* on the SaaS side (kept in sync manually — when the v1 block catalog
|
|
64
|
+
* grows past 5 primitives, a shared workspace package replaces this
|
|
65
|
+
* duplication per `docs/superpowers/specs/2026-05-15-cms-dashboard-editor-design.md`
|
|
66
|
+
* section §11 + ai-cms-v2 §"擴充性").
|
|
67
|
+
*/
|
|
68
|
+
export interface HeroProps {
|
|
69
|
+
title: string;
|
|
70
|
+
subtitle?: string;
|
|
71
|
+
image?: { url: string; alt: string };
|
|
72
|
+
cta?: { label: string; href: string };
|
|
73
|
+
}
|
|
74
|
+
export interface ArticleProps {
|
|
75
|
+
html: string;
|
|
76
|
+
}
|
|
77
|
+
export interface ModuleHeader {
|
|
78
|
+
heading?: string;
|
|
79
|
+
viewAll?: { label: string; href: string };
|
|
80
|
+
}
|
|
81
|
+
export type NavLayout = "grid" | "list" | "carousel";
|
|
82
|
+
export interface NavRecentPostsProps extends ModuleHeader {
|
|
83
|
+
limit: number;
|
|
84
|
+
layout: NavLayout;
|
|
85
|
+
contentType?: "article" | "all";
|
|
86
|
+
}
|
|
87
|
+
export interface NavTaxonomyListProps extends ModuleHeader {
|
|
88
|
+
/** category-by-path: slug prefix; tag: taxonomies.slug */
|
|
89
|
+
source: "category-by-path" | "tag";
|
|
90
|
+
path: string;
|
|
91
|
+
limit: number;
|
|
92
|
+
layout: NavLayout;
|
|
93
|
+
}
|
|
94
|
+
export interface NavSearchProps {
|
|
95
|
+
placeholder: string;
|
|
96
|
+
contentType?: "article" | "all";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type Block =
|
|
100
|
+
| { component: "hero"; id: string; props: HeroProps }
|
|
101
|
+
| { component: "article"; id: string; props: ArticleProps }
|
|
102
|
+
| {
|
|
103
|
+
component: "nav-recent-posts";
|
|
104
|
+
id: string;
|
|
105
|
+
props: NavRecentPostsProps;
|
|
106
|
+
}
|
|
107
|
+
| {
|
|
108
|
+
component: "nav-taxonomy-list";
|
|
109
|
+
id: string;
|
|
110
|
+
props: NavTaxonomyListProps;
|
|
111
|
+
}
|
|
112
|
+
| { component: "nav-search"; id: string; props: NavSearchProps };
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A single published CMS page returned by the smking public API
|
|
116
|
+
* (substrate v2). Render via `<SmkingCms>` server component which
|
|
117
|
+
* dispatches each block by component type.
|
|
118
|
+
*/
|
|
119
|
+
export interface CmsPage {
|
|
120
|
+
slug: string;
|
|
121
|
+
title: string;
|
|
122
|
+
contentType: CmsContentType;
|
|
123
|
+
blocks: Block[];
|
|
124
|
+
excerpt?: string | null;
|
|
125
|
+
featuredImageUrl?: string | null;
|
|
126
|
+
publishedAt: string | null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Mirrors AeoResponse status taxonomy so existing fail-open patterns
|
|
131
|
+
* apply identically. `page` + `seo` only present when status === "ready".
|
|
132
|
+
*
|
|
133
|
+
* SEO meta (v0.11+) lets the Server Component emit `<title>` / `<meta>`
|
|
134
|
+
* head tags alongside the body, so customer Next.js pages get a
|
|
135
|
+
* search-friendly meta block for free without a separate fetch.
|
|
136
|
+
* Server-resolved with fallback chains (ogTitle→seoTitle→title etc) —
|
|
137
|
+
* consumer just renders whatever's present.
|
|
138
|
+
*/
|
|
139
|
+
export interface CmsResponse {
|
|
140
|
+
status: CmsStatus;
|
|
141
|
+
page?: CmsPage;
|
|
142
|
+
seo?: SeoMeta | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface CmsParams {
|
|
146
|
+
/** Public API key (`pk_*`). Required. */
|
|
147
|
+
apiKey: string;
|
|
148
|
+
/** Page slug — required. PoC ships with hardcoded "hello" on the SaaS. */
|
|
149
|
+
slug: string;
|
|
150
|
+
/**
|
|
151
|
+
* smking deployment origin. Required — pass directly or set
|
|
152
|
+
* `SMKING_BASE_URL` env. Missing value short-circuits to fail-open.
|
|
153
|
+
*/
|
|
154
|
+
baseUrl?: string;
|
|
155
|
+
/**
|
|
156
|
+
* Next.js `fetch` revalidate seconds. Defaults to 300 (5min ISR
|
|
157
|
+
* backstop — shorter than AEO's 1h because CMS content is hand-edited
|
|
158
|
+
* and changes more often; matches `smking/laravel`'s `cms_ttl`).
|
|
159
|
+
*/
|
|
160
|
+
revalidate?: number;
|
|
161
|
+
}
|
|
162
|
+
|
|
45
163
|
export interface DiscoverParams {
|
|
46
164
|
/** Public API key (`pk_*`). Required. */
|
|
47
165
|
apiKey: string;
|
package/src/route.ts
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { revalidateTag } from "next/cache";
|
|
2
|
-
import { Buffer } from "node:buffer";
|
|
3
|
-
import crypto from "node:crypto";
|
|
4
|
-
|
|
5
|
-
interface WebhookPayload {
|
|
6
|
-
paths?: string[];
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
const BEARER_PREFIX = "Bearer ";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Webhook handler for SaaS-pushed cache invalidation. Drop-in install:
|
|
13
|
-
*
|
|
14
|
-
* ```ts
|
|
15
|
-
* // app/api/smking-revalidate/route.ts
|
|
16
|
-
* export { POST, GET } from '@soloworks/smking-next/route';
|
|
17
|
-
* ```
|
|
18
|
-
*
|
|
19
|
-
* Then set `SMKING_WEBHOOK_TOKEN` in your environment and configure the
|
|
20
|
-
* SaaS to POST `{ paths: [...] }` with `Authorization: Bearer <token>`.
|
|
21
|
-
*
|
|
22
|
-
* Bearer-token auth (not HMAC body signing) — sufficient for v1.
|
|
23
|
-
* HMAC-signed body verification can layer on later if attack surface
|
|
24
|
-
* justifies it. Token compare runs through `crypto.timingSafeEqual` to
|
|
25
|
-
* avoid leaking length / prefix info via response time.
|
|
26
|
-
*/
|
|
27
|
-
export async function POST(request: Request): Promise<Response> {
|
|
28
|
-
const expected = process.env.SMKING_WEBHOOK_TOKEN;
|
|
29
|
-
if (!expected) {
|
|
30
|
-
return Response.json(
|
|
31
|
-
{ error: "webhook_not_configured" },
|
|
32
|
-
{ status: 503 },
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const auth = request.headers.get("authorization");
|
|
37
|
-
if (!auth || !verifyBearer(auth, expected)) {
|
|
38
|
-
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
let payload: WebhookPayload;
|
|
42
|
-
try {
|
|
43
|
-
payload = (await request.json()) as WebhookPayload;
|
|
44
|
-
} catch {
|
|
45
|
-
return Response.json({ error: "invalid_json" }, { status: 400 });
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
let revalidated = 0;
|
|
49
|
-
let errors = 0;
|
|
50
|
-
for (const p of payload.paths ?? []) {
|
|
51
|
-
try {
|
|
52
|
-
// Next.js 16 requires a cache profile; 'default' matches the
|
|
53
|
-
// implicit profile a normal `'use cache'` block uses.
|
|
54
|
-
revalidateTag(`smking:path:${p}`, "default");
|
|
55
|
-
revalidated++;
|
|
56
|
-
} catch (err) {
|
|
57
|
-
errors++;
|
|
58
|
-
console.warn(
|
|
59
|
-
`[@soloworks/smking-next] revalidateTag failed for path "${p}":`,
|
|
60
|
-
err,
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return Response.json({ revalidated, errors });
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Liveness probe — POST-only public endpoint, but `GET` returns 401 with
|
|
70
|
-
* a `WWW-Authenticate` header so customers can verify the route is wired
|
|
71
|
-
* up by sending a token. Discourages anonymous probes from confirming
|
|
72
|
-
* the endpoint exists.
|
|
73
|
-
*/
|
|
74
|
-
export async function GET(request: Request): Promise<Response> {
|
|
75
|
-
const expected = process.env.SMKING_WEBHOOK_TOKEN;
|
|
76
|
-
if (!expected) {
|
|
77
|
-
return Response.json(
|
|
78
|
-
{ error: "webhook_not_configured" },
|
|
79
|
-
{ status: 503 },
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
const auth = request.headers.get("authorization");
|
|
83
|
-
if (auth && verifyBearer(auth, expected)) {
|
|
84
|
-
return new Response("OK", { status: 200 });
|
|
85
|
-
}
|
|
86
|
-
return new Response("Unauthorized", {
|
|
87
|
-
status: 401,
|
|
88
|
-
headers: { "WWW-Authenticate": 'Bearer realm="smking-webhook"' },
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Constant-time bearer-token compare. Returns false on:
|
|
94
|
-
* - missing or non-Bearer header
|
|
95
|
-
* - presented token has different length than expected (early-out;
|
|
96
|
-
* the lengths themselves leak nothing because the secret is set by
|
|
97
|
-
* the customer with a known length)
|
|
98
|
-
* - byte-different but same-length token
|
|
99
|
-
*/
|
|
100
|
-
function verifyBearer(authHeader: string, expected: string): boolean {
|
|
101
|
-
if (!authHeader.startsWith(BEARER_PREFIX)) return false;
|
|
102
|
-
const presented = authHeader.slice(BEARER_PREFIX.length);
|
|
103
|
-
if (presented.length !== expected.length) return false;
|
|
104
|
-
try {
|
|
105
|
-
return crypto.timingSafeEqual(
|
|
106
|
-
Buffer.from(presented),
|
|
107
|
-
Buffer.from(expected),
|
|
108
|
-
);
|
|
109
|
-
} catch {
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
}
|