@wavebirdai/api 0.1.0-beta.1 → 0.1.0-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -24
- package/bin/wavebird-next.mjs +2 -0
- package/build.mjs +10 -0
- package/dist/browser.d.ts +16 -6
- package/dist/browser.js +71 -17
- package/dist/generation.js +2 -0
- package/dist/postgres-turn-storage.js +81 -0
- package/dist/server.d.ts +48 -7
- package/dist/server.js +150 -26
- package/dist/turn-journal.js +157 -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 +37 -0
- package/src/browser.js +248 -0
- package/src/generation.js +98 -0
- package/src/postgres-turn-storage.js +81 -0
- package/src/server.d.ts +79 -0
- package/src/server.js +473 -0
- package/src/turn-journal.js +157 -0
- package/templates/NEXT-SETUP.md +7 -6
- package/templates/WavebirdPlacement.jsx +14 -4
- package/templates/WavebirdPlacement.tsx +16 -5
package/src/server.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export type PublisherTurnIdentity = { projectId: string; surfaceId: string; sessionId: string; turnId: string };
|
|
2
|
+
/** The successor journal must make claims and phase transitions durable across workers. */
|
|
3
|
+
export type PublisherTurnJournal = {
|
|
4
|
+
claimPlacement(turn: PublisherTurnIdentity): Promise<boolean>;
|
|
5
|
+
releaseRejectedPlacement(turn: PublisherTurnIdentity): Promise<void>;
|
|
6
|
+
bind(turn: PublisherTurnIdentity, jobId: string): Promise<void>;
|
|
7
|
+
claimGeneration(turn: PublisherTurnIdentity): Promise<boolean>;
|
|
8
|
+
phase(turn: PublisherTurnIdentity, event: "started" | "finished" | "failed"): Promise<void>;
|
|
9
|
+
reconcilePending?(projectId: string, surfaceId: string): Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
/** Implement this over an existing serializable, persistent application store. */
|
|
12
|
+
export type PublisherAtomicTurnStorage = {
|
|
13
|
+
atomic<T>(scope: string, key: string,
|
|
14
|
+
transition: (previous: unknown | null) => { state: unknown | null; result: T }): Promise<T>;
|
|
15
|
+
/** Return increasing hashed keys after cursor, at most limit. */
|
|
16
|
+
scan(scope: string, cursor: string, limit: number): Promise<string[]>;
|
|
17
|
+
prune?(scope: string): Promise<number>;
|
|
18
|
+
};
|
|
19
|
+
/** Use an existing pg-compatible pool; run install once in the publisher's normal migration step. */
|
|
20
|
+
export function createPostgresTurnStorage(pool: {
|
|
21
|
+
connect(): Promise<{ query(sql: string, params?: unknown[]): Promise<{ rows: Array<Record<string, unknown>>; rowCount?: number }>;
|
|
22
|
+
release(): void }>;
|
|
23
|
+
}): PublisherAtomicTurnStorage & { install(): Promise<void>; prune(scope: string): Promise<number> };
|
|
24
|
+
export function createPublisherTurnJournal(options: {
|
|
25
|
+
storage: PublisherAtomicTurnStorage;
|
|
26
|
+
reportGeneration: (jobId: string, event: "started" | "finished" | "failed") => Promise<void>;
|
|
27
|
+
/** Stable server-only secret for pseudonymous durable turn keys, at least 32 UTF-8 bytes. */
|
|
28
|
+
keySecret: string;
|
|
29
|
+
onDiagnostic?: (code: string) => void;
|
|
30
|
+
}): PublisherTurnJournal;
|
|
31
|
+
/** Derive a stable, domain-separated journal key from the existing server-only preference secret. */
|
|
32
|
+
export function derivePublisherTurnKeySecret(preferenceSecret: string): string;
|
|
33
|
+
type PublisherServerBaseOptions = {
|
|
34
|
+
apiBaseUrl?: string;
|
|
35
|
+
clientId: string;
|
|
36
|
+
secretKey: string;
|
|
37
|
+
preferenceSecret: string;
|
|
38
|
+
authorize: (request: Request) => boolean | Promise<boolean>;
|
|
39
|
+
onDiagnostic?: (code: string) => void;
|
|
40
|
+
publicOrigins: string[];
|
|
41
|
+
surfaceId: string;
|
|
42
|
+
placementKey: string;
|
|
43
|
+
jobType: "chat" | "code" | "image" | "voice" | "agent";
|
|
44
|
+
locale: string;
|
|
45
|
+
timing: "before" | "during" | "after";
|
|
46
|
+
consentMode: "wavebird_consent" | "own_cmp";
|
|
47
|
+
gdprApplies?: boolean;
|
|
48
|
+
publisher: { app_name: string; app_domain: string; categories: string[] };
|
|
49
|
+
allowedFormats: Array<"banner" | "native" | "clip">;
|
|
50
|
+
positionHint: "above" | "below" | "sidebar" | "between";
|
|
51
|
+
/** Optional advanced caps; saved allowedSizes determine the normal envelope. */
|
|
52
|
+
maxWidth?: number;
|
|
53
|
+
maxHeight?: number;
|
|
54
|
+
allowedSizes: Array<{ width: number; height: number; formats?: Array<"banner" | "native" | "clip"> }>;
|
|
55
|
+
responsiveSizeSelection?: boolean;
|
|
56
|
+
bidfloor?: number;
|
|
57
|
+
bidfloorCurrency?: string;
|
|
58
|
+
blockedCategories?: string[];
|
|
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
|
+
);
|
|
66
|
+
export type GenerationLifecycleEvent = {
|
|
67
|
+
type: "started" | "done" | "error" | "abort";
|
|
68
|
+
sessionId: string;
|
|
69
|
+
generationId: string;
|
|
70
|
+
};
|
|
71
|
+
export type PublisherServer = {
|
|
72
|
+
sessionGET(request: Request): Promise<Response>;
|
|
73
|
+
placementPOST(request: Request): Promise<Response>;
|
|
74
|
+
consentGET(request: Request): Promise<Response>;
|
|
75
|
+
consentPOST(request: Request): Promise<Response>;
|
|
76
|
+
withGeneration(options: { request: Request; run: (signal: AbortSignal) => Response | Promise<Response>; onLifecycle?: (event: GenerationLifecycleEvent) => void | Promise<void> }): Promise<Response>;
|
|
77
|
+
reportGeneration(jobId: string, event: "started" | "finished" | "failed"): Promise<void>;
|
|
78
|
+
};
|
|
79
|
+
export function createPublisherServer(options: PublisherServerOptions): PublisherServer;
|
package/src/server.js
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { generationTransport } from "./generation.js";
|
|
3
|
+
export { createPublisherTurnJournal, derivePublisherTurnKeySecret } from "./turn-journal.js";
|
|
4
|
+
export { createPostgresTurnStorage } from "./postgres-turn-storage.js";
|
|
5
|
+
|
|
6
|
+
const PREFERENCE_VERSION = "contextual-choice/v2";
|
|
7
|
+
const PREFERENCE_TTL = 30 * 24 * 60 * 60;
|
|
8
|
+
const MAX_PUBLISHER_BODY_BYTES = 16 * 1024;
|
|
9
|
+
const MAX_WAVEBIRD_RESPONSE_BYTES = 256 * 1024;
|
|
10
|
+
const MAX_PARALLEL_WAVEBIRD_REQUESTS = 2;
|
|
11
|
+
const MAX_ALLOWED_SIZES = 8;
|
|
12
|
+
const MAX_SIZE_EDGE = 4096;
|
|
13
|
+
const MAX_ENVELOPE_EDGE = 8192;
|
|
14
|
+
const ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
15
|
+
const JOB_ID = /^job_[A-Za-z0-9_-]{1,128}$/;
|
|
16
|
+
const PLACEMENT_KEY = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
17
|
+
const SERVER_OPTION_KEYS = new Set(["apiBaseUrl", "clientId", "secretKey", "preferenceSecret", "authorize",
|
|
18
|
+
"sessionIdForRequest", "turnJournal", "onDiagnostic", "publicOrigins", "surfaceId", "placementKey",
|
|
19
|
+
"jobType", "locale", "timing", "consentMode", "gdprApplies", "publisher", "allowedFormats",
|
|
20
|
+
"positionHint", "maxWidth", "maxHeight", "allowedSizes", "responsiveSizeSelection", "bidfloor",
|
|
21
|
+
"bidfloorCurrency", "blockedCategories"]);
|
|
22
|
+
|
|
23
|
+
function response(error, status, headers = {}) {
|
|
24
|
+
return Response.json({ error }, { status, headers: { "Cache-Control": "no-store", ...headers } });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeOptions(options) {
|
|
28
|
+
if (typeof document !== "undefined") throw new Error("publisher_server_only");
|
|
29
|
+
if (options && Object.keys(options).some(key => !SERVER_OPTION_KEYS.has(key))) throw new Error("publisher_option_unknown");
|
|
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");
|
|
33
|
+
if (typeof options.preferenceSecret !== "string" || new TextEncoder().encode(options.preferenceSecret).byteLength < 32) throw new Error("preference_secret_required");
|
|
34
|
+
if ((options.turnJournal === undefined) !== (options.sessionIdForRequest === undefined) ||
|
|
35
|
+
(options.turnJournal !== undefined && (typeof options.sessionIdForRequest !== "function" ||
|
|
36
|
+
["claimPlacement", "releaseRejectedPlacement", "bind", "claimGeneration", "phase"].some(name => typeof options.turnJournal?.[name] !== "function"))))
|
|
37
|
+
throw new Error("publisher_turn_journal_invalid");
|
|
38
|
+
if (typeof options.clientId !== "string" || !options.clientId.startsWith("wbproj_")) throw new Error("publisher_project_invalid");
|
|
39
|
+
if (typeof options.secretKey !== "string") throw new Error("publisher_server_key_invalid");
|
|
40
|
+
if (!options.secretKey.trim()) throw new Error("publisher_server_key_required");
|
|
41
|
+
if (!/^sk_(?:test|live)_/.test(options.secretKey)) throw new Error("publisher_server_key_invalid");
|
|
42
|
+
if (typeof options.surfaceId !== "string" || typeof options.placementKey !== "string" || !ID.test(options.surfaceId) || !PLACEMENT_KEY.test(options.placementKey)) throw new Error("publisher_placement_invalid");
|
|
43
|
+
if (!["before", "during", "after"].includes(options.timing)) throw new Error("publisher_timing_invalid");
|
|
44
|
+
if (!["wavebird_consent", "own_cmp"].includes(options.consentMode)) throw new Error("publisher_consent_mode_invalid");
|
|
45
|
+
if (options.gdprApplies !== undefined && typeof options.gdprApplies !== "boolean") throw new Error("publisher_gdpr_invalid");
|
|
46
|
+
if (!Array.isArray(options.allowedFormats) || options.allowedFormats.length === 0 || options.allowedFormats.some(value => !["banner", "native", "clip"].includes(value))) throw new Error("publisher_formats_invalid");
|
|
47
|
+
if (!["above", "below", "sidebar", "between"].includes(options.positionHint)) throw new Error("publisher_position_invalid");
|
|
48
|
+
if (!Array.isArray(options.allowedSizes) || options.allowedSizes.length === 0 || options.allowedSizes.length > MAX_ALLOWED_SIZES || options.allowedSizes.some(size => !size || typeof size !== "object" || Object.keys(size).some(key => !["width", "height", "formats"].includes(key)) || !Number.isSafeInteger(size.width) || !Number.isSafeInteger(size.height) || size.width < 1 || size.height < 1 || size.width > MAX_SIZE_EDGE || size.height > MAX_SIZE_EDGE ||
|
|
49
|
+
(size.formats !== undefined && (!Array.isArray(size.formats) || size.formats.length === 0 || size.formats.some(format => !options.allowedFormats.includes(format)))))) throw new Error("publisher_sizes_invalid");
|
|
50
|
+
if (new Set(options.allowedSizes.map(size => `${size.width}x${size.height}`)).size !== options.allowedSizes.length)
|
|
51
|
+
throw new Error("publisher_sizes_invalid");
|
|
52
|
+
const maxWidth = options.maxWidth === undefined ? options.allowedSizes.reduce((max, size) => Math.max(max, size.width), 0) : options.maxWidth;
|
|
53
|
+
const maxHeight = options.maxHeight === undefined ? options.allowedSizes.reduce((max, size) => Math.max(max, size.height), 0) : options.maxHeight;
|
|
54
|
+
if (!Number.isSafeInteger(maxWidth) || maxWidth < 1 || maxWidth > MAX_ENVELOPE_EDGE ||
|
|
55
|
+
!Number.isSafeInteger(maxHeight) || maxHeight < 1 || maxHeight > MAX_ENVELOPE_EDGE)
|
|
56
|
+
throw new Error("publisher_envelope_invalid");
|
|
57
|
+
const eligibleSizes = options.allowedSizes.filter(size => size.width <= maxWidth && size.height <= maxHeight);
|
|
58
|
+
const hasFloor = Object.hasOwn(options, "bidfloor");
|
|
59
|
+
const hasCurrency = Object.hasOwn(options, "bidfloorCurrency");
|
|
60
|
+
if (hasFloor !== hasCurrency || (hasFloor && (!Number.isFinite(options.bidfloor) || options.bidfloor < 0 || typeof options.bidfloorCurrency !== "string" || !/^[A-Za-z]{3}$/.test(options.bidfloorCurrency))))
|
|
61
|
+
throw new Error("publisher_bidfloor_override_invalid");
|
|
62
|
+
if (options.responsiveSizeSelection !== undefined && typeof options.responsiveSizeSelection !== "boolean") throw new Error("publisher_responsive_selection_invalid");
|
|
63
|
+
if (!options.publisher || typeof options.publisher !== "object" || Object.keys(options.publisher).some(key => !["app_name", "app_domain", "categories"].includes(key)) ||
|
|
64
|
+
typeof options.publisher.app_name !== "string" || !options.publisher.app_name.trim() ||
|
|
65
|
+
typeof options.publisher.app_domain !== "string" || !options.publisher.app_domain.trim() ||
|
|
66
|
+
!Array.isArray(options.publisher.categories) ||
|
|
67
|
+
options.publisher.categories.some(value => typeof value !== "string" || !value.trim())) throw new Error("publisher_identity_invalid");
|
|
68
|
+
if (options.blockedCategories !== undefined && (!Array.isArray(options.blockedCategories) ||
|
|
69
|
+
options.blockedCategories.some(value => typeof value !== "string" || !value.trim())))
|
|
70
|
+
throw new Error("publisher_blocked_categories_invalid");
|
|
71
|
+
if (!["chat", "code", "image", "voice", "agent"].includes(options.jobType) || typeof options.locale !== "string" || !options.locale) throw new Error("publisher_job_invalid");
|
|
72
|
+
const apiBaseUrl = options.apiBaseUrl === undefined ? "https://api.wavebird.ai" : options.apiBaseUrl;
|
|
73
|
+
if (typeof apiBaseUrl !== "string" || !apiBaseUrl.trim() || /[\\?#]/.test(apiBaseUrl)) throw new Error("publisher_api_https_required");
|
|
74
|
+
const endpoint = apiBaseUrl.trim();
|
|
75
|
+
let parsedEndpoint;
|
|
76
|
+
try { parsedEndpoint = new URL(endpoint); } catch { throw new Error("publisher_api_https_required"); }
|
|
77
|
+
if (parsedEndpoint.protocol !== "https:" || !parsedEndpoint.hostname || parsedEndpoint.username || parsedEndpoint.password ||
|
|
78
|
+
parsedEndpoint.search || parsedEndpoint.hash) throw new Error("publisher_api_https_required");
|
|
79
|
+
const base = endpoint.replace(/\/+$/, "").replace(/\/v1$/, "") + "/v1";
|
|
80
|
+
{
|
|
81
|
+
if (!Array.isArray(options.publicOrigins) || options.publicOrigins.length < 1 || options.publicOrigins.length > 8) throw new Error("publisher_public_origins_invalid");
|
|
82
|
+
for (const origin of options.publicOrigins) {
|
|
83
|
+
let url;
|
|
84
|
+
try { url = new URL(origin); } catch { throw new Error("publisher_public_origins_invalid"); }
|
|
85
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
86
|
+
if (url.origin !== origin || (url.protocol !== "https:" && !(loopback && url.protocol === "http:" && options.secretKey.startsWith("sk_test_")))) throw new Error("publisher_public_origins_invalid");
|
|
87
|
+
}
|
|
88
|
+
if (new Set(options.publicOrigins).size !== options.publicOrigins.length) throw new Error("publisher_public_origins_invalid");
|
|
89
|
+
}
|
|
90
|
+
return { ...options, allowedSizes: eligibleSizes, maxWidth, maxHeight, base,
|
|
91
|
+
consentSource: options.consentMode === "wavebird_consent" ? "wavebird_consent" : "wrapper_cmp" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function publicOrigin(request, options) {
|
|
95
|
+
const host = request.headers.get("host")?.toLowerCase();
|
|
96
|
+
if (!host) return null;
|
|
97
|
+
const matches = options.publicOrigins.filter(origin => new URL(origin).host.toLowerCase() === host);
|
|
98
|
+
const browserOrigin = request.headers.get("origin");
|
|
99
|
+
if (browserOrigin) return matches.includes(browserOrigin) ? browserOrigin : null;
|
|
100
|
+
if (matches.length === 1) return matches[0];
|
|
101
|
+
const routeOrigin = new URL(request.url).origin;
|
|
102
|
+
return matches.includes(routeOrigin) ? routeOrigin : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function validOrigin(request, options, strict = true) {
|
|
106
|
+
const origin = request.headers.get("origin");
|
|
107
|
+
const site = request.headers.get("sec-fetch-site");
|
|
108
|
+
const expected = publicOrigin(request, options);
|
|
109
|
+
return expected !== null && (strict ? origin === expected : !origin || origin === expected) &&
|
|
110
|
+
(strict ? site !== "cross-site" : !site || site === "same-origin");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function gpc(request) {
|
|
114
|
+
return (request.headers.get("sec-gpc") || "").split(",").some(value => value.trim() === "1");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function proxyResult(upstream, body, extra = {}) {
|
|
118
|
+
return new Response(body || null, { status: upstream.status, headers: {
|
|
119
|
+
"Content-Type": upstream.headers.get("content-type") || "application/json",
|
|
120
|
+
"Cache-Control": "no-store",
|
|
121
|
+
...(upstream.headers.get("x-request-id") ? { "x-request-id": upstream.headers.get("x-request-id") } : {}),
|
|
122
|
+
...extra
|
|
123
|
+
} });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function readBoundedText(value, limit, signal) {
|
|
127
|
+
if (!value.body) return "";
|
|
128
|
+
const reader = value.body.getReader();
|
|
129
|
+
const decoder = new TextDecoder();
|
|
130
|
+
let bytes = 0;
|
|
131
|
+
let output = "";
|
|
132
|
+
const cancel = () => { void reader.cancel().catch(() => {}); };
|
|
133
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
134
|
+
try {
|
|
135
|
+
while (true) {
|
|
136
|
+
if (signal.aborted) throw new Error("body_timeout");
|
|
137
|
+
const next = await reader.read();
|
|
138
|
+
if (signal.aborted) throw new Error("body_timeout");
|
|
139
|
+
if (next.done) break;
|
|
140
|
+
bytes += next.value.byteLength;
|
|
141
|
+
if (bytes > limit) { cancel(); throw new Error("body_limit_exceeded"); }
|
|
142
|
+
output += decoder.decode(next.value, { stream: true });
|
|
143
|
+
}
|
|
144
|
+
return output + decoder.decode();
|
|
145
|
+
} finally {
|
|
146
|
+
signal.removeEventListener("abort", cancel);
|
|
147
|
+
reader.releaseLock();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function readPublisherJson(request) {
|
|
152
|
+
const controller = new AbortController();
|
|
153
|
+
const timer = setTimeout(() => controller.abort(), 1500);
|
|
154
|
+
try { return JSON.parse(await readBoundedText(request, MAX_PUBLISHER_BODY_BYTES, controller.signal)); }
|
|
155
|
+
catch (error) {
|
|
156
|
+
const reason = error instanceof Error ? error.message : "invalid_json";
|
|
157
|
+
return response(reason, reason === "body_limit_exceeded" ? 413 : controller.signal.aborted ? 408 : 400);
|
|
158
|
+
} finally { clearTimeout(timer); }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function boundedFetcher() {
|
|
162
|
+
let active = 0;
|
|
163
|
+
return async (url, init) => {
|
|
164
|
+
if (active >= MAX_PARALLEL_WAVEBIRD_REQUESTS) return response("wavebird_capacity_exceeded", 429);
|
|
165
|
+
active++;
|
|
166
|
+
const controller = new AbortController();
|
|
167
|
+
const abort = () => controller.abort();
|
|
168
|
+
init.signal?.addEventListener("abort", abort, { once: true });
|
|
169
|
+
if (init.signal?.aborted) abort();
|
|
170
|
+
let timer;
|
|
171
|
+
const deadline = new Promise((_, reject) => {
|
|
172
|
+
timer = setTimeout(() => { abort(); reject(new Error("upstream_timeout")); }, 5000);
|
|
173
|
+
});
|
|
174
|
+
try {
|
|
175
|
+
return await Promise.race([deadline, (async () => {
|
|
176
|
+
const upstream = await fetch(url, { ...init, signal: controller.signal, redirect: "error", cache: "no-store" });
|
|
177
|
+
const body = await readBoundedText(upstream, MAX_WAVEBIRD_RESPONSE_BYTES, controller.signal);
|
|
178
|
+
return proxyResult(upstream, body);
|
|
179
|
+
})()]);
|
|
180
|
+
} finally {
|
|
181
|
+
controller.abort();
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
init.signal?.removeEventListener("abort", abort);
|
|
184
|
+
active--;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function boundedGenerationReporter() {
|
|
190
|
+
let active = 0;
|
|
191
|
+
return async (url, init) => {
|
|
192
|
+
if (active >= MAX_PARALLEL_WAVEBIRD_REQUESTS) throw new Error("wavebird_capacity_exceeded");
|
|
193
|
+
active++;
|
|
194
|
+
const controller = new AbortController();
|
|
195
|
+
let timer;
|
|
196
|
+
const deadline = new Promise((_, reject) => {
|
|
197
|
+
timer = setTimeout(() => { controller.abort(); reject(new Error("generation_report_timeout")); }, 5000);
|
|
198
|
+
});
|
|
199
|
+
try {
|
|
200
|
+
await Promise.race([deadline, (async () => {
|
|
201
|
+
const upstream = await fetch(url, { ...init, signal: controller.signal, redirect: "error", cache: "no-store" });
|
|
202
|
+
await upstream.body?.cancel();
|
|
203
|
+
if (!upstream.ok) throw new Error(`generation_report_http_${upstream.status}`);
|
|
204
|
+
})()]);
|
|
205
|
+
} finally {
|
|
206
|
+
controller.abort();
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
active--;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function observeGeneration(response, request, onLifecycle, onCritical) {
|
|
214
|
+
if (typeof onLifecycle !== "function" && typeof onCritical !== "function") return response;
|
|
215
|
+
if (response.headers.get("x-wavebird-generation-transport") !== "1" || !response.body) return response;
|
|
216
|
+
const reader = response.body.getReader();
|
|
217
|
+
const decoder = new TextDecoder();
|
|
218
|
+
let terminal = false;
|
|
219
|
+
const notify = async event => {
|
|
220
|
+
if (typeof onCritical === "function") {
|
|
221
|
+
try { await onCritical(event); } catch {}
|
|
222
|
+
}
|
|
223
|
+
if (typeof onLifecycle !== "function") return;
|
|
224
|
+
try { Promise.resolve(onLifecycle(event)).catch(() => {}); } catch {}
|
|
225
|
+
};
|
|
226
|
+
const body = new ReadableStream({
|
|
227
|
+
async pull(controller) {
|
|
228
|
+
try {
|
|
229
|
+
const next = await reader.read();
|
|
230
|
+
if (next.done) { controller.close(); return; }
|
|
231
|
+
// The canonical transport emits one complete JSONL record per chunk.
|
|
232
|
+
try {
|
|
233
|
+
const record = JSON.parse(decoder.decode(next.value));
|
|
234
|
+
if (["started", "done", "error", "abort"].includes(record.type)) {
|
|
235
|
+
if (record.type !== "started") terminal = true;
|
|
236
|
+
await notify({ type: record.type, sessionId: record.session_id, generationId: record.generation_id });
|
|
237
|
+
}
|
|
238
|
+
} catch {}
|
|
239
|
+
controller.enqueue(next.value);
|
|
240
|
+
} catch (error) { controller.error(error); }
|
|
241
|
+
},
|
|
242
|
+
async cancel(reason) {
|
|
243
|
+
if (!terminal) {
|
|
244
|
+
terminal = true;
|
|
245
|
+
await notify({ type: "abort", sessionId: request.headers.get("x-wavebird-session-id"), generationId: request.headers.get("x-wavebird-generation-id") });
|
|
246
|
+
}
|
|
247
|
+
try { await reader.cancel(reason); } catch {}
|
|
248
|
+
}
|
|
249
|
+
}, { highWaterMark: 1 });
|
|
250
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function createPublisherServer(inputOptions) {
|
|
254
|
+
const options = normalizeOptions(inputOptions);
|
|
255
|
+
const placementFetch = boundedFetcher();
|
|
256
|
+
const consentFetch = boundedFetcher();
|
|
257
|
+
const generationReportFetch = boundedGenerationReporter();
|
|
258
|
+
const diagnostic = code => { try { options.onDiagnostic?.(code); } catch {} };
|
|
259
|
+
if (typeof options.turnJournal?.reconcilePending === "function") {
|
|
260
|
+
void Promise.resolve().then(() => options.turnJournal.reconcilePending(options.clientId, options.surfaceId))
|
|
261
|
+
.catch(() => diagnostic("turn_journal_unavailable"));
|
|
262
|
+
}
|
|
263
|
+
async function journalCall(operation) {
|
|
264
|
+
let timer;
|
|
265
|
+
try { return await Promise.race([
|
|
266
|
+
Promise.resolve().then(operation),
|
|
267
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("turn_journal_timeout")), 1500); })
|
|
268
|
+
]); } finally { clearTimeout(timer); }
|
|
269
|
+
}
|
|
270
|
+
async function trustedTurn(request, sessionId, turnId) {
|
|
271
|
+
if (!ID.test(sessionId || "") || !ID.test(turnId || "")) return null;
|
|
272
|
+
let trusted;
|
|
273
|
+
try { trusted = await journalCall(() => options.sessionIdForRequest(request)); } catch { return null; }
|
|
274
|
+
return trusted === sessionId ? { projectId: options.clientId, surfaceId: options.surfaceId, sessionId, turnId } : null;
|
|
275
|
+
}
|
|
276
|
+
async function sessionGET(request) {
|
|
277
|
+
if (!options.turnJournal) return response("turn_binding_unavailable", 404);
|
|
278
|
+
if (!await authorized(request)) return response("authentication_required", 401);
|
|
279
|
+
if (!validOrigin(request, options, false)) return response("invalid_origin", 403);
|
|
280
|
+
let sessionId;
|
|
281
|
+
try { sessionId = await journalCall(() => options.sessionIdForRequest(request)); }
|
|
282
|
+
catch { diagnostic("publisher_session_unavailable"); return response("publisher_session_unavailable", 503); }
|
|
283
|
+
if (!ID.test(sessionId || "")) return response("publisher_session_unavailable", 401);
|
|
284
|
+
return Response.json({ session_id: sessionId }, { headers: { "Cache-Control": "no-store", Vary: "Cookie" } });
|
|
285
|
+
}
|
|
286
|
+
const cookieName = `__Host-wavebird-preference-${options.clientId}-${options.surfaceId}`;
|
|
287
|
+
const cookie = (value, ttl = PREFERENCE_TTL) => `${cookieName}=${value}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${ttl}`;
|
|
288
|
+
const scope = request => ({ project: options.clientId, surface: options.surfaceId, version: PREFERENCE_VERSION,
|
|
289
|
+
profile: options.consentMode === "wavebird_consent" ? "wavebird_dialog" : "publisher_custom", origin: publicOrigin(request, options) });
|
|
290
|
+
async function preferenceKey() {
|
|
291
|
+
if (new TextEncoder().encode(options.preferenceSecret).byteLength < 32) throw new Error("preference_secret_required");
|
|
292
|
+
return crypto.subtle.importKey("raw", new TextEncoder().encode(options.preferenceSecret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
|
|
293
|
+
}
|
|
294
|
+
async function signPreference(request, state) {
|
|
295
|
+
const now = Date.now();
|
|
296
|
+
const payload = Buffer.from(JSON.stringify({ ...scope(request), state, issued_at: now, expires_at: now + PREFERENCE_TTL * 1000 })).toString("base64url");
|
|
297
|
+
const signature = await crypto.subtle.sign("HMAC", await preferenceKey(), new TextEncoder().encode(payload));
|
|
298
|
+
return cookie(payload + "." + Buffer.from(signature).toString("base64url"));
|
|
299
|
+
}
|
|
300
|
+
async function authorized(request) {
|
|
301
|
+
try { return await options.authorize(request) === true; } catch { return false; }
|
|
302
|
+
}
|
|
303
|
+
async function placementPOST(request) {
|
|
304
|
+
if (!await authorized(request)) return response("authentication_required", 401);
|
|
305
|
+
if (gpc(request)) return response("privacy_signal_policy_required", 403);
|
|
306
|
+
if (!validOrigin(request, options)) return response("invalid_origin", 403);
|
|
307
|
+
const qaOutcomes = new URL(request.url).searchParams.getAll("qa_test_outcome");
|
|
308
|
+
if (qaOutcomes.length > 1 || (qaOutcomes.length === 1 && qaOutcomes[0] !== "no_fill")) return response("invalid_qa_test_outcome", 400);
|
|
309
|
+
if (qaOutcomes.length && !options.secretKey.startsWith("sk_test_")) return response("qa_test_outcome_requires_test_key", 403);
|
|
310
|
+
const input = await readPublisherJson(request);
|
|
311
|
+
if (input instanceof Response) return input;
|
|
312
|
+
const turn = options.turnJournal ? await trustedTurn(request, input?.session_id, input?.turn_id) : null;
|
|
313
|
+
if (options.turnJournal && !turn) return response("publisher_turn_context_invalid", 401);
|
|
314
|
+
let allowedSizes = options.allowedSizes.map(({ width, height }) => ({ width, height }));
|
|
315
|
+
let allowedFormats = options.allowedFormats;
|
|
316
|
+
let maxWidth = options.maxWidth;
|
|
317
|
+
let maxHeight = options.maxHeight;
|
|
318
|
+
const noFit = () => Response.json({ decision: { fill: false, reason: "no_fit" }, placement: null }, { headers: { "Cache-Control": "no-store" } });
|
|
319
|
+
if (options.responsiveSizeSelection !== false) {
|
|
320
|
+
const bounds = input?.container_size;
|
|
321
|
+
if (!bounds || !Number.isSafeInteger(bounds.width) || !Number.isSafeInteger(bounds.height) ||
|
|
322
|
+
bounds.width < 1 || bounds.height < 1 || bounds.width > 8192 || bounds.height > 8192) {
|
|
323
|
+
return response("publisher_container_size_invalid", 400);
|
|
324
|
+
}
|
|
325
|
+
const requestedFormat = input?.format ?? "auto";
|
|
326
|
+
if (requestedFormat !== "auto" && !options.allowedFormats.includes(requestedFormat)) return response("publisher_format_invalid", 400);
|
|
327
|
+
if (allowedSizes.length === 0) return noFit();
|
|
328
|
+
const fitting = options.allowedSizes.filter(size => size.width <= bounds.width && size.height <= bounds.height &&
|
|
329
|
+
(requestedFormat === "auto" || (size.formats ?? options.allowedFormats).includes(requestedFormat)))
|
|
330
|
+
.sort((a, b) => b.width - a.width || b.height - a.height);
|
|
331
|
+
if (fitting.length === 0) return noFit();
|
|
332
|
+
const selected = fitting[0];
|
|
333
|
+
allowedSizes = [{ width: selected.width, height: selected.height }];
|
|
334
|
+
allowedFormats = requestedFormat === "auto"
|
|
335
|
+
? options.allowedFormats.filter(format => fitting.some(size => size.width === selected.width && size.height === selected.height && (size.formats ?? options.allowedFormats).includes(format)))
|
|
336
|
+
: [requestedFormat];
|
|
337
|
+
maxWidth = Math.min(maxWidth, bounds.width);
|
|
338
|
+
maxHeight = Math.min(maxHeight, bounds.height);
|
|
339
|
+
}
|
|
340
|
+
else if (allowedSizes.length === 0) return noFit();
|
|
341
|
+
if (turn) {
|
|
342
|
+
let claimed;
|
|
343
|
+
try { claimed = await journalCall(() => options.turnJournal.claimPlacement(turn)); }
|
|
344
|
+
catch { diagnostic("turn_journal_unavailable"); return response("turn_journal_unavailable", 503); }
|
|
345
|
+
if (!claimed) return response("publisher_turn_replayed", 409);
|
|
346
|
+
}
|
|
347
|
+
const sessionId = typeof input?.session_id === "string" && input.session_id.trim() ? input.session_id.trim() : crypto.randomUUID();
|
|
348
|
+
const body = {
|
|
349
|
+
client_id: options.clientId, session_id: sessionId, job_type: options.jobType, locale: options.locale,
|
|
350
|
+
slots_requested: 1, placement_key: options.placementKey,
|
|
351
|
+
slot_hint: { position: options.positionHint, max_width: maxWidth, max_height: maxHeight,
|
|
352
|
+
allowed_sizes: allowedSizes, presentation_version: "sponsored_moment_v2" },
|
|
353
|
+
overrides: { allowed_formats: allowedFormats, timing: options.timing,
|
|
354
|
+
...(Object.hasOwn(options, "bidfloor") ? { bidfloor: options.bidfloor, bidfloor_currency: options.bidfloorCurrency.toUpperCase() } : {}),
|
|
355
|
+
publisher: { app_name: options.publisher.app_name, app_domain: options.publisher.app_domain,
|
|
356
|
+
...(options.publisher.categories.length ? { categories: options.publisher.categories } : {}) },
|
|
357
|
+
...(options.blockedCategories?.length ? { blocked_categories: options.blockedCategories } : {}) },
|
|
358
|
+
consent: { semantic_targeting: false, prompt_shared: false, gdpr_applies: options.gdprApplies === true,
|
|
359
|
+
consent_source: options.consentSource }
|
|
360
|
+
};
|
|
361
|
+
try {
|
|
362
|
+
const result = await placementFetch(`${options.base}/placements?wait_ms=1500${qaOutcomes.length ? "&test_outcome=no_fill" : ""}`, { method: "POST", headers: {
|
|
363
|
+
Authorization: `Bearer ${options.secretKey}`, "Content-Type": "application/json"
|
|
364
|
+
}, body: JSON.stringify(body), cache: "no-store" });
|
|
365
|
+
if (turn && [400, 401, 403].includes(result.status)) {
|
|
366
|
+
try { await journalCall(() => options.turnJournal.releaseRejectedPlacement(turn)); }
|
|
367
|
+
catch { diagnostic("turn_journal_unavailable"); }
|
|
368
|
+
}
|
|
369
|
+
if (turn && result.status === 200) {
|
|
370
|
+
let payload;
|
|
371
|
+
try { payload = await result.clone().json(); } catch {}
|
|
372
|
+
const jobId = payload?.job_id;
|
|
373
|
+
if (JOB_ID.test(jobId || "")) {
|
|
374
|
+
try { await journalCall(() => options.turnJournal.bind(turn, jobId)); }
|
|
375
|
+
catch { diagnostic("turn_binding_unavailable"); return response("turn_binding_unavailable", 502); }
|
|
376
|
+
} else if (!(payload?.decision?.fill === false &&
|
|
377
|
+
Object.hasOwn(payload ?? {}, "placement") && payload.placement === null)) {
|
|
378
|
+
diagnostic("turn_binding_unavailable");
|
|
379
|
+
return response("turn_binding_unavailable", 502);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return result;
|
|
383
|
+
} catch { return response("wavebird_placement_unavailable", 502); }
|
|
384
|
+
}
|
|
385
|
+
async function consentGET(request) {
|
|
386
|
+
if (!await authorized(request)) return response("authentication_required", 401);
|
|
387
|
+
const headers = { "Cache-Control": "no-store", Vary: "Cookie, Sec-GPC" };
|
|
388
|
+
if (!validOrigin(request, options, false)) return response("invalid_origin", 403, headers);
|
|
389
|
+
if (gpc(request)) return Response.json({ preference: null }, { headers });
|
|
390
|
+
try {
|
|
391
|
+
const value = (request.headers.get("cookie") || "").split(";").map(value => value.trim())
|
|
392
|
+
.find(value => value.startsWith(cookieName + "="))?.slice(cookieName.length + 1) || "";
|
|
393
|
+
if (value.length > 2048) throw new Error("preference_invalid");
|
|
394
|
+
const [payload, signature, extra] = value.split(".");
|
|
395
|
+
if (!payload || !signature || extra || !/^[A-Za-z0-9_-]+$/.test(payload) || !/^[A-Za-z0-9_-]+$/.test(signature)) return Response.json({ preference: null }, { headers });
|
|
396
|
+
if (!await crypto.subtle.verify("HMAC", await preferenceKey(), Buffer.from(signature, "base64url"), new TextEncoder().encode(payload))) return Response.json({ preference: null }, { headers });
|
|
397
|
+
const record = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
398
|
+
const now = Date.now();
|
|
399
|
+
if (Object.entries(scope(request)).some(([key, value]) => record[key] !== value) ||
|
|
400
|
+
!["granted", "denied", "withdrawn"].includes(record.state) || !Number.isSafeInteger(record.issued_at) ||
|
|
401
|
+
!Number.isSafeInteger(record.expires_at) || record.issued_at > now || record.expires_at <= now ||
|
|
402
|
+
record.expires_at - record.issued_at !== PREFERENCE_TTL * 1000) return Response.json({ preference: null }, { headers });
|
|
403
|
+
return Response.json({ preference: { state: record.state, expires_at: record.expires_at } }, { headers });
|
|
404
|
+
} catch { return Response.json({ preference: null, error: "preference_unavailable" }, { headers }); }
|
|
405
|
+
}
|
|
406
|
+
async function consentPOST(request) {
|
|
407
|
+
if (!await authorized(request)) return response("authentication_required", 401);
|
|
408
|
+
if (!validOrigin(request, options)) return response("invalid_origin", 403);
|
|
409
|
+
if (gpc(request)) return response("privacy_signal_policy_required", 403);
|
|
410
|
+
const input = await readPublisherJson(request);
|
|
411
|
+
if (input instanceof Response) return input;
|
|
412
|
+
if (!input || typeof input.session_id !== "string" || !input.session_id.trim() ||
|
|
413
|
+
!["granted", "denied", "withdrawn", "cmp_error"].includes(input.lifecycle_state) ||
|
|
414
|
+
!Number.isSafeInteger(input.revision) || input.revision < 1) return response("consent_input_invalid", 400);
|
|
415
|
+
if (["tcf_string", "gpp_string", "gpp_sections"].some(field => Object.hasOwn(input, field))) return response("external_consent_protocol_requires_policy", 400);
|
|
416
|
+
const clear = input.remember_for_30_days === true ? { "Set-Cookie": cookie("", 0) } : {};
|
|
417
|
+
if (input.remember_for_30_days === true) {
|
|
418
|
+
try { await preferenceKey(); } catch { return response("preference_unavailable", 503, clear); }
|
|
419
|
+
}
|
|
420
|
+
let upstream;
|
|
421
|
+
let text;
|
|
422
|
+
try {
|
|
423
|
+
upstream = await consentFetch(options.base + "/consent", { method: "POST", headers: {
|
|
424
|
+
Authorization: `Bearer ${options.secretKey}`, "Content-Type": "application/json"
|
|
425
|
+
}, cache: "no-store", body: JSON.stringify({
|
|
426
|
+
client_id: options.clientId, session_id: input.session_id.trim(),
|
|
427
|
+
decision: options.consentMode === "wavebird_consent" ? "custom" : "basic",
|
|
428
|
+
source: options.consentMode === "wavebird_consent" ? "wavebird_dialog" : "publisher_custom",
|
|
429
|
+
lifecycle_state: input.lifecycle_state, revision: input.revision,
|
|
430
|
+
purposes: { semantic_targeting: false, session_persistence: false, cross_session_persistence: false, prompt_shared: false }
|
|
431
|
+
}) });
|
|
432
|
+
text = await upstream.text();
|
|
433
|
+
} catch { return response("consent_sync_unavailable", 502, clear); }
|
|
434
|
+
let setCookie = clear;
|
|
435
|
+
if (upstream.ok && input.remember_for_30_days === true && input.lifecycle_state !== "cmp_error") {
|
|
436
|
+
let snapshot;
|
|
437
|
+
try { snapshot = JSON.parse(text).authoritative_consent; } catch {}
|
|
438
|
+
if (snapshot?.lifecycle_state !== input.lifecycle_state || snapshot.revision !== input.revision) return response("consent_snapshot_invalid", 502, clear);
|
|
439
|
+
try { setCookie = { "Set-Cookie": await signPreference(request, input.lifecycle_state) }; }
|
|
440
|
+
catch { return response("preference_unavailable", 503, clear); }
|
|
441
|
+
}
|
|
442
|
+
return proxyResult(upstream, text, setCookie);
|
|
443
|
+
}
|
|
444
|
+
async function withGeneration({ request, run, onLifecycle }) {
|
|
445
|
+
if (!await authorized(request)) return response("authentication_required", 401);
|
|
446
|
+
if (!validOrigin(request, options)) return response("invalid_origin", 403);
|
|
447
|
+
const turn = options.turnJournal ? await trustedTurn(request,
|
|
448
|
+
request.headers.get("x-wavebird-session-id"), request.headers.get("x-wavebird-generation-id")) : null;
|
|
449
|
+
if (options.turnJournal && !turn) return response("publisher_turn_context_invalid", 401);
|
|
450
|
+
if (turn) {
|
|
451
|
+
let claimed;
|
|
452
|
+
try { claimed = await journalCall(() => options.turnJournal.claimGeneration(turn)); }
|
|
453
|
+
catch { diagnostic("turn_journal_unavailable"); return run(request.signal); }
|
|
454
|
+
if (!claimed) return response("publisher_turn_replayed", 409);
|
|
455
|
+
try { await journalCall(() => options.turnJournal.phase(turn, "started")); }
|
|
456
|
+
catch { diagnostic("turn_journal_unavailable"); return run(request.signal); }
|
|
457
|
+
}
|
|
458
|
+
const result = await generationTransport({ request, run });
|
|
459
|
+
const critical = turn ? async event => {
|
|
460
|
+
if (event.type === "started") return;
|
|
461
|
+
try { await journalCall(() => options.turnJournal.phase(turn, event.type === "done" ? "finished" : "failed")); }
|
|
462
|
+
catch { diagnostic("turn_journal_unavailable"); }
|
|
463
|
+
} : null;
|
|
464
|
+
return observeGeneration(result, request, onLifecycle, critical);
|
|
465
|
+
}
|
|
466
|
+
async function reportGeneration(jobId, event) {
|
|
467
|
+
if (!JOB_ID.test(jobId) || !["started", "finished", "failed"].includes(event)) throw new Error("invalid_generation_report");
|
|
468
|
+
await generationReportFetch(`${options.base}/jobs/${encodeURIComponent(jobId)}/generation/${event}`, {
|
|
469
|
+
method: "POST", headers: { Authorization: `Bearer ${options.secretKey}`, "Content-Type": "application/json" }, body: "{}"
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
return { sessionGET, placementPOST, consentGET, consentPOST, withGeneration, reportGeneration };
|
|
473
|
+
}
|