@wavebirdai/api 0.1.0-beta.1 → 0.1.0-beta.11

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/README.md CHANGED
@@ -1,55 +1,99 @@
1
- # wavebird publisher API
1
+ # @wavebirdai/api
2
2
 
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.
3
+ Add Wavebird Banner, Native, and Clip placements to an authenticated chat app. The package provides server handlers for placement, consent, and generation reporting, plus a browser adapter for the hosted renderer. Your server keeps the Wavebird key; your app keeps control of its login, model stream, layout, and consent choice.
4
4
 
5
- ## Installation
5
+ ## Integrate with Next.js
6
6
 
7
- For the npm prerelease, run this in the existing Next.js app directory beside `package.json`:
7
+ You need a saved Wavebird Test project and placement in Dashboard, a Server Test Key, an existing authenticated Next.js App Router app, and a durable turn store. Start with the placement's saved formats, sizes, timing, and consent mode. Test traffic is nonpayable.
8
8
 
9
- ```sh
10
- npm install @wavebirdai/api@0.1.0-beta.1
11
- ```
9
+ 1. Install the package and generate the route files for your app directory:
12
10
 
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.
11
+ ```sh
12
+ npm install @wavebirdai/api@0.1.0-beta.11
13
+ ./node_modules/.bin/wavebird-next init --lang ts --root . --app-dir src/app
14
+ ```
14
15
 
15
- The [full REST reference](https://wavebird.ai/api/reference/endpoints) remains available for integrations without this package.
16
+ Use `--lang js --app-dir app` for a JavaScript app with `app/` at the project root. The generator adds files without overwriting existing ones and writes `WAVEBIRD-SETUP.md` with the exact connection points.
16
17
 
17
- Both JavaScript and TypeScript use the same package and URL; there is no separate language package.
18
+ 2. Set the server-only values in your deployment environment:
18
19
 
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
+ ```dotenv
21
+ WAVEBIRD_CLIENT_ID=<saved-project-id>
22
+ WAVEBIRD_SECRET_KEY=<server-test-key>
23
+ WAVEBIRD_PREFERENCE_SECRET=<stable-random-value-at-least-32-bytes>
24
+ WAVEBIRD_PUBLIC_ORIGINS=https://your-app.example
25
+ ```
20
26
 
21
- ## Next.js route generator
27
+ `WAVEBIRD_PUBLIC_ORIGINS` must contain each exact app origin, including scheme and port. For local Server Test-key work, add the exact loopback origin to this list while keeping the Dashboard project domain at its real HTTPS host. Never expose the server key or preference secret through `NEXT_PUBLIC_`, client props, or source control.
22
28
 
23
- After installing the package, run one of these commands in an existing Next.js App Router project:
29
+ 3. Connect the generated files to your app: copy the **saved** placement settings into `lib/publisher`; have `lib/auth` validate the real logged-in request and return a stable, non-credential session ID; replace `lib/turn-store` with your persistent store; and connect `lib/model` to your existing generation stream. The supplied auth, store, and model hooks fail closed until you do this. If you use PostgreSQL, the provided adapter uses your existing pool and its `install()` belongs in your normal migration step.
24
30
 
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
- ```
31
+ The generated server route keeps the Wavebird key on the server and wraps your existing stream:
29
32
 
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.
33
+ ```ts
34
+ import { getPublisher } from "../../../lib/publisher";
35
+ import { existingGeneration } from "../../../lib/model";
31
36
 
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.
37
+ export async function POST(request: Request): Promise<Response> {
38
+ return getPublisher().withGeneration({ request, run: existingGeneration });
39
+ }
40
+ ```
41
+
42
+ 4. Mount the generated `WavebirdPlacement` beside your app's result area using real authenticated user and conversation IDs, then wrap the existing generation request:
43
+
44
+ ```tsx
45
+ <WavebirdPlacement
46
+ clientId={publicProjectId}
47
+ userId={authenticatedUserId}
48
+ conversationId={activeConversationId}
49
+ onReady={setAds}
50
+ />
51
+
52
+ await ads.connectGeneration({
53
+ signal,
54
+ request: headers => fetch("/api/generate", { method: "POST", headers, signal }),
55
+ consume: async (response, lifecycle) => {
56
+ if (!response.ok || !response.body) throw new Error("generation_unavailable");
57
+ const reader = response.body.getReader();
58
+ while (true) {
59
+ const next = await reader.read();
60
+ if (next.done) break;
61
+ // Forward next.value to your existing stream consumer.
62
+ lifecycle.onDelta();
63
+ }
64
+ },
65
+ });
66
+ ```
67
+
68
+ The generated `WAVEBIRD-SETUP.md` shows where the component and routes go. Use Wavebird's consent dialog only when that matches the saved project; for `own_cmp`, connect your actual CMP lifecycle.
69
+
70
+ 5. Validate in Test: confirm the saved format and size, consent and GPC behavior, No-Fill, Stop/abort, and late responses. The generation result must continue independently of the ad path. Move to another package version or Live only through your normal project approval process.
71
+
72
+ JavaScript and TypeScript use this same package. Complete Next.js JavaScript and TypeScript examples are included in `node_modules/@wavebirdai/api/examples/next-js` and `node_modules/@wavebirdai/api/examples/next-ts`. For other frameworks, use the explicit `@wavebirdai/api/server` and `@wavebirdai/api/browser` exports described below. The [REST reference](https://wavebird.ai/api/reference/endpoints) covers integrations without this package. The established [`wavebird`](https://www.npmjs.com/package/wavebird) SDK is a separate package.
33
73
 
34
74
  ## Server and browser boundary
35
75
 
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.
76
+ `@wavebirdai/api/server` exports `createPublisherServer`. Configure it with server-only Project ID, Server Test or production 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 loopback is available only with a Server Test Key. A production key also requires its separately approved project and Wavebird server authority. Omit `bidfloor` and `bidfloorCurrency` to inherit saved pricing. An explicit override supplies both a nonnegative amount, including zero, and a three-letter currency; the API validates it against the saved project. 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
77
 
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.
78
+ Responsive size selection is enabled by default in this version. The 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. Numeric `maxWidth` and `maxHeight` are optional advanced caps: when omitted, the saved sizes define the envelope; when supplied, they can only narrow eligible sizes. A size can carry `formats: ["banner"]`, `formats: ["native"]`, `formats: ["clip"]`, or an approved combination; 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. Set `responsiveSizeSelection: false` only for a deliberate fixed-size compatibility integration. Reserve enough height for the selected creative and its controls; do not stretch the returned creative or place it over the model result.
39
79
 
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.
80
+ 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 key into the build process; the helper still validates configuration before handling traffic.
41
81
 
42
82
  `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
83
 
84
+ With `sessionIdForRequest` and `turnJournal` configured, the package validates the browser non-credential session ID against the publisher authenticated request, binds one turn to its authorized placement job, and automatically retries ordered generation reports. The PostgreSQL adapter uses the publisher existing pool; run its `install()` in the normal migration step. An unavailable journal suppresses ad placement while an authenticated model request can still run. The store and HMAC key must survive worker restarts. A durable per-project key-version guard rejects a changed preference secret before accepting another turn; pending reports can still reconcile. Coordinate key rotation after active turns are drained rather than clearing the guard while turns remain. This opt-in path requires a package version that includes these exports; earlier published versions retain their original behavior.
85
+
44
86
  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
87
 
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.
88
+ `@wavebirdai/api/browser` exports the existing tested `createPublisherAds` and `connectPublisherGeneration` lifecycle helper. Pass the mount's `ads` instance as the fourth `connectPublisherGeneration` argument when more than one surface can run; the original three-argument global lookup remains for older integrations. 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.
89
+
90
+ For `after`, resolve the generation consumer only after your app presents the result. The browser helper waits for both the transport finish and that consumer before starting the placement. If presentation fails, reject the consumer so the placement does not start.
47
91
 
48
92
  ## Next.js
49
93
 
50
94
  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
95
 
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.
96
+ 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. Reserve space for the selected creative and controls without distorting it. The publisher positions the section in its own UI.
53
97
 
54
98
  ## Test scope
55
99
 
@@ -35,10 +35,12 @@ export async function plan(argv) {
35
35
  const files = [
36
36
  [join(example, "lib", `publisher.${ext}`), join(base, "lib", `publisher.${ext}`)],
37
37
  [join(example, "lib", `auth.${ext}`), join(base, "lib", `auth.${ext}`)],
38
+ [join(example, "lib", `turn-store.${ext}`), join(base, "lib", `turn-store.${ext}`)],
38
39
  [resolve(packageRoot, "templates", `model.${ext}`), join(base, "lib", `model.${ext}`)],
39
40
  [join(example, "app", "api", "generate", `route.${ext}`), join(appPath, "api", "generate", `route.${ext}`)],
40
41
  [join(example, "app", "api", "sponsor-slot", `route.${ext}`), join(appPath, "api", "sponsor-slot", `route.${ext}`)],
41
42
  [join(example, "app", "api", "sponsor-consent", `route.${ext}`), join(appPath, "api", "sponsor-consent", `route.${ext}`)],
43
+ [join(example, "app", "api", "sponsor-session", `route.${ext}`), join(appPath, "api", "sponsor-session", `route.${ext}`)],
42
44
  [resolve(packageRoot, "templates", `WavebirdPlacement.${componentExt}`), join(base, "components", `WavebirdPlacement.${componentExt}`)],
43
45
  [resolve(packageRoot, "templates", "NEXT-SETUP.md"), join(projectRoot, "WAVEBIRD-SETUP.md")]
44
46
  ];
package/build.mjs ADDED
@@ -0,0 +1,10 @@
1
+ import { mkdir, copyFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, resolve } from "node:path";
4
+
5
+ const root = dirname(fileURLToPath(import.meta.url));
6
+ const dist = resolve(root, "dist");
7
+ await mkdir(dist, { recursive: true });
8
+ for (const file of ["generation.js", "turn-journal.js", "postgres-turn-storage.js", "browser.js", "server.js", "server.d.ts", "browser.d.ts"]) {
9
+ await copyFile(resolve(root, "src", file), resolve(dist, file));
10
+ }
package/dist/browser.d.ts CHANGED
@@ -1,27 +1,37 @@
1
+ /** Resolve after the publisher has presented the result; After timing starts then. */
1
2
  export type GenerationConsumer<T> = (response: Response, lifecycle: { onDelta(): void }) => T | Promise<T>;
2
- export type PublisherAds = {
3
+ export type PublisherAds<Bound extends boolean = boolean> = {
3
4
  ready: Promise<void>;
4
5
  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
+ setConversation(binding: Bound extends true
7
+ ? { userId: string; conversationId: string; sessionId: string }
8
+ : { userId: string; conversationId: string; sessionId?: string }): void;
6
9
  dispose(): void;
7
10
  openPrivacySettings(): void;
8
11
  grant(choice?: unknown): Promise<unknown>;
9
12
  deny(): Promise<unknown>;
10
13
  withdraw(): Promise<unknown>;
11
14
  };
12
- export function createPublisherAds(options: {
15
+ type PublisherAdsOptionsBase = {
13
16
  target: HTMLElement;
14
17
  clientId: string;
15
18
  surfaceId: string;
16
- jobType: string;
19
+ jobType: "chat" | "code" | "image" | "voice" | "agent";
17
20
  userId: string;
18
21
  conversationId: string;
19
22
  timing?: "before" | "during" | "after";
20
23
  consentMode?: "wavebird_consent" | "own_cmp";
24
+ /** Optional official renderer URL. Only https://api.wavebird.ai/v1/render.js is supported. */
21
25
  rendererUrl?: string;
22
26
  privacySettingsTarget?: string;
23
27
  getFormat?: () => "banner" | "native" | "clip" | "auto";
24
28
  placementEndpoint?: string;
25
29
  consentEndpoint?: string;
26
- }): PublisherAds;
27
- export function connectPublisherGeneration<T>(url: string, init: RequestInit, consume: GenerationConsumer<T>): Promise<T>;
30
+ };
31
+ export function createPublisherAds(options: PublisherAdsOptionsBase &
32
+ { turnBinding?: false; sessionId?: string }): PublisherAds<false>;
33
+ /** Opt-in successor: a trusted host session is required at mount and at each conversation change. */
34
+ export function createPublisherAds(options: PublisherAdsOptionsBase &
35
+ { turnBinding: true; sessionId: string }): PublisherAds<true>;
36
+ /** Pass the mount's ads instance to keep concurrent surfaces bound to their own generation. */
37
+ export function connectPublisherGeneration<T>(url: string, init: RequestInit, consume: GenerationConsumer<T>, ads?: PublisherAds): Promise<T>;
package/dist/browser.js CHANGED
@@ -20,17 +20,52 @@ function loadRenderer(url) {
20
20
 
21
21
  export function createPublisherAds(options) {
22
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";
23
+ const requiredText = value => typeof value === "string" && value.trim().length > 0;
24
+ if (!target || typeof target.append !== "function" || !requiredText(options.userId) || !requiredText(options.conversationId))
25
+ throw new Error("Application container and session are required");
26
+ if (!["chat", "code", "image", "voice", "agent"].includes(options.jobType)) throw new Error("publisher_job_invalid");
27
+ if (typeof options.clientId !== "string" || !options.clientId.startsWith("wbproj_"))
28
+ throw new Error("publisher_project_invalid");
29
+ if (typeof options.surfaceId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(options.surfaceId))
30
+ throw new Error("publisher_placement_invalid");
31
+ if (options.turnBinding !== undefined && typeof options.turnBinding !== "boolean")
32
+ throw new Error("publisher_turn_binding_invalid");
33
+ const turnBinding = options.turnBinding === true;
34
+ const validId = value => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
35
+ if (turnBinding && !validId(options.sessionId)) throw new Error("publisher_trusted_session_required");
36
+ const timing = options.timing === undefined ? "during" : options.timing;
37
+ const consentMode = options.consentMode === undefined ? "wavebird_consent" : options.consentMode;
25
38
  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() };
39
+ const validEndpoint = value => {
40
+ if (typeof value !== "string" || !value || value.trim() !== value) return false;
41
+ try {
42
+ const base = new URL(window.location?.href ?? "https://publisher.invalid/");
43
+ const endpoint = new URL(value, base);
44
+ return ["http:", "https:"].includes(endpoint.protocol) && endpoint.origin === base.origin &&
45
+ !endpoint.username && !endpoint.password && !endpoint.hash;
46
+ } catch { return false; }
47
+ };
48
+ if ((options.placementEndpoint !== undefined && !validEndpoint(options.placementEndpoint)) ||
49
+ (options.consentEndpoint !== undefined && !validEndpoint(options.consentEndpoint)))
50
+ throw new Error("publisher_endpoint_invalid");
51
+ const rendererUrl = options.rendererUrl === undefined ? "https://api.wavebird.ai/v1/render.js" : options.rendererUrl;
52
+ if (rendererUrl !== "https://api.wavebird.ai/v1/render.js") throw new Error("publisher_renderer_url_invalid");
53
+ if (options.getFormat !== undefined && typeof options.getFormat !== "function")
54
+ throw new Error("publisher_format_hook_invalid");
55
+ let privacyTarget = null;
56
+ if (consentMode === "wavebird_consent" && options.privacySettingsTarget !== undefined) {
57
+ if (typeof options.privacySettingsTarget !== "string" || !options.privacySettingsTarget.trim())
58
+ throw new Error("publisher_privacy_target_invalid");
59
+ try { privacyTarget = document.querySelector(options.privacySettingsTarget); }
60
+ catch { throw new Error("publisher_privacy_target_invalid"); }
61
+ }
62
+ let binding = { userId: options.userId, conversationId: options.conversationId,
63
+ sessionId: turnBinding ? options.sessionId : crypto.randomUUID() };
27
64
  let sponsor = null, disposed = false, context = 0, active = null;
28
65
  const slot = document.createElement("section");
29
66
  slot.setAttribute("aria-label", "Sponsored placement");
30
67
  slot.style.cssText = "width:100%;height:100%;overflow:hidden;margin:0 auto";
31
68
  target.append(slot);
32
- const privacyTarget = consentMode === "wavebird_consent" && options.privacySettingsTarget
33
- ? document.querySelector(options.privacySettingsTarget) : null;
34
69
  const openPrivacySettings = () => { if (!disposed) sponsor?.openPrivacySettings(); };
35
70
  privacyTarget?.addEventListener("click", openPrivacySettings);
36
71
  const notify = (method, id) => {
@@ -48,6 +83,8 @@ export function createPublisherAds(options) {
48
83
  job.state = "started"; notify("onStarted", job.id);
49
84
  } else if (type === "progress") {
50
85
  if (job.state === "started") { job.progressed = true; notify("onDelta", job.id); }
86
+ } else if (type === "no_result") {
87
+ if (job.state === "started") job.state = "no_result";
51
88
  } else if (["done", "error", "abort"].includes(type)) {
52
89
  if (job.state !== "started") return;
53
90
  job.state = type;
@@ -57,11 +94,16 @@ export function createPublisherAds(options) {
57
94
  const prepare = () => { if (!disposed && sponsor) void sponsor.prepare().catch(() => {}); };
58
95
  function setConversation(next) {
59
96
  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;
97
+ if (!requiredText(next?.userId) || !requiredText(next?.conversationId))
98
+ throw new Error("Application session is required");
99
+ if (turnBinding && !validId(next.sessionId)) throw new Error("publisher_trusted_session_required");
100
+ if (next.userId === binding.userId && next.conversationId === binding.conversationId &&
101
+ (!turnBinding || next.sessionId === binding.sessionId)) return;
102
+ if (turnBinding && next.sessionId === binding.sessionId) throw new Error("publisher_session_reuse");
62
103
  if (active) event(active, "abort");
63
104
  context++; active = null;
64
- binding = { userId: next.userId, conversationId: next.conversationId, sessionId: crypto.randomUUID() };
105
+ binding = { userId: next.userId, conversationId: next.conversationId,
106
+ sessionId: turnBinding ? next.sessionId : crypto.randomUUID() };
65
107
  sponsor?.setConversation(binding); prepare();
66
108
  }
67
109
  function dispose() {
@@ -73,10 +115,12 @@ export function createPublisherAds(options) {
73
115
  window.removeEventListener("pagehide", dispose);
74
116
  }
75
117
  window.addEventListener("pagehide", dispose);
76
- const ready = loadRenderer(options.rendererUrl || "https://api.wavebird.ai/v1/render.js").then(() => {
118
+ const ready = loadRenderer(rendererUrl).then(() => {
77
119
  if (disposed) return;
78
120
  sponsor = window.wavebird.createChatSponsor({ target: slot, ...binding,
79
121
  clientId: options.clientId, surfaceId: options.surfaceId, timing, consentMode,
122
+ cleanupAfterClip: true,
123
+ ...(turnBinding ? { turnBinding: true } : {}),
80
124
  ...(options.getFormat === undefined ? {} : { getFormat: options.getFormat }),
81
125
  ...(options.placementEndpoint === undefined ? {} : { placementEndpoint: options.placementEndpoint }),
82
126
  ...(options.consentEndpoint === undefined ? {} : { consentEndpoint: options.consentEndpoint }) });
@@ -102,8 +146,14 @@ export function createPublisherAds(options) {
102
146
  if (signal?.aborted) abort();
103
147
  const headers = { "x-wavebird-session-id": job.sessionId, "x-wavebird-generation-id": job.id };
104
148
  try {
149
+ if (signal?.aborted) throw new Error("generation_aborted");
105
150
  const response = await request(headers);
106
- if (response.headers.get("x-wavebird-generation-transport") !== "1") return await consume(response);
151
+ if (!current(job)) throw new Error("generation_aborted");
152
+ if (response.headers.get("x-wavebird-generation-transport") !== "1") {
153
+ const result = await consume(response, { onDelta() {} });
154
+ if (!current(job)) throw new Error("generation_aborted");
155
+ return result;
156
+ }
107
157
  if (!response.body) throw new Error("generation_transport_missing");
108
158
  reader = response.body.getReader();
109
159
  async function nextRecord() {
@@ -140,11 +190,12 @@ export function createPublisherAds(options) {
140
190
  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
191
  const raw = atob(record.data), bytes = Uint8Array.from(raw, value => value.charCodeAt(0));
142
192
  controller.enqueue(bytes);
143
- if (timing !== "before" && !["image", "image_generation"].includes(options.jobType)) event(job, "progress");
193
+ if (bytes.length && timing !== "before" && options.jobType !== "image") event(job, "progress");
144
194
  return;
145
195
  }
146
196
  if (["done", "error", "abort"].includes(record.type)) {
147
- ended = true; event(job, record.type);
197
+ ended = true;
198
+ if (record.type !== "done" || timing !== "after") event(job, record.type);
148
199
  if (record.type === "done" || !metadata.status.toString().startsWith("2")) controller.close();
149
200
  else controller.error(new Error("generation_" + record.type));
150
201
  return;
@@ -158,12 +209,16 @@ export function createPublisherAds(options) {
158
209
  if ([204, 205, 304].includes(metadata.status)) {
159
210
  const record = await nextRecord();
160
211
  if (!["done", "error", "abort"].includes(record.type)) throw new Error("generation_transport_invalid");
161
- ended = true; event(job, record.type);
212
+ ended = true;
213
+ if (record.type !== "done" || timing !== "after") event(job, record.type);
162
214
  }
163
- const primary = new Response([204, 205, 304].includes(metadata.status) ? null : output, {
215
+ const noBody = [204, 205, 304].includes(metadata.status);
216
+ const primary = new Response(noBody ? null : output, {
164
217
  status: metadata.status, statusText: metadata.status_text || "", headers: metadata.headers
165
218
  });
166
- return await consume(primary, { onDelta: () => event(job, "progress") });
219
+ const result = await consume(primary, { onDelta: () => event(job, "progress") });
220
+ if (timing === "after" && ended) event(job, noBody ? "no_result" : "done");
221
+ return result;
167
222
  } catch (error) {
168
223
  event(job, signal?.aborted ? "abort" : "error");
169
224
  throw error;
@@ -179,8 +234,7 @@ export function createPublisherAds(options) {
179
234
  withdraw: () => sponsor ? sponsor.withdraw() : Promise.resolve({ status: "renderer_unavailable" }) };
180
235
  }
181
236
 
182
- export async function connectPublisherGeneration(url, init, consume) {
183
- const ads = window.publisherAds;
237
+ export async function connectPublisherGeneration(url, init, consume, ads = window.publisherAds) {
184
238
  if (!ads) return consume(await fetch(url, init), { onDelta() {} });
185
239
  return ads.connectGeneration({
186
240
  signal: init.signal ?? undefined,
@@ -1,3 +1,5 @@
1
+ import { Buffer } from "node:buffer";
2
+
1
3
  function generationTransport({ request, run }) {
2
4
  const sessionId = request.headers.get("x-wavebird-session-id") || "";
3
5
  const generationId = request.headers.get("x-wavebird-generation-id") || "";
@@ -0,0 +1,81 @@
1
+ /** PostgreSQL adapter for an existing publisher database; no database client is bundled. */
2
+ const TABLE = "wavebird_publisher_turns";
3
+ const CREATE_TABLE = `CREATE TABLE IF NOT EXISTS ${TABLE} (
4
+ scope_id text NOT NULL,
5
+ turn_key text NOT NULL,
6
+ state_json jsonb NOT NULL,
7
+ pending boolean NOT NULL,
8
+ created_at_ms bigint NOT NULL,
9
+ updated_at_ms bigint NOT NULL,
10
+ PRIMARY KEY (scope_id, turn_key)
11
+ )`;
12
+ const CREATE_INDEX = `CREATE INDEX IF NOT EXISTS wavebird_publisher_turns_pending
13
+ ON ${TABLE} (scope_id, turn_key) WHERE pending = true`;
14
+
15
+ export function createPostgresTurnStorage(pool) {
16
+ if (!pool || typeof pool.connect !== "function") throw new Error("publisher_postgres_pool_required");
17
+ async function withClient(run) {
18
+ const client = await pool.connect();
19
+ try { return await run(client); } finally { client.release(); }
20
+ }
21
+ async function install() {
22
+ await withClient(async client => {
23
+ await client.query(CREATE_TABLE);
24
+ await client.query(CREATE_INDEX);
25
+ });
26
+ }
27
+ async function atomic(scope, key, transition) {
28
+ return withClient(async client => {
29
+ await client.query("BEGIN");
30
+ try {
31
+ const now = Date.now();
32
+ await client.query(`INSERT INTO ${TABLE}(scope_id,turn_key,state_json,pending,created_at_ms,updated_at_ms)
33
+ VALUES ($1,$2,'null'::jsonb,false,$3,$3) ON CONFLICT DO NOTHING`, [scope, key, now]);
34
+ const row = await client.query(`SELECT state_json FROM ${TABLE} WHERE scope_id=$1 AND turn_key=$2 FOR UPDATE`, [scope, key]);
35
+ if (row.rows.length !== 1) throw new Error("publisher_turn_storage_conflict");
36
+ const value = row.rows[0].state_json;
37
+ const previous = typeof value === "string" ? JSON.parse(value) : value;
38
+ const { state, result } = transition(previous);
39
+ if (state === null) {
40
+ await client.query(`DELETE FROM ${TABLE} WHERE scope_id=$1 AND turn_key=$2`, [scope, key]);
41
+ } else {
42
+ const pending = Boolean(state.jobId && state.phase !== "idle" &&
43
+ (!state.startedReported || (["finished", "failed"].includes(state.phase) && !state.terminalReported)));
44
+ await client.query(`UPDATE ${TABLE} SET state_json=$3::jsonb,pending=$4,updated_at_ms=$5
45
+ WHERE scope_id=$1 AND turn_key=$2`, [scope, key, JSON.stringify(state), pending, now]);
46
+ }
47
+ await client.query("COMMIT");
48
+ return result;
49
+ } catch (error) {
50
+ try { await client.query("ROLLBACK"); } catch {}
51
+ throw error;
52
+ }
53
+ });
54
+ }
55
+ async function scan(scope, cursor, limit) {
56
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error("publisher_turn_scan_invalid");
57
+ return withClient(async client => {
58
+ const result = await client.query(`SELECT turn_key FROM ${TABLE}
59
+ WHERE scope_id=$1 AND pending=true AND turn_key>$2 ORDER BY turn_key LIMIT $3`, [scope, cursor, limit]);
60
+ return result.rows.map(row => row.turn_key);
61
+ });
62
+ }
63
+ async function prune(scope) {
64
+ return withClient(async client => {
65
+ await client.query("BEGIN");
66
+ try {
67
+ const now = Date.now();
68
+ await client.query(`DELETE FROM ${TABLE} WHERE scope_id=$1 AND pending=false AND updated_at_ms<$2`,
69
+ [scope, now - 86_400_000]);
70
+ const expired = await client.query(`DELETE FROM ${TABLE} WHERE scope_id=$1 AND pending=true AND created_at_ms<$2 RETURNING turn_key`,
71
+ [scope, now - 7 * 86_400_000]);
72
+ await client.query("COMMIT");
73
+ return expired.rowCount;
74
+ } catch (error) {
75
+ try { await client.query("ROLLBACK"); } catch {}
76
+ throw error;
77
+ }
78
+ });
79
+ }
80
+ return { install, atomic, scan, prune };
81
+ }
package/dist/server.d.ts CHANGED
@@ -1,34 +1,75 @@
1
- export type PublisherServerOptions = {
1
+ export type PublisherTurnIdentity = { projectId: string; surfaceId: string; sessionId: string; turnId: string };
2
+ /** The successor journal must make claims and phase transitions durable across workers. */
3
+ export type PublisherTurnJournal = {
4
+ claimPlacement(turn: PublisherTurnIdentity): Promise<boolean>;
5
+ releaseRejectedPlacement(turn: PublisherTurnIdentity): Promise<void>;
6
+ bind(turn: PublisherTurnIdentity, jobId: string): Promise<void>;
7
+ claimGeneration(turn: PublisherTurnIdentity): Promise<boolean>;
8
+ phase(turn: PublisherTurnIdentity, event: "started" | "finished" | "failed"): Promise<void>;
9
+ reconcilePending?(projectId: string, surfaceId: string): Promise<void>;
10
+ };
11
+ /** Implement this over an existing serializable, persistent application store. */
12
+ export type PublisherAtomicTurnStorage = {
13
+ atomic<T>(scope: string, key: string,
14
+ transition: (previous: unknown | null) => { state: unknown | null; result: T }): Promise<T>;
15
+ /** Return increasing hashed keys after cursor, at most limit. */
16
+ scan(scope: string, cursor: string, limit: number): Promise<string[]>;
17
+ prune?(scope: string): Promise<number>;
18
+ };
19
+ /** Use an existing pg-compatible pool; run install once in the publisher's normal migration step. */
20
+ export function createPostgresTurnStorage(pool: {
21
+ connect(): Promise<{ query(sql: string, params?: unknown[]): Promise<{ rows: Array<Record<string, unknown>>; rowCount?: number }>;
22
+ release(): void }>;
23
+ }): PublisherAtomicTurnStorage & { install(): Promise<void>; prune(scope: string): Promise<number> };
24
+ export function createPublisherTurnJournal(options: {
25
+ storage: PublisherAtomicTurnStorage;
26
+ reportGeneration: (jobId: string, event: "started" | "finished" | "failed") => Promise<void>;
27
+ /** Stable server-only secret for pseudonymous durable turn keys, at least 32 UTF-8 bytes. */
28
+ keySecret: string;
29
+ onDiagnostic?: (code: string) => void;
30
+ }): PublisherTurnJournal;
31
+ /** Derive a stable, domain-separated journal key from the existing server-only preference secret. */
32
+ export function derivePublisherTurnKeySecret(preferenceSecret: string): string;
33
+ type PublisherServerBaseOptions = {
2
34
  apiBaseUrl?: string;
3
35
  clientId: string;
4
36
  secretKey: string;
5
37
  preferenceSecret: string;
6
38
  authorize: (request: Request) => boolean | Promise<boolean>;
39
+ onDiagnostic?: (code: string) => void;
7
40
  publicOrigins: string[];
8
41
  surfaceId: string;
9
42
  placementKey: string;
10
- jobType: string;
43
+ jobType: "chat" | "code" | "image" | "voice" | "agent";
11
44
  locale: string;
12
45
  timing: "before" | "during" | "after";
13
46
  consentMode: "wavebird_consent" | "own_cmp";
14
- gdprApplies: boolean;
47
+ gdprApplies?: boolean;
15
48
  publisher: { app_name: string; app_domain: string; categories: string[] };
16
- allowedFormats: Array<"banner" | "native">;
49
+ allowedFormats: Array<"banner" | "native" | "clip">;
17
50
  positionHint: "above" | "below" | "sidebar" | "between";
18
- maxWidth: number;
19
- maxHeight: number;
20
- allowedSizes: Array<{ width: number; height: number; formats?: Array<"banner" | "native"> }>;
51
+ /** Optional advanced caps; saved allowedSizes determine the normal envelope. */
52
+ maxWidth?: number;
53
+ maxHeight?: number;
54
+ allowedSizes: Array<{ width: number; height: number; formats?: Array<"banner" | "native" | "clip"> }>;
21
55
  responsiveSizeSelection?: boolean;
22
56
  bidfloor?: number;
23
57
  bidfloorCurrency?: string;
24
58
  blockedCategories?: string[];
25
59
  };
60
+ /** Trusted session resolution and the durable turn journal must be supplied together. */
61
+ export type PublisherServerOptions = PublisherServerBaseOptions & (
62
+ { sessionIdForRequest?: never; turnJournal?: never } |
63
+ { sessionIdForRequest: (request: Request) => string | null | Promise<string | null>;
64
+ turnJournal: PublisherTurnJournal }
65
+ );
26
66
  export type GenerationLifecycleEvent = {
27
67
  type: "started" | "done" | "error" | "abort";
28
68
  sessionId: string;
29
69
  generationId: string;
30
70
  };
31
71
  export type PublisherServer = {
72
+ sessionGET(request: Request): Promise<Response>;
32
73
  placementPOST(request: Request): Promise<Response>;
33
74
  consentGET(request: Request): Promise<Response>;
34
75
  consentPOST(request: Request): Promise<Response>;