@wavebirdai/api 0.1.0-beta.10 → 0.1.0-beta.12
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 +2 -2
- package/dist/browser.d.ts +20 -9
- package/dist/browser.js +62 -16
- package/dist/server.d.ts +8 -5
- package/dist/server.js +2 -0
- package/dist/turn-journal.js +2 -0
- package/package.json +1 -1
- package/src/browser.d.ts +20 -9
- package/src/browser.js +62 -16
- package/src/server.d.ts +8 -5
- package/src/server.js +2 -0
- package/src/turn-journal.js +2 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ You need a saved Wavebird Test project and placement in Dashboard, a Server Test
|
|
|
9
9
|
1. Install the package and generate the route files for your app directory:
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
|
-
npm install @wavebirdai/api@0.1.0-beta.
|
|
12
|
+
npm install @wavebirdai/api@0.1.0-beta.12
|
|
13
13
|
./node_modules/.bin/wavebird-next init --lang ts --root . --app-dir src/app
|
|
14
14
|
```
|
|
15
15
|
|
|
@@ -85,7 +85,7 @@ With `sessionIdForRequest` and `turnJournal` configured, the package validates t
|
|
|
85
85
|
|
|
86
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.
|
|
87
87
|
|
|
88
|
-
`@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
89
|
|
|
90
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.
|
|
91
91
|
|
package/dist/browser.d.ts
CHANGED
|
@@ -1,31 +1,42 @@
|
|
|
1
1
|
/** Resolve after the publisher has presented the result; After timing starts then. */
|
|
2
|
-
export type GenerationConsumer<T> = (response: Response, lifecycle: {
|
|
3
|
-
|
|
2
|
+
export type GenerationConsumer<T> = (response: Response, lifecycle: {
|
|
3
|
+
/** Streaming output progress. For Before and During Image, call after the first result is presented. */
|
|
4
|
+
onDelta(): void;
|
|
5
|
+
/** Explicitly mark the result as presented. Idempotent and bound to this generation. */
|
|
6
|
+
onPresented(): void;
|
|
7
|
+
}) => T | Promise<T>;
|
|
8
|
+
export type PublisherAds<Bound extends boolean = boolean> = {
|
|
4
9
|
ready: Promise<void>;
|
|
5
10
|
connectGeneration<T>(options: { request: (headers: Record<string, string>) => Response | Promise<Response>; consume: GenerationConsumer<T>; signal?: AbortSignal }): Promise<T>;
|
|
6
|
-
setConversation(binding:
|
|
11
|
+
setConversation(binding: Bound extends true
|
|
12
|
+
? { userId: string; conversationId: string; sessionId: string }
|
|
13
|
+
: { userId: string; conversationId: string; sessionId?: string }): void;
|
|
7
14
|
dispose(): void;
|
|
8
15
|
openPrivacySettings(): void;
|
|
9
16
|
grant(choice?: unknown): Promise<unknown>;
|
|
10
17
|
deny(): Promise<unknown>;
|
|
11
18
|
withdraw(): Promise<unknown>;
|
|
12
19
|
};
|
|
13
|
-
|
|
20
|
+
type PublisherAdsOptionsBase = {
|
|
14
21
|
target: HTMLElement;
|
|
15
22
|
clientId: string;
|
|
16
23
|
surfaceId: string;
|
|
17
24
|
jobType: "chat" | "code" | "image" | "voice" | "agent";
|
|
18
25
|
userId: string;
|
|
19
26
|
conversationId: string;
|
|
20
|
-
/** Opt-in successor contract: a session ID supplied by the host's authenticated context. */
|
|
21
|
-
sessionId?: string;
|
|
22
|
-
turnBinding?: boolean;
|
|
23
27
|
timing?: "before" | "during" | "after";
|
|
24
28
|
consentMode?: "wavebird_consent" | "own_cmp";
|
|
29
|
+
/** Optional official renderer URL. Only https://api.wavebird.ai/v1/render.js is supported. */
|
|
25
30
|
rendererUrl?: string;
|
|
26
31
|
privacySettingsTarget?: string;
|
|
27
32
|
getFormat?: () => "banner" | "native" | "clip" | "auto";
|
|
28
33
|
placementEndpoint?: string;
|
|
29
34
|
consentEndpoint?: string;
|
|
30
|
-
}
|
|
31
|
-
export function
|
|
35
|
+
};
|
|
36
|
+
export function createPublisherAds(options: PublisherAdsOptionsBase &
|
|
37
|
+
{ turnBinding?: false; sessionId?: string }): PublisherAds<false>;
|
|
38
|
+
/** Opt-in successor: a trusted host session is required at mount and at each conversation change. */
|
|
39
|
+
export function createPublisherAds(options: PublisherAdsOptionsBase &
|
|
40
|
+
{ turnBinding: true; sessionId: string }): PublisherAds<true>;
|
|
41
|
+
/** Pass the mount's ads instance to keep concurrent surfaces bound to their own generation. */
|
|
42
|
+
export function connectPublisherGeneration<T>(url: string, init: RequestInit, consume: GenerationConsumer<T>, ads?: PublisherAds): Promise<T>;
|
package/dist/browser.js
CHANGED
|
@@ -21,19 +21,44 @@ function loadRenderer(url) {
|
|
|
21
21
|
export function createPublisherAds(options) {
|
|
22
22
|
const target = options.target;
|
|
23
23
|
const requiredText = value => typeof value === "string" && value.trim().length > 0;
|
|
24
|
-
if (!target || !requiredText(options.userId) || !requiredText(options.conversationId))
|
|
24
|
+
if (!target || typeof target.append !== "function" || !requiredText(options.userId) || !requiredText(options.conversationId))
|
|
25
25
|
throw new Error("Application container and session are required");
|
|
26
26
|
if (!["chat", "code", "image", "voice", "agent"].includes(options.jobType)) throw new Error("publisher_job_invalid");
|
|
27
27
|
if (typeof options.clientId !== "string" || !options.clientId.startsWith("wbproj_"))
|
|
28
28
|
throw new Error("publisher_project_invalid");
|
|
29
29
|
if (typeof options.surfaceId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(options.surfaceId))
|
|
30
30
|
throw new Error("publisher_placement_invalid");
|
|
31
|
+
if (options.turnBinding !== undefined && typeof options.turnBinding !== "boolean")
|
|
32
|
+
throw new Error("publisher_turn_binding_invalid");
|
|
31
33
|
const turnBinding = options.turnBinding === true;
|
|
32
34
|
const validId = value => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
|
33
35
|
if (turnBinding && !validId(options.sessionId)) throw new Error("publisher_trusted_session_required");
|
|
34
36
|
const timing = options.timing === undefined ? "during" : options.timing;
|
|
35
37
|
const consentMode = options.consentMode === undefined ? "wavebird_consent" : options.consentMode;
|
|
36
38
|
if (!["before", "during", "after"].includes(timing) || !["wavebird_consent", "own_cmp"].includes(consentMode)) throw new Error("Invalid publisher configuration");
|
|
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
|
+
}
|
|
37
62
|
let binding = { userId: options.userId, conversationId: options.conversationId,
|
|
38
63
|
sessionId: turnBinding ? options.sessionId : crypto.randomUUID() };
|
|
39
64
|
let sponsor = null, disposed = false, context = 0, active = null;
|
|
@@ -41,8 +66,6 @@ export function createPublisherAds(options) {
|
|
|
41
66
|
slot.setAttribute("aria-label", "Sponsored placement");
|
|
42
67
|
slot.style.cssText = "width:100%;height:100%;overflow:hidden;margin:0 auto";
|
|
43
68
|
target.append(slot);
|
|
44
|
-
const privacyTarget = consentMode === "wavebird_consent" && options.privacySettingsTarget
|
|
45
|
-
? document.querySelector(options.privacySettingsTarget) : null;
|
|
46
69
|
const openPrivacySettings = () => { if (!disposed) sponsor?.openPrivacySettings(); };
|
|
47
70
|
privacyTarget?.addEventListener("click", openPrivacySettings);
|
|
48
71
|
const notify = (method, id) => {
|
|
@@ -52,6 +75,10 @@ export function createPublisherAds(options) {
|
|
|
52
75
|
sponsor = null; slot.hidden = true; slot.replaceChildren();
|
|
53
76
|
}
|
|
54
77
|
};
|
|
78
|
+
const notifyPresented = id => {
|
|
79
|
+
// Additive bridge: an already deployed renderer may predate this event.
|
|
80
|
+
if (typeof sponsor?.onPresented === "function") notify("onPresented", id);
|
|
81
|
+
};
|
|
55
82
|
const current = job => !disposed && !job.cancelled && job.context === context && active === job;
|
|
56
83
|
const event = (job, type) => {
|
|
57
84
|
if (!current(job)) return;
|
|
@@ -61,9 +88,15 @@ export function createPublisherAds(options) {
|
|
|
61
88
|
} else if (type === "progress") {
|
|
62
89
|
if (job.state === "started") { job.progressed = true; notify("onDelta", job.id); }
|
|
63
90
|
} else if (type === "no_result") {
|
|
64
|
-
if (job.state === "started") job.state = "no_result";
|
|
65
|
-
} else if (["done", "error", "abort"].includes(type)) {
|
|
66
91
|
if (job.state !== "started") return;
|
|
92
|
+
job.state = "no_result";
|
|
93
|
+
if (timing !== "after") notify("onError", job.id);
|
|
94
|
+
} else if (type === "presented") {
|
|
95
|
+
if (!["started", "done"].includes(job.state) || job.presented) return;
|
|
96
|
+
job.presented = true; notifyPresented(job.id);
|
|
97
|
+
} else if (["done", "error", "abort"].includes(type)) {
|
|
98
|
+
if (job.state !== "started" &&
|
|
99
|
+
!(job.state === "done" && !job.consumed && ["error", "abort"].includes(type))) return;
|
|
67
100
|
job.state = type;
|
|
68
101
|
notify(type === "done" ? "onDone" : type === "abort" ? "onAbort" : "onError", job.id);
|
|
69
102
|
}
|
|
@@ -92,10 +125,10 @@ export function createPublisherAds(options) {
|
|
|
92
125
|
window.removeEventListener("pagehide", dispose);
|
|
93
126
|
}
|
|
94
127
|
window.addEventListener("pagehide", dispose);
|
|
95
|
-
const ready = loadRenderer(
|
|
128
|
+
const ready = loadRenderer(rendererUrl).then(() => {
|
|
96
129
|
if (disposed) return;
|
|
97
130
|
sponsor = window.wavebird.createChatSponsor({ target: slot, ...binding,
|
|
98
|
-
clientId: options.clientId, surfaceId: options.surfaceId, timing, consentMode,
|
|
131
|
+
clientId: options.clientId, surfaceId: options.surfaceId, jobType: options.jobType, timing, consentMode,
|
|
99
132
|
cleanupAfterClip: true,
|
|
100
133
|
...(turnBinding ? { turnBinding: true } : {}),
|
|
101
134
|
...(options.getFormat === undefined ? {} : { getFormat: options.getFormat }),
|
|
@@ -113,7 +146,8 @@ export function createPublisherAds(options) {
|
|
|
113
146
|
}).catch(() => {});
|
|
114
147
|
|
|
115
148
|
async function connectGeneration({ request, consume, signal }) {
|
|
116
|
-
const job = { id: crypto.randomUUID(), sessionId: binding.sessionId, context, state: "pending", cancelled: false
|
|
149
|
+
const job = { id: crypto.randomUUID(), sessionId: binding.sessionId, context, state: "pending", cancelled: false,
|
|
150
|
+
presented: false, consumed: false };
|
|
117
151
|
if (active) event(active, "abort");
|
|
118
152
|
active = job;
|
|
119
153
|
let reader = null, ended = false, buffer = "";
|
|
@@ -123,8 +157,14 @@ export function createPublisherAds(options) {
|
|
|
123
157
|
if (signal?.aborted) abort();
|
|
124
158
|
const headers = { "x-wavebird-session-id": job.sessionId, "x-wavebird-generation-id": job.id };
|
|
125
159
|
try {
|
|
160
|
+
if (signal?.aborted) throw new Error("generation_aborted");
|
|
126
161
|
const response = await request(headers);
|
|
127
|
-
if (
|
|
162
|
+
if (!current(job)) throw new Error("generation_aborted");
|
|
163
|
+
if (response.headers.get("x-wavebird-generation-transport") !== "1") {
|
|
164
|
+
const result = await consume(response, { onDelta() {} });
|
|
165
|
+
if (!current(job)) throw new Error("generation_aborted");
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
128
168
|
if (!response.body) throw new Error("generation_transport_missing");
|
|
129
169
|
reader = response.body.getReader();
|
|
130
170
|
async function nextRecord() {
|
|
@@ -161,12 +201,13 @@ export function createPublisherAds(options) {
|
|
|
161
201
|
if (typeof record.data !== "string" || record.data.length > 21848 || !/^[A-Za-z0-9+/]*={0,2}$/.test(record.data)) throw new Error("generation_bytes_invalid");
|
|
162
202
|
const raw = atob(record.data), bytes = Uint8Array.from(raw, value => value.charCodeAt(0));
|
|
163
203
|
controller.enqueue(bytes);
|
|
164
|
-
if (timing !== "before" && options.jobType !== "image") event(job, "progress");
|
|
204
|
+
if (bytes.length && timing !== "before" && options.jobType !== "image") event(job, "progress");
|
|
165
205
|
return;
|
|
166
206
|
}
|
|
167
207
|
if (["done", "error", "abort"].includes(record.type)) {
|
|
168
208
|
ended = true;
|
|
169
|
-
if (record.type
|
|
209
|
+
if (record.type === "done" && [204, 205, 304].includes(metadata.status)) event(job, "no_result");
|
|
210
|
+
else if (record.type !== "done" || timing !== "after") event(job, record.type);
|
|
170
211
|
if (record.type === "done" || !metadata.status.toString().startsWith("2")) controller.close();
|
|
171
212
|
else controller.error(new Error("generation_" + record.type));
|
|
172
213
|
return;
|
|
@@ -181,14 +222,20 @@ export function createPublisherAds(options) {
|
|
|
181
222
|
const record = await nextRecord();
|
|
182
223
|
if (!["done", "error", "abort"].includes(record.type)) throw new Error("generation_transport_invalid");
|
|
183
224
|
ended = true;
|
|
184
|
-
if (record.type
|
|
225
|
+
if (record.type === "done") event(job, "no_result");
|
|
226
|
+
else event(job, record.type);
|
|
185
227
|
}
|
|
186
228
|
const noBody = [204, 205, 304].includes(metadata.status);
|
|
187
229
|
const primary = new Response(noBody ? null : output, {
|
|
188
230
|
status: metadata.status, statusText: metadata.status_text || "", headers: metadata.headers
|
|
189
231
|
});
|
|
190
|
-
const result = await consume(primary, { onDelta: () =>
|
|
191
|
-
|
|
232
|
+
const result = await consume(primary, { onDelta: () => {
|
|
233
|
+
event(job, "progress");
|
|
234
|
+
if (timing === "before" || (timing === "during" && options.jobType === "image")) event(job, "presented");
|
|
235
|
+
}, onPresented: () => event(job, "presented") });
|
|
236
|
+
job.consumed = true;
|
|
237
|
+
if (timing === "before" || (timing === "during" && options.jobType === "image")) event(job, "presented");
|
|
238
|
+
if (timing === "after" && ended && !noBody) event(job, "done");
|
|
192
239
|
return result;
|
|
193
240
|
} catch (error) {
|
|
194
241
|
event(job, signal?.aborted ? "abort" : "error");
|
|
@@ -205,8 +252,7 @@ export function createPublisherAds(options) {
|
|
|
205
252
|
withdraw: () => sponsor ? sponsor.withdraw() : Promise.resolve({ status: "renderer_unavailable" }) };
|
|
206
253
|
}
|
|
207
254
|
|
|
208
|
-
export async function connectPublisherGeneration(url, init, consume) {
|
|
209
|
-
const ads = window.publisherAds;
|
|
255
|
+
export async function connectPublisherGeneration(url, init, consume, ads = window.publisherAds) {
|
|
210
256
|
if (!ads) return consume(await fetch(url, init), { onDelta() {} });
|
|
211
257
|
return ads.connectGeneration({
|
|
212
258
|
signal: init.signal ?? undefined,
|
package/dist/server.d.ts
CHANGED
|
@@ -30,15 +30,12 @@ export function createPublisherTurnJournal(options: {
|
|
|
30
30
|
}): PublisherTurnJournal;
|
|
31
31
|
/** Derive a stable, domain-separated journal key from the existing server-only preference secret. */
|
|
32
32
|
export function derivePublisherTurnKeySecret(preferenceSecret: string): string;
|
|
33
|
-
|
|
33
|
+
type PublisherServerBaseOptions = {
|
|
34
34
|
apiBaseUrl?: string;
|
|
35
35
|
clientId: string;
|
|
36
36
|
secretKey: string;
|
|
37
37
|
preferenceSecret: string;
|
|
38
38
|
authorize: (request: Request) => boolean | Promise<boolean>;
|
|
39
|
-
/** Opt-in successor: derive the exact browser session from the host's authenticated request. */
|
|
40
|
-
sessionIdForRequest?: (request: Request) => string | null | Promise<string | null>;
|
|
41
|
-
turnJournal?: PublisherTurnJournal;
|
|
42
39
|
onDiagnostic?: (code: string) => void;
|
|
43
40
|
publicOrigins: string[];
|
|
44
41
|
surfaceId: string;
|
|
@@ -47,7 +44,7 @@ export type PublisherServerOptions = {
|
|
|
47
44
|
locale: string;
|
|
48
45
|
timing: "before" | "during" | "after";
|
|
49
46
|
consentMode: "wavebird_consent" | "own_cmp";
|
|
50
|
-
gdprApplies
|
|
47
|
+
gdprApplies?: boolean;
|
|
51
48
|
publisher: { app_name: string; app_domain: string; categories: string[] };
|
|
52
49
|
allowedFormats: Array<"banner" | "native" | "clip">;
|
|
53
50
|
positionHint: "above" | "below" | "sidebar" | "between";
|
|
@@ -60,6 +57,12 @@ export type PublisherServerOptions = {
|
|
|
60
57
|
bidfloorCurrency?: string;
|
|
61
58
|
blockedCategories?: string[];
|
|
62
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
|
+
);
|
|
63
66
|
export type GenerationLifecycleEvent = {
|
|
64
67
|
type: "started" | "done" | "error" | "abort";
|
|
65
68
|
sessionId: string;
|
package/dist/server.js
CHANGED
|
@@ -28,6 +28,8 @@ function normalizeOptions(options) {
|
|
|
28
28
|
if (typeof document !== "undefined") throw new Error("publisher_server_only");
|
|
29
29
|
if (options && Object.keys(options).some(key => !SERVER_OPTION_KEYS.has(key))) throw new Error("publisher_option_unknown");
|
|
30
30
|
if (!options || typeof options.authorize !== "function") throw new Error("publisher_authorize_required");
|
|
31
|
+
if (options.onDiagnostic !== undefined && typeof options.onDiagnostic !== "function")
|
|
32
|
+
throw new Error("publisher_diagnostic_invalid");
|
|
31
33
|
if (typeof options.preferenceSecret !== "string" || new TextEncoder().encode(options.preferenceSecret).byteLength < 32) throw new Error("preference_secret_required");
|
|
32
34
|
if ((options.turnJournal === undefined) !== (options.sessionIdForRequest === undefined) ||
|
|
33
35
|
(options.turnJournal !== undefined && (typeof options.sessionIdForRequest !== "function" ||
|
package/dist/turn-journal.js
CHANGED
|
@@ -17,6 +17,8 @@ export function createPublisherTurnJournal({ storage, reportGeneration, keySecre
|
|
|
17
17
|
typeof reportGeneration !== "function") throw new Error("publisher_turn_storage_invalid");
|
|
18
18
|
if (typeof keySecret !== "string" || Buffer.byteLength(keySecret, "utf8") < 32)
|
|
19
19
|
throw new Error("publisher_turn_key_secret_invalid");
|
|
20
|
+
if (onDiagnostic !== undefined && typeof onDiagnostic !== "function")
|
|
21
|
+
throw new Error("publisher_diagnostic_invalid");
|
|
20
22
|
const tasks = new Set();
|
|
21
23
|
let activeReports = 0;
|
|
22
24
|
const reportWaiters = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wavebirdai/api",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.12",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Integrate Banner, Native, and Clip placements into authenticated chat apps with server-side keys, consent, and generation lifecycle handling",
|
package/src/browser.d.ts
CHANGED
|
@@ -1,31 +1,42 @@
|
|
|
1
1
|
/** Resolve after the publisher has presented the result; After timing starts then. */
|
|
2
|
-
export type GenerationConsumer<T> = (response: Response, lifecycle: {
|
|
3
|
-
|
|
2
|
+
export type GenerationConsumer<T> = (response: Response, lifecycle: {
|
|
3
|
+
/** Streaming output progress. For Before and During Image, call after the first result is presented. */
|
|
4
|
+
onDelta(): void;
|
|
5
|
+
/** Explicitly mark the result as presented. Idempotent and bound to this generation. */
|
|
6
|
+
onPresented(): void;
|
|
7
|
+
}) => T | Promise<T>;
|
|
8
|
+
export type PublisherAds<Bound extends boolean = boolean> = {
|
|
4
9
|
ready: Promise<void>;
|
|
5
10
|
connectGeneration<T>(options: { request: (headers: Record<string, string>) => Response | Promise<Response>; consume: GenerationConsumer<T>; signal?: AbortSignal }): Promise<T>;
|
|
6
|
-
setConversation(binding:
|
|
11
|
+
setConversation(binding: Bound extends true
|
|
12
|
+
? { userId: string; conversationId: string; sessionId: string }
|
|
13
|
+
: { userId: string; conversationId: string; sessionId?: string }): void;
|
|
7
14
|
dispose(): void;
|
|
8
15
|
openPrivacySettings(): void;
|
|
9
16
|
grant(choice?: unknown): Promise<unknown>;
|
|
10
17
|
deny(): Promise<unknown>;
|
|
11
18
|
withdraw(): Promise<unknown>;
|
|
12
19
|
};
|
|
13
|
-
|
|
20
|
+
type PublisherAdsOptionsBase = {
|
|
14
21
|
target: HTMLElement;
|
|
15
22
|
clientId: string;
|
|
16
23
|
surfaceId: string;
|
|
17
24
|
jobType: "chat" | "code" | "image" | "voice" | "agent";
|
|
18
25
|
userId: string;
|
|
19
26
|
conversationId: string;
|
|
20
|
-
/** Opt-in successor contract: a session ID supplied by the host's authenticated context. */
|
|
21
|
-
sessionId?: string;
|
|
22
|
-
turnBinding?: boolean;
|
|
23
27
|
timing?: "before" | "during" | "after";
|
|
24
28
|
consentMode?: "wavebird_consent" | "own_cmp";
|
|
29
|
+
/** Optional official renderer URL. Only https://api.wavebird.ai/v1/render.js is supported. */
|
|
25
30
|
rendererUrl?: string;
|
|
26
31
|
privacySettingsTarget?: string;
|
|
27
32
|
getFormat?: () => "banner" | "native" | "clip" | "auto";
|
|
28
33
|
placementEndpoint?: string;
|
|
29
34
|
consentEndpoint?: string;
|
|
30
|
-
}
|
|
31
|
-
export function
|
|
35
|
+
};
|
|
36
|
+
export function createPublisherAds(options: PublisherAdsOptionsBase &
|
|
37
|
+
{ turnBinding?: false; sessionId?: string }): PublisherAds<false>;
|
|
38
|
+
/** Opt-in successor: a trusted host session is required at mount and at each conversation change. */
|
|
39
|
+
export function createPublisherAds(options: PublisherAdsOptionsBase &
|
|
40
|
+
{ turnBinding: true; sessionId: string }): PublisherAds<true>;
|
|
41
|
+
/** Pass the mount's ads instance to keep concurrent surfaces bound to their own generation. */
|
|
42
|
+
export function connectPublisherGeneration<T>(url: string, init: RequestInit, consume: GenerationConsumer<T>, ads?: PublisherAds): Promise<T>;
|
package/src/browser.js
CHANGED
|
@@ -21,19 +21,44 @@ function loadRenderer(url) {
|
|
|
21
21
|
export function createPublisherAds(options) {
|
|
22
22
|
const target = options.target;
|
|
23
23
|
const requiredText = value => typeof value === "string" && value.trim().length > 0;
|
|
24
|
-
if (!target || !requiredText(options.userId) || !requiredText(options.conversationId))
|
|
24
|
+
if (!target || typeof target.append !== "function" || !requiredText(options.userId) || !requiredText(options.conversationId))
|
|
25
25
|
throw new Error("Application container and session are required");
|
|
26
26
|
if (!["chat", "code", "image", "voice", "agent"].includes(options.jobType)) throw new Error("publisher_job_invalid");
|
|
27
27
|
if (typeof options.clientId !== "string" || !options.clientId.startsWith("wbproj_"))
|
|
28
28
|
throw new Error("publisher_project_invalid");
|
|
29
29
|
if (typeof options.surfaceId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(options.surfaceId))
|
|
30
30
|
throw new Error("publisher_placement_invalid");
|
|
31
|
+
if (options.turnBinding !== undefined && typeof options.turnBinding !== "boolean")
|
|
32
|
+
throw new Error("publisher_turn_binding_invalid");
|
|
31
33
|
const turnBinding = options.turnBinding === true;
|
|
32
34
|
const validId = value => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
|
33
35
|
if (turnBinding && !validId(options.sessionId)) throw new Error("publisher_trusted_session_required");
|
|
34
36
|
const timing = options.timing === undefined ? "during" : options.timing;
|
|
35
37
|
const consentMode = options.consentMode === undefined ? "wavebird_consent" : options.consentMode;
|
|
36
38
|
if (!["before", "during", "after"].includes(timing) || !["wavebird_consent", "own_cmp"].includes(consentMode)) throw new Error("Invalid publisher configuration");
|
|
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
|
+
}
|
|
37
62
|
let binding = { userId: options.userId, conversationId: options.conversationId,
|
|
38
63
|
sessionId: turnBinding ? options.sessionId : crypto.randomUUID() };
|
|
39
64
|
let sponsor = null, disposed = false, context = 0, active = null;
|
|
@@ -41,8 +66,6 @@ export function createPublisherAds(options) {
|
|
|
41
66
|
slot.setAttribute("aria-label", "Sponsored placement");
|
|
42
67
|
slot.style.cssText = "width:100%;height:100%;overflow:hidden;margin:0 auto";
|
|
43
68
|
target.append(slot);
|
|
44
|
-
const privacyTarget = consentMode === "wavebird_consent" && options.privacySettingsTarget
|
|
45
|
-
? document.querySelector(options.privacySettingsTarget) : null;
|
|
46
69
|
const openPrivacySettings = () => { if (!disposed) sponsor?.openPrivacySettings(); };
|
|
47
70
|
privacyTarget?.addEventListener("click", openPrivacySettings);
|
|
48
71
|
const notify = (method, id) => {
|
|
@@ -52,6 +75,10 @@ export function createPublisherAds(options) {
|
|
|
52
75
|
sponsor = null; slot.hidden = true; slot.replaceChildren();
|
|
53
76
|
}
|
|
54
77
|
};
|
|
78
|
+
const notifyPresented = id => {
|
|
79
|
+
// Additive bridge: an already deployed renderer may predate this event.
|
|
80
|
+
if (typeof sponsor?.onPresented === "function") notify("onPresented", id);
|
|
81
|
+
};
|
|
55
82
|
const current = job => !disposed && !job.cancelled && job.context === context && active === job;
|
|
56
83
|
const event = (job, type) => {
|
|
57
84
|
if (!current(job)) return;
|
|
@@ -61,9 +88,15 @@ export function createPublisherAds(options) {
|
|
|
61
88
|
} else if (type === "progress") {
|
|
62
89
|
if (job.state === "started") { job.progressed = true; notify("onDelta", job.id); }
|
|
63
90
|
} else if (type === "no_result") {
|
|
64
|
-
if (job.state === "started") job.state = "no_result";
|
|
65
|
-
} else if (["done", "error", "abort"].includes(type)) {
|
|
66
91
|
if (job.state !== "started") return;
|
|
92
|
+
job.state = "no_result";
|
|
93
|
+
if (timing !== "after") notify("onError", job.id);
|
|
94
|
+
} else if (type === "presented") {
|
|
95
|
+
if (!["started", "done"].includes(job.state) || job.presented) return;
|
|
96
|
+
job.presented = true; notifyPresented(job.id);
|
|
97
|
+
} else if (["done", "error", "abort"].includes(type)) {
|
|
98
|
+
if (job.state !== "started" &&
|
|
99
|
+
!(job.state === "done" && !job.consumed && ["error", "abort"].includes(type))) return;
|
|
67
100
|
job.state = type;
|
|
68
101
|
notify(type === "done" ? "onDone" : type === "abort" ? "onAbort" : "onError", job.id);
|
|
69
102
|
}
|
|
@@ -92,10 +125,10 @@ export function createPublisherAds(options) {
|
|
|
92
125
|
window.removeEventListener("pagehide", dispose);
|
|
93
126
|
}
|
|
94
127
|
window.addEventListener("pagehide", dispose);
|
|
95
|
-
const ready = loadRenderer(
|
|
128
|
+
const ready = loadRenderer(rendererUrl).then(() => {
|
|
96
129
|
if (disposed) return;
|
|
97
130
|
sponsor = window.wavebird.createChatSponsor({ target: slot, ...binding,
|
|
98
|
-
clientId: options.clientId, surfaceId: options.surfaceId, timing, consentMode,
|
|
131
|
+
clientId: options.clientId, surfaceId: options.surfaceId, jobType: options.jobType, timing, consentMode,
|
|
99
132
|
cleanupAfterClip: true,
|
|
100
133
|
...(turnBinding ? { turnBinding: true } : {}),
|
|
101
134
|
...(options.getFormat === undefined ? {} : { getFormat: options.getFormat }),
|
|
@@ -113,7 +146,8 @@ export function createPublisherAds(options) {
|
|
|
113
146
|
}).catch(() => {});
|
|
114
147
|
|
|
115
148
|
async function connectGeneration({ request, consume, signal }) {
|
|
116
|
-
const job = { id: crypto.randomUUID(), sessionId: binding.sessionId, context, state: "pending", cancelled: false
|
|
149
|
+
const job = { id: crypto.randomUUID(), sessionId: binding.sessionId, context, state: "pending", cancelled: false,
|
|
150
|
+
presented: false, consumed: false };
|
|
117
151
|
if (active) event(active, "abort");
|
|
118
152
|
active = job;
|
|
119
153
|
let reader = null, ended = false, buffer = "";
|
|
@@ -123,8 +157,14 @@ export function createPublisherAds(options) {
|
|
|
123
157
|
if (signal?.aborted) abort();
|
|
124
158
|
const headers = { "x-wavebird-session-id": job.sessionId, "x-wavebird-generation-id": job.id };
|
|
125
159
|
try {
|
|
160
|
+
if (signal?.aborted) throw new Error("generation_aborted");
|
|
126
161
|
const response = await request(headers);
|
|
127
|
-
if (
|
|
162
|
+
if (!current(job)) throw new Error("generation_aborted");
|
|
163
|
+
if (response.headers.get("x-wavebird-generation-transport") !== "1") {
|
|
164
|
+
const result = await consume(response, { onDelta() {} });
|
|
165
|
+
if (!current(job)) throw new Error("generation_aborted");
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
128
168
|
if (!response.body) throw new Error("generation_transport_missing");
|
|
129
169
|
reader = response.body.getReader();
|
|
130
170
|
async function nextRecord() {
|
|
@@ -161,12 +201,13 @@ export function createPublisherAds(options) {
|
|
|
161
201
|
if (typeof record.data !== "string" || record.data.length > 21848 || !/^[A-Za-z0-9+/]*={0,2}$/.test(record.data)) throw new Error("generation_bytes_invalid");
|
|
162
202
|
const raw = atob(record.data), bytes = Uint8Array.from(raw, value => value.charCodeAt(0));
|
|
163
203
|
controller.enqueue(bytes);
|
|
164
|
-
if (timing !== "before" && options.jobType !== "image") event(job, "progress");
|
|
204
|
+
if (bytes.length && timing !== "before" && options.jobType !== "image") event(job, "progress");
|
|
165
205
|
return;
|
|
166
206
|
}
|
|
167
207
|
if (["done", "error", "abort"].includes(record.type)) {
|
|
168
208
|
ended = true;
|
|
169
|
-
if (record.type
|
|
209
|
+
if (record.type === "done" && [204, 205, 304].includes(metadata.status)) event(job, "no_result");
|
|
210
|
+
else if (record.type !== "done" || timing !== "after") event(job, record.type);
|
|
170
211
|
if (record.type === "done" || !metadata.status.toString().startsWith("2")) controller.close();
|
|
171
212
|
else controller.error(new Error("generation_" + record.type));
|
|
172
213
|
return;
|
|
@@ -181,14 +222,20 @@ export function createPublisherAds(options) {
|
|
|
181
222
|
const record = await nextRecord();
|
|
182
223
|
if (!["done", "error", "abort"].includes(record.type)) throw new Error("generation_transport_invalid");
|
|
183
224
|
ended = true;
|
|
184
|
-
if (record.type
|
|
225
|
+
if (record.type === "done") event(job, "no_result");
|
|
226
|
+
else event(job, record.type);
|
|
185
227
|
}
|
|
186
228
|
const noBody = [204, 205, 304].includes(metadata.status);
|
|
187
229
|
const primary = new Response(noBody ? null : output, {
|
|
188
230
|
status: metadata.status, statusText: metadata.status_text || "", headers: metadata.headers
|
|
189
231
|
});
|
|
190
|
-
const result = await consume(primary, { onDelta: () =>
|
|
191
|
-
|
|
232
|
+
const result = await consume(primary, { onDelta: () => {
|
|
233
|
+
event(job, "progress");
|
|
234
|
+
if (timing === "before" || (timing === "during" && options.jobType === "image")) event(job, "presented");
|
|
235
|
+
}, onPresented: () => event(job, "presented") });
|
|
236
|
+
job.consumed = true;
|
|
237
|
+
if (timing === "before" || (timing === "during" && options.jobType === "image")) event(job, "presented");
|
|
238
|
+
if (timing === "after" && ended && !noBody) event(job, "done");
|
|
192
239
|
return result;
|
|
193
240
|
} catch (error) {
|
|
194
241
|
event(job, signal?.aborted ? "abort" : "error");
|
|
@@ -205,8 +252,7 @@ export function createPublisherAds(options) {
|
|
|
205
252
|
withdraw: () => sponsor ? sponsor.withdraw() : Promise.resolve({ status: "renderer_unavailable" }) };
|
|
206
253
|
}
|
|
207
254
|
|
|
208
|
-
export async function connectPublisherGeneration(url, init, consume) {
|
|
209
|
-
const ads = window.publisherAds;
|
|
255
|
+
export async function connectPublisherGeneration(url, init, consume, ads = window.publisherAds) {
|
|
210
256
|
if (!ads) return consume(await fetch(url, init), { onDelta() {} });
|
|
211
257
|
return ads.connectGeneration({
|
|
212
258
|
signal: init.signal ?? undefined,
|
package/src/server.d.ts
CHANGED
|
@@ -30,15 +30,12 @@ export function createPublisherTurnJournal(options: {
|
|
|
30
30
|
}): PublisherTurnJournal;
|
|
31
31
|
/** Derive a stable, domain-separated journal key from the existing server-only preference secret. */
|
|
32
32
|
export function derivePublisherTurnKeySecret(preferenceSecret: string): string;
|
|
33
|
-
|
|
33
|
+
type PublisherServerBaseOptions = {
|
|
34
34
|
apiBaseUrl?: string;
|
|
35
35
|
clientId: string;
|
|
36
36
|
secretKey: string;
|
|
37
37
|
preferenceSecret: string;
|
|
38
38
|
authorize: (request: Request) => boolean | Promise<boolean>;
|
|
39
|
-
/** Opt-in successor: derive the exact browser session from the host's authenticated request. */
|
|
40
|
-
sessionIdForRequest?: (request: Request) => string | null | Promise<string | null>;
|
|
41
|
-
turnJournal?: PublisherTurnJournal;
|
|
42
39
|
onDiagnostic?: (code: string) => void;
|
|
43
40
|
publicOrigins: string[];
|
|
44
41
|
surfaceId: string;
|
|
@@ -47,7 +44,7 @@ export type PublisherServerOptions = {
|
|
|
47
44
|
locale: string;
|
|
48
45
|
timing: "before" | "during" | "after";
|
|
49
46
|
consentMode: "wavebird_consent" | "own_cmp";
|
|
50
|
-
gdprApplies
|
|
47
|
+
gdprApplies?: boolean;
|
|
51
48
|
publisher: { app_name: string; app_domain: string; categories: string[] };
|
|
52
49
|
allowedFormats: Array<"banner" | "native" | "clip">;
|
|
53
50
|
positionHint: "above" | "below" | "sidebar" | "between";
|
|
@@ -60,6 +57,12 @@ export type PublisherServerOptions = {
|
|
|
60
57
|
bidfloorCurrency?: string;
|
|
61
58
|
blockedCategories?: string[];
|
|
62
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
|
+
);
|
|
63
66
|
export type GenerationLifecycleEvent = {
|
|
64
67
|
type: "started" | "done" | "error" | "abort";
|
|
65
68
|
sessionId: string;
|
package/src/server.js
CHANGED
|
@@ -28,6 +28,8 @@ function normalizeOptions(options) {
|
|
|
28
28
|
if (typeof document !== "undefined") throw new Error("publisher_server_only");
|
|
29
29
|
if (options && Object.keys(options).some(key => !SERVER_OPTION_KEYS.has(key))) throw new Error("publisher_option_unknown");
|
|
30
30
|
if (!options || typeof options.authorize !== "function") throw new Error("publisher_authorize_required");
|
|
31
|
+
if (options.onDiagnostic !== undefined && typeof options.onDiagnostic !== "function")
|
|
32
|
+
throw new Error("publisher_diagnostic_invalid");
|
|
31
33
|
if (typeof options.preferenceSecret !== "string" || new TextEncoder().encode(options.preferenceSecret).byteLength < 32) throw new Error("preference_secret_required");
|
|
32
34
|
if ((options.turnJournal === undefined) !== (options.sessionIdForRequest === undefined) ||
|
|
33
35
|
(options.turnJournal !== undefined && (typeof options.sessionIdForRequest !== "function" ||
|
package/src/turn-journal.js
CHANGED
|
@@ -17,6 +17,8 @@ export function createPublisherTurnJournal({ storage, reportGeneration, keySecre
|
|
|
17
17
|
typeof reportGeneration !== "function") throw new Error("publisher_turn_storage_invalid");
|
|
18
18
|
if (typeof keySecret !== "string" || Buffer.byteLength(keySecret, "utf8") < 32)
|
|
19
19
|
throw new Error("publisher_turn_key_secret_invalid");
|
|
20
|
+
if (onDiagnostic !== undefined && typeof onDiagnostic !== "function")
|
|
21
|
+
throw new Error("publisher_diagnostic_invalid");
|
|
20
22
|
const tasks = new Set();
|
|
21
23
|
let activeReports = 0;
|
|
22
24
|
const reportWaiters = [];
|