@wavebirdai/api 0.0.0-stage → 0.1.0-beta.1

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.
Files changed (35) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +55 -2
  3. package/bin/wavebird-next.mjs +67 -0
  4. package/dist/browser.d.ts +27 -0
  5. package/dist/browser.js +194 -0
  6. package/dist/generation.js +96 -0
  7. package/dist/server.d.ts +38 -0
  8. package/dist/server.js +349 -0
  9. package/examples/next-js/.env.example +6 -0
  10. package/examples/next-js/app/api/generate/route.js +6 -0
  11. package/examples/next-js/app/api/sponsor-consent/route.js +4 -0
  12. package/examples/next-js/app/api/sponsor-slot/route.js +3 -0
  13. package/examples/next-js/app/layout.jsx +3 -0
  14. package/examples/next-js/app/page.jsx +59 -0
  15. package/examples/next-js/lib/auth.js +12 -0
  16. package/examples/next-js/lib/model.js +3 -0
  17. package/examples/next-js/lib/publisher.js +30 -0
  18. package/examples/next-js/package.json +7 -0
  19. package/examples/next-ts/.env.example +6 -0
  20. package/examples/next-ts/app/api/generate/route.ts +6 -0
  21. package/examples/next-ts/app/api/sponsor-consent/route.ts +4 -0
  22. package/examples/next-ts/app/api/sponsor-slot/route.ts +3 -0
  23. package/examples/next-ts/app/layout.tsx +5 -0
  24. package/examples/next-ts/app/page.tsx +60 -0
  25. package/examples/next-ts/lib/auth.ts +12 -0
  26. package/examples/next-ts/lib/model.ts +3 -0
  27. package/examples/next-ts/lib/publisher.ts +30 -0
  28. package/examples/next-ts/package.json +8 -0
  29. package/examples/next-ts/tsconfig.json +20 -0
  30. package/package.json +21 -4
  31. package/templates/NEXT-SETUP.md +11 -0
  32. package/templates/WavebirdPlacement.jsx +31 -0
  33. package/templates/WavebirdPlacement.tsx +36 -0
  34. package/templates/model.js +3 -0
  35. package/templates/model.ts +3 -0
package/dist/server.js ADDED
@@ -0,0 +1,349 @@
1
+ import { generationTransport } from "./generation.js";
2
+
3
+ const PREFERENCE_VERSION = "contextual-choice/v2";
4
+ const PREFERENCE_TTL = 30 * 24 * 60 * 60;
5
+ const MAX_PUBLISHER_BODY_BYTES = 16 * 1024;
6
+ const MAX_WAVEBIRD_RESPONSE_BYTES = 256 * 1024;
7
+ const MAX_PARALLEL_WAVEBIRD_REQUESTS = 2;
8
+ const ID = /^[A-Za-z0-9_-]{1,128}$/;
9
+ const JOB_ID = /^job_[A-Za-z0-9_-]{1,128}$/;
10
+ const PLACEMENT_KEY = /^[a-z0-9][a-z0-9_-]{0,63}$/;
11
+
12
+ function response(error, status, headers = {}) {
13
+ return Response.json({ error }, { status, headers: { "Cache-Control": "no-store", ...headers } });
14
+ }
15
+
16
+ function normalizeOptions(options) {
17
+ if (typeof window !== "undefined") throw new Error("publisher_server_only");
18
+ if (!options || typeof options.authorize !== "function") throw new Error("publisher_authorize_required");
19
+ if (!String(options.clientId || "").startsWith("wbproj_")) throw new Error("publisher_project_invalid");
20
+ if (!String(options.secretKey || "").trim()) throw new Error("publisher_server_key_required");
21
+ if (!ID.test(options.surfaceId || "") || !PLACEMENT_KEY.test(options.placementKey || "")) throw new Error("publisher_placement_invalid");
22
+ if (!["before", "during", "after"].includes(options.timing)) throw new Error("publisher_timing_invalid");
23
+ if (!["wavebird_consent", "own_cmp"].includes(options.consentMode)) throw new Error("publisher_consent_mode_invalid");
24
+ if (!Array.isArray(options.allowedFormats) || options.allowedFormats.length === 0 || options.allowedFormats.some(value => !["banner", "native"].includes(value))) throw new Error("publisher_formats_invalid");
25
+ if (!["above", "below", "sidebar", "between"].includes(options.positionHint)) throw new Error("publisher_position_invalid");
26
+ if (!Number.isSafeInteger(options.maxWidth) || options.maxWidth < 1 || !Number.isSafeInteger(options.maxHeight) || options.maxHeight < 1) throw new Error("publisher_envelope_invalid");
27
+ if (!Array.isArray(options.allowedSizes) || options.allowedSizes.length === 0 || options.allowedSizes.some(size => !Number.isSafeInteger(size.width) || !Number.isSafeInteger(size.height) || size.width < 1 || size.height < 1 || size.width > options.maxWidth || size.height > options.maxHeight ||
28
+ (size.formats !== undefined && (!Array.isArray(size.formats) || size.formats.length === 0 || size.formats.some(format => !options.allowedFormats.includes(format)))))) throw new Error("publisher_sizes_invalid");
29
+ if (options.responsiveSizeSelection !== undefined && typeof options.responsiveSizeSelection !== "boolean") throw new Error("publisher_responsive_selection_invalid");
30
+ if (!options.publisher || typeof options.publisher.app_name !== "string" || typeof options.publisher.app_domain !== "string" || !Array.isArray(options.publisher.categories)) throw new Error("publisher_identity_invalid");
31
+ if (typeof options.jobType !== "string" || !options.jobType || typeof options.locale !== "string" || !options.locale) throw new Error("publisher_job_invalid");
32
+ const base = String(options.apiBaseUrl || "https://api.wavebird.ai").trim().replace(/\/+$/, "").replace(/\/v1$/, "") + "/v1";
33
+ if (!/^https:\/\//.test(base)) throw new Error("publisher_api_https_required");
34
+ {
35
+ if (!Array.isArray(options.publicOrigins) || options.publicOrigins.length < 1 || options.publicOrigins.length > 8) throw new Error("publisher_public_origins_invalid");
36
+ for (const origin of options.publicOrigins) {
37
+ let url;
38
+ try { url = new URL(origin); } catch { throw new Error("publisher_public_origins_invalid"); }
39
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1";
40
+ if (url.origin !== origin || (url.protocol !== "https:" && !(loopback && url.protocol === "http:"))) throw new Error("publisher_public_origins_invalid");
41
+ }
42
+ if (new Set(options.publicOrigins).size !== options.publicOrigins.length) throw new Error("publisher_public_origins_invalid");
43
+ }
44
+ return { ...options, base, consentSource: options.consentMode === "wavebird_consent" ? "wavebird_consent" : "wrapper_cmp" };
45
+ }
46
+
47
+ function publicOrigin(request, options) {
48
+ const host = request.headers.get("host")?.toLowerCase();
49
+ if (!host) return null;
50
+ const matches = options.publicOrigins.filter(origin => new URL(origin).host.toLowerCase() === host);
51
+ const browserOrigin = request.headers.get("origin");
52
+ if (browserOrigin) return matches.includes(browserOrigin) ? browserOrigin : null;
53
+ if (matches.length === 1) return matches[0];
54
+ const routeOrigin = new URL(request.url).origin;
55
+ return matches.includes(routeOrigin) ? routeOrigin : null;
56
+ }
57
+
58
+ function validOrigin(request, options, strict = true) {
59
+ const origin = request.headers.get("origin");
60
+ const site = request.headers.get("sec-fetch-site");
61
+ const expected = publicOrigin(request, options);
62
+ return expected !== null && (strict ? origin === expected : !origin || origin === expected) &&
63
+ (strict ? site !== "cross-site" : !site || site === "same-origin");
64
+ }
65
+
66
+ function gpc(request) {
67
+ return (request.headers.get("sec-gpc") || "").split(",").some(value => value.trim() === "1");
68
+ }
69
+
70
+ function proxyResult(upstream, body, extra = {}) {
71
+ return new Response(body || null, { status: upstream.status, headers: {
72
+ "Content-Type": upstream.headers.get("content-type") || "application/json",
73
+ "Cache-Control": "no-store",
74
+ ...(upstream.headers.get("x-request-id") ? { "x-request-id": upstream.headers.get("x-request-id") } : {}),
75
+ ...extra
76
+ } });
77
+ }
78
+
79
+ async function readBoundedText(value, limit, signal) {
80
+ if (!value.body) return "";
81
+ const reader = value.body.getReader();
82
+ const decoder = new TextDecoder();
83
+ let bytes = 0;
84
+ let output = "";
85
+ const cancel = () => { void reader.cancel().catch(() => {}); };
86
+ signal.addEventListener("abort", cancel, { once: true });
87
+ try {
88
+ while (true) {
89
+ if (signal.aborted) throw new Error("body_timeout");
90
+ const next = await reader.read();
91
+ if (signal.aborted) throw new Error("body_timeout");
92
+ if (next.done) break;
93
+ bytes += next.value.byteLength;
94
+ if (bytes > limit) { cancel(); throw new Error("body_limit_exceeded"); }
95
+ output += decoder.decode(next.value, { stream: true });
96
+ }
97
+ return output + decoder.decode();
98
+ } finally {
99
+ signal.removeEventListener("abort", cancel);
100
+ reader.releaseLock();
101
+ }
102
+ }
103
+
104
+ async function readPublisherJson(request) {
105
+ const controller = new AbortController();
106
+ const timer = setTimeout(() => controller.abort(), 1500);
107
+ try { return JSON.parse(await readBoundedText(request, MAX_PUBLISHER_BODY_BYTES, controller.signal)); }
108
+ catch (error) {
109
+ const reason = error instanceof Error ? error.message : "invalid_json";
110
+ return response(reason, reason === "body_limit_exceeded" ? 413 : controller.signal.aborted ? 408 : 400);
111
+ } finally { clearTimeout(timer); }
112
+ }
113
+
114
+ function boundedFetcher() {
115
+ let active = 0;
116
+ return async (url, init) => {
117
+ if (active >= MAX_PARALLEL_WAVEBIRD_REQUESTS) return response("wavebird_capacity_exceeded", 429);
118
+ active++;
119
+ const controller = new AbortController();
120
+ const abort = () => controller.abort();
121
+ init.signal?.addEventListener("abort", abort, { once: true });
122
+ if (init.signal?.aborted) abort();
123
+ let timer;
124
+ const deadline = new Promise((_, reject) => {
125
+ timer = setTimeout(() => { abort(); reject(new Error("upstream_timeout")); }, 5000);
126
+ });
127
+ try {
128
+ return await Promise.race([deadline, (async () => {
129
+ const upstream = await fetch(url, { ...init, signal: controller.signal, redirect: "error", cache: "no-store" });
130
+ const body = await readBoundedText(upstream, MAX_WAVEBIRD_RESPONSE_BYTES, controller.signal);
131
+ return proxyResult(upstream, body);
132
+ })()]);
133
+ } finally {
134
+ controller.abort();
135
+ clearTimeout(timer);
136
+ init.signal?.removeEventListener("abort", abort);
137
+ active--;
138
+ }
139
+ };
140
+ }
141
+
142
+ function boundedGenerationReporter() {
143
+ let active = 0;
144
+ return async (url, init) => {
145
+ if (active >= MAX_PARALLEL_WAVEBIRD_REQUESTS) throw new Error("wavebird_capacity_exceeded");
146
+ active++;
147
+ const controller = new AbortController();
148
+ let timer;
149
+ const deadline = new Promise((_, reject) => {
150
+ timer = setTimeout(() => { controller.abort(); reject(new Error("generation_report_timeout")); }, 5000);
151
+ });
152
+ try {
153
+ await Promise.race([deadline, (async () => {
154
+ const upstream = await fetch(url, { ...init, signal: controller.signal, redirect: "error", cache: "no-store" });
155
+ await upstream.body?.cancel();
156
+ if (!upstream.ok) throw new Error(`generation_report_http_${upstream.status}`);
157
+ })()]);
158
+ } finally {
159
+ controller.abort();
160
+ clearTimeout(timer);
161
+ active--;
162
+ }
163
+ };
164
+ }
165
+
166
+ function observeGeneration(response, request, onLifecycle) {
167
+ if (typeof onLifecycle !== "function" || response.headers.get("x-wavebird-generation-transport") !== "1" || !response.body) return response;
168
+ const reader = response.body.getReader();
169
+ const decoder = new TextDecoder();
170
+ let terminal = false;
171
+ const notify = event => {
172
+ try { Promise.resolve(onLifecycle(event)).catch(() => {}); } catch {}
173
+ };
174
+ const body = new ReadableStream({
175
+ async pull(controller) {
176
+ try {
177
+ const next = await reader.read();
178
+ if (next.done) { controller.close(); return; }
179
+ // The canonical transport emits one complete JSONL record per chunk.
180
+ try {
181
+ const record = JSON.parse(decoder.decode(next.value));
182
+ if (["started", "done", "error", "abort"].includes(record.type)) {
183
+ if (record.type !== "started") terminal = true;
184
+ notify({ type: record.type, sessionId: record.session_id, generationId: record.generation_id });
185
+ }
186
+ } catch {}
187
+ controller.enqueue(next.value);
188
+ } catch (error) { controller.error(error); }
189
+ },
190
+ async cancel(reason) {
191
+ if (!terminal) {
192
+ terminal = true;
193
+ notify({ type: "abort", sessionId: request.headers.get("x-wavebird-session-id"), generationId: request.headers.get("x-wavebird-generation-id") });
194
+ }
195
+ try { await reader.cancel(reason); } catch {}
196
+ }
197
+ }, { highWaterMark: 1 });
198
+ return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
199
+ }
200
+
201
+ export function createPublisherServer(inputOptions) {
202
+ const options = normalizeOptions(inputOptions);
203
+ const placementFetch = boundedFetcher();
204
+ const consentFetch = boundedFetcher();
205
+ const generationReportFetch = boundedGenerationReporter();
206
+ const cookieName = `__Host-wavebird-preference-${options.clientId}-${options.surfaceId}`;
207
+ const cookie = (value, ttl = PREFERENCE_TTL) => `${cookieName}=${value}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${ttl}`;
208
+ const scope = request => ({ project: options.clientId, surface: options.surfaceId, version: PREFERENCE_VERSION,
209
+ profile: options.consentMode === "wavebird_consent" ? "wavebird_dialog" : "publisher_custom", origin: publicOrigin(request, options) });
210
+ async function preferenceKey() {
211
+ if (!options.preferenceSecret || options.preferenceSecret.length < 32) throw new Error("preference_secret_required");
212
+ return crypto.subtle.importKey("raw", new TextEncoder().encode(options.preferenceSecret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
213
+ }
214
+ async function signPreference(request, state) {
215
+ const now = Date.now();
216
+ const payload = Buffer.from(JSON.stringify({ ...scope(request), state, issued_at: now, expires_at: now + PREFERENCE_TTL * 1000 })).toString("base64url");
217
+ const signature = await crypto.subtle.sign("HMAC", await preferenceKey(), new TextEncoder().encode(payload));
218
+ return cookie(payload + "." + Buffer.from(signature).toString("base64url"));
219
+ }
220
+ async function authorized(request) {
221
+ try { return await options.authorize(request) === true; } catch { return false; }
222
+ }
223
+ async function placementPOST(request) {
224
+ if (!await authorized(request)) return response("authentication_required", 401);
225
+ if (gpc(request)) return response("privacy_signal_policy_required", 403);
226
+ if (!validOrigin(request, options)) return response("invalid_origin", 403);
227
+ const qaOutcomes = new URL(request.url).searchParams.getAll("qa_test_outcome");
228
+ if (qaOutcomes.length > 1 || (qaOutcomes.length === 1 && qaOutcomes[0] !== "no_fill")) return response("invalid_qa_test_outcome", 400);
229
+ if (qaOutcomes.length && !options.secretKey.startsWith("sk_test_")) return response("qa_test_outcome_requires_test_key", 403);
230
+ const input = await readPublisherJson(request);
231
+ if (input instanceof Response) return input;
232
+ let allowedSizes = options.allowedSizes.map(({ width, height }) => ({ width, height }));
233
+ let allowedFormats = options.allowedFormats;
234
+ let maxWidth = options.maxWidth;
235
+ let maxHeight = options.maxHeight;
236
+ if (options.responsiveSizeSelection === true) {
237
+ const bounds = input?.container_size;
238
+ if (!bounds || !Number.isSafeInteger(bounds.width) || !Number.isSafeInteger(bounds.height) ||
239
+ bounds.width < 1 || bounds.height < 1 || bounds.width > 8192 || bounds.height > 8192) {
240
+ return response("publisher_container_size_invalid", 400);
241
+ }
242
+ const requestedFormat = input?.format ?? "auto";
243
+ if (requestedFormat !== "auto" && !options.allowedFormats.includes(requestedFormat)) return response("publisher_format_invalid", 400);
244
+ const fitting = options.allowedSizes.filter(size => size.width <= bounds.width && size.height <= bounds.height &&
245
+ (requestedFormat === "auto" || (size.formats ?? options.allowedFormats).includes(requestedFormat)))
246
+ .sort((a, b) => b.width - a.width || b.height - a.height);
247
+ if (fitting.length === 0) return Response.json({ decision: { fill: false, reason: "no_fit" }, placement: null }, { headers: { "Cache-Control": "no-store" } });
248
+ const selected = fitting[0];
249
+ allowedSizes = [{ width: selected.width, height: selected.height }];
250
+ allowedFormats = requestedFormat === "auto"
251
+ ? options.allowedFormats.filter(format => fitting.some(size => size.width === selected.width && size.height === selected.height && (size.formats ?? options.allowedFormats).includes(format)))
252
+ : [requestedFormat];
253
+ maxWidth = Math.min(maxWidth, bounds.width);
254
+ maxHeight = Math.min(maxHeight, bounds.height);
255
+ }
256
+ const sessionId = typeof input?.session_id === "string" && input.session_id.trim() ? input.session_id.trim() : crypto.randomUUID();
257
+ const body = {
258
+ client_id: options.clientId, session_id: sessionId, job_type: options.jobType, locale: options.locale,
259
+ slots_requested: 1, placement_key: options.placementKey,
260
+ slot_hint: { position: options.positionHint, max_width: maxWidth, max_height: maxHeight,
261
+ allowed_sizes: allowedSizes, presentation_version: "sponsored_moment_v2" },
262
+ overrides: { allowed_formats: allowedFormats, timing: options.timing,
263
+ bidfloor: options.bidfloor ?? 0.5, bidfloor_currency: options.bidfloorCurrency ?? "EUR",
264
+ gdpr_applies: options.gdprApplies === true,
265
+ publisher: { app_name: options.publisher.app_name, app_domain: options.publisher.app_domain,
266
+ ...(options.publisher.categories.length ? { categories: options.publisher.categories } : {}) },
267
+ ...(options.blockedCategories?.length ? { blocked_categories: options.blockedCategories } : {}) },
268
+ consent: { semantic_targeting: false, prompt_shared: false, gdpr_applies: options.gdprApplies === true,
269
+ consent_source: options.consentSource }
270
+ };
271
+ try {
272
+ return await placementFetch(`${options.base}/placements?wait_ms=1500${qaOutcomes.length ? "&test_outcome=no_fill" : ""}`, { method: "POST", headers: {
273
+ Authorization: `Bearer ${options.secretKey}`, "Content-Type": "application/json"
274
+ }, body: JSON.stringify(body), cache: "no-store" });
275
+ } catch { return response("wavebird_placement_unavailable", 502); }
276
+ }
277
+ async function consentGET(request) {
278
+ if (!await authorized(request)) return response("authentication_required", 401);
279
+ const headers = { "Cache-Control": "no-store", Vary: "Cookie, Sec-GPC" };
280
+ if (!validOrigin(request, options, false)) return response("invalid_origin", 403, headers);
281
+ if (gpc(request)) return Response.json({ preference: null }, { headers });
282
+ try {
283
+ const value = (request.headers.get("cookie") || "").split(";").map(value => value.trim())
284
+ .find(value => value.startsWith(cookieName + "="))?.slice(cookieName.length + 1) || "";
285
+ if (value.length > 2048) throw new Error("preference_invalid");
286
+ const [payload, signature, extra] = value.split(".");
287
+ if (!payload || !signature || extra || !/^[A-Za-z0-9_-]+$/.test(payload) || !/^[A-Za-z0-9_-]+$/.test(signature)) return Response.json({ preference: null }, { headers });
288
+ if (!await crypto.subtle.verify("HMAC", await preferenceKey(), Buffer.from(signature, "base64url"), new TextEncoder().encode(payload))) return Response.json({ preference: null }, { headers });
289
+ const record = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
290
+ const now = Date.now();
291
+ if (Object.entries(scope(request)).some(([key, value]) => record[key] !== value) ||
292
+ !["granted", "denied", "withdrawn"].includes(record.state) || !Number.isSafeInteger(record.issued_at) ||
293
+ !Number.isSafeInteger(record.expires_at) || record.issued_at > now || record.expires_at <= now ||
294
+ record.expires_at - record.issued_at !== PREFERENCE_TTL * 1000) return Response.json({ preference: null }, { headers });
295
+ return Response.json({ preference: { state: record.state, expires_at: record.expires_at } }, { headers });
296
+ } catch { return Response.json({ preference: null, error: "preference_unavailable" }, { headers }); }
297
+ }
298
+ async function consentPOST(request) {
299
+ if (!await authorized(request)) return response("authentication_required", 401);
300
+ if (!validOrigin(request, options)) return response("invalid_origin", 403);
301
+ if (gpc(request)) return response("privacy_signal_policy_required", 403);
302
+ const input = await readPublisherJson(request);
303
+ if (input instanceof Response) return input;
304
+ if (!input || typeof input.session_id !== "string" || !input.session_id.trim() ||
305
+ !["granted", "denied", "withdrawn", "cmp_error"].includes(input.lifecycle_state) ||
306
+ !Number.isSafeInteger(input.revision) || input.revision < 1) return response("consent_input_invalid", 400);
307
+ if (["tcf_string", "gpp_string", "gpp_sections"].some(field => Object.hasOwn(input, field))) return response("external_consent_protocol_requires_policy", 400);
308
+ const clear = input.remember_for_30_days === true ? { "Set-Cookie": cookie("", 0) } : {};
309
+ if (input.remember_for_30_days === true) {
310
+ try { await preferenceKey(); } catch { return response("preference_unavailable", 503, clear); }
311
+ }
312
+ let upstream;
313
+ let text;
314
+ try {
315
+ upstream = await consentFetch(options.base + "/consent", { method: "POST", headers: {
316
+ Authorization: `Bearer ${options.secretKey}`, "Content-Type": "application/json"
317
+ }, cache: "no-store", body: JSON.stringify({
318
+ client_id: options.clientId, session_id: input.session_id.trim(),
319
+ decision: options.consentMode === "wavebird_consent" ? "custom" : "basic",
320
+ source: options.consentMode === "wavebird_consent" ? "wavebird_dialog" : "publisher_custom",
321
+ lifecycle_state: input.lifecycle_state, revision: input.revision,
322
+ purposes: { semantic_targeting: false, session_persistence: false, cross_session_persistence: false, prompt_shared: false }
323
+ }) });
324
+ text = await upstream.text();
325
+ } catch { return response("consent_sync_unavailable", 502, clear); }
326
+ let setCookie = clear;
327
+ if (upstream.ok && input.remember_for_30_days === true && input.lifecycle_state !== "cmp_error") {
328
+ let snapshot;
329
+ try { snapshot = JSON.parse(text).authoritative_consent; } catch {}
330
+ if (snapshot?.lifecycle_state !== input.lifecycle_state || snapshot.revision !== input.revision) return response("consent_snapshot_invalid", 502, clear);
331
+ try { setCookie = { "Set-Cookie": await signPreference(request, input.lifecycle_state) }; }
332
+ catch { return response("preference_unavailable", 503, clear); }
333
+ }
334
+ return proxyResult(upstream, text, setCookie);
335
+ }
336
+ async function withGeneration({ request, run, onLifecycle }) {
337
+ if (!await authorized(request)) return response("authentication_required", 401);
338
+ if (!validOrigin(request, options)) return response("invalid_origin", 403);
339
+ const result = await generationTransport({ request, run });
340
+ return observeGeneration(result, request, onLifecycle);
341
+ }
342
+ async function reportGeneration(jobId, event) {
343
+ if (!JOB_ID.test(jobId) || !["started", "finished", "failed"].includes(event)) throw new Error("invalid_generation_report");
344
+ await generationReportFetch(`${options.base}/jobs/${encodeURIComponent(jobId)}/generation/${event}`, {
345
+ method: "POST", headers: { Authorization: `Bearer ${options.secretKey}`, "Content-Type": "application/json" }, body: "{}"
346
+ });
347
+ }
348
+ return { placementPOST, consentGET, consentPOST, withGeneration, reportGeneration };
349
+ }
@@ -0,0 +1,6 @@
1
+ WAVEBIRD_API_BASE_URL=https://api.wavebird.ai
2
+ WAVEBIRD_CLIENT_ID=wbproj_replace_from_dashboard
3
+ WAVEBIRD_SECRET_KEY=sk_test_replace_from_dashboard
4
+ WAVEBIRD_PREFERENCE_SECRET=replace_with_at_least_32_random_characters
5
+ PUBLISHER_AUTH_CHECK_PATH=/api/your-existing-session-check
6
+ WAVEBIRD_PUBLIC_ORIGINS=
@@ -0,0 +1,6 @@
1
+ import { getPublisher } from "../../../lib/publisher.js";
2
+ import { existingGeneration } from "../../../lib/model.js";
3
+
4
+ export async function POST(request) {
5
+ return getPublisher().withGeneration({ request, run: existingGeneration });
6
+ }
@@ -0,0 +1,4 @@
1
+ import { getPublisher } from "../../../lib/publisher.js";
2
+
3
+ export const GET = (request) => getPublisher().consentGET(request);
4
+ export const POST = (request) => getPublisher().consentPOST(request);
@@ -0,0 +1,3 @@
1
+ import { getPublisher } from "../../../lib/publisher.js";
2
+
3
+ export const POST = (request) => getPublisher().placementPOST(request);
@@ -0,0 +1,3 @@
1
+ export default function RootLayout({ children }) {
2
+ return <html lang="en"><body>{children}</body></html>;
3
+ }
@@ -0,0 +1,59 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { createPublisherAds } from "@wavebirdai/api/browser";
5
+
6
+ export default function Page() {
7
+ const slot = useRef(null);
8
+ const ads = useRef(null);
9
+ const abort = useRef(null);
10
+ const [answer, setAnswer] = useState("");
11
+ useEffect(() => {
12
+ const current = createPublisherAds({
13
+ target: slot.current,
14
+ clientId: "wbproj_replace_from_dashboard",
15
+ surfaceId: "chat_assistant",
16
+ jobType: "chat",
17
+ timing: "during",
18
+ consentMode: "wavebird_consent",
19
+ userId: "replace-with-authenticated-user-id",
20
+ conversationId: "replace-with-current-conversation-id",
21
+ rendererUrl: "https://api.wavebird.ai/v1/render.js",
22
+ placementEndpoint: "/api/sponsor-slot",
23
+ consentEndpoint: "/api/sponsor-consent",
24
+ privacySettingsTarget: "#privacy-settings"
25
+ });
26
+ ads.current = current;
27
+ return () => { abort.current?.abort(); current.dispose(); ads.current = null; };
28
+ }, []);
29
+ async function generate() {
30
+ if (!ads.current) return;
31
+ const current = new AbortController();
32
+ abort.current = current;
33
+ setAnswer("");
34
+ try {
35
+ await ads.current.connectGeneration({
36
+ signal: current.signal,
37
+ request: headers => fetch("/api/generate", { method: "POST", headers, signal: current.signal }),
38
+ consume: async (response, lifecycle) => {
39
+ if (!response.ok || !response.body) throw new Error("generation_unavailable");
40
+ const reader = response.body.getReader();
41
+ const decoder = new TextDecoder();
42
+ while (true) {
43
+ const next = await reader.read();
44
+ if (next.done) break;
45
+ setAnswer(value => value + decoder.decode(next.value, { stream: true }));
46
+ lifecycle.onDelta();
47
+ }
48
+ }
49
+ });
50
+ } catch { setAnswer(value => value || "Generation unavailable."); }
51
+ }
52
+ return <main style={{ maxWidth: 760, margin: "2rem auto" }}>
53
+ <h1>Publisher chat</h1>
54
+ <button id="privacy-settings" type="button">Sponsored choices</button>
55
+ <p aria-live="polite">{answer}</p>
56
+ <section ref={slot} aria-label="Sponsored placement area" style={{ width: "100%", maxWidth: 728, height: 346, margin: "1rem auto" }} />
57
+ <div><button type="button" onClick={generate}>Generate</button><button type="button" onClick={() => abort.current?.abort()}>Stop</button></div>
58
+ </main>;
59
+ }
@@ -0,0 +1,12 @@
1
+ export async function authorizePublisherRequest(request) {
2
+ const path = process.env.PUBLISHER_AUTH_CHECK_PATH;
3
+ if (!path || !/^\/(?!\/)[A-Za-z0-9/_-]+$/.test(path) || path.startsWith("/api/sponsor") || path === "/api/generate") return false;
4
+ try {
5
+ const result = await fetch(new URL(path, request.url), {
6
+ headers: { cookie: request.headers.get("cookie") || "" },
7
+ cache: "no-store",
8
+ redirect: "manual"
9
+ });
10
+ return result.status === 204;
11
+ } catch { return false; }
12
+ }
@@ -0,0 +1,3 @@
1
+ export async function existingGeneration(_signal) {
2
+ throw new Error("Connect your existing authenticated generation stream in lib/model.js");
3
+ }
@@ -0,0 +1,30 @@
1
+ import "server-only";
2
+ import { createPublisherServer } from "@wavebirdai/api/server";
3
+ import { authorizePublisherRequest } from "./auth.js";
4
+
5
+ let publisher;
6
+
7
+ export function getPublisher() {
8
+ return publisher ??= createPublisherServer({
9
+ apiBaseUrl: process.env.WAVEBIRD_API_BASE_URL,
10
+ clientId: process.env.WAVEBIRD_CLIENT_ID,
11
+ secretKey: process.env.WAVEBIRD_SECRET_KEY,
12
+ preferenceSecret: process.env.WAVEBIRD_PREFERENCE_SECRET,
13
+ authorize: authorizePublisherRequest,
14
+ publicOrigins: (process.env.WAVEBIRD_PUBLIC_ORIGINS ?? "").split(",").map(value => value.trim()).filter(Boolean),
15
+ surfaceId: "chat_assistant",
16
+ placementKey: "default",
17
+ jobType: "chat",
18
+ locale: "en-US",
19
+ timing: "during",
20
+ consentMode: "wavebird_consent",
21
+ gdprApplies: true,
22
+ publisher: { app_name: "Your saved app name", app_domain: "your.example", categories: [] },
23
+ allowedFormats: ["banner"],
24
+ positionHint: "between",
25
+ maxWidth: 728,
26
+ maxHeight: 90,
27
+ allowedSizes: [{ width: 728, height: 90 }, { width: 320, height: 50 }],
28
+ responsiveSizeSelection: true
29
+ });
30
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "wavebird-api-next-js-example",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": { "dev": "next dev", "build": "next build --webpack" },
6
+ "dependencies": { "next": "16.3.8", "react": "19.2.4", "react-dom": "19.2.4" }
7
+ }
@@ -0,0 +1,6 @@
1
+ WAVEBIRD_API_BASE_URL=https://api.wavebird.ai
2
+ WAVEBIRD_CLIENT_ID=wbproj_replace_from_dashboard
3
+ WAVEBIRD_SECRET_KEY=sk_test_replace_from_dashboard
4
+ WAVEBIRD_PREFERENCE_SECRET=replace_with_at_least_32_random_characters
5
+ PUBLISHER_AUTH_CHECK_PATH=/api/your-existing-session-check
6
+ WAVEBIRD_PUBLIC_ORIGINS=
@@ -0,0 +1,6 @@
1
+ import { getPublisher } from "../../../lib/publisher";
2
+ import { existingGeneration } from "../../../lib/model";
3
+
4
+ export async function POST(request: Request): Promise<Response> {
5
+ return getPublisher().withGeneration({ request, run: existingGeneration });
6
+ }
@@ -0,0 +1,4 @@
1
+ import { getPublisher } from "../../../lib/publisher";
2
+
3
+ export const GET = (request: Request): Promise<Response> => getPublisher().consentGET(request);
4
+ export const POST = (request: Request): Promise<Response> => getPublisher().consentPOST(request);
@@ -0,0 +1,3 @@
1
+ import { getPublisher } from "../../../lib/publisher";
2
+
3
+ export const POST = (request: Request): Promise<Response> => getPublisher().placementPOST(request);
@@ -0,0 +1,5 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export default function RootLayout({ children }: { children: ReactNode }) {
4
+ return <html lang="en"><body>{children}</body></html>;
5
+ }
@@ -0,0 +1,60 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { createPublisherAds, type PublisherAds } from "@wavebirdai/api/browser";
5
+
6
+ export default function Page() {
7
+ const slot = useRef<HTMLElement | null>(null);
8
+ const ads = useRef<PublisherAds | null>(null);
9
+ const abort = useRef<AbortController | null>(null);
10
+ const [answer, setAnswer] = useState("");
11
+ useEffect(() => {
12
+ if (!slot.current) return;
13
+ const current = createPublisherAds({
14
+ target: slot.current,
15
+ clientId: "wbproj_replace_from_dashboard",
16
+ surfaceId: "chat_assistant",
17
+ jobType: "chat",
18
+ timing: "during",
19
+ consentMode: "wavebird_consent",
20
+ userId: "replace-with-authenticated-user-id",
21
+ conversationId: "replace-with-current-conversation-id",
22
+ rendererUrl: "https://api.wavebird.ai/v1/render.js",
23
+ placementEndpoint: "/api/sponsor-slot",
24
+ consentEndpoint: "/api/sponsor-consent",
25
+ privacySettingsTarget: "#privacy-settings"
26
+ });
27
+ ads.current = current;
28
+ return () => { abort.current?.abort(); current.dispose(); ads.current = null; };
29
+ }, []);
30
+ async function generate() {
31
+ if (!ads.current) return;
32
+ const current = new AbortController();
33
+ abort.current = current;
34
+ setAnswer("");
35
+ try {
36
+ await ads.current.connectGeneration({
37
+ signal: current.signal,
38
+ request: headers => fetch("/api/generate", { method: "POST", headers, signal: current.signal }),
39
+ consume: async (response, lifecycle) => {
40
+ if (!response.ok || !response.body) throw new Error("generation_unavailable");
41
+ const reader = response.body.getReader();
42
+ const decoder = new TextDecoder();
43
+ while (true) {
44
+ const next = await reader.read();
45
+ if (next.done) break;
46
+ setAnswer(value => value + decoder.decode(next.value, { stream: true }));
47
+ lifecycle.onDelta();
48
+ }
49
+ }
50
+ });
51
+ } catch { setAnswer(value => value || "Generation unavailable."); }
52
+ }
53
+ return <main style={{ maxWidth: 760, margin: "2rem auto" }}>
54
+ <h1>Publisher chat</h1>
55
+ <button id="privacy-settings" type="button">Sponsored choices</button>
56
+ <p aria-live="polite">{answer}</p>
57
+ <section ref={slot} aria-label="Sponsored placement area" style={{ width: "100%", maxWidth: 728, height: 346, margin: "1rem auto" }} />
58
+ <div><button type="button" onClick={generate}>Generate</button><button type="button" onClick={() => abort.current?.abort()}>Stop</button></div>
59
+ </main>;
60
+ }
@@ -0,0 +1,12 @@
1
+ export async function authorizePublisherRequest(request: Request): Promise<boolean> {
2
+ const path = process.env.PUBLISHER_AUTH_CHECK_PATH;
3
+ if (!path || !/^\/(?!\/)[A-Za-z0-9/_-]+$/.test(path) || path.startsWith("/api/sponsor") || path === "/api/generate") return false;
4
+ try {
5
+ const result = await fetch(new URL(path, request.url), {
6
+ headers: { cookie: request.headers.get("cookie") || "" },
7
+ cache: "no-store",
8
+ redirect: "manual"
9
+ });
10
+ return result.status === 204;
11
+ } catch { return false; }
12
+ }
@@ -0,0 +1,3 @@
1
+ export async function existingGeneration(_signal: AbortSignal): Promise<Response> {
2
+ throw new Error("Connect your existing authenticated generation stream in lib/model.ts");
3
+ }
@@ -0,0 +1,30 @@
1
+ import "server-only";
2
+ import { createPublisherServer } from "@wavebirdai/api/server";
3
+ import { authorizePublisherRequest } from "./auth";
4
+
5
+ let publisher: ReturnType<typeof createPublisherServer> | null = null;
6
+
7
+ export function getPublisher(): ReturnType<typeof createPublisherServer> {
8
+ return publisher ??= createPublisherServer({
9
+ apiBaseUrl: process.env.WAVEBIRD_API_BASE_URL,
10
+ clientId: process.env.WAVEBIRD_CLIENT_ID ?? "",
11
+ secretKey: process.env.WAVEBIRD_SECRET_KEY ?? "",
12
+ preferenceSecret: process.env.WAVEBIRD_PREFERENCE_SECRET ?? "",
13
+ authorize: authorizePublisherRequest,
14
+ publicOrigins: (process.env.WAVEBIRD_PUBLIC_ORIGINS ?? "").split(",").map(value => value.trim()).filter(Boolean),
15
+ surfaceId: "chat_assistant",
16
+ placementKey: "default",
17
+ jobType: "chat",
18
+ locale: "en-US",
19
+ timing: "during",
20
+ consentMode: "wavebird_consent",
21
+ gdprApplies: true,
22
+ publisher: { app_name: "Your saved app name", app_domain: "your.example", categories: [] },
23
+ allowedFormats: ["banner"],
24
+ positionHint: "between",
25
+ maxWidth: 728,
26
+ maxHeight: 90,
27
+ allowedSizes: [{ width: 728, height: 90 }, { width: 320, height: 50 }],
28
+ responsiveSizeSelection: true
29
+ });
30
+ }