@smalk/nextjs-ads 0.1.1 → 1.2.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 +30 -0
- package/README.md +57 -1
- package/dist/app.cjs +57 -22
- package/dist/app.cjs.map +1 -1
- package/dist/app.js +2 -2
- package/dist/chunk-4QWOGJYF.js +219 -0
- package/dist/chunk-4QWOGJYF.js.map +1 -0
- package/dist/{chunk-YA6M2IA4.js → chunk-INI3DN37.js} +5 -5
- package/dist/chunk-INI3DN37.js.map +1 -0
- package/dist/index.cjs +232 -21
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +89 -5
- package/dist/index.d.ts +89 -5
- package/dist/index.js +25 -3
- package/dist/middleware.cjs +60 -25
- package/dist/middleware.cjs.map +1 -1
- package/dist/middleware.js +2 -2
- package/dist/pages.cjs +57 -22
- package/dist/pages.cjs.map +1 -1
- package/dist/pages.d.cts +1 -0
- package/dist/pages.d.ts +1 -0
- package/dist/pages.js +2 -2
- package/package.json +10 -6
- package/dist/chunk-HX4ZGDYQ.js +0 -31
- package/dist/chunk-HX4ZGDYQ.js.map +0 -1
- package/dist/chunk-YA6M2IA4.js.map +0 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.2.0 (2026-05-19)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Pluggable `ActiveUrlsStore` interface: `InMemoryActiveUrlsStore`, `FilesystemActiveUrlsStore` (default), `VercelKVActiveUrlsStore`, `CloudflareKVActiveUrlsStore`. Use `setActiveUrlsStore()` at app bootstrap to persist the active-ad URL cache across cold starts on serverless.
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- `loadConfig().apiBaseUrl` now strips the `/api/vN` suffix from `apiBase` for backward compatibility — pre-1.1.0 publishers building URLs as `${apiBaseUrl}/api/v1/...` no longer get double-prefixed.
|
|
10
|
+
- `tests/app.test.tsx` + `tests/middleware.test.ts` collection-time failures (vi.mock hoisting) fixed via `vi.hoisted()` pattern.
|
|
11
|
+
- WordPress plugin v1.1.0 publisher cache-bust uses `SMALK_AI_ADS_PLUGIN_VERSION` constant (was hardcoded 1.0.14).
|
|
12
|
+
|
|
13
|
+
### Internal
|
|
14
|
+
- Config unified: `getSmalkConfig()` canonical (returns `SmalkConfig`); `loadConfig()` kept as legacy alias returning `LegacySmalkConfig` with `apiBaseUrl` mirroring `apiBase` minus the API path.
|
|
15
|
+
|
|
16
|
+
## 0.1.1 — 2026-05-13
|
|
17
|
+
|
|
18
|
+
- `<SmalkAd>` now calls `unstable_noStore()` (when available) in addition to
|
|
19
|
+
`headers()`, to defensively opt the surrounding route out of Static
|
|
20
|
+
Generation. Belt-and-braces: prevents the ad fetch from being baked into a
|
|
21
|
+
prerendered HTML and never re-running, in setups where the `headers()`
|
|
22
|
+
heuristic alone wasn't enough (Next 14+ static cache, certain build configs).
|
|
23
|
+
- README: documented the `output: 'export'` / `force-dynamic` fallback for
|
|
24
|
+
publishers whose project config overrides the dynamic-rendering heuristics.
|
|
25
|
+
|
|
26
|
+
## 0.1.0 — 2026-05-05
|
|
27
|
+
|
|
28
|
+
Initial release. App Router (`<SmalkAd>`), Pages Router (`getSmalkAd` /
|
|
29
|
+
`<AdHtml>`), middleware variant (`withSmalkAds`), preview mode, 20-min revalidate
|
|
30
|
+
cache with sha256 hash-compare auto-bust.
|
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ SMALK_PROJECT_KEY=your-workspace-uuid
|
|
|
21
21
|
SMALK_API_KEY=your-api-key
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
(`
|
|
24
|
+
(`SMALK_API_BASE` is optional — defaults to `https://api.smalk.ai/api/v1`. The legacy `SMALK_API_BASE_URL` env var is also accepted as a fallback.)
|
|
25
25
|
|
|
26
26
|
## Usage — App Router
|
|
27
27
|
|
|
@@ -103,6 +103,62 @@ You only need to act manually in two edge cases:
|
|
|
103
103
|
|
|
104
104
|
If you're not sure, leave the plugin to handle it and check `view-source` of your deployed page: if you see `<!-- smalk: no ad -->` updating between deploys (and our dashboard registers a new AdPlacement after first request), you're good.
|
|
105
105
|
|
|
106
|
+
## Freshness sweep
|
|
107
|
+
|
|
108
|
+
The `/api/smalk/freshness` route and freshness middleware bump `Last-Modified` headers on active-ad URLs to signal recency to AI crawlers.
|
|
109
|
+
|
|
110
|
+
- **Active-URL cache:** stored in the OS tempdir (`os.tmpdir()/smalk-active-ad-paths.json`) by default so it works on serverless runtimes (Vercel, AWS Lambda, Cloudflare Pages Functions) where the project filesystem is read-only. For production serverless, swap in a persistent KV store — see "Persistent KV cache" below.
|
|
111
|
+
- **Per-instance + ephemeral (filesystem default):** each Lambda / Vercel function instance keeps its own copy and the file evaporates when the instance is recycled. The middleware reloads from disk every 60 s and falls back to a fresh API fetch on cold start.
|
|
112
|
+
- **`Last-Modified` value:** a UTC-day bucket (one bump per UTC day), not `Date.now()`. This keeps real users with `If-Modified-Since` getting 304s within the day while still giving AI crawlers a fresh signal on the next day rollover.
|
|
113
|
+
|
|
114
|
+
## Persistent KV cache
|
|
115
|
+
|
|
116
|
+
On serverless platforms (Vercel, Cloudflare Workers/Pages, AWS Lambda) the default `os.tmpdir()` file cache is per-instance and lost on cold start. Swap in a KV-backed store at app bootstrap so the active-URLs list survives across instances:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
// app bootstrap (e.g. instrumentation.ts on Vercel, or top of middleware.ts)
|
|
120
|
+
import { setActiveUrlsStore, VercelKVActiveUrlsStore } from "@smalk/nextjs-ads";
|
|
121
|
+
|
|
122
|
+
setActiveUrlsStore(new VercelKVActiveUrlsStore());
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Built-in stores:
|
|
126
|
+
|
|
127
|
+
| Store | Use case | Requires |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| `InMemoryActiveUrlsStore` | Tests, single-process Node | — |
|
|
130
|
+
| `FilesystemActiveUrlsStore` | Local dev, long-running Node servers (default) | — |
|
|
131
|
+
| `VercelKVActiveUrlsStore` | Vercel deployments | `@vercel/kv` peer-installed by publisher + env vars |
|
|
132
|
+
| `CloudflareKVActiveUrlsStore` | Cloudflare Workers / Pages Functions | Pass the `KVNamespace` binding into the constructor |
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
// Cloudflare example — pass the binding from env
|
|
136
|
+
import { setActiveUrlsStore, CloudflareKVActiveUrlsStore } from "@smalk/nextjs-ads";
|
|
137
|
+
|
|
138
|
+
export default {
|
|
139
|
+
async fetch(req: Request, env: { SMALK_KV: KVNamespace }) {
|
|
140
|
+
setActiveUrlsStore(new CloudflareKVActiveUrlsStore(env.SMALK_KV));
|
|
141
|
+
// ... rest of handler
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
For other KV backends (Upstash, Redis, DynamoDB, etc.), implement the `ActiveUrlsStore` interface (`read()` / `write()` returning `{ paths, updated_at }`) and pass it to `setActiveUrlsStore()`.
|
|
147
|
+
|
|
148
|
+
## Legacy `loadConfig()` shape
|
|
149
|
+
|
|
150
|
+
`loadConfig()` is retained for pre-P2.5 publisher code that imported it from `@smalk/nextjs-ads`. It returns the canonical `SmalkConfig` plus an `apiBaseUrl` alias.
|
|
151
|
+
|
|
152
|
+
To preserve backward compatibility with callers that build URLs as `${cfg.apiBaseUrl}/api/v1/...`, `apiBaseUrl` is derived from `apiBase` by stripping a trailing `/api/vN` segment:
|
|
153
|
+
|
|
154
|
+
| Source `apiBase` | `loadConfig().apiBase` | `loadConfig().apiBaseUrl` |
|
|
155
|
+
|-----------------------------------|-----------------------------------|-------------------------------|
|
|
156
|
+
| `https://api.smalk.ai/api/v1` | `https://api.smalk.ai/api/v1` | `https://api.smalk.ai` |
|
|
157
|
+
| `http://localhost:8000/api/v1` | `http://localhost:8000/api/v1` | `http://localhost:8000` |
|
|
158
|
+
| `https://custom.example.com` | `https://custom.example.com` | `https://custom.example.com` |
|
|
159
|
+
|
|
160
|
+
**New code should use `getSmalkConfig().apiBase` directly** — it already includes the `/api/v1` segment, so append endpoint paths like `${cfg.apiBase}/transform/ads/content/` without re-adding the version.
|
|
161
|
+
|
|
106
162
|
## Trust Boundary
|
|
107
163
|
|
|
108
164
|
Ad HTML is rendered via React's raw-HTML escape hatch inside package-owned components only. The publisher never calls the unsafe React API directly. We do **not** ship a sanitizer (DOMPurify): ads include `<script type="application/ld+json">` JSON-LD that AI crawlers parse for citation freshness, and the default DOMPurify config strips it. Smalk vets ad content server-side; this is the same trust model used by the WordPress (`smalk-ai-ads-pro`) and Drupal (`smalk_d8`) plugins.
|
package/dist/app.cjs
CHANGED
|
@@ -36,34 +36,69 @@ module.exports = __toCommonJS(app_exports);
|
|
|
36
36
|
var import_server_only = require("server-only");
|
|
37
37
|
var import_headers = require("next/headers");
|
|
38
38
|
|
|
39
|
-
// src/
|
|
40
|
-
var
|
|
41
|
-
var
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
if (!projectKey) {
|
|
49
|
-
if (isProd) throw new Error("@smalk/nextjs-ads: SMALK_PROJECT_KEY env var is required in production");
|
|
50
|
-
console.warn("[smalk] SMALK_PROJECT_KEY not set \u2014 ad fetch will be skipped");
|
|
51
|
-
}
|
|
52
|
-
if (!apiKey) {
|
|
53
|
-
if (isProd) throw new Error("@smalk/nextjs-ads: SMALK_API_KEY env var is required in production");
|
|
54
|
-
console.warn("[smalk] SMALK_API_KEY not set \u2014 ad fetch will be skipped");
|
|
55
|
-
}
|
|
39
|
+
// src/config.ts
|
|
40
|
+
var DEFAULT_API_BASE = "https://api.smalk.ai/api/v1";
|
|
41
|
+
var PLUGIN_VERSION = "1.1.0";
|
|
42
|
+
function getSmalkConfig() {
|
|
43
|
+
const apiBase = process.env.SMALK_API_BASE || process.env.SMALK_API_BASE_URL || DEFAULT_API_BASE;
|
|
44
|
+
const apiKey = process.env.SMALK_API_KEY ?? "";
|
|
45
|
+
const projectKey = process.env.SMALK_PROJECT_KEY ?? "";
|
|
46
|
+
const cronSecret = process.env.SMALK_CRON_SECRET ?? "";
|
|
47
|
+
const publicHost = process.env.SMALK_PUBLIC_HOST || void 0;
|
|
56
48
|
return {
|
|
57
|
-
|
|
58
|
-
apiKey
|
|
59
|
-
|
|
49
|
+
apiBase,
|
|
50
|
+
apiKey,
|
|
51
|
+
projectKey,
|
|
52
|
+
cronSecret,
|
|
53
|
+
pluginVersion: PLUGIN_VERSION,
|
|
54
|
+
publicHost
|
|
60
55
|
};
|
|
61
56
|
}
|
|
62
57
|
|
|
58
|
+
// src/freshness/route.ts
|
|
59
|
+
var import_cache = require("next/cache");
|
|
60
|
+
var import_server = require("next/server");
|
|
61
|
+
|
|
62
|
+
// src/freshness/activeUrlsStore.ts
|
|
63
|
+
var FilesystemActiveUrlsStore = class {
|
|
64
|
+
constructor(filePath) {
|
|
65
|
+
this.filePath = filePath;
|
|
66
|
+
}
|
|
67
|
+
filePath;
|
|
68
|
+
async read() {
|
|
69
|
+
const fs = await import("fs/promises");
|
|
70
|
+
const path = await import("path");
|
|
71
|
+
const os = await import("os");
|
|
72
|
+
const f = this.filePath || path.join(os.tmpdir(), "smalk-active-ad-paths.json");
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(await fs.readFile(f, "utf8"));
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async write(payload) {
|
|
80
|
+
const fs = await import("fs/promises");
|
|
81
|
+
const path = await import("path");
|
|
82
|
+
const os = await import("os");
|
|
83
|
+
const f = this.filePath || path.join(os.tmpdir(), "smalk-active-ad-paths.json");
|
|
84
|
+
await fs.mkdir(path.dirname(f), { recursive: true });
|
|
85
|
+
await fs.writeFile(f, JSON.stringify(payload));
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
var currentStore = new FilesystemActiveUrlsStore();
|
|
89
|
+
|
|
90
|
+
// src/freshness/middleware.ts
|
|
91
|
+
var import_server2 = require("next/server");
|
|
92
|
+
|
|
93
|
+
// src/index.ts
|
|
94
|
+
var DEFAULT_TIMEOUT_MS = 100;
|
|
95
|
+
var DEFAULT_REVALIDATE_S = 1200;
|
|
96
|
+
var API_PATH = "/transform/ads/content/";
|
|
97
|
+
|
|
63
98
|
// src/client.ts
|
|
64
99
|
var lastHashByUrl = /* @__PURE__ */ new Map();
|
|
65
100
|
async function fetchAd(input, opts = {}) {
|
|
66
|
-
const cfg =
|
|
101
|
+
const cfg = getSmalkConfig();
|
|
67
102
|
if (!cfg.projectKey || !cfg.apiKey) return null;
|
|
68
103
|
const controller = new AbortController();
|
|
69
104
|
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
|
@@ -78,7 +113,7 @@ async function fetchAd(input, opts = {}) {
|
|
|
78
113
|
if (opts.preview !== void 0 && opts.preview !== false) {
|
|
79
114
|
body.preview = opts.preview;
|
|
80
115
|
}
|
|
81
|
-
const res = await fetch(`${cfg.
|
|
116
|
+
const res = await fetch(`${cfg.apiBase}${API_PATH}`, {
|
|
82
117
|
method: "POST",
|
|
83
118
|
headers: {
|
|
84
119
|
"Authorization": `Api-Key ${cfg.apiKey}`,
|
package/dist/app.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/app.tsx","../src/index.ts","../src/client.ts"],"sourcesContent":["import 'server-only';\nimport * as React from 'react';\nimport { headers } from 'next/headers';\nimport { fetchAd } from './client';\n\nexport interface SmalkAdProps {\n /** Override revalidation TTL in seconds (default 1200 = 20 min). */\n revalidate?: number;\n /** Page pathname; if omitted, derived from request headers / `/`. */\n pathname?: string;\n /** Optional className applied to the wrapper element. */\n className?: string;\n /** Preview mode: `true` (PARAGRAPH placeholder) or a format string (e.g. `'FAQ'`). Bypasses booking lookup. */\n preview?: boolean | string;\n}\n\nexport async function SmalkAd(props: SmalkAdProps = {}): Promise<React.ReactElement> {\n // Opt the surrounding route out of Static Generation so the ad fetch runs at\n // request time, not at build time. Two layers:\n // 1) `headers()` below — opts out since Next 13.4 (built-in heuristic).\n // 2) `unstable_noStore()` — explicit opt-out marker, added in Next 14.\n // Best-effort: dynamic import, swallow if unavailable (Next 13.x or moved).\n await noStoreIfAvailable();\n const h = await readHeadersCompat();\n const userAgent = h.get('user-agent') ?? '';\n const referer = h.get('referer') ?? '';\n const clientIp = pickClientIp(h);\n const host = h.get('host') ?? '';\n const pathname = props.pathname ?? '/';\n if (!host) return <SmalkAdComment reason=\"no host\" />;\n const pageUrl = `https://${host}${pathname}`;\n\n const ad = await fetchAd(\n { pageUrl, userAgent, referer, clientIp },\n { revalidate: props.revalidate, preview: props.preview },\n );\n\n if (!ad) {\n return <SmalkAdComment reason=\"no ad\" />;\n }\n\n return (\n <div\n className={props.className ?? 'smalk-ads'}\n data-smalk-booking={ad.bookingId ?? undefined}\n // Trust boundary: API output is vetted server-side; raw HTML rendered here.\n // No DOMPurify — strips required <script type=\"application/ld+json\"> JSON-LD.\n dangerouslySetInnerHTML={{ __html: ad.html }}\n />\n );\n}\n\nfunction SmalkAdComment({ reason }: { reason: string }): React.ReactElement {\n return <span data-smalk=\"comment\" dangerouslySetInnerHTML={{ __html: `<!-- smalk: ${reason} -->` }} />;\n}\n\nasync function noStoreIfAvailable(): Promise<void> {\n try {\n const mod = (await import('next/cache')) as unknown as { unstable_noStore?: () => void };\n if (typeof mod.unstable_noStore === 'function') {\n mod.unstable_noStore();\n }\n } catch {\n // next/cache not available (Next 13.x) — the `headers()` call below still\n // opts the route out of Static Generation in that version range.\n }\n}\n\nasync function readHeadersCompat(): Promise<{ get: (k: string) => string | null }> {\n // Next 13.4–14.x: headers() is sync. Next 15+: async.\n const result = headers() as unknown;\n if (result && typeof (result as Promise<unknown>).then === 'function') {\n return (await (result as Promise<{ get: (k: string) => string | null }>)) as { get: (k: string) => string | null };\n }\n return result as { get: (k: string) => string | null };\n}\n\nfunction pickClientIp(h: { get: (k: string) => string | null }): string {\n for (const key of ['cf-connecting-ip', 'x-real-ip', 'x-forwarded-for']) {\n const v = h.get(key);\n if (v) return v.split(',')[0].trim();\n }\n return '';\n}\n","export interface SmalkAdsConfig {\n projectKey: string;\n apiKey: string;\n apiBaseUrl: string;\n}\n\nexport interface AdInput {\n pageUrl: string;\n userAgent: string;\n referer: string;\n clientIp: string;\n}\n\nexport interface AdResult {\n html: string;\n bookingId: string | null;\n}\n\nexport interface ApiResponse {\n html?: string;\n booking_id?: string;\n metadata?: Record<string, unknown>;\n}\n\nexport const DEFAULT_TIMEOUT_MS = 100;\nexport const DEFAULT_REVALIDATE_S = 1200;\nexport const API_PATH = '/api/v1/transform/ads/content/';\n\nexport function loadConfig(): SmalkAdsConfig {\n const projectKey = process.env.SMALK_PROJECT_KEY;\n const apiKey = process.env.SMALK_API_KEY;\n const apiBaseUrl = process.env.SMALK_API_BASE_URL || 'https://api.smalk.ai';\n const isProd = process.env.NODE_ENV === 'production';\n\n if (!projectKey) {\n if (isProd) throw new Error('@smalk/nextjs-ads: SMALK_PROJECT_KEY env var is required in production');\n console.warn('[smalk] SMALK_PROJECT_KEY not set — ad fetch will be skipped');\n }\n if (!apiKey) {\n if (isProd) throw new Error('@smalk/nextjs-ads: SMALK_API_KEY env var is required in production');\n console.warn('[smalk] SMALK_API_KEY not set — ad fetch will be skipped');\n }\n\n return {\n projectKey: projectKey ?? '',\n apiKey: apiKey ?? '',\n apiBaseUrl,\n };\n}\n","import { loadConfig, AdInput, AdResult, ApiResponse, API_PATH, DEFAULT_TIMEOUT_MS, DEFAULT_REVALIDATE_S } from './index';\n\nconst lastHashByUrl = new Map<string, string>();\n\nexport function _resetHashCacheForTests(): void {\n lastHashByUrl.clear();\n}\n\nexport interface FetchAdOptions {\n revalidate?: number;\n /** Preview mode: `true` (PARAGRAPH placeholder) or a format string (e.g. `'FAQ'`). Bypasses booking lookup. */\n preview?: boolean | string;\n}\n\nexport async function fetchAd(input: AdInput, opts: FetchAdOptions = {}): Promise<AdResult | null> {\n const cfg = loadConfig();\n if (!cfg.projectKey || !cfg.apiKey) return null;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);\n\n try {\n const body: Record<string, unknown> = {\n project_key: cfg.projectKey,\n page_url: input.pageUrl,\n user_agent: input.userAgent,\n referer: input.referer,\n client_ip: input.clientIp,\n };\n if (opts.preview !== undefined && opts.preview !== false) {\n body.preview = opts.preview;\n }\n const res = await fetch(`${cfg.apiBaseUrl}${API_PATH}`, {\n method: 'POST',\n headers: {\n 'Authorization': `Api-Key ${cfg.apiKey}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n signal: controller.signal,\n // `next` is a Next.js fetch extension; type provided by next types when present.\n next: {\n revalidate: opts.revalidate ?? DEFAULT_REVALIDATE_S,\n tags: ['smalk-ad', `smalk-ad:${input.pageUrl}`],\n },\n } as RequestInit);\n\n if (!res.ok) {\n logErrorOnce(`smalk-ads fetch failed: HTTP ${res.status}`);\n return null;\n }\n\n const data = (await res.json()) as ApiResponse;\n if (!data.html) return null;\n\n const hash = await sha256Hex(data.html);\n const prev = lastHashByUrl.get(input.pageUrl);\n if (prev && prev !== hash) {\n await maybeRevalidatePath(input.pageUrl);\n }\n lastHashByUrl.set(input.pageUrl, hash);\n\n return { html: data.html, bookingId: data.booking_id ?? null };\n } catch (err) {\n if (err instanceof Error && err.name !== 'AbortError') {\n logErrorOnce(`smalk-ads fetch error: ${err.message}`);\n }\n return null;\n } finally {\n clearTimeout(timer);\n }\n}\n\nasync function maybeRevalidatePath(pageUrl: string): Promise<void> {\n try {\n const mod = await import('next/cache');\n if (typeof mod.revalidatePath === 'function') {\n const pathname = new URL(pageUrl).pathname;\n mod.revalidatePath(pathname);\n }\n } catch {\n // next/cache not available (Pages Router runtime, edge, or test env without next).\n }\n}\n\nconst seenErrors = new Set<string>();\nfunction logErrorOnce(msg: string): void {\n if (seenErrors.has(msg)) return;\n seenErrors.add(msg);\n console.error(`[smalk-nextjs-ads] ${msg}`);\n}\n\nasync function sha256Hex(input: string): Promise<string> {\n const data = new TextEncoder().encode(input);\n const buf = await globalThis.crypto.subtle.digest('SHA-256', data);\n const bytes = new Uint8Array(buf);\n let hex = '';\n for (let i = 0; i < bytes.length; i++) {\n hex += bytes[i].toString(16).padStart(2, '0');\n }\n return hex;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/app.tsx","../src/config.ts","../src/freshness/route.ts","../src/freshness/activeUrlsStore.ts","../src/freshness/middleware.ts","../src/index.ts","../src/client.ts"],"sourcesContent":["import 'server-only';\nimport * as React from 'react';\nimport { headers } from 'next/headers';\nimport { fetchAd } from './client';\n\nexport interface SmalkAdProps {\n /** Override revalidation TTL in seconds (default 1200 = 20 min). */\n revalidate?: number;\n /** Page pathname; if omitted, derived from request headers / `/`. */\n pathname?: string;\n /** Optional className applied to the wrapper element. */\n className?: string;\n /** Preview mode: `true` (PARAGRAPH placeholder) or a format string (e.g. `'FAQ'`). Bypasses booking lookup. */\n preview?: boolean | string;\n}\n\nexport async function SmalkAd(props: SmalkAdProps = {}): Promise<React.ReactElement> {\n // Opt the surrounding route out of Static Generation so the ad fetch runs at\n // request time, not at build time. Two layers:\n // 1) `headers()` below — opts out since Next 13.4 (built-in heuristic).\n // 2) `unstable_noStore()` — explicit opt-out marker, added in Next 14.\n // Best-effort: dynamic import, swallow if unavailable (Next 13.x or moved).\n await noStoreIfAvailable();\n const h = await readHeadersCompat();\n const userAgent = h.get('user-agent') ?? '';\n const referer = h.get('referer') ?? '';\n const clientIp = pickClientIp(h);\n const host = h.get('host') ?? '';\n const pathname = props.pathname ?? '/';\n if (!host) return <SmalkAdComment reason=\"no host\" />;\n const pageUrl = `https://${host}${pathname}`;\n\n const ad = await fetchAd(\n { pageUrl, userAgent, referer, clientIp },\n { revalidate: props.revalidate, preview: props.preview },\n );\n\n if (!ad) {\n return <SmalkAdComment reason=\"no ad\" />;\n }\n\n return (\n <div\n className={props.className ?? 'smalk-ads'}\n data-smalk-booking={ad.bookingId ?? undefined}\n // Trust boundary: API output is vetted server-side; raw HTML rendered here.\n // No DOMPurify — strips required <script type=\"application/ld+json\"> JSON-LD.\n dangerouslySetInnerHTML={{ __html: ad.html }}\n />\n );\n}\n\nfunction SmalkAdComment({ reason }: { reason: string }): React.ReactElement {\n return <span data-smalk=\"comment\" dangerouslySetInnerHTML={{ __html: `<!-- smalk: ${reason} -->` }} />;\n}\n\nasync function noStoreIfAvailable(): Promise<void> {\n try {\n const mod = (await import('next/cache')) as unknown as { unstable_noStore?: () => void };\n if (typeof mod.unstable_noStore === 'function') {\n mod.unstable_noStore();\n }\n } catch {\n // next/cache not available (Next 13.x) — the `headers()` call below still\n // opts the route out of Static Generation in that version range.\n }\n}\n\nasync function readHeadersCompat(): Promise<{ get: (k: string) => string | null }> {\n // Next 13.4–14.x: headers() is sync. Next 15+: async.\n const result = headers() as unknown;\n if (result && typeof (result as Promise<unknown>).then === 'function') {\n return (await (result as Promise<{ get: (k: string) => string | null }>)) as { get: (k: string) => string | null };\n }\n return result as { get: (k: string) => string | null };\n}\n\nfunction pickClientIp(h: { get: (k: string) => string | null }): string {\n for (const key of ['cf-connecting-ip', 'x-real-ip', 'x-forwarded-for']) {\n const v = h.get(key);\n if (v) return v.split(',')[0].trim();\n }\n return '';\n}\n","export interface SmalkConfig {\n /** Full API base including version path, e.g. https://api.smalk.ai/api/v1 */\n apiBase: string;\n apiKey: string;\n projectKey: string;\n cronSecret: string;\n pluginVersion: string;\n publicHost?: string;\n}\n\n/**\n * Backwards-compatible config shape for legacy callers (pre-P2). Adds an\n * `apiBaseUrl` alias for `apiBase`. New code should use `getSmalkConfig()`\n * and read `apiBase` directly.\n */\nexport interface LegacySmalkConfig extends SmalkConfig {\n /**\n * @deprecated Use `apiBase` instead.\n *\n * Pre-P2.5 shape: host-only (e.g. `https://api.smalk.ai`), and external\n * publisher code appended `/api/v1/...`. To preserve that shape after the\n * P2.5 Next config unify (which moved `/api/v1` into the canonical\n * `apiBase`), this field strips a trailing `/api/vN` segment from\n * `apiBase` before exposing it. New code should read `apiBase` directly.\n */\n apiBaseUrl: string;\n}\n\nconst DEFAULT_API_BASE = \"https://api.smalk.ai/api/v1\";\nconst PLUGIN_VERSION = \"1.1.0\";\n\nexport function getSmalkConfig(): SmalkConfig {\n const apiBase = process.env.SMALK_API_BASE || process.env.SMALK_API_BASE_URL || DEFAULT_API_BASE;\n const apiKey = process.env.SMALK_API_KEY ?? \"\";\n const projectKey = process.env.SMALK_PROJECT_KEY ?? \"\";\n const cronSecret = process.env.SMALK_CRON_SECRET ?? \"\";\n const publicHost = process.env.SMALK_PUBLIC_HOST || undefined;\n\n return {\n apiBase,\n apiKey,\n projectKey,\n cronSecret,\n pluginVersion: PLUGIN_VERSION,\n publicHost,\n };\n}\n\n/**\n * Strip a trailing `/api/vN` (or `/api/vN/`) segment from an API base URL.\n * Preserves backward compatibility with publishers building URLs like\n * `${apiBaseUrl}/api/v1/...` — without stripping, those would double-prefix\n * to `/api/v1/api/v1/...` and 404.\n */\nfunction stripApiPathSuffix(apiBase: string): string {\n return apiBase.replace(/\\/api\\/v\\d+\\/?$/, \"\");\n}\n\n/**\n * Legacy loader retained for external consumers (publisher apps) that import\n * `loadConfig` from `@smalk/nextjs-ads`. Returns the canonical `SmalkConfig`\n * plus an `apiBaseUrl` alias whose shape matches the pre-P2.5 contract\n * (host-only, no `/api/v1` suffix). New code should prefer `getSmalkConfig()`\n * and read `apiBase` directly.\n */\nexport function loadConfig(): LegacySmalkConfig {\n const cfg = getSmalkConfig();\n return { ...cfg, apiBaseUrl: stripApiPathSuffix(cfg.apiBase) };\n}\n","import { revalidatePath } from \"next/cache\";\nimport { NextRequest, NextResponse } from \"next/server\";\n\nimport { getSmalkConfig } from \"../config\";\nimport { cacheActiveUrls } from \"./activeUrlsCache\";\n\nexport async function smalkFreshnessRevalidateHandler(req: NextRequest) {\n const cfg = getSmalkConfig();\n const secret = req.headers.get(\"x-smalk-cron-secret\");\n if (!secret || secret !== cfg.cronSecret) {\n return NextResponse.json({ error: \"unauthorized\" }, { status: 401 });\n }\n\n const endpoint = `${cfg.apiBase}/projects/${cfg.projectKey}/ads/inventory/active-ad-urls/`;\n const resp = await fetch(endpoint, {\n headers: {\n \"X-API-Key\": cfg.apiKey,\n \"X-Smalk-CMS\": \"nextjs\",\n \"X-Smalk-Plugin\": cfg.pluginVersion,\n },\n cache: \"no-store\",\n });\n if (!resp.ok) {\n return NextResponse.json({ error: `api ${resp.status}` }, { status: 502 });\n }\n const payload = (await resp.json()) as { urls: { url: string }[] };\n const paths = payload.urls\n .map((u) => safePath(u.url, cfg.publicHost))\n .filter((p): p is string => Boolean(p));\n\n await cacheActiveUrls(paths);\n\n let revalidated = 0;\n for (const p of paths) {\n try {\n revalidatePath(p);\n revalidated++;\n } catch (e) {\n console.warn(`[smalk] revalidatePath failed for ${p}`, e);\n }\n }\n\n return NextResponse.json({\n ok: true,\n total: paths.length,\n revalidated,\n ran_at: new Date().toISOString(),\n });\n}\n\nfunction safePath(url: string, publicHost?: string): string | null {\n try {\n const u = new URL(url);\n if (publicHost && u.host !== publicHost) return null;\n return u.pathname;\n } catch {\n return null;\n }\n}\n","export interface ActiveUrlsCachedPayload {\n paths: string[];\n updated_at: number;\n}\n\nexport interface ActiveUrlsStore {\n read(): Promise<ActiveUrlsCachedPayload | null>;\n write(payload: ActiveUrlsCachedPayload): Promise<void>;\n}\n\n// In-memory store (per-process, fast, lost on restart — good for tests).\nexport class InMemoryActiveUrlsStore implements ActiveUrlsStore {\n private value: ActiveUrlsCachedPayload | null = null;\n async read() { return this.value; }\n async write(payload: ActiveUrlsCachedPayload) { this.value = payload; }\n}\n\n// Filesystem fallback — uses os.tmpdir() (current behavior, kept for Node dev).\nexport class FilesystemActiveUrlsStore implements ActiveUrlsStore {\n constructor(private filePath?: string) {}\n async read(): Promise<ActiveUrlsCachedPayload | null> {\n const fs = await import(\"node:fs/promises\");\n const path = await import(\"node:path\");\n const os = await import(\"node:os\");\n const f = this.filePath || path.join(os.tmpdir(), \"smalk-active-ad-paths.json\");\n try {\n return JSON.parse(await fs.readFile(f, \"utf8\"));\n } catch {\n return null;\n }\n }\n async write(payload: ActiveUrlsCachedPayload) {\n const fs = await import(\"node:fs/promises\");\n const path = await import(\"node:path\");\n const os = await import(\"node:os\");\n const f = this.filePath || path.join(os.tmpdir(), \"smalk-active-ad-paths.json\");\n await fs.mkdir(path.dirname(f), { recursive: true });\n await fs.writeFile(f, JSON.stringify(payload));\n }\n}\n\n// Vercel KV adapter — requires @vercel/kv installed by the publisher.\n// We don't bundle the dep; lazy-import via a non-literal specifier so\n// bundlers (vite/vitest/tsup/webpack) don't try to resolve it at build time.\nconst VERCEL_KV_MODULE = \"@vercel/kv\";\nasync function loadVercelKv(): Promise<{ kv: { get(k: string): Promise<unknown>; set(k: string, v: string): Promise<unknown> } }> {\n // Indirect specifier — opaque to bundler static analysis.\n return (await import(/* @vite-ignore */ /* webpackIgnore: true */ VERCEL_KV_MODULE)) as {\n kv: { get(k: string): Promise<unknown>; set(k: string, v: string): Promise<unknown> };\n };\n}\n\nexport class VercelKVActiveUrlsStore implements ActiveUrlsStore {\n private readonly key: string;\n constructor(key = \"smalk:active-ad-paths\") { this.key = key; }\n async read(): Promise<ActiveUrlsCachedPayload | null> {\n const { kv } = await loadVercelKv();\n const raw = await kv.get(this.key);\n if (!raw) return null;\n try { return typeof raw === \"string\" ? JSON.parse(raw) : (raw as ActiveUrlsCachedPayload); }\n catch { return null; }\n }\n async write(payload: ActiveUrlsCachedPayload) {\n const { kv } = await loadVercelKv();\n await kv.set(this.key, JSON.stringify(payload));\n }\n}\n\n// Cloudflare KV adapter — publisher passes the KVNamespace binding.\nexport class CloudflareKVActiveUrlsStore implements ActiveUrlsStore {\n constructor(private namespace: { get(key: string): Promise<string | null>; put(key: string, value: string): Promise<void> }, private key = \"smalk:active-ad-paths\") {}\n async read(): Promise<ActiveUrlsCachedPayload | null> {\n const raw = await this.namespace.get(this.key);\n return raw ? JSON.parse(raw) : null;\n }\n async write(payload: ActiveUrlsCachedPayload) {\n await this.namespace.put(this.key, JSON.stringify(payload));\n }\n}\n\n// Module-level default — process-wide singleton, swap with setActiveUrlsStore().\nlet currentStore: ActiveUrlsStore = new FilesystemActiveUrlsStore();\n\nexport function getActiveUrlsStore(): ActiveUrlsStore {\n return currentStore;\n}\n\nexport function setActiveUrlsStore(store: ActiveUrlsStore): void {\n currentStore = store;\n}\n","import { NextRequest, NextResponse } from \"next/server\";\n\nimport { readActiveUrls } from \"./activeUrlsCache\";\n\nlet pathsCache: Set<string> | null = null;\nlet pathsLoadedAt = 0;\nconst RELOAD_INTERVAL_MS = 60_000;\n\nasync function getPaths(): Promise<Set<string>> {\n const now = Date.now();\n if (!pathsCache || now - pathsLoadedAt > RELOAD_INTERVAL_MS) {\n const data = await readActiveUrls();\n pathsCache = new Set(data?.paths ?? []);\n pathsLoadedAt = now;\n }\n return pathsCache;\n}\n\nexport async function smalkFreshnessMiddleware(req: NextRequest): Promise<NextResponse | undefined> {\n const paths = await getPaths();\n if (!paths.has(req.nextUrl.pathname)) return undefined;\n const res = NextResponse.next();\n // Day-bucket stable Last-Modified so real users with If-Modified-Since\n // still get 304s within the day; AI crawlers (which hit infrequently)\n // get a fresh signal on the next UTC day rollover.\n const dayBucket = Math.floor(Date.now() / 86_400_000) * 86_400_000;\n res.headers.set(\"Last-Modified\", new Date(dayBucket).toUTCString());\n res.headers.set(\"X-Smalk-Freshness\", \"bumped\");\n return res;\n}\n","export interface AdInput {\n pageUrl: string;\n userAgent: string;\n referer: string;\n clientIp: string;\n}\n\nexport interface AdResult {\n html: string;\n bookingId: string | null;\n}\n\nexport interface ApiResponse {\n html?: string;\n booking_id?: string;\n metadata?: Record<string, unknown>;\n}\n\nexport const DEFAULT_TIMEOUT_MS = 100;\nexport const DEFAULT_REVALIDATE_S = 1200;\nexport const API_PATH = '/transform/ads/content/';\n\nexport { getSmalkConfig, loadConfig } from './config';\nexport type { SmalkConfig, LegacySmalkConfig } from './config';\n\n/** @deprecated Use `SmalkConfig` from `./config`. Kept as alias for legacy imports. */\nexport type { LegacySmalkConfig as SmalkAdsConfig } from './config';\n\nexport { smalkFreshnessRevalidateHandler } from \"./freshness/route\";\nexport { smalkFreshnessMiddleware } from \"./freshness/middleware\";\nexport { cacheActiveUrls, readActiveUrls } from \"./freshness/activeUrlsCache\";\nexport {\n InMemoryActiveUrlsStore,\n FilesystemActiveUrlsStore,\n VercelKVActiveUrlsStore,\n CloudflareKVActiveUrlsStore,\n getActiveUrlsStore,\n setActiveUrlsStore,\n} from \"./freshness/activeUrlsStore\";\nexport type { ActiveUrlsStore, ActiveUrlsCachedPayload } from \"./freshness/activeUrlsStore\";\n","import { getSmalkConfig } from './config';\nimport { AdInput, AdResult, ApiResponse, API_PATH, DEFAULT_TIMEOUT_MS, DEFAULT_REVALIDATE_S } from './index';\n\nconst lastHashByUrl = new Map<string, string>();\n\nexport function _resetHashCacheForTests(): void {\n lastHashByUrl.clear();\n}\n\nexport interface FetchAdOptions {\n revalidate?: number;\n /** Preview mode: `true` (PARAGRAPH placeholder) or a format string (e.g. `'FAQ'`). Bypasses booking lookup. */\n preview?: boolean | string;\n}\n\nexport async function fetchAd(input: AdInput, opts: FetchAdOptions = {}): Promise<AdResult | null> {\n const cfg = getSmalkConfig();\n if (!cfg.projectKey || !cfg.apiKey) return null;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);\n\n try {\n const body: Record<string, unknown> = {\n project_key: cfg.projectKey,\n page_url: input.pageUrl,\n user_agent: input.userAgent,\n referer: input.referer,\n client_ip: input.clientIp,\n };\n if (opts.preview !== undefined && opts.preview !== false) {\n body.preview = opts.preview;\n }\n const res = await fetch(`${cfg.apiBase}${API_PATH}`, {\n method: 'POST',\n headers: {\n 'Authorization': `Api-Key ${cfg.apiKey}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n signal: controller.signal,\n // `next` is a Next.js fetch extension; type provided by next types when present.\n next: {\n revalidate: opts.revalidate ?? DEFAULT_REVALIDATE_S,\n tags: ['smalk-ad', `smalk-ad:${input.pageUrl}`],\n },\n } as RequestInit);\n\n if (!res.ok) {\n logErrorOnce(`smalk-ads fetch failed: HTTP ${res.status}`);\n return null;\n }\n\n const data = (await res.json()) as ApiResponse;\n if (!data.html) return null;\n\n const hash = await sha256Hex(data.html);\n const prev = lastHashByUrl.get(input.pageUrl);\n if (prev && prev !== hash) {\n await maybeRevalidatePath(input.pageUrl);\n }\n lastHashByUrl.set(input.pageUrl, hash);\n\n return { html: data.html, bookingId: data.booking_id ?? null };\n } catch (err) {\n if (err instanceof Error && err.name !== 'AbortError') {\n logErrorOnce(`smalk-ads fetch error: ${err.message}`);\n }\n return null;\n } finally {\n clearTimeout(timer);\n }\n}\n\nasync function maybeRevalidatePath(pageUrl: string): Promise<void> {\n try {\n const mod = await import('next/cache');\n if (typeof mod.revalidatePath === 'function') {\n const pathname = new URL(pageUrl).pathname;\n mod.revalidatePath(pathname);\n }\n } catch {\n // next/cache not available (Pages Router runtime, edge, or test env without next).\n }\n}\n\nconst seenErrors = new Set<string>();\nfunction logErrorOnce(msg: string): void {\n if (seenErrors.has(msg)) return;\n seenErrors.add(msg);\n console.error(`[smalk-nextjs-ads] ${msg}`);\n}\n\nasync function sha256Hex(input: string): Promise<string> {\n const data = new TextEncoder().encode(input);\n const buf = await globalThis.crypto.subtle.digest('SHA-256', data);\n const bytes = new Uint8Array(buf);\n let hex = '';\n for (let i = 0; i < bytes.length; i++) {\n hex += bytes[i].toString(16).padStart(2, '0');\n }\n return hex;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,yBAAO;AAEP,qBAAwB;;;AC0BxB,IAAM,mBAAmB;AACzB,IAAM,iBAAiB;AAEhB,SAAS,iBAA8B;AAC1C,QAAM,UAAU,QAAQ,IAAI,kBAAkB,QAAQ,IAAI,sBAAsB;AAChF,QAAM,SAAS,QAAQ,IAAI,iBAAiB;AAC5C,QAAM,aAAa,QAAQ,IAAI,qBAAqB;AACpD,QAAM,aAAa,QAAQ,IAAI,qBAAqB;AACpD,QAAM,aAAa,QAAQ,IAAI,qBAAqB;AAEpD,SAAO;AAAA,IACH;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,eAAe;AAAA,IACf;AAAA,EACJ;AACJ;;;AC9CA,mBAA+B;AAC/B,oBAA0C;;;ACiBnC,IAAM,4BAAN,MAA2D;AAAA,EAC9D,YAAoB,UAAmB;AAAnB;AAAA,EAAoB;AAAA,EAApB;AAAA,EACpB,MAAM,OAAgD;AAClD,UAAM,KAAK,MAAM,OAAO,aAAkB;AAC1C,UAAM,OAAO,MAAM,OAAO,MAAW;AACrC,UAAM,KAAK,MAAM,OAAO,IAAS;AACjC,UAAM,IAAI,KAAK,YAAY,KAAK,KAAK,GAAG,OAAO,GAAG,4BAA4B;AAC9E,QAAI;AACA,aAAO,KAAK,MAAM,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;AAAA,IAClD,QAAQ;AACJ,aAAO;AAAA,IACX;AAAA,EACJ;AAAA,EACA,MAAM,MAAM,SAAkC;AAC1C,UAAM,KAAK,MAAM,OAAO,aAAkB;AAC1C,UAAM,OAAO,MAAM,OAAO,MAAW;AACrC,UAAM,KAAK,MAAM,OAAO,IAAS;AACjC,UAAM,IAAI,KAAK,YAAY,KAAK,KAAK,GAAG,OAAO,GAAG,4BAA4B;AAC9E,UAAM,GAAG,MAAM,KAAK,QAAQ,CAAC,GAAG,EAAE,WAAW,KAAK,CAAC;AACnD,UAAM,GAAG,UAAU,GAAG,KAAK,UAAU,OAAO,CAAC;AAAA,EACjD;AACJ;AA0CA,IAAI,eAAgC,IAAI,0BAA0B;;;ACjFlE,IAAAA,iBAA0C;;;ACkBnC,IAAM,qBAAqB;AAC3B,IAAM,uBAAuB;AAC7B,IAAM,WAAW;;;ACjBxB,IAAM,gBAAgB,oBAAI,IAAoB;AAY9C,eAAsB,QAAQ,OAAgB,OAAuB,CAAC,GAA6B;AACjG,QAAM,MAAM,eAAe;AAC3B,MAAI,CAAC,IAAI,cAAc,CAAC,IAAI,OAAQ,QAAO;AAE3C,QAAM,aAAa,IAAI,gBAAgB;AACvC,QAAM,QAAQ,WAAW,MAAM,WAAW,MAAM,GAAG,kBAAkB;AAErE,MAAI;AACF,UAAM,OAAgC;AAAA,MACpC,aAAa,IAAI;AAAA,MACjB,UAAU,MAAM;AAAA,MAChB,YAAY,MAAM;AAAA,MAClB,SAAS,MAAM;AAAA,MACf,WAAW,MAAM;AAAA,IACnB;AACA,QAAI,KAAK,YAAY,UAAa,KAAK,YAAY,OAAO;AACxD,WAAK,UAAU,KAAK;AAAA,IACtB;AACA,UAAM,MAAM,MAAM,MAAM,GAAG,IAAI,OAAO,GAAG,QAAQ,IAAI;AAAA,MACnD,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,iBAAiB,WAAW,IAAI,MAAM;AAAA,QACtC,gBAAgB;AAAA,MAClB;AAAA,MACA,MAAM,KAAK,UAAU,IAAI;AAAA,MACzB,QAAQ,WAAW;AAAA;AAAA,MAEnB,MAAM;AAAA,QACJ,YAAY,KAAK,cAAc;AAAA,QAC/B,MAAM,CAAC,YAAY,YAAY,MAAM,OAAO,EAAE;AAAA,MAChD;AAAA,IACF,CAAgB;AAEhB,QAAI,CAAC,IAAI,IAAI;AACX,mBAAa,gCAAgC,IAAI,MAAM,EAAE;AACzD,aAAO;AAAA,IACT;AAEA,UAAM,OAAQ,MAAM,IAAI,KAAK;AAC7B,QAAI,CAAC,KAAK,KAAM,QAAO;AAEvB,UAAM,OAAO,MAAM,UAAU,KAAK,IAAI;AACtC,UAAM,OAAO,cAAc,IAAI,MAAM,OAAO;AAC5C,QAAI,QAAQ,SAAS,MAAM;AACzB,YAAM,oBAAoB,MAAM,OAAO;AAAA,IACzC;AACA,kBAAc,IAAI,MAAM,SAAS,IAAI;AAErC,WAAO,EAAE,MAAM,KAAK,MAAM,WAAW,KAAK,cAAc,KAAK;AAAA,EAC/D,SAAS,KAAK;AACZ,QAAI,eAAe,SAAS,IAAI,SAAS,cAAc;AACrD,mBAAa,0BAA0B,IAAI,OAAO,EAAE;AAAA,IACtD;AACA,WAAO;AAAA,EACT,UAAE;AACA,iBAAa,KAAK;AAAA,EACpB;AACF;AAEA,eAAe,oBAAoB,SAAgC;AACjE,MAAI;AACF,UAAM,MAAM,MAAM,OAAO,YAAY;AACrC,QAAI,OAAO,IAAI,mBAAmB,YAAY;AAC5C,YAAM,WAAW,IAAI,IAAI,OAAO,EAAE;AAClC,UAAI,eAAe,QAAQ;AAAA,IAC7B;AAAA,EACF,QAAQ;AAAA,EAER;AACF;AAEA,IAAM,aAAa,oBAAI,IAAY;AACnC,SAAS,aAAa,KAAmB;AACvC,MAAI,WAAW,IAAI,GAAG,EAAG;AACzB,aAAW,IAAI,GAAG;AAClB,UAAQ,MAAM,sBAAsB,GAAG,EAAE;AAC3C;AAEA,eAAe,UAAU,OAAgC;AACvD,QAAM,OAAO,IAAI,YAAY,EAAE,OAAO,KAAK;AAC3C,QAAM,MAAM,MAAM,WAAW,OAAO,OAAO,OAAO,WAAW,IAAI;AACjE,QAAM,QAAQ,IAAI,WAAW,GAAG;AAChC,MAAI,MAAM;AACV,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACrC,WAAO,MAAM,CAAC,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG;AAAA,EAC9C;AACA,SAAO;AACT;;;ANzEoB;AAbpB,eAAsB,QAAQ,QAAsB,CAAC,GAAgC;AAMnF,QAAM,mBAAmB;AACzB,QAAM,IAAI,MAAM,kBAAkB;AAClC,QAAM,YAAY,EAAE,IAAI,YAAY,KAAK;AACzC,QAAM,UAAU,EAAE,IAAI,SAAS,KAAK;AACpC,QAAM,WAAW,aAAa,CAAC;AAC/B,QAAM,OAAO,EAAE,IAAI,MAAM,KAAK;AAC9B,QAAM,WAAW,MAAM,YAAY;AACnC,MAAI,CAAC,KAAM,QAAO,4CAAC,kBAAe,QAAO,WAAU;AACnD,QAAM,UAAU,WAAW,IAAI,GAAG,QAAQ;AAE1C,QAAM,KAAK,MAAM;AAAA,IACf,EAAE,SAAS,WAAW,SAAS,SAAS;AAAA,IACxC,EAAE,YAAY,MAAM,YAAY,SAAS,MAAM,QAAQ;AAAA,EACzD;AAEA,MAAI,CAAC,IAAI;AACP,WAAO,4CAAC,kBAAe,QAAO,SAAQ;AAAA,EACxC;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW,MAAM,aAAa;AAAA,MAC9B,sBAAoB,GAAG,aAAa;AAAA,MAGpC,yBAAyB,EAAE,QAAQ,GAAG,KAAK;AAAA;AAAA,EAC7C;AAEJ;AAEA,SAAS,eAAe,EAAE,OAAO,GAA2C;AAC1E,SAAO,4CAAC,UAAK,cAAW,WAAU,yBAAyB,EAAE,QAAQ,eAAe,MAAM,OAAO,GAAG;AACtG;AAEA,eAAe,qBAAoC;AACjD,MAAI;AACF,UAAM,MAAO,MAAM,OAAO,YAAY;AACtC,QAAI,OAAO,IAAI,qBAAqB,YAAY;AAC9C,UAAI,iBAAiB;AAAA,IACvB;AAAA,EACF,QAAQ;AAAA,EAGR;AACF;AAEA,eAAe,oBAAoE;AAEjF,QAAM,aAAS,wBAAQ;AACvB,MAAI,UAAU,OAAQ,OAA4B,SAAS,YAAY;AACrE,WAAQ,MAAO;AAAA,EACjB;AACA,SAAO;AACT;AAEA,SAAS,aAAa,GAAkD;AACtE,aAAW,OAAO,CAAC,oBAAoB,aAAa,iBAAiB,GAAG;AACtE,UAAM,IAAI,EAAE,IAAI,GAAG;AACnB,QAAI,EAAG,QAAO,EAAE,MAAM,GAAG,EAAE,CAAC,EAAE,KAAK;AAAA,EACrC;AACA,SAAO;AACT;","names":["import_server"]}
|
package/dist/app.js
CHANGED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
var DEFAULT_API_BASE = "https://api.smalk.ai/api/v1";
|
|
3
|
+
var PLUGIN_VERSION = "1.1.0";
|
|
4
|
+
function getSmalkConfig() {
|
|
5
|
+
const apiBase = process.env.SMALK_API_BASE || process.env.SMALK_API_BASE_URL || DEFAULT_API_BASE;
|
|
6
|
+
const apiKey = process.env.SMALK_API_KEY ?? "";
|
|
7
|
+
const projectKey = process.env.SMALK_PROJECT_KEY ?? "";
|
|
8
|
+
const cronSecret = process.env.SMALK_CRON_SECRET ?? "";
|
|
9
|
+
const publicHost = process.env.SMALK_PUBLIC_HOST || void 0;
|
|
10
|
+
return {
|
|
11
|
+
apiBase,
|
|
12
|
+
apiKey,
|
|
13
|
+
projectKey,
|
|
14
|
+
cronSecret,
|
|
15
|
+
pluginVersion: PLUGIN_VERSION,
|
|
16
|
+
publicHost
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function stripApiPathSuffix(apiBase) {
|
|
20
|
+
return apiBase.replace(/\/api\/v\d+\/?$/, "");
|
|
21
|
+
}
|
|
22
|
+
function loadConfig() {
|
|
23
|
+
const cfg = getSmalkConfig();
|
|
24
|
+
return { ...cfg, apiBaseUrl: stripApiPathSuffix(cfg.apiBase) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// src/freshness/route.ts
|
|
28
|
+
import { revalidatePath } from "next/cache";
|
|
29
|
+
import { NextResponse } from "next/server";
|
|
30
|
+
|
|
31
|
+
// src/freshness/activeUrlsStore.ts
|
|
32
|
+
var InMemoryActiveUrlsStore = class {
|
|
33
|
+
value = null;
|
|
34
|
+
async read() {
|
|
35
|
+
return this.value;
|
|
36
|
+
}
|
|
37
|
+
async write(payload) {
|
|
38
|
+
this.value = payload;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var FilesystemActiveUrlsStore = class {
|
|
42
|
+
constructor(filePath) {
|
|
43
|
+
this.filePath = filePath;
|
|
44
|
+
}
|
|
45
|
+
filePath;
|
|
46
|
+
async read() {
|
|
47
|
+
const fs = await import("fs/promises");
|
|
48
|
+
const path = await import("path");
|
|
49
|
+
const os = await import("os");
|
|
50
|
+
const f = this.filePath || path.join(os.tmpdir(), "smalk-active-ad-paths.json");
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(await fs.readFile(f, "utf8"));
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async write(payload) {
|
|
58
|
+
const fs = await import("fs/promises");
|
|
59
|
+
const path = await import("path");
|
|
60
|
+
const os = await import("os");
|
|
61
|
+
const f = this.filePath || path.join(os.tmpdir(), "smalk-active-ad-paths.json");
|
|
62
|
+
await fs.mkdir(path.dirname(f), { recursive: true });
|
|
63
|
+
await fs.writeFile(f, JSON.stringify(payload));
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
var VERCEL_KV_MODULE = "@vercel/kv";
|
|
67
|
+
async function loadVercelKv() {
|
|
68
|
+
return await import(
|
|
69
|
+
/* @vite-ignore */
|
|
70
|
+
/* webpackIgnore: true */
|
|
71
|
+
VERCEL_KV_MODULE
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
var VercelKVActiveUrlsStore = class {
|
|
75
|
+
key;
|
|
76
|
+
constructor(key = "smalk:active-ad-paths") {
|
|
77
|
+
this.key = key;
|
|
78
|
+
}
|
|
79
|
+
async read() {
|
|
80
|
+
const { kv } = await loadVercelKv();
|
|
81
|
+
const raw = await kv.get(this.key);
|
|
82
|
+
if (!raw) return null;
|
|
83
|
+
try {
|
|
84
|
+
return typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async write(payload) {
|
|
90
|
+
const { kv } = await loadVercelKv();
|
|
91
|
+
await kv.set(this.key, JSON.stringify(payload));
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
var CloudflareKVActiveUrlsStore = class {
|
|
95
|
+
constructor(namespace, key = "smalk:active-ad-paths") {
|
|
96
|
+
this.namespace = namespace;
|
|
97
|
+
this.key = key;
|
|
98
|
+
}
|
|
99
|
+
namespace;
|
|
100
|
+
key;
|
|
101
|
+
async read() {
|
|
102
|
+
const raw = await this.namespace.get(this.key);
|
|
103
|
+
return raw ? JSON.parse(raw) : null;
|
|
104
|
+
}
|
|
105
|
+
async write(payload) {
|
|
106
|
+
await this.namespace.put(this.key, JSON.stringify(payload));
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
var currentStore = new FilesystemActiveUrlsStore();
|
|
110
|
+
function getActiveUrlsStore() {
|
|
111
|
+
return currentStore;
|
|
112
|
+
}
|
|
113
|
+
function setActiveUrlsStore(store) {
|
|
114
|
+
currentStore = store;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/freshness/activeUrlsCache.ts
|
|
118
|
+
async function cacheActiveUrls(paths) {
|
|
119
|
+
await getActiveUrlsStore().write({ paths, updated_at: Date.now() });
|
|
120
|
+
}
|
|
121
|
+
async function readActiveUrls() {
|
|
122
|
+
return getActiveUrlsStore().read();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// src/freshness/route.ts
|
|
126
|
+
async function smalkFreshnessRevalidateHandler(req) {
|
|
127
|
+
const cfg = getSmalkConfig();
|
|
128
|
+
const secret = req.headers.get("x-smalk-cron-secret");
|
|
129
|
+
if (!secret || secret !== cfg.cronSecret) {
|
|
130
|
+
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
|
131
|
+
}
|
|
132
|
+
const endpoint = `${cfg.apiBase}/projects/${cfg.projectKey}/ads/inventory/active-ad-urls/`;
|
|
133
|
+
const resp = await fetch(endpoint, {
|
|
134
|
+
headers: {
|
|
135
|
+
"X-API-Key": cfg.apiKey,
|
|
136
|
+
"X-Smalk-CMS": "nextjs",
|
|
137
|
+
"X-Smalk-Plugin": cfg.pluginVersion
|
|
138
|
+
},
|
|
139
|
+
cache: "no-store"
|
|
140
|
+
});
|
|
141
|
+
if (!resp.ok) {
|
|
142
|
+
return NextResponse.json({ error: `api ${resp.status}` }, { status: 502 });
|
|
143
|
+
}
|
|
144
|
+
const payload = await resp.json();
|
|
145
|
+
const paths = payload.urls.map((u) => safePath(u.url, cfg.publicHost)).filter((p) => Boolean(p));
|
|
146
|
+
await cacheActiveUrls(paths);
|
|
147
|
+
let revalidated = 0;
|
|
148
|
+
for (const p of paths) {
|
|
149
|
+
try {
|
|
150
|
+
revalidatePath(p);
|
|
151
|
+
revalidated++;
|
|
152
|
+
} catch (e) {
|
|
153
|
+
console.warn(`[smalk] revalidatePath failed for ${p}`, e);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return NextResponse.json({
|
|
157
|
+
ok: true,
|
|
158
|
+
total: paths.length,
|
|
159
|
+
revalidated,
|
|
160
|
+
ran_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
function safePath(url, publicHost) {
|
|
164
|
+
try {
|
|
165
|
+
const u = new URL(url);
|
|
166
|
+
if (publicHost && u.host !== publicHost) return null;
|
|
167
|
+
return u.pathname;
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/freshness/middleware.ts
|
|
174
|
+
import { NextResponse as NextResponse2 } from "next/server";
|
|
175
|
+
var pathsCache = null;
|
|
176
|
+
var pathsLoadedAt = 0;
|
|
177
|
+
var RELOAD_INTERVAL_MS = 6e4;
|
|
178
|
+
async function getPaths() {
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
if (!pathsCache || now - pathsLoadedAt > RELOAD_INTERVAL_MS) {
|
|
181
|
+
const data = await readActiveUrls();
|
|
182
|
+
pathsCache = new Set(data?.paths ?? []);
|
|
183
|
+
pathsLoadedAt = now;
|
|
184
|
+
}
|
|
185
|
+
return pathsCache;
|
|
186
|
+
}
|
|
187
|
+
async function smalkFreshnessMiddleware(req) {
|
|
188
|
+
const paths = await getPaths();
|
|
189
|
+
if (!paths.has(req.nextUrl.pathname)) return void 0;
|
|
190
|
+
const res = NextResponse2.next();
|
|
191
|
+
const dayBucket = Math.floor(Date.now() / 864e5) * 864e5;
|
|
192
|
+
res.headers.set("Last-Modified", new Date(dayBucket).toUTCString());
|
|
193
|
+
res.headers.set("X-Smalk-Freshness", "bumped");
|
|
194
|
+
return res;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// src/index.ts
|
|
198
|
+
var DEFAULT_TIMEOUT_MS = 100;
|
|
199
|
+
var DEFAULT_REVALIDATE_S = 1200;
|
|
200
|
+
var API_PATH = "/transform/ads/content/";
|
|
201
|
+
|
|
202
|
+
export {
|
|
203
|
+
getSmalkConfig,
|
|
204
|
+
loadConfig,
|
|
205
|
+
InMemoryActiveUrlsStore,
|
|
206
|
+
FilesystemActiveUrlsStore,
|
|
207
|
+
VercelKVActiveUrlsStore,
|
|
208
|
+
CloudflareKVActiveUrlsStore,
|
|
209
|
+
getActiveUrlsStore,
|
|
210
|
+
setActiveUrlsStore,
|
|
211
|
+
cacheActiveUrls,
|
|
212
|
+
readActiveUrls,
|
|
213
|
+
smalkFreshnessRevalidateHandler,
|
|
214
|
+
smalkFreshnessMiddleware,
|
|
215
|
+
DEFAULT_TIMEOUT_MS,
|
|
216
|
+
DEFAULT_REVALIDATE_S,
|
|
217
|
+
API_PATH
|
|
218
|
+
};
|
|
219
|
+
//# sourceMappingURL=chunk-4QWOGJYF.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/config.ts","../src/freshness/route.ts","../src/freshness/activeUrlsStore.ts","../src/freshness/activeUrlsCache.ts","../src/freshness/middleware.ts","../src/index.ts"],"sourcesContent":["export interface SmalkConfig {\n /** Full API base including version path, e.g. https://api.smalk.ai/api/v1 */\n apiBase: string;\n apiKey: string;\n projectKey: string;\n cronSecret: string;\n pluginVersion: string;\n publicHost?: string;\n}\n\n/**\n * Backwards-compatible config shape for legacy callers (pre-P2). Adds an\n * `apiBaseUrl` alias for `apiBase`. New code should use `getSmalkConfig()`\n * and read `apiBase` directly.\n */\nexport interface LegacySmalkConfig extends SmalkConfig {\n /**\n * @deprecated Use `apiBase` instead.\n *\n * Pre-P2.5 shape: host-only (e.g. `https://api.smalk.ai`), and external\n * publisher code appended `/api/v1/...`. To preserve that shape after the\n * P2.5 Next config unify (which moved `/api/v1` into the canonical\n * `apiBase`), this field strips a trailing `/api/vN` segment from\n * `apiBase` before exposing it. New code should read `apiBase` directly.\n */\n apiBaseUrl: string;\n}\n\nconst DEFAULT_API_BASE = \"https://api.smalk.ai/api/v1\";\nconst PLUGIN_VERSION = \"1.1.0\";\n\nexport function getSmalkConfig(): SmalkConfig {\n const apiBase = process.env.SMALK_API_BASE || process.env.SMALK_API_BASE_URL || DEFAULT_API_BASE;\n const apiKey = process.env.SMALK_API_KEY ?? \"\";\n const projectKey = process.env.SMALK_PROJECT_KEY ?? \"\";\n const cronSecret = process.env.SMALK_CRON_SECRET ?? \"\";\n const publicHost = process.env.SMALK_PUBLIC_HOST || undefined;\n\n return {\n apiBase,\n apiKey,\n projectKey,\n cronSecret,\n pluginVersion: PLUGIN_VERSION,\n publicHost,\n };\n}\n\n/**\n * Strip a trailing `/api/vN` (or `/api/vN/`) segment from an API base URL.\n * Preserves backward compatibility with publishers building URLs like\n * `${apiBaseUrl}/api/v1/...` — without stripping, those would double-prefix\n * to `/api/v1/api/v1/...` and 404.\n */\nfunction stripApiPathSuffix(apiBase: string): string {\n return apiBase.replace(/\\/api\\/v\\d+\\/?$/, \"\");\n}\n\n/**\n * Legacy loader retained for external consumers (publisher apps) that import\n * `loadConfig` from `@smalk/nextjs-ads`. Returns the canonical `SmalkConfig`\n * plus an `apiBaseUrl` alias whose shape matches the pre-P2.5 contract\n * (host-only, no `/api/v1` suffix). New code should prefer `getSmalkConfig()`\n * and read `apiBase` directly.\n */\nexport function loadConfig(): LegacySmalkConfig {\n const cfg = getSmalkConfig();\n return { ...cfg, apiBaseUrl: stripApiPathSuffix(cfg.apiBase) };\n}\n","import { revalidatePath } from \"next/cache\";\nimport { NextRequest, NextResponse } from \"next/server\";\n\nimport { getSmalkConfig } from \"../config\";\nimport { cacheActiveUrls } from \"./activeUrlsCache\";\n\nexport async function smalkFreshnessRevalidateHandler(req: NextRequest) {\n const cfg = getSmalkConfig();\n const secret = req.headers.get(\"x-smalk-cron-secret\");\n if (!secret || secret !== cfg.cronSecret) {\n return NextResponse.json({ error: \"unauthorized\" }, { status: 401 });\n }\n\n const endpoint = `${cfg.apiBase}/projects/${cfg.projectKey}/ads/inventory/active-ad-urls/`;\n const resp = await fetch(endpoint, {\n headers: {\n \"X-API-Key\": cfg.apiKey,\n \"X-Smalk-CMS\": \"nextjs\",\n \"X-Smalk-Plugin\": cfg.pluginVersion,\n },\n cache: \"no-store\",\n });\n if (!resp.ok) {\n return NextResponse.json({ error: `api ${resp.status}` }, { status: 502 });\n }\n const payload = (await resp.json()) as { urls: { url: string }[] };\n const paths = payload.urls\n .map((u) => safePath(u.url, cfg.publicHost))\n .filter((p): p is string => Boolean(p));\n\n await cacheActiveUrls(paths);\n\n let revalidated = 0;\n for (const p of paths) {\n try {\n revalidatePath(p);\n revalidated++;\n } catch (e) {\n console.warn(`[smalk] revalidatePath failed for ${p}`, e);\n }\n }\n\n return NextResponse.json({\n ok: true,\n total: paths.length,\n revalidated,\n ran_at: new Date().toISOString(),\n });\n}\n\nfunction safePath(url: string, publicHost?: string): string | null {\n try {\n const u = new URL(url);\n if (publicHost && u.host !== publicHost) return null;\n return u.pathname;\n } catch {\n return null;\n }\n}\n","export interface ActiveUrlsCachedPayload {\n paths: string[];\n updated_at: number;\n}\n\nexport interface ActiveUrlsStore {\n read(): Promise<ActiveUrlsCachedPayload | null>;\n write(payload: ActiveUrlsCachedPayload): Promise<void>;\n}\n\n// In-memory store (per-process, fast, lost on restart — good for tests).\nexport class InMemoryActiveUrlsStore implements ActiveUrlsStore {\n private value: ActiveUrlsCachedPayload | null = null;\n async read() { return this.value; }\n async write(payload: ActiveUrlsCachedPayload) { this.value = payload; }\n}\n\n// Filesystem fallback — uses os.tmpdir() (current behavior, kept for Node dev).\nexport class FilesystemActiveUrlsStore implements ActiveUrlsStore {\n constructor(private filePath?: string) {}\n async read(): Promise<ActiveUrlsCachedPayload | null> {\n const fs = await import(\"node:fs/promises\");\n const path = await import(\"node:path\");\n const os = await import(\"node:os\");\n const f = this.filePath || path.join(os.tmpdir(), \"smalk-active-ad-paths.json\");\n try {\n return JSON.parse(await fs.readFile(f, \"utf8\"));\n } catch {\n return null;\n }\n }\n async write(payload: ActiveUrlsCachedPayload) {\n const fs = await import(\"node:fs/promises\");\n const path = await import(\"node:path\");\n const os = await import(\"node:os\");\n const f = this.filePath || path.join(os.tmpdir(), \"smalk-active-ad-paths.json\");\n await fs.mkdir(path.dirname(f), { recursive: true });\n await fs.writeFile(f, JSON.stringify(payload));\n }\n}\n\n// Vercel KV adapter — requires @vercel/kv installed by the publisher.\n// We don't bundle the dep; lazy-import via a non-literal specifier so\n// bundlers (vite/vitest/tsup/webpack) don't try to resolve it at build time.\nconst VERCEL_KV_MODULE = \"@vercel/kv\";\nasync function loadVercelKv(): Promise<{ kv: { get(k: string): Promise<unknown>; set(k: string, v: string): Promise<unknown> } }> {\n // Indirect specifier — opaque to bundler static analysis.\n return (await import(/* @vite-ignore */ /* webpackIgnore: true */ VERCEL_KV_MODULE)) as {\n kv: { get(k: string): Promise<unknown>; set(k: string, v: string): Promise<unknown> };\n };\n}\n\nexport class VercelKVActiveUrlsStore implements ActiveUrlsStore {\n private readonly key: string;\n constructor(key = \"smalk:active-ad-paths\") { this.key = key; }\n async read(): Promise<ActiveUrlsCachedPayload | null> {\n const { kv } = await loadVercelKv();\n const raw = await kv.get(this.key);\n if (!raw) return null;\n try { return typeof raw === \"string\" ? JSON.parse(raw) : (raw as ActiveUrlsCachedPayload); }\n catch { return null; }\n }\n async write(payload: ActiveUrlsCachedPayload) {\n const { kv } = await loadVercelKv();\n await kv.set(this.key, JSON.stringify(payload));\n }\n}\n\n// Cloudflare KV adapter — publisher passes the KVNamespace binding.\nexport class CloudflareKVActiveUrlsStore implements ActiveUrlsStore {\n constructor(private namespace: { get(key: string): Promise<string | null>; put(key: string, value: string): Promise<void> }, private key = \"smalk:active-ad-paths\") {}\n async read(): Promise<ActiveUrlsCachedPayload | null> {\n const raw = await this.namespace.get(this.key);\n return raw ? JSON.parse(raw) : null;\n }\n async write(payload: ActiveUrlsCachedPayload) {\n await this.namespace.put(this.key, JSON.stringify(payload));\n }\n}\n\n// Module-level default — process-wide singleton, swap with setActiveUrlsStore().\nlet currentStore: ActiveUrlsStore = new FilesystemActiveUrlsStore();\n\nexport function getActiveUrlsStore(): ActiveUrlsStore {\n return currentStore;\n}\n\nexport function setActiveUrlsStore(store: ActiveUrlsStore): void {\n currentStore = store;\n}\n","import { getActiveUrlsStore, type ActiveUrlsCachedPayload } from \"./activeUrlsStore\";\n\nexport async function cacheActiveUrls(paths: string[]) {\n await getActiveUrlsStore().write({ paths, updated_at: Date.now() });\n}\n\nexport async function readActiveUrls(): Promise<ActiveUrlsCachedPayload | null> {\n return getActiveUrlsStore().read();\n}\n\n// Re-export for publishers configuring their own store\nexport { getActiveUrlsStore, setActiveUrlsStore } from \"./activeUrlsStore\";\nexport type { ActiveUrlsStore, ActiveUrlsCachedPayload } from \"./activeUrlsStore\";\n","import { NextRequest, NextResponse } from \"next/server\";\n\nimport { readActiveUrls } from \"./activeUrlsCache\";\n\nlet pathsCache: Set<string> | null = null;\nlet pathsLoadedAt = 0;\nconst RELOAD_INTERVAL_MS = 60_000;\n\nasync function getPaths(): Promise<Set<string>> {\n const now = Date.now();\n if (!pathsCache || now - pathsLoadedAt > RELOAD_INTERVAL_MS) {\n const data = await readActiveUrls();\n pathsCache = new Set(data?.paths ?? []);\n pathsLoadedAt = now;\n }\n return pathsCache;\n}\n\nexport async function smalkFreshnessMiddleware(req: NextRequest): Promise<NextResponse | undefined> {\n const paths = await getPaths();\n if (!paths.has(req.nextUrl.pathname)) return undefined;\n const res = NextResponse.next();\n // Day-bucket stable Last-Modified so real users with If-Modified-Since\n // still get 304s within the day; AI crawlers (which hit infrequently)\n // get a fresh signal on the next UTC day rollover.\n const dayBucket = Math.floor(Date.now() / 86_400_000) * 86_400_000;\n res.headers.set(\"Last-Modified\", new Date(dayBucket).toUTCString());\n res.headers.set(\"X-Smalk-Freshness\", \"bumped\");\n return res;\n}\n","export interface AdInput {\n pageUrl: string;\n userAgent: string;\n referer: string;\n clientIp: string;\n}\n\nexport interface AdResult {\n html: string;\n bookingId: string | null;\n}\n\nexport interface ApiResponse {\n html?: string;\n booking_id?: string;\n metadata?: Record<string, unknown>;\n}\n\nexport const DEFAULT_TIMEOUT_MS = 100;\nexport const DEFAULT_REVALIDATE_S = 1200;\nexport const API_PATH = '/transform/ads/content/';\n\nexport { getSmalkConfig, loadConfig } from './config';\nexport type { SmalkConfig, LegacySmalkConfig } from './config';\n\n/** @deprecated Use `SmalkConfig` from `./config`. Kept as alias for legacy imports. */\nexport type { LegacySmalkConfig as SmalkAdsConfig } from './config';\n\nexport { smalkFreshnessRevalidateHandler } from \"./freshness/route\";\nexport { smalkFreshnessMiddleware } from \"./freshness/middleware\";\nexport { cacheActiveUrls, readActiveUrls } from \"./freshness/activeUrlsCache\";\nexport {\n InMemoryActiveUrlsStore,\n FilesystemActiveUrlsStore,\n VercelKVActiveUrlsStore,\n CloudflareKVActiveUrlsStore,\n getActiveUrlsStore,\n setActiveUrlsStore,\n} from \"./freshness/activeUrlsStore\";\nexport type { ActiveUrlsStore, ActiveUrlsCachedPayload } from \"./freshness/activeUrlsStore\";\n"],"mappings":"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","names":["NextResponse","NextResponse"]}
|
|
@@ -2,13 +2,13 @@ import {
|
|
|
2
2
|
API_PATH,
|
|
3
3
|
DEFAULT_REVALIDATE_S,
|
|
4
4
|
DEFAULT_TIMEOUT_MS,
|
|
5
|
-
|
|
6
|
-
} from "./chunk-
|
|
5
|
+
getSmalkConfig
|
|
6
|
+
} from "./chunk-4QWOGJYF.js";
|
|
7
7
|
|
|
8
8
|
// src/client.ts
|
|
9
9
|
var lastHashByUrl = /* @__PURE__ */ new Map();
|
|
10
10
|
async function fetchAd(input, opts = {}) {
|
|
11
|
-
const cfg =
|
|
11
|
+
const cfg = getSmalkConfig();
|
|
12
12
|
if (!cfg.projectKey || !cfg.apiKey) return null;
|
|
13
13
|
const controller = new AbortController();
|
|
14
14
|
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
|
@@ -23,7 +23,7 @@ async function fetchAd(input, opts = {}) {
|
|
|
23
23
|
if (opts.preview !== void 0 && opts.preview !== false) {
|
|
24
24
|
body.preview = opts.preview;
|
|
25
25
|
}
|
|
26
|
-
const res = await fetch(`${cfg.
|
|
26
|
+
const res = await fetch(`${cfg.apiBase}${API_PATH}`, {
|
|
27
27
|
method: "POST",
|
|
28
28
|
headers: {
|
|
29
29
|
"Authorization": `Api-Key ${cfg.apiKey}`,
|
|
@@ -89,4 +89,4 @@ async function sha256Hex(input) {
|
|
|
89
89
|
export {
|
|
90
90
|
fetchAd
|
|
91
91
|
};
|
|
92
|
-
//# sourceMappingURL=chunk-
|
|
92
|
+
//# sourceMappingURL=chunk-INI3DN37.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/client.ts"],"sourcesContent":["import { getSmalkConfig } from './config';\nimport { AdInput, AdResult, ApiResponse, API_PATH, DEFAULT_TIMEOUT_MS, DEFAULT_REVALIDATE_S } from './index';\n\nconst lastHashByUrl = new Map<string, string>();\n\nexport function _resetHashCacheForTests(): void {\n lastHashByUrl.clear();\n}\n\nexport interface FetchAdOptions {\n revalidate?: number;\n /** Preview mode: `true` (PARAGRAPH placeholder) or a format string (e.g. `'FAQ'`). Bypasses booking lookup. */\n preview?: boolean | string;\n}\n\nexport async function fetchAd(input: AdInput, opts: FetchAdOptions = {}): Promise<AdResult | null> {\n const cfg = getSmalkConfig();\n if (!cfg.projectKey || !cfg.apiKey) return null;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);\n\n try {\n const body: Record<string, unknown> = {\n project_key: cfg.projectKey,\n page_url: input.pageUrl,\n user_agent: input.userAgent,\n referer: input.referer,\n client_ip: input.clientIp,\n };\n if (opts.preview !== undefined && opts.preview !== false) {\n body.preview = opts.preview;\n }\n const res = await fetch(`${cfg.apiBase}${API_PATH}`, {\n method: 'POST',\n headers: {\n 'Authorization': `Api-Key ${cfg.apiKey}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n signal: controller.signal,\n // `next` is a Next.js fetch extension; type provided by next types when present.\n next: {\n revalidate: opts.revalidate ?? DEFAULT_REVALIDATE_S,\n tags: ['smalk-ad', `smalk-ad:${input.pageUrl}`],\n },\n } as RequestInit);\n\n if (!res.ok) {\n logErrorOnce(`smalk-ads fetch failed: HTTP ${res.status}`);\n return null;\n }\n\n const data = (await res.json()) as ApiResponse;\n if (!data.html) return null;\n\n const hash = await sha256Hex(data.html);\n const prev = lastHashByUrl.get(input.pageUrl);\n if (prev && prev !== hash) {\n await maybeRevalidatePath(input.pageUrl);\n }\n lastHashByUrl.set(input.pageUrl, hash);\n\n return { html: data.html, bookingId: data.booking_id ?? null };\n } catch (err) {\n if (err instanceof Error && err.name !== 'AbortError') {\n logErrorOnce(`smalk-ads fetch error: ${err.message}`);\n }\n return null;\n } finally {\n clearTimeout(timer);\n }\n}\n\nasync function maybeRevalidatePath(pageUrl: string): Promise<void> {\n try {\n const mod = await import('next/cache');\n if (typeof mod.revalidatePath === 'function') {\n const pathname = new URL(pageUrl).pathname;\n mod.revalidatePath(pathname);\n }\n } catch {\n // next/cache not available (Pages Router runtime, edge, or test env without next).\n }\n}\n\nconst seenErrors = new Set<string>();\nfunction logErrorOnce(msg: string): void {\n if (seenErrors.has(msg)) return;\n seenErrors.add(msg);\n console.error(`[smalk-nextjs-ads] ${msg}`);\n}\n\nasync function sha256Hex(input: string): Promise<string> {\n const data = new TextEncoder().encode(input);\n const buf = await globalThis.crypto.subtle.digest('SHA-256', data);\n const bytes = new Uint8Array(buf);\n let hex = '';\n for (let i = 0; i < bytes.length; i++) {\n hex += bytes[i].toString(16).padStart(2, '0');\n }\n return hex;\n}\n"],"mappings":";;;;;;;;AAGA,IAAM,gBAAgB,oBAAI,IAAoB;AAY9C,eAAsB,QAAQ,OAAgB,OAAuB,CAAC,GAA6B;AACjG,QAAM,MAAM,eAAe;AAC3B,MAAI,CAAC,IAAI,cAAc,CAAC,IAAI,OAAQ,QAAO;AAE3C,QAAM,aAAa,IAAI,gBAAgB;AACvC,QAAM,QAAQ,WAAW,MAAM,WAAW,MAAM,GAAG,kBAAkB;AAErE,MAAI;AACF,UAAM,OAAgC;AAAA,MACpC,aAAa,IAAI;AAAA,MACjB,UAAU,MAAM;AAAA,MAChB,YAAY,MAAM;AAAA,MAClB,SAAS,MAAM;AAAA,MACf,WAAW,MAAM;AAAA,IACnB;AACA,QAAI,KAAK,YAAY,UAAa,KAAK,YAAY,OAAO;AACxD,WAAK,UAAU,KAAK;AAAA,IACtB;AACA,UAAM,MAAM,MAAM,MAAM,GAAG,IAAI,OAAO,GAAG,QAAQ,IAAI;AAAA,MACnD,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,iBAAiB,WAAW,IAAI,MAAM;AAAA,QACtC,gBAAgB;AAAA,MAClB;AAAA,MACA,MAAM,KAAK,UAAU,IAAI;AAAA,MACzB,QAAQ,WAAW;AAAA;AAAA,MAEnB,MAAM;AAAA,QACJ,YAAY,KAAK,cAAc;AAAA,QAC/B,MAAM,CAAC,YAAY,YAAY,MAAM,OAAO,EAAE;AAAA,MAChD;AAAA,IACF,CAAgB;AAEhB,QAAI,CAAC,IAAI,IAAI;AACX,mBAAa,gCAAgC,IAAI,MAAM,EAAE;AACzD,aAAO;AAAA,IACT;AAEA,UAAM,OAAQ,MAAM,IAAI,KAAK;AAC7B,QAAI,CAAC,KAAK,KAAM,QAAO;AAEvB,UAAM,OAAO,MAAM,UAAU,KAAK,IAAI;AACtC,UAAM,OAAO,cAAc,IAAI,MAAM,OAAO;AAC5C,QAAI,QAAQ,SAAS,MAAM;AACzB,YAAM,oBAAoB,MAAM,OAAO;AAAA,IACzC;AACA,kBAAc,IAAI,MAAM,SAAS,IAAI;AAErC,WAAO,EAAE,MAAM,KAAK,MAAM,WAAW,KAAK,cAAc,KAAK;AAAA,EAC/D,SAAS,KAAK;AACZ,QAAI,eAAe,SAAS,IAAI,SAAS,cAAc;AACrD,mBAAa,0BAA0B,IAAI,OAAO,EAAE;AAAA,IACtD;AACA,WAAO;AAAA,EACT,UAAE;AACA,iBAAa,KAAK;AAAA,EACpB;AACF;AAEA,eAAe,oBAAoB,SAAgC;AACjE,MAAI;AACF,UAAM,MAAM,MAAM,OAAO,YAAY;AACrC,QAAI,OAAO,IAAI,mBAAmB,YAAY;AAC5C,YAAM,WAAW,IAAI,IAAI,OAAO,EAAE;AAClC,UAAI,eAAe,QAAQ;AAAA,IAC7B;AAAA,EACF,QAAQ;AAAA,EAER;AACF;AAEA,IAAM,aAAa,oBAAI,IAAY;AACnC,SAAS,aAAa,KAAmB;AACvC,MAAI,WAAW,IAAI,GAAG,EAAG;AACzB,aAAW,IAAI,GAAG;AAClB,UAAQ,MAAM,sBAAsB,GAAG,EAAE;AAC3C;AAEA,eAAe,UAAU,OAAgC;AACvD,QAAM,OAAO,IAAI,YAAY,EAAE,OAAO,KAAK;AAC3C,QAAM,MAAM,MAAM,WAAW,OAAO,OAAO,OAAO,WAAW,IAAI;AACjE,QAAM,QAAQ,IAAI,WAAW,GAAG;AAChC,MAAI,MAAM;AACV,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACrC,WAAO,MAAM,CAAC,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG;AAAA,EAC9C;AACA,SAAO;AACT;","names":[]}
|