@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CSL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,56 @@
1
- # Temporary Holding Version
1
+ # wavebird publisher API
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `@wavebirdai/api` provides Banner/Native integration with the wavebird REST API and hosted renderer. Version `0.1.0-beta.1` is an opt-in prerelease. The pinned public GitHub `.4` and optional `.5` archives remain available; existing publishers keep their installed version unless they opt in. The established [`wavebird`](https://www.npmjs.com/package/wavebird) SDK remains separate.
4
+
5
+ ## Installation
6
+
7
+ For the npm prerelease, run this in the existing Next.js app directory beside `package.json`:
8
+
9
+ ```sh
10
+ npm install @wavebirdai/api@0.1.0-beta.1
11
+ ```
12
+
13
+ The public `.4` GitHub asset uses fixed sizes. Public `.5` adds optional responsive selection. This prerelease corrects the responsive generator example and package guidance; neither public archive is overwritten. A version change does not migrate an existing integration automatically.
14
+
15
+ The [full REST reference](https://wavebird.ai/api/reference/endpoints) remains available for integrations without this package.
16
+
17
+ Both JavaScript and TypeScript use the same package and URL; there is no separate language package.
18
+
19
+ For a saved single-format placement such as Banner-only After, pass `getFormat: () => "banner"` to `createPublisherAds` along with the matching same-origin placement endpoint. The package forwards that choice to the hosted renderer; the server still enforces the saved project and format. Omit `getFormat` when the saved placement intentionally lets wavebird choose among supported formats.
20
+
21
+ ## Next.js route generator
22
+
23
+ After installing the package, run one of these commands in an existing Next.js App Router project:
24
+
25
+ ```sh
26
+ ./node_modules/.bin/wavebird-next init --lang js --root . --app-dir app
27
+ ./node_modules/.bin/wavebird-next init --lang ts --root . --app-dir src/app
28
+ ```
29
+
30
+ Choose one language and the actual App Router directory. The command refuses to overwrite existing files. It installs `lib/publisher`, `lib/auth`, and a fail-closed `lib/model`; required `app/api/generate`, `app/api/sponsor-slot`, and `app/api/sponsor-consent` route files; `components/WavebirdPlacement`; and `WAVEBIRD-SETUP.md`. The `src/app` option puts `lib` and `components` under `src/`. These routes are application files produced by the generator, not implicit package exports. JS and TS Next.js example files are also included in the package's `examples/` directory; connect their authorization and generation hooks before use. The generated publisher lists two illustrative Banner sizes and enables responsive selection; replace both sizes with the exact saved project sizes before Test traffic.
31
+
32
+ Set the server-only environment variables, including `WAVEBIRD_PUBLIC_ORIGINS` with the exact scheme, host, and port where your app is opened. Keep the Dashboard project domain at its real HTTPS host; for local Server Test-key testing, add the exact loopback origin to this server-only list without changing that project domain. Browser Test Keys have a separate Allowed origins field in API Keys. Insert actual saved Dashboard settings in `lib/publisher`, connect your real session check in `lib/auth`, and connect your existing model stream in `lib/model`. The supplied auth hook fails closed without its publisher-owned check path, and the generated model hook throws until replaced. The generated client component requires authenticated user and conversation IDs from the publisher. Mount it by the existing result area and call its returned ads object's `connectGeneration` around the existing request/stream consumer. The generated setup file shows the short entry point. Use your real consent lifecycle: the template selects wavebird's consent dialog, while an `own_cmp` saved project must be wired to the publisher's actual CMP before any served test.
33
+
34
+ ## Server and browser boundary
35
+
36
+ `@wavebirdai/api/server` exports `createPublisherServer`. Configure it with server-only Project ID, Server Test or Live Key, preference signing secret, the publisher's real request authorization function, exact `publicOrigins`, and the saved project/placement values shown in the dashboard. The origin list is required. The package compares the browser Origin with a configured public origin whose host matches the route Host, even when Next.js or a proxy gives the route an internal URL. `localhost` and `127.0.0.1` are separate origins, and each port and scheme must match exactly; HTTP is accepted only for loopback. It returns authenticated `placementPOST`, `consentGET`, `consentPOST`, and `withGeneration` handlers, plus the server-only `reportGeneration(jobId, event)` method. The helper constructs the existing `/v1/placements`, `/v1/consent`, and `/v1/jobs/{id}/generation/{event}` requests; it does not introduce a new wavebird protocol. The wavebird API remains authoritative for saved scope, creative formats, duration, fallback, and financial classification.
37
+
38
+ Responsive selection is opt-in with `responsiveSizeSelection: true`. The hosted browser adapter sends the measured reserved container size. The server chooses one of the saved `allowedSizes` that fits, or returns `no_fit` without an upstream placement request. A size can carry `formats: ["banner"]`, `formats: ["native"]`, or both; this annotation remains on the publisher server and is stripped from the wavebird request. A browser format preference is accepted only when it is among the saved `allowedFormats` and compatible with the selected size. Without opt-in, existing fixed-size requests retain their previous behavior. Reserve enough height for the selected creative and its controls; do not stretch the returned creative or place it over the model result.
39
+
40
+ Create the server helper when a request arrives, as the included Next.js examples do. This lets Next.js build a publisher app without loading its server-only Test key into the build process; the helper still validates configuration before handling traffic.
41
+
42
+ `withGeneration({ request, run, onLifecycle })` accepts an optional server-side observer. It receives `{ type, sessionId, generationId }` for `started` and one terminal state: `done`, `error`, or `abort`. Use it to forward lifecycle events to your own generation reporter; observer failures do not change the model response. It runs only for authenticated, same-origin requests with valid generation transport IDs. The callback is not awaited, and it receives no prompt or model bytes.
43
+
44
+ Call `reportGeneration` only from trusted server code with a job ID returned by an authorized placement. Its events are `started`, `finished`, and `failed`. The server key scopes the request at wavebird; invalid IDs or events are rejected locally, and each report has a five-second deadline and a parallel request limit. Reporting errors reject the returned promise so the caller can handle them outside the model response path.
45
+
46
+ `@wavebirdai/api/browser` exports the existing tested `createPublisherAds` and `connectPublisherGeneration` lifecycle helper. It loads the hosted renderer and attaches only to a reserved container selected by the publisher. Initialize it with the current app user and conversation IDs; pass optional same-origin `placementEndpoint` and `consentEndpoint` paths when using the server routes in this package. The helper forwards those paths to the hosted renderer. Keep the Server Key and preference signing secret in the server configuration; never pass them as browser options. Use the real wavebird consent dialog or the publisher's actual CMP. Consent on an idle page does not request an ad. For `during`, the helper displays only during an active generation. Its generation request and response stream run without waiting for consent synchronization, the ad response, or renderer readiness. It clears late responses on completion, error, abort, conversation change, GPC, or withdrawal.
47
+
48
+ ## Next.js
49
+
50
+ The package contains equivalent JavaScript and TypeScript Next.js example files in `examples/next-js` and `examples/next-ts`. Each shows a publisher-owned reserved slot outside the input and scrolling transcript, same-origin consent and placement routes, the server-only configuration, and an existing generation stream wrapped by `withGeneration`. Their authorization hook fails closed until connected to the publisher's actual session validation. The sample generation stream is a local demonstration and is meant to be replaced by the publisher's existing model call.
51
+
52
+ The required server-only values are `WAVEBIRD_CLIENT_ID`, `WAVEBIRD_SECRET_KEY`, `WAVEBIRD_PREFERENCE_SECRET`, and `WAVEBIRD_PUBLIC_ORIGINS`. Keep secrets outside browser bundles and source control. Set project surface, placement, job type, timing, formats, locale, consent profile, identity/domain, and ad-space dimensions from the saved dashboard configuration. Do not shrink the reserve below the saved creative envelope plus the 256px Banner/Native control space. The publisher positions the section in its own UI.
53
+
54
+ ## Test scope
55
+
56
+ The browser and generation transport sources are included in the package. Before use in a publisher app, test the server routes, browser placement, consent, and generation lifecycle against its saved Test project and supported browser and host. Test traffic must remain nonpayable; installing this package does not activate Live ads.
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ import { constants } from "node:fs";
3
+ import { access, copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
+ const usage = "Usage: wavebird-next init --lang js|ts --root <existing Next.js project> [--app-dir app|src/app]";
9
+
10
+ function options(argv) {
11
+ if (argv[0] !== "init") throw new Error(usage);
12
+ const out = { appDir: "app" };
13
+ for (let i = 1; i < argv.length; i += 2) {
14
+ const key = argv[i];
15
+ const value = argv[i + 1];
16
+ const field = key === "--app-dir" ? "appDir" : key.slice(2);
17
+ if (!value || !["--lang", "--root", "--app-dir"].includes(key) || (field !== "appDir" && out[field] !== undefined)) throw new Error(usage);
18
+ if (key === "--app-dir") out.appDir = value;
19
+ else out[key.slice(2)] = value;
20
+ }
21
+ if (!["js", "ts"].includes(out.lang) || !out.root || !["app", "src/app"].includes(out.appDir)) throw new Error(usage);
22
+ return out;
23
+ }
24
+
25
+ export async function plan(argv) {
26
+ const { lang, root, appDir } = options(argv);
27
+ const projectRoot = resolve(root);
28
+ await access(projectRoot, constants.R_OK | constants.W_OK);
29
+ const appPath = resolve(projectRoot, appDir);
30
+ await access(appPath, constants.R_OK);
31
+ const base = dirname(appPath);
32
+ const ext = lang === "ts" ? "ts" : "js";
33
+ const componentExt = lang === "ts" ? "tsx" : "jsx";
34
+ const example = resolve(packageRoot, "examples", `next-${lang}`);
35
+ const files = [
36
+ [join(example, "lib", `publisher.${ext}`), join(base, "lib", `publisher.${ext}`)],
37
+ [join(example, "lib", `auth.${ext}`), join(base, "lib", `auth.${ext}`)],
38
+ [resolve(packageRoot, "templates", `model.${ext}`), join(base, "lib", `model.${ext}`)],
39
+ [join(example, "app", "api", "generate", `route.${ext}`), join(appPath, "api", "generate", `route.${ext}`)],
40
+ [join(example, "app", "api", "sponsor-slot", `route.${ext}`), join(appPath, "api", "sponsor-slot", `route.${ext}`)],
41
+ [join(example, "app", "api", "sponsor-consent", `route.${ext}`), join(appPath, "api", "sponsor-consent", `route.${ext}`)],
42
+ [resolve(packageRoot, "templates", `WavebirdPlacement.${componentExt}`), join(base, "components", `WavebirdPlacement.${componentExt}`)],
43
+ [resolve(packageRoot, "templates", "NEXT-SETUP.md"), join(projectRoot, "WAVEBIRD-SETUP.md")]
44
+ ];
45
+ for (const [source, dest] of files) {
46
+ await access(source, constants.R_OK);
47
+ try { await access(dest); throw new Error(`Refusing to overwrite ${dest}`); }
48
+ catch (error) { if (error.code !== "ENOENT") throw error; }
49
+ }
50
+ return { lang, projectRoot, appDir, files };
51
+ }
52
+
53
+ export async function install(argv) {
54
+ const result = await plan(argv);
55
+ for (const [source, dest] of result.files) {
56
+ await mkdir(dirname(dest), { recursive: true });
57
+ if (source.endsWith("NEXT-SETUP.md")) await writeFile(dest, await readFile(source, "utf8"), { flag: "wx" });
58
+ else await copyFile(source, dest, constants.COPYFILE_EXCL);
59
+ }
60
+ return result;
61
+ }
62
+
63
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
64
+ install(process.argv.slice(2)).then(result => {
65
+ process.stdout.write(`Installed ${result.lang.toUpperCase()} wavebird routes and connectors in ${result.projectRoot}. Read WAVEBIRD-SETUP.md.\n`);
66
+ }).catch(error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; });
67
+ }
@@ -0,0 +1,27 @@
1
+ export type GenerationConsumer<T> = (response: Response, lifecycle: { onDelta(): void }) => T | Promise<T>;
2
+ export type PublisherAds = {
3
+ ready: Promise<void>;
4
+ connectGeneration<T>(options: { request: (headers: Record<string, string>) => Response | Promise<Response>; consume: GenerationConsumer<T>; signal?: AbortSignal }): Promise<T>;
5
+ setConversation(binding: { userId: string; conversationId: string }): void;
6
+ dispose(): void;
7
+ openPrivacySettings(): void;
8
+ grant(choice?: unknown): Promise<unknown>;
9
+ deny(): Promise<unknown>;
10
+ withdraw(): Promise<unknown>;
11
+ };
12
+ export function createPublisherAds(options: {
13
+ target: HTMLElement;
14
+ clientId: string;
15
+ surfaceId: string;
16
+ jobType: string;
17
+ userId: string;
18
+ conversationId: string;
19
+ timing?: "before" | "during" | "after";
20
+ consentMode?: "wavebird_consent" | "own_cmp";
21
+ rendererUrl?: string;
22
+ privacySettingsTarget?: string;
23
+ getFormat?: () => "banner" | "native" | "clip" | "auto";
24
+ placementEndpoint?: string;
25
+ consentEndpoint?: string;
26
+ }): PublisherAds;
27
+ export function connectPublisherGeneration<T>(url: string, init: RequestInit, consume: GenerationConsumer<T>): Promise<T>;
@@ -0,0 +1,194 @@
1
+ let rendererReady = null;
2
+ let rendererSource = null;
3
+
4
+ function loadRenderer(url) {
5
+ if (window.wavebird?.createChatSponsor) return Promise.resolve();
6
+ if (rendererReady && rendererSource !== url) return Promise.reject(new Error("renderer_source_mismatch"));
7
+ if (!rendererReady) {
8
+ rendererSource = url;
9
+ rendererReady = new Promise((resolve, reject) => {
10
+ const script = document.createElement("script"); script.src = url;
11
+ const fail = () => { clearTimeout(timer); script.onload = null; script.onerror = null; script.remove(); reject(new Error("renderer_unavailable")); };
12
+ const timer = setTimeout(fail, 8000);
13
+ script.onerror = fail;
14
+ script.onload = () => { clearTimeout(timer); if (!window.wavebird?.createChatSponsor) fail(); else resolve(); };
15
+ document.head.append(script);
16
+ }).catch(error => { rendererReady = null; rendererSource = null; throw error; });
17
+ }
18
+ return rendererReady;
19
+ }
20
+
21
+ export function createPublisherAds(options) {
22
+ const target = options.target;
23
+ if (!target || !options.userId || !options.conversationId) throw new Error("Application container and session are required");
24
+ const timing = options.timing || "during", consentMode = options.consentMode || "wavebird_consent";
25
+ if (!["before", "during", "after"].includes(timing) || !["wavebird_consent", "own_cmp"].includes(consentMode)) throw new Error("Invalid publisher configuration");
26
+ let binding = { userId: options.userId, conversationId: options.conversationId, sessionId: crypto.randomUUID() };
27
+ let sponsor = null, disposed = false, context = 0, active = null;
28
+ const slot = document.createElement("section");
29
+ slot.setAttribute("aria-label", "Sponsored placement");
30
+ slot.style.cssText = "width:100%;height:100%;overflow:hidden;margin:0 auto";
31
+ target.append(slot);
32
+ const privacyTarget = consentMode === "wavebird_consent" && options.privacySettingsTarget
33
+ ? document.querySelector(options.privacySettingsTarget) : null;
34
+ const openPrivacySettings = () => { if (!disposed) sponsor?.openPrivacySettings(); };
35
+ privacyTarget?.addEventListener("click", openPrivacySettings);
36
+ const notify = (method, id) => {
37
+ try { sponsor?.[method](id); }
38
+ catch {
39
+ try { sponsor?.dispose(); } catch {}
40
+ sponsor = null; slot.hidden = true; slot.replaceChildren();
41
+ }
42
+ };
43
+ const current = job => !disposed && !job.cancelled && job.context === context && active === job;
44
+ const event = (job, type) => {
45
+ if (!current(job)) return;
46
+ if (type === "started") {
47
+ if (job.state !== "pending") return;
48
+ job.state = "started"; notify("onStarted", job.id);
49
+ } else if (type === "progress") {
50
+ if (job.state === "started") { job.progressed = true; notify("onDelta", job.id); }
51
+ } else if (["done", "error", "abort"].includes(type)) {
52
+ if (job.state !== "started") return;
53
+ job.state = type;
54
+ notify(type === "done" ? "onDone" : type === "abort" ? "onAbort" : "onError", job.id);
55
+ }
56
+ };
57
+ const prepare = () => { if (!disposed && sponsor) void sponsor.prepare().catch(() => {}); };
58
+ function setConversation(next) {
59
+ if (disposed) return;
60
+ if (!next.userId || !next.conversationId) throw new Error("Application session is required");
61
+ if (next.userId === binding.userId && next.conversationId === binding.conversationId) return;
62
+ if (active) event(active, "abort");
63
+ context++; active = null;
64
+ binding = { userId: next.userId, conversationId: next.conversationId, sessionId: crypto.randomUUID() };
65
+ sponsor?.setConversation(binding); prepare();
66
+ }
67
+ function dispose() {
68
+ if (disposed) return;
69
+ if (active) event(active, "abort");
70
+ disposed = true; context++; active = null;
71
+ sponsor?.dispose(); sponsor = null; slot.remove();
72
+ privacyTarget?.removeEventListener("click", openPrivacySettings);
73
+ window.removeEventListener("pagehide", dispose);
74
+ }
75
+ window.addEventListener("pagehide", dispose);
76
+ const ready = loadRenderer(options.rendererUrl || "https://api.wavebird.ai/v1/render.js").then(() => {
77
+ if (disposed) return;
78
+ sponsor = window.wavebird.createChatSponsor({ target: slot, ...binding,
79
+ clientId: options.clientId, surfaceId: options.surfaceId, timing, consentMode,
80
+ ...(options.getFormat === undefined ? {} : { getFormat: options.getFormat }),
81
+ ...(options.placementEndpoint === undefined ? {} : { placementEndpoint: options.placementEndpoint }),
82
+ ...(options.consentEndpoint === undefined ? {} : { consentEndpoint: options.consentEndpoint }) });
83
+ prepare();
84
+ if (active && current(active)) {
85
+ if (active.state === "started") {
86
+ notify("onStarted", active.id);
87
+ if (active.progressed) notify("onDelta", active.id);
88
+ } else if (timing === "after" && active.state === "done") {
89
+ notify("onStarted", active.id); notify("onDone", active.id);
90
+ }
91
+ }
92
+ }).catch(() => {});
93
+
94
+ async function connectGeneration({ request, consume, signal }) {
95
+ const job = { id: crypto.randomUUID(), sessionId: binding.sessionId, context, state: "pending", cancelled: false };
96
+ if (active) event(active, "abort");
97
+ active = job;
98
+ let reader = null, ended = false, buffer = "";
99
+ const decoder = new TextDecoder();
100
+ const abort = () => { event(job, "abort"); job.cancelled = true; void reader?.cancel().catch(() => {}); };
101
+ signal?.addEventListener("abort", abort, { once: true });
102
+ if (signal?.aborted) abort();
103
+ const headers = { "x-wavebird-session-id": job.sessionId, "x-wavebird-generation-id": job.id };
104
+ try {
105
+ const response = await request(headers);
106
+ if (response.headers.get("x-wavebird-generation-transport") !== "1") return await consume(response);
107
+ if (!response.body) throw new Error("generation_transport_missing");
108
+ reader = response.body.getReader();
109
+ async function nextRecord() {
110
+ while (true) {
111
+ const boundary = buffer.indexOf("\n");
112
+ if (boundary >= 0) {
113
+ const line = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 1);
114
+ if (!line || line.length > 32768) throw new Error("generation_transport_invalid");
115
+ const record = JSON.parse(line);
116
+ if (record.session_id !== job.sessionId || record.generation_id !== job.id) throw new Error("generation_transport_context_mismatch");
117
+ return record;
118
+ }
119
+ if (buffer.length > 32768) throw new Error("generation_transport_limit");
120
+ const next = await reader.read();
121
+ if (signal?.aborted) throw new Error("generation_aborted");
122
+ if (next.done) throw new Error("generation_transport_truncated");
123
+ buffer += decoder.decode(next.value, { stream: true });
124
+ }
125
+ }
126
+ let metadata;
127
+ while (!metadata) {
128
+ const record = await nextRecord();
129
+ if (record.type === "started") event(job, "started");
130
+ else if (record.type === "response") metadata = record;
131
+ else throw new Error("generation_failed_before_response");
132
+ }
133
+ if (!Number.isInteger(metadata.status) || metadata.status < 200 || metadata.status > 599 || !Array.isArray(metadata.headers)) throw new Error("generation_response_invalid");
134
+ const output = new ReadableStream({
135
+ async pull(controller) {
136
+ try {
137
+ while (true) {
138
+ const record = await nextRecord();
139
+ if (record.type === "bytes") {
140
+ if (typeof record.data !== "string" || record.data.length > 21848 || !/^[A-Za-z0-9+/]*={0,2}$/.test(record.data)) throw new Error("generation_bytes_invalid");
141
+ const raw = atob(record.data), bytes = Uint8Array.from(raw, value => value.charCodeAt(0));
142
+ controller.enqueue(bytes);
143
+ if (timing !== "before" && !["image", "image_generation"].includes(options.jobType)) event(job, "progress");
144
+ return;
145
+ }
146
+ if (["done", "error", "abort"].includes(record.type)) {
147
+ ended = true; event(job, record.type);
148
+ if (record.type === "done" || !metadata.status.toString().startsWith("2")) controller.close();
149
+ else controller.error(new Error("generation_" + record.type));
150
+ return;
151
+ }
152
+ throw new Error("generation_transport_invalid");
153
+ }
154
+ } catch (error) { event(job, signal?.aborted ? "abort" : "error"); controller.error(error); }
155
+ },
156
+ cancel() { event(job, "abort"); void reader?.cancel().catch(() => {}); }
157
+ }, { highWaterMark: 0 });
158
+ if ([204, 205, 304].includes(metadata.status)) {
159
+ const record = await nextRecord();
160
+ if (!["done", "error", "abort"].includes(record.type)) throw new Error("generation_transport_invalid");
161
+ ended = true; event(job, record.type);
162
+ }
163
+ const primary = new Response([204, 205, 304].includes(metadata.status) ? null : output, {
164
+ status: metadata.status, statusText: metadata.status_text || "", headers: metadata.headers
165
+ });
166
+ return await consume(primary, { onDelta: () => event(job, "progress") });
167
+ } catch (error) {
168
+ event(job, signal?.aborted ? "abort" : "error");
169
+ throw error;
170
+ } finally {
171
+ if (!ended && job.state === "started") event(job, "abort");
172
+ signal?.removeEventListener("abort", abort);
173
+ if (reader) { try { await reader.cancel(); } catch {} reader.releaseLock(); }
174
+ }
175
+ }
176
+ return { ready, connectGeneration, setConversation, dispose, openPrivacySettings,
177
+ grant: choice => sponsor ? sponsor.grant(choice) : Promise.resolve({ status: "renderer_unavailable" }),
178
+ deny: () => sponsor ? sponsor.deny() : Promise.resolve({ status: "renderer_unavailable" }),
179
+ withdraw: () => sponsor ? sponsor.withdraw() : Promise.resolve({ status: "renderer_unavailable" }) };
180
+ }
181
+
182
+ export async function connectPublisherGeneration(url, init, consume) {
183
+ const ads = window.publisherAds;
184
+ if (!ads) return consume(await fetch(url, init), { onDelta() {} });
185
+ return ads.connectGeneration({
186
+ signal: init.signal ?? undefined,
187
+ request: values => {
188
+ const headers = new Headers(init.headers);
189
+ for (const [name, value] of Object.entries(values)) headers.set(name, value);
190
+ return fetch(url, { ...init, headers });
191
+ },
192
+ consume
193
+ });
194
+ }
@@ -0,0 +1,96 @@
1
+ function generationTransport({ request, run }) {
2
+ const sessionId = request.headers.get("x-wavebird-session-id") || "";
3
+ const generationId = request.headers.get("x-wavebird-generation-id") || "";
4
+ const validId = (value) => /^[A-Za-z0-9_-]{1,128}$/.test(value);
5
+ if (!validId(sessionId) || !validId(generationId))
6
+ return run(request.signal);
7
+ const abort = new AbortController();
8
+ const stop = () => abort.abort(request.signal.reason);
9
+ request.signal.addEventListener("abort", stop, { once: true });
10
+ if (request.signal.aborted)
11
+ stop();
12
+ let reader = null;
13
+ let pending;
14
+ try {
15
+ pending = Promise.resolve(run(abort.signal));
16
+ }
17
+ catch (error) {
18
+ pending = Promise.reject(error);
19
+ }
20
+ pending.catch(() => { });
21
+ const encoder = new TextEncoder();
22
+ const record = (value) => encoder.encode(JSON.stringify({
23
+ session_id: sessionId, generation_id: generationId, ...value
24
+ }) + "\n");
25
+ async function* records() {
26
+ try {
27
+ if (abort.signal.aborted)
28
+ throw new Error("generation_aborted");
29
+ yield record({ type: "started" });
30
+ const response = await pending;
31
+ if (!(response instanceof Response))
32
+ throw new Error("model_response_required");
33
+ if (abort.signal.aborted)
34
+ throw new Error("generation_aborted");
35
+ const headers = [...response.headers.entries()].filter(([name]) => !["set-cookie", "content-length", "content-encoding", "transfer-encoding"].includes(name.toLowerCase()));
36
+ if (response.headers.has("set-cookie"))
37
+ throw new Error("model_response_cookie_requires_outer_route");
38
+ yield record({ type: "response", status: response.status, status_text: response.statusText, headers });
39
+ reader = response.body?.getReader() || null;
40
+ if (reader) {
41
+ while (true) {
42
+ if (abort.signal.aborted)
43
+ throw new Error("generation_aborted");
44
+ const next = await reader.read();
45
+ if (abort.signal.aborted)
46
+ throw new Error("generation_aborted");
47
+ if (next.done)
48
+ break;
49
+ for (let offset = 0; offset < next.value.length; offset += 16384) {
50
+ yield record({ type: "bytes", data: Buffer.from(next.value.subarray(offset, offset + 16384)).toString("base64") });
51
+ }
52
+ }
53
+ }
54
+ yield record({ type: response.ok ? "done" : "error" });
55
+ }
56
+ catch {
57
+ yield record({ type: abort.signal.aborted ? "abort" : "error" });
58
+ }
59
+ finally {
60
+ abort.abort();
61
+ if (reader) {
62
+ try {
63
+ await reader.cancel();
64
+ }
65
+ catch { }
66
+ reader.releaseLock();
67
+ }
68
+ request.signal.removeEventListener("abort", stop);
69
+ }
70
+ }
71
+ const iterator = records();
72
+ const body = new ReadableStream({
73
+ async pull(controller) {
74
+ try {
75
+ const next = await iterator.next();
76
+ if (next.done)
77
+ controller.close();
78
+ else
79
+ controller.enqueue(next.value);
80
+ }
81
+ catch (error) {
82
+ controller.error(error);
83
+ }
84
+ },
85
+ cancel(reason) {
86
+ abort.abort(reason);
87
+ void reader?.cancel(reason).catch(() => { });
88
+ void iterator.return(undefined).catch(() => { });
89
+ }
90
+ }, { highWaterMark: 1 });
91
+ return new Response(body, { headers: {
92
+ "Content-Type": "application/x-wavebird-generation+jsonl",
93
+ "Cache-Control": "no-store", "X-Wavebird-Generation-Transport": "1"
94
+ } });
95
+ }
96
+ export { generationTransport };
@@ -0,0 +1,38 @@
1
+ export type PublisherServerOptions = {
2
+ apiBaseUrl?: string;
3
+ clientId: string;
4
+ secretKey: string;
5
+ preferenceSecret: string;
6
+ authorize: (request: Request) => boolean | Promise<boolean>;
7
+ publicOrigins: string[];
8
+ surfaceId: string;
9
+ placementKey: string;
10
+ jobType: string;
11
+ locale: string;
12
+ timing: "before" | "during" | "after";
13
+ consentMode: "wavebird_consent" | "own_cmp";
14
+ gdprApplies: boolean;
15
+ publisher: { app_name: string; app_domain: string; categories: string[] };
16
+ allowedFormats: Array<"banner" | "native">;
17
+ positionHint: "above" | "below" | "sidebar" | "between";
18
+ maxWidth: number;
19
+ maxHeight: number;
20
+ allowedSizes: Array<{ width: number; height: number; formats?: Array<"banner" | "native"> }>;
21
+ responsiveSizeSelection?: boolean;
22
+ bidfloor?: number;
23
+ bidfloorCurrency?: string;
24
+ blockedCategories?: string[];
25
+ };
26
+ export type GenerationLifecycleEvent = {
27
+ type: "started" | "done" | "error" | "abort";
28
+ sessionId: string;
29
+ generationId: string;
30
+ };
31
+ export type PublisherServer = {
32
+ placementPOST(request: Request): Promise<Response>;
33
+ consentGET(request: Request): Promise<Response>;
34
+ consentPOST(request: Request): Promise<Response>;
35
+ withGeneration(options: { request: Request; run: (signal: AbortSignal) => Response | Promise<Response>; onLifecycle?: (event: GenerationLifecycleEvent) => void | Promise<void> }): Promise<Response>;
36
+ reportGeneration(jobId: string, event: "started" | "finished" | "failed"): Promise<void>;
37
+ };
38
+ export function createPublisherServer(options: PublisherServerOptions): PublisherServer;