@wavebirdai/api 0.1.0-beta.1 → 0.1.0-beta.4
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 +67 -23
- package/bin/wavebird-next.mjs +2 -0
- package/build.mjs +10 -0
- package/dist/browser.d.ts +5 -1
- package/dist/browser.js +24 -7
- package/dist/postgres-turn-storage.js +81 -0
- package/dist/server.d.ts +42 -4
- package/dist/server.js +116 -17
- package/dist/turn-journal.js +154 -0
- package/examples/next-js/app/api/sponsor-session/route.js +5 -0
- package/examples/next-js/app/page.jsx +15 -4
- package/examples/next-js/lib/auth.js +31 -10
- package/examples/next-js/lib/publisher.js +12 -5
- package/examples/next-js/lib/turn-store.js +9 -0
- package/examples/next-ts/app/api/sponsor-session/route.ts +5 -0
- package/examples/next-ts/app/page.tsx +16 -5
- package/examples/next-ts/lib/auth.ts +31 -10
- package/examples/next-ts/lib/publisher.ts +12 -5
- package/examples/next-ts/lib/turn-store.ts +10 -0
- package/package.json +3 -3
- package/src/browser.d.ts +31 -0
- package/src/browser.js +211 -0
- package/src/generation.js +96 -0
- package/src/postgres-turn-storage.js +81 -0
- package/src/server.d.ts +76 -0
- package/src/server.js +448 -0
- package/src/turn-journal.js +154 -0
- package/templates/NEXT-SETUP.md +7 -6
- package/templates/WavebirdPlacement.jsx +14 -4
- package/templates/WavebirdPlacement.tsx +16 -5
package/README.md
CHANGED
|
@@ -1,55 +1,99 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @wavebirdai/api
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
##
|
|
5
|
+
## Integrate with Next.js
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
+
```sh
|
|
12
|
+
npm install @wavebirdai/api@0.1.0-beta.4
|
|
13
|
+
./node_modules/.bin/wavebird-next init --lang ts --root . --app-dir src/app
|
|
14
|
+
```
|
|
14
15
|
|
|
15
|
-
|
|
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
|
-
|
|
18
|
+
2. Set the server-only values in your deployment environment:
|
|
18
19
|
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
+
```ts
|
|
34
|
+
import { getPublisher } from "../../../lib/publisher";
|
|
35
|
+
import { existingGeneration } from "../../../lib/model";
|
|
31
36
|
|
|
32
|
-
|
|
37
|
+
export async function POST(request: Request): Promise<Response> {
|
|
38
|
+
return getPublisher().withGeneration({ request, run: existingGeneration });
|
|
39
|
+
}
|
|
40
|
+
```
|
|
33
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.
|
|
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
|
|
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
|
|
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
|
|
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
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.
|
|
47
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.
|
|
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.
|
|
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
|
|
package/bin/wavebird-next.mjs
CHANGED
|
@@ -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,8 +1,9 @@
|
|
|
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
3
|
export type PublisherAds = {
|
|
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: { userId: string; conversationId: string; sessionId?: string }): void;
|
|
6
7
|
dispose(): void;
|
|
7
8
|
openPrivacySettings(): void;
|
|
8
9
|
grant(choice?: unknown): Promise<unknown>;
|
|
@@ -16,6 +17,9 @@ export function createPublisherAds(options: {
|
|
|
16
17
|
jobType: string;
|
|
17
18
|
userId: string;
|
|
18
19
|
conversationId: string;
|
|
20
|
+
/** Opt-in successor contract: a session ID supplied by the host's authenticated context. */
|
|
21
|
+
sessionId?: string;
|
|
22
|
+
turnBinding?: boolean;
|
|
19
23
|
timing?: "before" | "during" | "after";
|
|
20
24
|
consentMode?: "wavebird_consent" | "own_cmp";
|
|
21
25
|
rendererUrl?: string;
|
package/dist/browser.js
CHANGED
|
@@ -21,9 +21,13 @@ function loadRenderer(url) {
|
|
|
21
21
|
export function createPublisherAds(options) {
|
|
22
22
|
const target = options.target;
|
|
23
23
|
if (!target || !options.userId || !options.conversationId) throw new Error("Application container and session are required");
|
|
24
|
+
const turnBinding = options.turnBinding === true;
|
|
25
|
+
const validId = value => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
|
26
|
+
if (turnBinding && !validId(options.sessionId)) throw new Error("publisher_trusted_session_required");
|
|
24
27
|
const timing = options.timing || "during", consentMode = options.consentMode || "wavebird_consent";
|
|
25
28
|
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,
|
|
29
|
+
let binding = { userId: options.userId, conversationId: options.conversationId,
|
|
30
|
+
sessionId: turnBinding ? options.sessionId : crypto.randomUUID() };
|
|
27
31
|
let sponsor = null, disposed = false, context = 0, active = null;
|
|
28
32
|
const slot = document.createElement("section");
|
|
29
33
|
slot.setAttribute("aria-label", "Sponsored placement");
|
|
@@ -48,6 +52,8 @@ export function createPublisherAds(options) {
|
|
|
48
52
|
job.state = "started"; notify("onStarted", job.id);
|
|
49
53
|
} else if (type === "progress") {
|
|
50
54
|
if (job.state === "started") { job.progressed = true; notify("onDelta", job.id); }
|
|
55
|
+
} else if (type === "no_result") {
|
|
56
|
+
if (job.state === "started") job.state = "no_result";
|
|
51
57
|
} else if (["done", "error", "abort"].includes(type)) {
|
|
52
58
|
if (job.state !== "started") return;
|
|
53
59
|
job.state = type;
|
|
@@ -58,10 +64,14 @@ export function createPublisherAds(options) {
|
|
|
58
64
|
function setConversation(next) {
|
|
59
65
|
if (disposed) return;
|
|
60
66
|
if (!next.userId || !next.conversationId) throw new Error("Application session is required");
|
|
61
|
-
if (
|
|
67
|
+
if (turnBinding && !validId(next.sessionId)) throw new Error("publisher_trusted_session_required");
|
|
68
|
+
if (next.userId === binding.userId && next.conversationId === binding.conversationId &&
|
|
69
|
+
(!turnBinding || next.sessionId === binding.sessionId)) return;
|
|
70
|
+
if (turnBinding && next.sessionId === binding.sessionId) throw new Error("publisher_session_reuse");
|
|
62
71
|
if (active) event(active, "abort");
|
|
63
72
|
context++; active = null;
|
|
64
|
-
binding = { userId: next.userId, conversationId: next.conversationId,
|
|
73
|
+
binding = { userId: next.userId, conversationId: next.conversationId,
|
|
74
|
+
sessionId: turnBinding ? next.sessionId : crypto.randomUUID() };
|
|
65
75
|
sponsor?.setConversation(binding); prepare();
|
|
66
76
|
}
|
|
67
77
|
function dispose() {
|
|
@@ -77,6 +87,8 @@ export function createPublisherAds(options) {
|
|
|
77
87
|
if (disposed) return;
|
|
78
88
|
sponsor = window.wavebird.createChatSponsor({ target: slot, ...binding,
|
|
79
89
|
clientId: options.clientId, surfaceId: options.surfaceId, timing, consentMode,
|
|
90
|
+
cleanupAfterClip: true,
|
|
91
|
+
...(turnBinding ? { turnBinding: true } : {}),
|
|
80
92
|
...(options.getFormat === undefined ? {} : { getFormat: options.getFormat }),
|
|
81
93
|
...(options.placementEndpoint === undefined ? {} : { placementEndpoint: options.placementEndpoint }),
|
|
82
94
|
...(options.consentEndpoint === undefined ? {} : { consentEndpoint: options.consentEndpoint }) });
|
|
@@ -144,7 +156,8 @@ export function createPublisherAds(options) {
|
|
|
144
156
|
return;
|
|
145
157
|
}
|
|
146
158
|
if (["done", "error", "abort"].includes(record.type)) {
|
|
147
|
-
ended = true;
|
|
159
|
+
ended = true;
|
|
160
|
+
if (record.type !== "done" || timing !== "after") event(job, record.type);
|
|
148
161
|
if (record.type === "done" || !metadata.status.toString().startsWith("2")) controller.close();
|
|
149
162
|
else controller.error(new Error("generation_" + record.type));
|
|
150
163
|
return;
|
|
@@ -158,12 +171,16 @@ export function createPublisherAds(options) {
|
|
|
158
171
|
if ([204, 205, 304].includes(metadata.status)) {
|
|
159
172
|
const record = await nextRecord();
|
|
160
173
|
if (!["done", "error", "abort"].includes(record.type)) throw new Error("generation_transport_invalid");
|
|
161
|
-
ended = true;
|
|
174
|
+
ended = true;
|
|
175
|
+
if (record.type !== "done" || timing !== "after") event(job, record.type);
|
|
162
176
|
}
|
|
163
|
-
const
|
|
177
|
+
const noBody = [204, 205, 304].includes(metadata.status);
|
|
178
|
+
const primary = new Response(noBody ? null : output, {
|
|
164
179
|
status: metadata.status, statusText: metadata.status_text || "", headers: metadata.headers
|
|
165
180
|
});
|
|
166
|
-
|
|
181
|
+
const result = await consume(primary, { onDelta: () => event(job, "progress") });
|
|
182
|
+
if (timing === "after" && ended) event(job, noBody ? "no_result" : "done");
|
|
183
|
+
return result;
|
|
167
184
|
} catch (error) {
|
|
168
185
|
event(job, signal?.aborted ? "abort" : "error");
|
|
169
186
|
throw error;
|
|
@@ -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,9 +1,45 @@
|
|
|
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;
|
|
1
33
|
export type PublisherServerOptions = {
|
|
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
|
+
/** 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
|
+
onDiagnostic?: (code: string) => void;
|
|
7
43
|
publicOrigins: string[];
|
|
8
44
|
surfaceId: string;
|
|
9
45
|
placementKey: string;
|
|
@@ -13,11 +49,12 @@ export type PublisherServerOptions = {
|
|
|
13
49
|
consentMode: "wavebird_consent" | "own_cmp";
|
|
14
50
|
gdprApplies: boolean;
|
|
15
51
|
publisher: { app_name: string; app_domain: string; categories: string[] };
|
|
16
|
-
allowedFormats: Array<"banner" | "native">;
|
|
52
|
+
allowedFormats: Array<"banner" | "native" | "clip">;
|
|
17
53
|
positionHint: "above" | "below" | "sidebar" | "between";
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
54
|
+
/** Optional advanced caps; saved allowedSizes determine the normal envelope. */
|
|
55
|
+
maxWidth?: number;
|
|
56
|
+
maxHeight?: number;
|
|
57
|
+
allowedSizes: Array<{ width: number; height: number; formats?: Array<"banner" | "native" | "clip"> }>;
|
|
21
58
|
responsiveSizeSelection?: boolean;
|
|
22
59
|
bidfloor?: number;
|
|
23
60
|
bidfloorCurrency?: string;
|
|
@@ -29,6 +66,7 @@ export type GenerationLifecycleEvent = {
|
|
|
29
66
|
generationId: string;
|
|
30
67
|
};
|
|
31
68
|
export type PublisherServer = {
|
|
69
|
+
sessionGET(request: Request): Promise<Response>;
|
|
32
70
|
placementPOST(request: Request): Promise<Response>;
|
|
33
71
|
consentGET(request: Request): Promise<Response>;
|
|
34
72
|
consentPOST(request: Request): Promise<Response>;
|