@wavebirdai/api 0.1.0-beta.18 → 0.1.0-beta.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +5 -5
- package/dist/browser.js +14 -9
- package/dist/generation.js +13 -2
- package/dist/server.d.ts +2 -1
- package/dist/server.js +9 -1
- package/dist/turn-journal.js +13 -11
- package/docs/web/generation.md +5 -11
- package/docs/web/nextjs.md +3 -1
- package/docs/web/reference.md +8 -2
- package/examples/next-js/lib/auth.js +1 -0
- package/examples/next-ts/lib/auth.ts +1 -0
- package/package.json +2 -2
- package/src/browser.js +14 -9
- package/src/generation.js +13 -2
- package/src/server.d.ts +2 -1
- package/src/server.js +9 -1
- package/src/turn-journal.js +13 -11
- package/templates/NEXT-SETUP.md +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-beta.19
|
|
4
|
+
|
|
5
|
+
- Bind consent writes to the authenticated publisher session when a trusted session resolver is configured. A stale or different session is rejected before any consent write or preference-cookie change.
|
|
6
|
+
- Release late model response bodies after cancellation, including browser disposal and conversation changes.
|
|
7
|
+
- Reject canceled or superseded generations after asynchronous result presentation instead of returning success.
|
|
8
|
+
- Supply both lifecycle callbacks for raw/model-only responses and the helper without an ad mount.
|
|
9
|
+
- Acquire generation-report leases only when a dispatch slot is available, preventing expiry while queued.
|
|
10
|
+
- Reuse each server instance's non-exportable preference signing key and release rejected auth-check bodies in the examples.
|
|
11
|
+
- Clarify maintained Node.js runtimes, response cancellation, database timeouts and pending-report recovery. Existing package pins and technical minimum remain unchanged.
|
|
12
|
+
|
|
3
13
|
## 0.1.0-beta.18
|
|
4
14
|
|
|
5
15
|
- Prevent fractional clipping of the hosted ad frame at some device scales.
|
package/README.md
CHANGED
|
@@ -16,17 +16,17 @@ Native iOS and Android packages are not currently available. See [integration pa
|
|
|
16
16
|
|
|
17
17
|
## Set up JavaScript or TypeScript
|
|
18
18
|
|
|
19
|
-
Use Node.js 20
|
|
19
|
+
Use a [supported Node.js LTS release](https://github.com/nodejs/Release#release-schedule) with current security updates. The technical minimum remains Node.js 20, which is no longer maintained.
|
|
20
20
|
|
|
21
|
-
1. **Open your
|
|
21
|
+
1. **Open your app in Dashboard.** Choose JavaScript or TypeScript. New apps use the current package; existing apps keep their saved version. Verify your domain with an HTTPS file or DNS TXT record, then save your HTTPS app URL and approved origins. `www.example.com` and `example.com` are separate hosts.
|
|
22
22
|
2. **Install the package.** Run this in your app's terminal, in the folder containing `package.json`:
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
|
-
npm install @wavebirdai/api@0.1.0-beta.
|
|
25
|
+
npm install @wavebirdai/api@0.1.0-beta.19
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
3. **Save the Server ENV from Overview, Integration step 2.** The key-only download is not the full configuration. Keep `WAVEBIRD_SECRET_KEY` and the stable, project-specific `WAVEBIRD_PREFERENCE_SECRET` out of browser code and Git. Include the saved `WAVEBIRD_PUBLIC_ORIGINS`.
|
|
29
|
-
4. **Connect the backend and frontend code.** Follow Dashboard's generated files to connect your app session, durable turn storage, model stream and ad slot. The source ZIP contains code only; download secret Server ENV separately. Keep the
|
|
29
|
+
4. **Connect the backend and frontend code.** Follow Dashboard's generated files to connect your app session, durable turn storage, model stream and ad slot. The source ZIP contains code only; download secret Server ENV separately. Keep the project-approved formats, timing and size limits. For apps with a Content Security Policy, follow the [exact renderer rules](https://github.com/wavebird-ai/api/blob/main/docs/web/nextjs.md#generate-files).
|
|
30
30
|
5. **Run your first Test ad.** Confirm that the ad appears and the request is recorded in Test Logs. Check that generation still works with declined consent, no available ad and Stop.
|
|
31
31
|
|
|
32
32
|
Test ads are nonpayable. Complete the [Live prerequisites](https://wavebird.ai/api/guides/going-live) before requesting Live activation.
|
|
@@ -35,7 +35,7 @@ For Next.js, you can use the [generator](https://github.com/wavebird-ai/api/blob
|
|
|
35
35
|
|
|
36
36
|
## Connect your model output
|
|
37
37
|
|
|
38
|
-
Use `@wavebirdai/api/server` on the server and `@wavebirdai/api/browser` for the ad slot. Your response consumer must display the model output and finish only after the result is visible.
|
|
38
|
+
Use `@wavebirdai/api/server` on the server and `@wavebirdai/api/browser` for the ad slot. Your response consumer must display the model output and finish only after the result is visible. Forward the same abort signal to your request and stop your own UI updates when it is aborted. Canceled or superseded generations reject with `generation_aborted`.
|
|
39
39
|
|
|
40
40
|
[JavaScript and TypeScript examples](https://github.com/wavebird-ai/api/blob/main/docs/web/generation.md) | [Server and browser reference](https://github.com/wavebird-ai/api/blob/main/docs/web/reference.md)
|
|
41
41
|
|
package/dist/browser.js
CHANGED
|
@@ -145,7 +145,7 @@ export function createPublisherAds(options) {
|
|
|
145
145
|
if (next.userId === binding.userId && next.conversationId === binding.conversationId &&
|
|
146
146
|
(!turnBinding || next.sessionId === binding.sessionId)) return;
|
|
147
147
|
if (turnBinding && next.sessionId === binding.sessionId) throw new Error("publisher_session_reuse");
|
|
148
|
-
|
|
148
|
+
active?.cancel?.();
|
|
149
149
|
context++; active = null;
|
|
150
150
|
binding = { userId: next.userId, conversationId: next.conversationId,
|
|
151
151
|
sessionId: turnBinding ? next.sessionId : crypto.randomUUID() };
|
|
@@ -153,7 +153,7 @@ export function createPublisherAds(options) {
|
|
|
153
153
|
}
|
|
154
154
|
function dispose() {
|
|
155
155
|
if (disposed) return;
|
|
156
|
-
|
|
156
|
+
active?.cancel?.();
|
|
157
157
|
disposed = true; context++; active = null;
|
|
158
158
|
sponsor?.dispose(); sponsor = null; slot.remove();
|
|
159
159
|
slot.removeEventListener?.("ad:render_failed", onRenderFailure, true);
|
|
@@ -187,20 +187,21 @@ export function createPublisherAds(options) {
|
|
|
187
187
|
async function connectGeneration({ request, consume, signal }) {
|
|
188
188
|
const job = { id: crypto.randomUUID(), sessionId: binding.sessionId, context, state: "pending", cancelled: false,
|
|
189
189
|
presented: false, consumed: false };
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
let reader = null, ended = false, buffer = "";
|
|
190
|
+
active?.cancel?.();
|
|
191
|
+
let reader = null, response = null, ended = false, buffer = "";
|
|
193
192
|
const decoder = new TextDecoder();
|
|
194
193
|
const abort = () => { event(job, "abort"); job.cancelled = true; void reader?.cancel().catch(() => {}); };
|
|
194
|
+
job.cancel = abort;
|
|
195
|
+
active = job;
|
|
195
196
|
signal?.addEventListener("abort", abort, { once: true });
|
|
196
197
|
if (signal?.aborted) abort();
|
|
197
198
|
const headers = { "x-wavebird-session-id": job.sessionId, "x-wavebird-generation-id": job.id };
|
|
198
199
|
try {
|
|
199
200
|
if (signal?.aborted) throw new Error("generation_aborted");
|
|
200
|
-
|
|
201
|
+
response = await request(headers);
|
|
201
202
|
if (!current(job)) throw new Error("generation_aborted");
|
|
202
203
|
if (response.headers.get("x-wavebird-generation-transport") !== "1") {
|
|
203
|
-
const result = await consume(response, { onDelta() {} });
|
|
204
|
+
const result = await consume(response, { onDelta() {}, onPresented() {} });
|
|
204
205
|
if (!current(job)) throw new Error("generation_aborted");
|
|
205
206
|
return result;
|
|
206
207
|
}
|
|
@@ -208,6 +209,7 @@ export function createPublisherAds(options) {
|
|
|
208
209
|
reader = response.body.getReader();
|
|
209
210
|
async function nextRecord() {
|
|
210
211
|
while (true) {
|
|
212
|
+
if (!current(job)) throw new Error("generation_aborted");
|
|
211
213
|
const boundary = buffer.indexOf("\n");
|
|
212
214
|
if (boundary >= 0) {
|
|
213
215
|
const line = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 1);
|
|
@@ -218,7 +220,7 @@ export function createPublisherAds(options) {
|
|
|
218
220
|
}
|
|
219
221
|
if (buffer.length > 32768) throw new Error("generation_transport_limit");
|
|
220
222
|
const next = await reader.read();
|
|
221
|
-
if (
|
|
223
|
+
if (!current(job)) throw new Error("generation_aborted");
|
|
222
224
|
if (next.done) throw new Error("generation_transport_truncated");
|
|
223
225
|
buffer += decoder.decode(next.value, { stream: true });
|
|
224
226
|
}
|
|
@@ -272,6 +274,7 @@ export function createPublisherAds(options) {
|
|
|
272
274
|
event(job, "progress");
|
|
273
275
|
if (timing === "before" || (timing === "during" && options.jobType === "image")) event(job, "presented");
|
|
274
276
|
}, onPresented: () => event(job, "presented") });
|
|
277
|
+
if (!current(job)) throw new Error("generation_aborted");
|
|
275
278
|
job.consumed = true;
|
|
276
279
|
if (timing === "before" || (timing === "during" && options.jobType === "image")) event(job, "presented");
|
|
277
280
|
if (timing === "after" && ended && !noBody) event(job, "done");
|
|
@@ -283,6 +286,8 @@ export function createPublisherAds(options) {
|
|
|
283
286
|
if (!ended && job.state === "started") event(job, "abort");
|
|
284
287
|
signal?.removeEventListener("abort", abort);
|
|
285
288
|
if (reader) { try { await reader.cancel(); } catch {} reader.releaseLock(); }
|
|
289
|
+
else if (response?.body && !response.body.locked) void response.body.cancel().catch(() => {});
|
|
290
|
+
job.cancel = null;
|
|
286
291
|
}
|
|
287
292
|
}
|
|
288
293
|
return { ready, connectGeneration, setConversation, dispose, openPrivacySettings,
|
|
@@ -292,7 +297,7 @@ export function createPublisherAds(options) {
|
|
|
292
297
|
}
|
|
293
298
|
|
|
294
299
|
export async function connectPublisherGeneration(url, init, consume, ads = window.publisherAds) {
|
|
295
|
-
if (!ads) return consume(await fetch(url, init), { onDelta() {} });
|
|
300
|
+
if (!ads) return consume(await fetch(url, init), { onDelta() {}, onPresented() {} });
|
|
296
301
|
return ads.connectGeneration({
|
|
297
302
|
signal: init.signal ?? undefined,
|
|
298
303
|
request: values => {
|
package/dist/generation.js
CHANGED
|
@@ -11,10 +11,15 @@ function generationTransport({ request, run }) {
|
|
|
11
11
|
request.signal.addEventListener("abort", stop, { once: true });
|
|
12
12
|
if (request.signal.aborted)
|
|
13
13
|
stop();
|
|
14
|
-
let reader = null;
|
|
14
|
+
let reader = null, modelResponse = null;
|
|
15
15
|
let pending;
|
|
16
16
|
try {
|
|
17
|
-
pending = Promise.resolve(run(abort.signal))
|
|
17
|
+
pending = Promise.resolve(run(abort.signal)).then(response => {
|
|
18
|
+
modelResponse = response;
|
|
19
|
+
if (abort.signal.aborted && response?.body && !response.body.locked)
|
|
20
|
+
void response.body.cancel().catch(() => { });
|
|
21
|
+
return response;
|
|
22
|
+
});
|
|
18
23
|
}
|
|
19
24
|
catch (error) {
|
|
20
25
|
pending = Promise.reject(error);
|
|
@@ -67,6 +72,9 @@ function generationTransport({ request, run }) {
|
|
|
67
72
|
catch { }
|
|
68
73
|
reader.releaseLock();
|
|
69
74
|
}
|
|
75
|
+
else if (modelResponse?.body && !modelResponse.body.locked) {
|
|
76
|
+
void modelResponse.body.cancel().catch(() => { });
|
|
77
|
+
}
|
|
70
78
|
request.signal.removeEventListener("abort", stop);
|
|
71
79
|
}
|
|
72
80
|
}
|
|
@@ -87,6 +95,9 @@ function generationTransport({ request, run }) {
|
|
|
87
95
|
cancel(reason) {
|
|
88
96
|
abort.abort(reason);
|
|
89
97
|
void reader?.cancel(reason).catch(() => { });
|
|
98
|
+
if (!reader && modelResponse?.body && !modelResponse.body.locked)
|
|
99
|
+
void modelResponse.body.cancel(reason).catch(() => { });
|
|
100
|
+
request.signal.removeEventListener("abort", stop);
|
|
90
101
|
void iterator.return(undefined).catch(() => { });
|
|
91
102
|
}
|
|
92
103
|
}, { highWaterMark: 1 });
|
package/dist/server.d.ts
CHANGED
|
@@ -61,7 +61,8 @@ type PublisherServerBaseOptions = {
|
|
|
61
61
|
/** Trusted session resolution and the durable turn journal must be supplied together. */
|
|
62
62
|
export type PublisherServerOptions = PublisherServerBaseOptions & (
|
|
63
63
|
{ sessionIdForRequest?: never; turnJournal?: never } |
|
|
64
|
-
{
|
|
64
|
+
{ /** Resolve the current authenticated session for consent, placement and generation requests. */
|
|
65
|
+
sessionIdForRequest: (request: Request) => string | null | Promise<string | null>;
|
|
65
66
|
turnJournal: PublisherTurnJournal }
|
|
66
67
|
);
|
|
67
68
|
export type GenerationLifecycleEvent = {
|
package/dist/server.js
CHANGED
|
@@ -288,9 +288,10 @@ export function createPublisherServer(inputOptions) {
|
|
|
288
288
|
const cookie = (value, ttl = PREFERENCE_TTL) => `${cookieName}=${value}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${ttl}`;
|
|
289
289
|
const scope = request => ({ project: options.clientId, surface: options.surfaceId, version: PREFERENCE_VERSION,
|
|
290
290
|
profile: options.consentMode === "wavebird_consent" ? "wavebird_dialog" : "publisher_custom", origin: publicOrigin(request, options) });
|
|
291
|
+
let preferenceKeyPromise;
|
|
291
292
|
async function preferenceKey() {
|
|
292
293
|
if (new TextEncoder().encode(options.preferenceSecret).byteLength < 32) throw new Error("preference_secret_required");
|
|
293
|
-
return crypto.subtle.importKey("raw", new TextEncoder().encode(options.preferenceSecret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
|
|
294
|
+
return preferenceKeyPromise ??= crypto.subtle.importKey("raw", new TextEncoder().encode(options.preferenceSecret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
|
|
294
295
|
}
|
|
295
296
|
async function signPreference(request, state) {
|
|
296
297
|
const now = Date.now();
|
|
@@ -432,6 +433,13 @@ export function createPublisherServer(inputOptions) {
|
|
|
432
433
|
!["granted", "denied", "withdrawn", "cmp_error"].includes(input.lifecycle_state) ||
|
|
433
434
|
!Number.isSafeInteger(input.revision) || input.revision < 1) return response("consent_input_invalid", 400);
|
|
434
435
|
if (["tcf_string", "gpp_string", "gpp_sections"].some(field => Object.hasOwn(input, field))) return response("external_consent_protocol_requires_policy", 400);
|
|
436
|
+
if (options.sessionIdForRequest) {
|
|
437
|
+
let sessionId;
|
|
438
|
+
try { sessionId = await journalCall(() => options.sessionIdForRequest(request)); }
|
|
439
|
+
catch { return response("publisher_session_unavailable", 503); }
|
|
440
|
+
if (!ID.test(sessionId || "")) return response("publisher_session_unavailable", 401);
|
|
441
|
+
if (input.session_id.trim() !== sessionId) return response("publisher_session_mismatch", 401);
|
|
442
|
+
}
|
|
435
443
|
const clear = input.remember_for_30_days === true ? { "Set-Cookie": cookie("", 0) } : {};
|
|
436
444
|
if (input.remember_for_30_days === true) {
|
|
437
445
|
try { await preferenceKey(); } catch { return response("preference_unavailable", 503, clear); }
|
package/dist/turn-journal.js
CHANGED
|
@@ -19,7 +19,6 @@ export function createPublisherTurnJournal({ storage, reportGeneration, keySecre
|
|
|
19
19
|
throw new Error("publisher_turn_key_secret_invalid");
|
|
20
20
|
if (onDiagnostic !== undefined && typeof onDiagnostic !== "function")
|
|
21
21
|
throw new Error("publisher_diagnostic_invalid");
|
|
22
|
-
const tasks = new Set();
|
|
23
22
|
let activeReports = 0;
|
|
24
23
|
const reportWaiters = [];
|
|
25
24
|
const diagnostic = code => { try { Promise.resolve(onDiagnostic?.(code)).catch(() => {}); } catch {} };
|
|
@@ -77,9 +76,7 @@ export function createPublisherTurnJournal({ storage, reportGeneration, keySecre
|
|
|
77
76
|
});
|
|
78
77
|
}
|
|
79
78
|
function schedule(scope, key) {
|
|
80
|
-
|
|
81
|
-
tasks.add(task);
|
|
82
|
-
void task.finally(() => tasks.delete(task));
|
|
79
|
+
void drain(scope, key).catch(() => diagnostic("turn_journal_unavailable"));
|
|
83
80
|
}
|
|
84
81
|
async function bind(turn, jobId) {
|
|
85
82
|
if (!JOB_ID.test(jobId || "")) throw new Error("publisher_job_id_invalid");
|
|
@@ -125,15 +122,20 @@ export function createPublisherTurnJournal({ storage, reportGeneration, keySecre
|
|
|
125
122
|
}
|
|
126
123
|
async function drain(scope, key) {
|
|
127
124
|
while (true) {
|
|
128
|
-
const next = await lease(scope, key);
|
|
129
|
-
if (!next) return;
|
|
130
125
|
if (activeReports >= 4) await new Promise(resolve => reportWaiters.push(resolve));
|
|
131
|
-
activeReports++;
|
|
126
|
+
else activeReports++;
|
|
132
127
|
let accepted = false;
|
|
133
|
-
try {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
128
|
+
try {
|
|
129
|
+
const next = await lease(scope, key);
|
|
130
|
+
if (!next) return;
|
|
131
|
+
try { await reportGeneration(next.jobId, next.event); accepted = true; }
|
|
132
|
+
catch { diagnostic("generation_report_unavailable"); }
|
|
133
|
+
await ack(scope, key, next.event, next.leaseId, accepted);
|
|
134
|
+
} finally {
|
|
135
|
+
const waiter = reportWaiters.shift();
|
|
136
|
+
if (waiter) waiter();
|
|
137
|
+
else activeReports--;
|
|
138
|
+
}
|
|
137
139
|
if (!accepted) return;
|
|
138
140
|
}
|
|
139
141
|
}
|
package/docs/web/generation.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Connect generation to the displayed result
|
|
2
2
|
|
|
3
|
-
These are connection helpers, not complete apps. Use the same installed `@wavebirdai/api@0.1.0-beta.
|
|
3
|
+
These are connection helpers, not complete apps. Use the same installed `@wavebirdai/api@0.1.0-beta.19` as your server routes.
|
|
4
4
|
|
|
5
5
|
- `ads` is the instance for this surface, returned by `createPublisherAds` or the generated component's `onReady` callback.
|
|
6
6
|
- `sendExistingRequest` sends your existing authenticated model request. Merge the provided headers into its headers and preserve its body and abort signal. Call the route wrapped by `withGeneration`.
|
|
7
|
-
- `consumeAndPresentResponse` is your existing response parser and UI consumer. Call `onDelta` as output becomes visible
|
|
7
|
+
- `consumeAndPresentResponse` is your existing response parser and UI consumer. Call `onDelta` as output becomes visible and `onPresented` when the result is presented. Resolve after presentation and reject on failure. Capture the same abort signal to stop your own UI updates after Stop or a new submit. For an image, do not report progress before the result is displayed.
|
|
8
8
|
|
|
9
9
|
## TypeScript
|
|
10
10
|
|
|
@@ -22,10 +22,7 @@ export function runGeneration<T>(
|
|
|
22
22
|
return ads.connectGeneration({
|
|
23
23
|
signal,
|
|
24
24
|
request: headers => sendExistingRequest(headers, signal),
|
|
25
|
-
consume:
|
|
26
|
-
onDelta: lifecycle.onDelta,
|
|
27
|
-
onPresented: () => lifecycle.onPresented?.(),
|
|
28
|
-
}),
|
|
25
|
+
consume: consumeAndPresentResponse,
|
|
29
26
|
});
|
|
30
27
|
}
|
|
31
28
|
```
|
|
@@ -39,15 +36,12 @@ export function runGeneration(
|
|
|
39
36
|
return ads.connectGeneration({
|
|
40
37
|
signal,
|
|
41
38
|
request: headers => sendExistingRequest(headers, signal),
|
|
42
|
-
consume:
|
|
43
|
-
onDelta: lifecycle.onDelta,
|
|
44
|
-
onPresented: () => lifecycle.onPresented?.(),
|
|
45
|
-
}),
|
|
39
|
+
consume: consumeAndPresentResponse,
|
|
46
40
|
});
|
|
47
41
|
}
|
|
48
42
|
```
|
|
49
43
|
|
|
50
|
-
|
|
44
|
+
Both callbacks are available for every response in this version. For raw/model-only fallback responses they are no-ops; calling them does not start an ad or mark a failed ad as successful.
|
|
51
45
|
|
|
52
46
|
## Timing and cleanup
|
|
53
47
|
|
package/docs/web/nextjs.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Use this path instead of copying Dashboard's route files. It supports an existing Next.js App Router app with JavaScript or TypeScript. Use the saved Dashboard configuration in either path.
|
|
4
4
|
|
|
5
|
+
Run the app on a supported Node.js LTS release with current security updates. Node.js 20 remains the technical minimum, but is no longer maintained.
|
|
6
|
+
|
|
5
7
|
## Generate files
|
|
6
8
|
|
|
7
9
|
The generator never overwrites existing files. If it reports a conflict, keep your app's file and merge the authentication, storage and generation hooks from [Dashboard Overview](https://dashboard.wavebird.ai/wavebird/overview). Do not delete your model implementation to make the generator run.
|
|
@@ -11,7 +13,7 @@ If your app sets Content Security Policy, allow `https://api.wavebird.ai` in its
|
|
|
11
13
|
From the folder containing `package.json`:
|
|
12
14
|
|
|
13
15
|
```sh
|
|
14
|
-
npm install @wavebirdai/api@0.1.0-beta.
|
|
16
|
+
npm install @wavebirdai/api@0.1.0-beta.19 server-only
|
|
15
17
|
./node_modules/.bin/wavebird-next init --lang ts --root . --app-dir src/app
|
|
16
18
|
```
|
|
17
19
|
|
package/docs/web/reference.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Web integration reference
|
|
2
2
|
|
|
3
|
-
Applies to `@wavebirdai/api@0.1.0-beta.
|
|
3
|
+
Applies to `@wavebirdai/api@0.1.0-beta.19`. [Start with the integration steps](../../README.md#set-up-javascript-or-typescript).
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
`@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.
|
|
@@ -11,7 +11,9 @@ Create the server helper when a request arrives, as the included Next.js example
|
|
|
11
11
|
|
|
12
12
|
`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.
|
|
13
13
|
|
|
14
|
-
With `sessionIdForRequest` and `turnJournal` configured, the package validates the
|
|
14
|
+
With `sessionIdForRequest` and `turnJournal` configured, the package validates the non-credential browser session ID against the authenticated request and binds each turn to its authorized placement job. Failed generation reports remain in the durable store. Turn updates and server-helper initialization retry pending reports in order. For recovery during idle periods, call `turnJournal.reconcilePending(projectId, surfaceId)` from an existing server maintenance job; the package does not start a background timer. Delivery is repeatable, so the receiving service must remain idempotent.
|
|
15
|
+
|
|
16
|
+
The PostgreSQL adapter uses your existing pool. Run its `install()` in your normal migration step and configure pool acquisition, query and lock timeouts. The helper's wait deadline alone does not cancel a database operation. An unavailable journal suppresses ad placement while an authenticated model request can still run. Keep the store and HMAC key stable across workers and restarts. A durable project key-version guard rejects changed keys before new turns; pending reports can still reconcile. Drain active turns before coordinating a key rotation. Never clear the guard to restore ad service. These exports are included from `0.1.0-beta.12`; earlier versions retain their behavior.
|
|
15
17
|
|
|
16
18
|
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.
|
|
17
19
|
|
|
@@ -19,6 +21,8 @@ Call `reportGeneration` only from trusted server code with a job ID returned by
|
|
|
19
21
|
|
|
20
22
|
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.
|
|
21
23
|
|
|
24
|
+
Pass the same abort signal to the request and capture it in your response consumer. Stop, a new submit, conversation changes and disposal invalidate the old generation; it must not report success after its consumer finishes. `generation_aborted` is an expected cancellation, not a reason to replace a key. The consumer must stop its own UI updates when aborted. In beta.19, raw/model-only fallback responses supply both `onDelta` and `onPresented` as no-ops, preserving the typed callback contract without starting an ad.
|
|
25
|
+
|
|
22
26
|
|
|
23
27
|
## Placement and diagnosis
|
|
24
28
|
|
|
@@ -51,6 +55,8 @@ This is a configuration fragment, not a complete integration. Appearance does no
|
|
|
51
55
|
With the trusted-session integration, consent restoration reads the current server revision before synchronizing the saved choice. Reloading does not reset consent revisions. An unavailable revision keeps ads disabled until consent can be synchronized; it does not block the model response.
|
|
52
56
|
|
|
53
57
|
- **401:** check the app session and server authorization before changing the wavebird key.
|
|
58
|
+
- **`publisher_session_mismatch`:** the page's session no longer matches the authenticated request. Reload the page and use the current session from your session endpoint. Do not retry with an old ID, replace the key or reset consent counters.
|
|
59
|
+
- **Consent conflict:** reload older open tabs after updating the hosted integration. Request the current consent revision before an explicit choice. A genuine concurrent choice must fail closed; never automatically retry a grant.
|
|
54
60
|
- **Origin rejected:** compare scheme, hostname and port with the saved project and server allowlist. `www.example.com` and `example.com` are separate origins.
|
|
55
61
|
- **No-Fill or `no_fit`:** keep the model result usable. Check saved formats and the measured slot size. Do not stretch a creative to force it to fit.
|
|
56
62
|
- **Empty frame:** record only the fixed error category and relative timing. HTTP 200 alone does not prove visible rendering. Never share signed URLs, headers, tokens or a HAR containing credentials.
|
|
@@ -21,6 +21,7 @@ export function sessionIdForRequest(request) {
|
|
|
21
21
|
cache: "no-store", redirect: "manual", signal: AbortSignal.timeout(1500),
|
|
22
22
|
});
|
|
23
23
|
const id = result.headers.get("x-publisher-session-id");
|
|
24
|
+
void result.body?.cancel().catch(() => {});
|
|
24
25
|
return result.status === 204 && ID.test(id || "") ? id : null;
|
|
25
26
|
} catch { return null; }
|
|
26
27
|
})();
|
|
@@ -21,6 +21,7 @@ export function sessionIdForRequest(request: Request): Promise<string | null> {
|
|
|
21
21
|
cache: "no-store", redirect: "manual", signal: AbortSignal.timeout(1500),
|
|
22
22
|
});
|
|
23
23
|
const id = result.headers.get("x-publisher-session-id");
|
|
24
|
+
void result.body?.cancel().catch(() => {});
|
|
24
25
|
return result.status === 204 && ID.test(id || "") ? id : null;
|
|
25
26
|
} catch { return null; }
|
|
26
27
|
})();
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wavebirdai/api",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.19",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "JavaScript and TypeScript
|
|
6
|
+
"description": "JavaScript and TypeScript helpers for wavebird Banner, Native and Clip ads, with consent and generation lifecycle handling.",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
package/src/browser.js
CHANGED
|
@@ -145,7 +145,7 @@ export function createPublisherAds(options) {
|
|
|
145
145
|
if (next.userId === binding.userId && next.conversationId === binding.conversationId &&
|
|
146
146
|
(!turnBinding || next.sessionId === binding.sessionId)) return;
|
|
147
147
|
if (turnBinding && next.sessionId === binding.sessionId) throw new Error("publisher_session_reuse");
|
|
148
|
-
|
|
148
|
+
active?.cancel?.();
|
|
149
149
|
context++; active = null;
|
|
150
150
|
binding = { userId: next.userId, conversationId: next.conversationId,
|
|
151
151
|
sessionId: turnBinding ? next.sessionId : crypto.randomUUID() };
|
|
@@ -153,7 +153,7 @@ export function createPublisherAds(options) {
|
|
|
153
153
|
}
|
|
154
154
|
function dispose() {
|
|
155
155
|
if (disposed) return;
|
|
156
|
-
|
|
156
|
+
active?.cancel?.();
|
|
157
157
|
disposed = true; context++; active = null;
|
|
158
158
|
sponsor?.dispose(); sponsor = null; slot.remove();
|
|
159
159
|
slot.removeEventListener?.("ad:render_failed", onRenderFailure, true);
|
|
@@ -187,20 +187,21 @@ export function createPublisherAds(options) {
|
|
|
187
187
|
async function connectGeneration({ request, consume, signal }) {
|
|
188
188
|
const job = { id: crypto.randomUUID(), sessionId: binding.sessionId, context, state: "pending", cancelled: false,
|
|
189
189
|
presented: false, consumed: false };
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
let reader = null, ended = false, buffer = "";
|
|
190
|
+
active?.cancel?.();
|
|
191
|
+
let reader = null, response = null, ended = false, buffer = "";
|
|
193
192
|
const decoder = new TextDecoder();
|
|
194
193
|
const abort = () => { event(job, "abort"); job.cancelled = true; void reader?.cancel().catch(() => {}); };
|
|
194
|
+
job.cancel = abort;
|
|
195
|
+
active = job;
|
|
195
196
|
signal?.addEventListener("abort", abort, { once: true });
|
|
196
197
|
if (signal?.aborted) abort();
|
|
197
198
|
const headers = { "x-wavebird-session-id": job.sessionId, "x-wavebird-generation-id": job.id };
|
|
198
199
|
try {
|
|
199
200
|
if (signal?.aborted) throw new Error("generation_aborted");
|
|
200
|
-
|
|
201
|
+
response = await request(headers);
|
|
201
202
|
if (!current(job)) throw new Error("generation_aborted");
|
|
202
203
|
if (response.headers.get("x-wavebird-generation-transport") !== "1") {
|
|
203
|
-
const result = await consume(response, { onDelta() {} });
|
|
204
|
+
const result = await consume(response, { onDelta() {}, onPresented() {} });
|
|
204
205
|
if (!current(job)) throw new Error("generation_aborted");
|
|
205
206
|
return result;
|
|
206
207
|
}
|
|
@@ -208,6 +209,7 @@ export function createPublisherAds(options) {
|
|
|
208
209
|
reader = response.body.getReader();
|
|
209
210
|
async function nextRecord() {
|
|
210
211
|
while (true) {
|
|
212
|
+
if (!current(job)) throw new Error("generation_aborted");
|
|
211
213
|
const boundary = buffer.indexOf("\n");
|
|
212
214
|
if (boundary >= 0) {
|
|
213
215
|
const line = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 1);
|
|
@@ -218,7 +220,7 @@ export function createPublisherAds(options) {
|
|
|
218
220
|
}
|
|
219
221
|
if (buffer.length > 32768) throw new Error("generation_transport_limit");
|
|
220
222
|
const next = await reader.read();
|
|
221
|
-
if (
|
|
223
|
+
if (!current(job)) throw new Error("generation_aborted");
|
|
222
224
|
if (next.done) throw new Error("generation_transport_truncated");
|
|
223
225
|
buffer += decoder.decode(next.value, { stream: true });
|
|
224
226
|
}
|
|
@@ -272,6 +274,7 @@ export function createPublisherAds(options) {
|
|
|
272
274
|
event(job, "progress");
|
|
273
275
|
if (timing === "before" || (timing === "during" && options.jobType === "image")) event(job, "presented");
|
|
274
276
|
}, onPresented: () => event(job, "presented") });
|
|
277
|
+
if (!current(job)) throw new Error("generation_aborted");
|
|
275
278
|
job.consumed = true;
|
|
276
279
|
if (timing === "before" || (timing === "during" && options.jobType === "image")) event(job, "presented");
|
|
277
280
|
if (timing === "after" && ended && !noBody) event(job, "done");
|
|
@@ -283,6 +286,8 @@ export function createPublisherAds(options) {
|
|
|
283
286
|
if (!ended && job.state === "started") event(job, "abort");
|
|
284
287
|
signal?.removeEventListener("abort", abort);
|
|
285
288
|
if (reader) { try { await reader.cancel(); } catch {} reader.releaseLock(); }
|
|
289
|
+
else if (response?.body && !response.body.locked) void response.body.cancel().catch(() => {});
|
|
290
|
+
job.cancel = null;
|
|
286
291
|
}
|
|
287
292
|
}
|
|
288
293
|
return { ready, connectGeneration, setConversation, dispose, openPrivacySettings,
|
|
@@ -292,7 +297,7 @@ export function createPublisherAds(options) {
|
|
|
292
297
|
}
|
|
293
298
|
|
|
294
299
|
export async function connectPublisherGeneration(url, init, consume, ads = window.publisherAds) {
|
|
295
|
-
if (!ads) return consume(await fetch(url, init), { onDelta() {} });
|
|
300
|
+
if (!ads) return consume(await fetch(url, init), { onDelta() {}, onPresented() {} });
|
|
296
301
|
return ads.connectGeneration({
|
|
297
302
|
signal: init.signal ?? undefined,
|
|
298
303
|
request: values => {
|
package/src/generation.js
CHANGED
|
@@ -11,10 +11,15 @@ function generationTransport({ request, run }) {
|
|
|
11
11
|
request.signal.addEventListener("abort", stop, { once: true });
|
|
12
12
|
if (request.signal.aborted)
|
|
13
13
|
stop();
|
|
14
|
-
let reader = null;
|
|
14
|
+
let reader = null, modelResponse = null;
|
|
15
15
|
let pending;
|
|
16
16
|
try {
|
|
17
|
-
pending = Promise.resolve(run(abort.signal))
|
|
17
|
+
pending = Promise.resolve(run(abort.signal)).then(response => {
|
|
18
|
+
modelResponse = response;
|
|
19
|
+
if (abort.signal.aborted && response?.body && !response.body.locked)
|
|
20
|
+
void response.body.cancel().catch(() => { });
|
|
21
|
+
return response;
|
|
22
|
+
});
|
|
18
23
|
}
|
|
19
24
|
catch (error) {
|
|
20
25
|
pending = Promise.reject(error);
|
|
@@ -67,6 +72,9 @@ function generationTransport({ request, run }) {
|
|
|
67
72
|
catch { }
|
|
68
73
|
reader.releaseLock();
|
|
69
74
|
}
|
|
75
|
+
else if (modelResponse?.body && !modelResponse.body.locked) {
|
|
76
|
+
void modelResponse.body.cancel().catch(() => { });
|
|
77
|
+
}
|
|
70
78
|
request.signal.removeEventListener("abort", stop);
|
|
71
79
|
}
|
|
72
80
|
}
|
|
@@ -87,6 +95,9 @@ function generationTransport({ request, run }) {
|
|
|
87
95
|
cancel(reason) {
|
|
88
96
|
abort.abort(reason);
|
|
89
97
|
void reader?.cancel(reason).catch(() => { });
|
|
98
|
+
if (!reader && modelResponse?.body && !modelResponse.body.locked)
|
|
99
|
+
void modelResponse.body.cancel(reason).catch(() => { });
|
|
100
|
+
request.signal.removeEventListener("abort", stop);
|
|
90
101
|
void iterator.return(undefined).catch(() => { });
|
|
91
102
|
}
|
|
92
103
|
}, { highWaterMark: 1 });
|
package/src/server.d.ts
CHANGED
|
@@ -61,7 +61,8 @@ type PublisherServerBaseOptions = {
|
|
|
61
61
|
/** Trusted session resolution and the durable turn journal must be supplied together. */
|
|
62
62
|
export type PublisherServerOptions = PublisherServerBaseOptions & (
|
|
63
63
|
{ sessionIdForRequest?: never; turnJournal?: never } |
|
|
64
|
-
{
|
|
64
|
+
{ /** Resolve the current authenticated session for consent, placement and generation requests. */
|
|
65
|
+
sessionIdForRequest: (request: Request) => string | null | Promise<string | null>;
|
|
65
66
|
turnJournal: PublisherTurnJournal }
|
|
66
67
|
);
|
|
67
68
|
export type GenerationLifecycleEvent = {
|
package/src/server.js
CHANGED
|
@@ -288,9 +288,10 @@ export function createPublisherServer(inputOptions) {
|
|
|
288
288
|
const cookie = (value, ttl = PREFERENCE_TTL) => `${cookieName}=${value}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${ttl}`;
|
|
289
289
|
const scope = request => ({ project: options.clientId, surface: options.surfaceId, version: PREFERENCE_VERSION,
|
|
290
290
|
profile: options.consentMode === "wavebird_consent" ? "wavebird_dialog" : "publisher_custom", origin: publicOrigin(request, options) });
|
|
291
|
+
let preferenceKeyPromise;
|
|
291
292
|
async function preferenceKey() {
|
|
292
293
|
if (new TextEncoder().encode(options.preferenceSecret).byteLength < 32) throw new Error("preference_secret_required");
|
|
293
|
-
return crypto.subtle.importKey("raw", new TextEncoder().encode(options.preferenceSecret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
|
|
294
|
+
return preferenceKeyPromise ??= crypto.subtle.importKey("raw", new TextEncoder().encode(options.preferenceSecret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
|
|
294
295
|
}
|
|
295
296
|
async function signPreference(request, state) {
|
|
296
297
|
const now = Date.now();
|
|
@@ -432,6 +433,13 @@ export function createPublisherServer(inputOptions) {
|
|
|
432
433
|
!["granted", "denied", "withdrawn", "cmp_error"].includes(input.lifecycle_state) ||
|
|
433
434
|
!Number.isSafeInteger(input.revision) || input.revision < 1) return response("consent_input_invalid", 400);
|
|
434
435
|
if (["tcf_string", "gpp_string", "gpp_sections"].some(field => Object.hasOwn(input, field))) return response("external_consent_protocol_requires_policy", 400);
|
|
436
|
+
if (options.sessionIdForRequest) {
|
|
437
|
+
let sessionId;
|
|
438
|
+
try { sessionId = await journalCall(() => options.sessionIdForRequest(request)); }
|
|
439
|
+
catch { return response("publisher_session_unavailable", 503); }
|
|
440
|
+
if (!ID.test(sessionId || "")) return response("publisher_session_unavailable", 401);
|
|
441
|
+
if (input.session_id.trim() !== sessionId) return response("publisher_session_mismatch", 401);
|
|
442
|
+
}
|
|
435
443
|
const clear = input.remember_for_30_days === true ? { "Set-Cookie": cookie("", 0) } : {};
|
|
436
444
|
if (input.remember_for_30_days === true) {
|
|
437
445
|
try { await preferenceKey(); } catch { return response("preference_unavailable", 503, clear); }
|
package/src/turn-journal.js
CHANGED
|
@@ -19,7 +19,6 @@ export function createPublisherTurnJournal({ storage, reportGeneration, keySecre
|
|
|
19
19
|
throw new Error("publisher_turn_key_secret_invalid");
|
|
20
20
|
if (onDiagnostic !== undefined && typeof onDiagnostic !== "function")
|
|
21
21
|
throw new Error("publisher_diagnostic_invalid");
|
|
22
|
-
const tasks = new Set();
|
|
23
22
|
let activeReports = 0;
|
|
24
23
|
const reportWaiters = [];
|
|
25
24
|
const diagnostic = code => { try { Promise.resolve(onDiagnostic?.(code)).catch(() => {}); } catch {} };
|
|
@@ -77,9 +76,7 @@ export function createPublisherTurnJournal({ storage, reportGeneration, keySecre
|
|
|
77
76
|
});
|
|
78
77
|
}
|
|
79
78
|
function schedule(scope, key) {
|
|
80
|
-
|
|
81
|
-
tasks.add(task);
|
|
82
|
-
void task.finally(() => tasks.delete(task));
|
|
79
|
+
void drain(scope, key).catch(() => diagnostic("turn_journal_unavailable"));
|
|
83
80
|
}
|
|
84
81
|
async function bind(turn, jobId) {
|
|
85
82
|
if (!JOB_ID.test(jobId || "")) throw new Error("publisher_job_id_invalid");
|
|
@@ -125,15 +122,20 @@ export function createPublisherTurnJournal({ storage, reportGeneration, keySecre
|
|
|
125
122
|
}
|
|
126
123
|
async function drain(scope, key) {
|
|
127
124
|
while (true) {
|
|
128
|
-
const next = await lease(scope, key);
|
|
129
|
-
if (!next) return;
|
|
130
125
|
if (activeReports >= 4) await new Promise(resolve => reportWaiters.push(resolve));
|
|
131
|
-
activeReports++;
|
|
126
|
+
else activeReports++;
|
|
132
127
|
let accepted = false;
|
|
133
|
-
try {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
128
|
+
try {
|
|
129
|
+
const next = await lease(scope, key);
|
|
130
|
+
if (!next) return;
|
|
131
|
+
try { await reportGeneration(next.jobId, next.event); accepted = true; }
|
|
132
|
+
catch { diagnostic("generation_report_unavailable"); }
|
|
133
|
+
await ack(scope, key, next.event, next.leaseId, accepted);
|
|
134
|
+
} finally {
|
|
135
|
+
const waiter = reportWaiters.shift();
|
|
136
|
+
if (waiter) waiter();
|
|
137
|
+
else activeReports--;
|
|
138
|
+
}
|
|
137
139
|
if (!accepted) return;
|
|
138
140
|
}
|
|
139
141
|
}
|
package/templates/NEXT-SETUP.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Connect wavebird to this Next.js app
|
|
2
2
|
|
|
3
|
+
Use a supported Node.js LTS release with current security updates. Node.js 20 remains the technical minimum, but is no longer maintained.
|
|
4
|
+
|
|
3
5
|
If your app sets Content Security Policy, merge `https://api.wavebird.ai` into `script-src`, `frame-src` and `connect-src`. Preserve nonces and existing protections. Do not disable CSP or allow wildcard origins. Keep secret Server ENV outside source downloads, Git and browser bundles.
|
|
4
6
|
|
|
5
7
|
The generator installed `lib/publisher`, `lib/auth`, `lib/turn-store` and `lib/model`; `app/api/generate`, `app/api/sponsor-slot`, `app/api/sponsor-consent` and `app/api/sponsor-session`; and `components/WavebirdPlacement` (under `src/` when `src/app` was selected). No existing page was replaced.
|
|
@@ -14,3 +16,5 @@ Install `server-only` in your app (`npm install server-only`). Install `pg` too
|
|
|
14
16
|
6. In an existing client page, mount `<WavebirdPlacement clientId={publicProjectId} userId={authenticatedUserId} conversationId={activeConversationId} onReady={setAds} />` beside the real generation result, outside the input and transcript. The component retrieves the non-credential session ID through the authenticated same-origin route. When the user starts their existing generation, call `ads.connectGeneration({ signal, request: headers => fetch('/api/generate', { method: 'POST', headers, signal }), consume: async (response, lifecycle) => { /* consume your existing stream and call lifecycle.onDelta() for each delta */ } })`. Abort that same signal on Stop, logout or unmount. Use a stable `onReady` callback. This example uses wavebird's consent dialog; if the saved project uses the publisher's own CMP, connect its real grant/deny/withdrawal/expiry/GPC lifecycle instead of changing only the label.
|
|
15
17
|
|
|
16
18
|
These routes are real required Next.js files, not hidden package behavior. The package has no knowledge of your login session, CMP, or model stream. Start with a Test project and check that No-Fill, abort and late responses never block the primary result; all Test facts must remain nonpayable.
|
|
19
|
+
|
|
20
|
+
Your response consumer must stop UI updates when the same abort signal is canceled and resolve only after the result is visible. Call `lifecycle.onPresented()` when the result is presented. Superseded or stopped generations reject with `generation_aborted`. Set pool acquisition, query and lock timeouts on your existing database pool. If idle periods require pending-report recovery, call `turnJournal.reconcilePending(projectId, surfaceId)` from your existing server maintenance job.
|