@wavebirdai/api 0.1.0-beta.2 → 0.1.0-beta.5
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 -20
- package/bin/wavebird-next.mjs +2 -0
- package/build.mjs +10 -0
- package/dist/browser.d.ts +6 -2
- package/dist/browser.js +26 -8
- package/dist/postgres-turn-storage.js +81 -0
- package/dist/server.d.ts +41 -3
- package/dist/server.js +121 -21
- 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 +212 -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 +449 -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
|
@@ -1,12 +1,33 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
const cache = new WeakMap<Request, Promise<string | null>>();
|
|
4
|
+
const ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
5
|
+
|
|
6
|
+
/** Resolve a non-credential correlation ID from the app's authenticated session. */
|
|
7
|
+
export function sessionIdForRequest(request: Request): Promise<string | null> {
|
|
8
|
+
const prior = cache.get(request);
|
|
9
|
+
if (prior) return prior;
|
|
10
|
+
const pending = (async () => {
|
|
11
|
+
const path = process.env.PUBLISHER_AUTH_CHECK_PATH;
|
|
12
|
+
if (!path || !/^\/(?!\/)[A-Za-z0-9/_-]+$/.test(path) ||
|
|
13
|
+
path.startsWith("/api/sponsor") || path === "/api/generate") return null;
|
|
14
|
+
try {
|
|
15
|
+
const requestOrigin = new URL(request.url).origin;
|
|
16
|
+
const trustedOrigin = (process.env.WAVEBIRD_PUBLIC_ORIGINS ?? "").split(",")
|
|
17
|
+
.map(value => value.trim()).find(value => value === requestOrigin);
|
|
18
|
+
if (!trustedOrigin) return null;
|
|
19
|
+
const result = await fetch(new URL(path, trustedOrigin), {
|
|
20
|
+
headers: { cookie: request.headers.get("cookie") || "" },
|
|
21
|
+
cache: "no-store", redirect: "manual", signal: AbortSignal.timeout(1500),
|
|
22
|
+
});
|
|
23
|
+
const id = result.headers.get("x-publisher-session-id");
|
|
24
|
+
return result.status === 204 && ID.test(id || "") ? id : null;
|
|
25
|
+
} catch { return null; }
|
|
26
|
+
})();
|
|
27
|
+
cache.set(request, pending);
|
|
28
|
+
return pending;
|
|
29
|
+
}
|
|
30
|
+
|
|
1
31
|
export async function authorizePublisherRequest(request: Request): Promise<boolean> {
|
|
2
|
-
|
|
3
|
-
if (!path || !/^\/(?!\/)[A-Za-z0-9/_-]+$/.test(path) || path.startsWith("/api/sponsor") || path === "/api/generate") return false;
|
|
4
|
-
try {
|
|
5
|
-
const result = await fetch(new URL(path, request.url), {
|
|
6
|
-
headers: { cookie: request.headers.get("cookie") || "" },
|
|
7
|
-
cache: "no-store",
|
|
8
|
-
redirect: "manual"
|
|
9
|
-
});
|
|
10
|
-
return result.status === 204;
|
|
11
|
-
} catch { return false; }
|
|
32
|
+
return (await sessionIdForRequest(request)) !== null;
|
|
12
33
|
}
|
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
import "server-only";
|
|
2
|
-
import { createPublisherServer } from "@wavebirdai/api/server";
|
|
3
|
-
import { authorizePublisherRequest } from "./auth";
|
|
2
|
+
import { createPublisherServer, createPublisherTurnJournal, derivePublisherTurnKeySecret } from "@wavebirdai/api/server";
|
|
3
|
+
import { authorizePublisherRequest, sessionIdForRequest } from "./auth";
|
|
4
|
+
import { publisherTurnStorage } from "./turn-store";
|
|
4
5
|
|
|
5
6
|
let publisher: ReturnType<typeof createPublisherServer> | null = null;
|
|
6
7
|
|
|
7
8
|
export function getPublisher(): ReturnType<typeof createPublisherServer> {
|
|
8
|
-
|
|
9
|
+
if (publisher) return publisher;
|
|
10
|
+
const turnJournal = createPublisherTurnJournal({
|
|
11
|
+
storage: publisherTurnStorage(),
|
|
12
|
+
keySecret: derivePublisherTurnKeySecret(process.env.WAVEBIRD_PREFERENCE_SECRET ?? ""),
|
|
13
|
+
reportGeneration: (jobId, event) => getPublisher().reportGeneration(jobId, event),
|
|
14
|
+
});
|
|
15
|
+
return publisher = createPublisherServer({
|
|
9
16
|
apiBaseUrl: process.env.WAVEBIRD_API_BASE_URL,
|
|
10
17
|
clientId: process.env.WAVEBIRD_CLIENT_ID ?? "",
|
|
11
18
|
secretKey: process.env.WAVEBIRD_SECRET_KEY ?? "",
|
|
12
19
|
preferenceSecret: process.env.WAVEBIRD_PREFERENCE_SECRET ?? "",
|
|
13
20
|
authorize: authorizePublisherRequest,
|
|
21
|
+
sessionIdForRequest,
|
|
22
|
+
turnJournal,
|
|
14
23
|
publicOrigins: (process.env.WAVEBIRD_PUBLIC_ORIGINS ?? "").split(",").map(value => value.trim()).filter(Boolean),
|
|
15
24
|
surfaceId: "chat_assistant",
|
|
16
25
|
placementKey: "default",
|
|
@@ -22,8 +31,6 @@ export function getPublisher(): ReturnType<typeof createPublisherServer> {
|
|
|
22
31
|
publisher: { app_name: "Your saved app name", app_domain: "your.example", categories: [] },
|
|
23
32
|
allowedFormats: ["banner"],
|
|
24
33
|
positionHint: "between",
|
|
25
|
-
maxWidth: 728,
|
|
26
|
-
maxHeight: 90,
|
|
27
34
|
allowedSizes: [{ width: 728, height: 90 }, { width: 320, height: 50 }],
|
|
28
35
|
responsiveSizeSelection: true
|
|
29
36
|
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import type { PublisherAtomicTurnStorage } from "@wavebirdai/api/server";
|
|
3
|
+
|
|
4
|
+
/** Replace this fail-closed stub with createPostgresTurnStorage(your existing pg pool). */
|
|
5
|
+
export function publisherTurnStorage(): PublisherAtomicTurnStorage {
|
|
6
|
+
return {
|
|
7
|
+
async atomic<T>(): Promise<T> { throw new Error("Connect your existing durable turn store"); },
|
|
8
|
+
async scan(): Promise<string[]> { throw new Error("Connect your existing durable turn store"); },
|
|
9
|
+
};
|
|
10
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wavebirdai/api",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "Banner, Native, and Clip
|
|
6
|
+
"description": "Integrate Banner, Native, and Clip placements into authenticated chat apps with server-side keys, consent, and generation lifecycle handling",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"repository": { "type": "git", "url": "https://github.com/wavebird-ai/api" },
|
|
9
9
|
"engines": { "node": ">=20" },
|
|
10
|
-
"files": ["dist", "examples", "bin", "templates", "README.md", "LICENSE"],
|
|
10
|
+
"files": ["dist", "src", "build.mjs", "examples", "bin", "templates", "README.md", "LICENSE"],
|
|
11
11
|
"bin": { "wavebird-next": "./bin/wavebird-next.mjs" },
|
|
12
12
|
"exports": {
|
|
13
13
|
"./server": { "types": "./dist/server.d.ts", "import": "./dist/server.js" },
|
package/src/browser.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Resolve after the publisher has presented the result; After timing starts then. */
|
|
2
|
+
export type GenerationConsumer<T> = (response: Response, lifecycle: { onDelta(): void }) => T | Promise<T>;
|
|
3
|
+
export type PublisherAds = {
|
|
4
|
+
ready: Promise<void>;
|
|
5
|
+
connectGeneration<T>(options: { request: (headers: Record<string, string>) => Response | Promise<Response>; consume: GenerationConsumer<T>; signal?: AbortSignal }): Promise<T>;
|
|
6
|
+
setConversation(binding: { userId: string; conversationId: string; sessionId?: string }): void;
|
|
7
|
+
dispose(): void;
|
|
8
|
+
openPrivacySettings(): void;
|
|
9
|
+
grant(choice?: unknown): Promise<unknown>;
|
|
10
|
+
deny(): Promise<unknown>;
|
|
11
|
+
withdraw(): Promise<unknown>;
|
|
12
|
+
};
|
|
13
|
+
export function createPublisherAds(options: {
|
|
14
|
+
target: HTMLElement;
|
|
15
|
+
clientId: string;
|
|
16
|
+
surfaceId: string;
|
|
17
|
+
jobType: "chat" | "code" | "image" | "voice" | "agent";
|
|
18
|
+
userId: string;
|
|
19
|
+
conversationId: string;
|
|
20
|
+
/** Opt-in successor contract: a session ID supplied by the host's authenticated context. */
|
|
21
|
+
sessionId?: string;
|
|
22
|
+
turnBinding?: boolean;
|
|
23
|
+
timing?: "before" | "during" | "after";
|
|
24
|
+
consentMode?: "wavebird_consent" | "own_cmp";
|
|
25
|
+
rendererUrl?: string;
|
|
26
|
+
privacySettingsTarget?: string;
|
|
27
|
+
getFormat?: () => "banner" | "native" | "clip" | "auto";
|
|
28
|
+
placementEndpoint?: string;
|
|
29
|
+
consentEndpoint?: string;
|
|
30
|
+
}): PublisherAds;
|
|
31
|
+
export function connectPublisherGeneration<T>(url: string, init: RequestInit, consume: GenerationConsumer<T>): Promise<T>;
|
package/src/browser.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
let rendererReady = null;
|
|
2
|
+
let rendererSource = null;
|
|
3
|
+
|
|
4
|
+
function loadRenderer(url) {
|
|
5
|
+
if (window.wavebird?.createChatSponsor) return Promise.resolve();
|
|
6
|
+
if (rendererReady && rendererSource !== url) return Promise.reject(new Error("renderer_source_mismatch"));
|
|
7
|
+
if (!rendererReady) {
|
|
8
|
+
rendererSource = url;
|
|
9
|
+
rendererReady = new Promise((resolve, reject) => {
|
|
10
|
+
const script = document.createElement("script"); script.src = url;
|
|
11
|
+
const fail = () => { clearTimeout(timer); script.onload = null; script.onerror = null; script.remove(); reject(new Error("renderer_unavailable")); };
|
|
12
|
+
const timer = setTimeout(fail, 8000);
|
|
13
|
+
script.onerror = fail;
|
|
14
|
+
script.onload = () => { clearTimeout(timer); if (!window.wavebird?.createChatSponsor) fail(); else resolve(); };
|
|
15
|
+
document.head.append(script);
|
|
16
|
+
}).catch(error => { rendererReady = null; rendererSource = null; throw error; });
|
|
17
|
+
}
|
|
18
|
+
return rendererReady;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createPublisherAds(options) {
|
|
22
|
+
const target = options.target;
|
|
23
|
+
if (!target || !options.userId || !options.conversationId) throw new Error("Application container and session are required");
|
|
24
|
+
if (!["chat", "code", "image", "voice", "agent"].includes(options.jobType)) throw new Error("publisher_job_invalid");
|
|
25
|
+
const turnBinding = options.turnBinding === true;
|
|
26
|
+
const validId = value => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
|
27
|
+
if (turnBinding && !validId(options.sessionId)) throw new Error("publisher_trusted_session_required");
|
|
28
|
+
const timing = options.timing || "during", consentMode = options.consentMode || "wavebird_consent";
|
|
29
|
+
if (!["before", "during", "after"].includes(timing) || !["wavebird_consent", "own_cmp"].includes(consentMode)) throw new Error("Invalid publisher configuration");
|
|
30
|
+
let binding = { userId: options.userId, conversationId: options.conversationId,
|
|
31
|
+
sessionId: turnBinding ? options.sessionId : crypto.randomUUID() };
|
|
32
|
+
let sponsor = null, disposed = false, context = 0, active = null;
|
|
33
|
+
const slot = document.createElement("section");
|
|
34
|
+
slot.setAttribute("aria-label", "Sponsored placement");
|
|
35
|
+
slot.style.cssText = "width:100%;height:100%;overflow:hidden;margin:0 auto";
|
|
36
|
+
target.append(slot);
|
|
37
|
+
const privacyTarget = consentMode === "wavebird_consent" && options.privacySettingsTarget
|
|
38
|
+
? document.querySelector(options.privacySettingsTarget) : null;
|
|
39
|
+
const openPrivacySettings = () => { if (!disposed) sponsor?.openPrivacySettings(); };
|
|
40
|
+
privacyTarget?.addEventListener("click", openPrivacySettings);
|
|
41
|
+
const notify = (method, id) => {
|
|
42
|
+
try { sponsor?.[method](id); }
|
|
43
|
+
catch {
|
|
44
|
+
try { sponsor?.dispose(); } catch {}
|
|
45
|
+
sponsor = null; slot.hidden = true; slot.replaceChildren();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const current = job => !disposed && !job.cancelled && job.context === context && active === job;
|
|
49
|
+
const event = (job, type) => {
|
|
50
|
+
if (!current(job)) return;
|
|
51
|
+
if (type === "started") {
|
|
52
|
+
if (job.state !== "pending") return;
|
|
53
|
+
job.state = "started"; notify("onStarted", job.id);
|
|
54
|
+
} else if (type === "progress") {
|
|
55
|
+
if (job.state === "started") { job.progressed = true; notify("onDelta", job.id); }
|
|
56
|
+
} else if (type === "no_result") {
|
|
57
|
+
if (job.state === "started") job.state = "no_result";
|
|
58
|
+
} else if (["done", "error", "abort"].includes(type)) {
|
|
59
|
+
if (job.state !== "started") return;
|
|
60
|
+
job.state = type;
|
|
61
|
+
notify(type === "done" ? "onDone" : type === "abort" ? "onAbort" : "onError", job.id);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const prepare = () => { if (!disposed && sponsor) void sponsor.prepare().catch(() => {}); };
|
|
65
|
+
function setConversation(next) {
|
|
66
|
+
if (disposed) return;
|
|
67
|
+
if (!next.userId || !next.conversationId) throw new Error("Application session is required");
|
|
68
|
+
if (turnBinding && !validId(next.sessionId)) throw new Error("publisher_trusted_session_required");
|
|
69
|
+
if (next.userId === binding.userId && next.conversationId === binding.conversationId &&
|
|
70
|
+
(!turnBinding || next.sessionId === binding.sessionId)) return;
|
|
71
|
+
if (turnBinding && next.sessionId === binding.sessionId) throw new Error("publisher_session_reuse");
|
|
72
|
+
if (active) event(active, "abort");
|
|
73
|
+
context++; active = null;
|
|
74
|
+
binding = { userId: next.userId, conversationId: next.conversationId,
|
|
75
|
+
sessionId: turnBinding ? next.sessionId : crypto.randomUUID() };
|
|
76
|
+
sponsor?.setConversation(binding); prepare();
|
|
77
|
+
}
|
|
78
|
+
function dispose() {
|
|
79
|
+
if (disposed) return;
|
|
80
|
+
if (active) event(active, "abort");
|
|
81
|
+
disposed = true; context++; active = null;
|
|
82
|
+
sponsor?.dispose(); sponsor = null; slot.remove();
|
|
83
|
+
privacyTarget?.removeEventListener("click", openPrivacySettings);
|
|
84
|
+
window.removeEventListener("pagehide", dispose);
|
|
85
|
+
}
|
|
86
|
+
window.addEventListener("pagehide", dispose);
|
|
87
|
+
const ready = loadRenderer(options.rendererUrl || "https://api.wavebird.ai/v1/render.js").then(() => {
|
|
88
|
+
if (disposed) return;
|
|
89
|
+
sponsor = window.wavebird.createChatSponsor({ target: slot, ...binding,
|
|
90
|
+
clientId: options.clientId, surfaceId: options.surfaceId, timing, consentMode,
|
|
91
|
+
cleanupAfterClip: true,
|
|
92
|
+
...(turnBinding ? { turnBinding: true } : {}),
|
|
93
|
+
...(options.getFormat === undefined ? {} : { getFormat: options.getFormat }),
|
|
94
|
+
...(options.placementEndpoint === undefined ? {} : { placementEndpoint: options.placementEndpoint }),
|
|
95
|
+
...(options.consentEndpoint === undefined ? {} : { consentEndpoint: options.consentEndpoint }) });
|
|
96
|
+
prepare();
|
|
97
|
+
if (active && current(active)) {
|
|
98
|
+
if (active.state === "started") {
|
|
99
|
+
notify("onStarted", active.id);
|
|
100
|
+
if (active.progressed) notify("onDelta", active.id);
|
|
101
|
+
} else if (timing === "after" && active.state === "done") {
|
|
102
|
+
notify("onStarted", active.id); notify("onDone", active.id);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}).catch(() => {});
|
|
106
|
+
|
|
107
|
+
async function connectGeneration({ request, consume, signal }) {
|
|
108
|
+
const job = { id: crypto.randomUUID(), sessionId: binding.sessionId, context, state: "pending", cancelled: false };
|
|
109
|
+
if (active) event(active, "abort");
|
|
110
|
+
active = job;
|
|
111
|
+
let reader = null, ended = false, buffer = "";
|
|
112
|
+
const decoder = new TextDecoder();
|
|
113
|
+
const abort = () => { event(job, "abort"); job.cancelled = true; void reader?.cancel().catch(() => {}); };
|
|
114
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
115
|
+
if (signal?.aborted) abort();
|
|
116
|
+
const headers = { "x-wavebird-session-id": job.sessionId, "x-wavebird-generation-id": job.id };
|
|
117
|
+
try {
|
|
118
|
+
const response = await request(headers);
|
|
119
|
+
if (response.headers.get("x-wavebird-generation-transport") !== "1") return await consume(response);
|
|
120
|
+
if (!response.body) throw new Error("generation_transport_missing");
|
|
121
|
+
reader = response.body.getReader();
|
|
122
|
+
async function nextRecord() {
|
|
123
|
+
while (true) {
|
|
124
|
+
const boundary = buffer.indexOf("\n");
|
|
125
|
+
if (boundary >= 0) {
|
|
126
|
+
const line = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 1);
|
|
127
|
+
if (!line || line.length > 32768) throw new Error("generation_transport_invalid");
|
|
128
|
+
const record = JSON.parse(line);
|
|
129
|
+
if (record.session_id !== job.sessionId || record.generation_id !== job.id) throw new Error("generation_transport_context_mismatch");
|
|
130
|
+
return record;
|
|
131
|
+
}
|
|
132
|
+
if (buffer.length > 32768) throw new Error("generation_transport_limit");
|
|
133
|
+
const next = await reader.read();
|
|
134
|
+
if (signal?.aborted) throw new Error("generation_aborted");
|
|
135
|
+
if (next.done) throw new Error("generation_transport_truncated");
|
|
136
|
+
buffer += decoder.decode(next.value, { stream: true });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
let metadata;
|
|
140
|
+
while (!metadata) {
|
|
141
|
+
const record = await nextRecord();
|
|
142
|
+
if (record.type === "started") event(job, "started");
|
|
143
|
+
else if (record.type === "response") metadata = record;
|
|
144
|
+
else throw new Error("generation_failed_before_response");
|
|
145
|
+
}
|
|
146
|
+
if (!Number.isInteger(metadata.status) || metadata.status < 200 || metadata.status > 599 || !Array.isArray(metadata.headers)) throw new Error("generation_response_invalid");
|
|
147
|
+
const output = new ReadableStream({
|
|
148
|
+
async pull(controller) {
|
|
149
|
+
try {
|
|
150
|
+
while (true) {
|
|
151
|
+
const record = await nextRecord();
|
|
152
|
+
if (record.type === "bytes") {
|
|
153
|
+
if (typeof record.data !== "string" || record.data.length > 21848 || !/^[A-Za-z0-9+/]*={0,2}$/.test(record.data)) throw new Error("generation_bytes_invalid");
|
|
154
|
+
const raw = atob(record.data), bytes = Uint8Array.from(raw, value => value.charCodeAt(0));
|
|
155
|
+
controller.enqueue(bytes);
|
|
156
|
+
if (timing !== "before" && options.jobType !== "image") event(job, "progress");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (["done", "error", "abort"].includes(record.type)) {
|
|
160
|
+
ended = true;
|
|
161
|
+
if (record.type !== "done" || timing !== "after") event(job, record.type);
|
|
162
|
+
if (record.type === "done" || !metadata.status.toString().startsWith("2")) controller.close();
|
|
163
|
+
else controller.error(new Error("generation_" + record.type));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
throw new Error("generation_transport_invalid");
|
|
167
|
+
}
|
|
168
|
+
} catch (error) { event(job, signal?.aborted ? "abort" : "error"); controller.error(error); }
|
|
169
|
+
},
|
|
170
|
+
cancel() { event(job, "abort"); void reader?.cancel().catch(() => {}); }
|
|
171
|
+
}, { highWaterMark: 0 });
|
|
172
|
+
if ([204, 205, 304].includes(metadata.status)) {
|
|
173
|
+
const record = await nextRecord();
|
|
174
|
+
if (!["done", "error", "abort"].includes(record.type)) throw new Error("generation_transport_invalid");
|
|
175
|
+
ended = true;
|
|
176
|
+
if (record.type !== "done" || timing !== "after") event(job, record.type);
|
|
177
|
+
}
|
|
178
|
+
const noBody = [204, 205, 304].includes(metadata.status);
|
|
179
|
+
const primary = new Response(noBody ? null : output, {
|
|
180
|
+
status: metadata.status, statusText: metadata.status_text || "", headers: metadata.headers
|
|
181
|
+
});
|
|
182
|
+
const result = await consume(primary, { onDelta: () => event(job, "progress") });
|
|
183
|
+
if (timing === "after" && ended) event(job, noBody ? "no_result" : "done");
|
|
184
|
+
return result;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
event(job, signal?.aborted ? "abort" : "error");
|
|
187
|
+
throw error;
|
|
188
|
+
} finally {
|
|
189
|
+
if (!ended && job.state === "started") event(job, "abort");
|
|
190
|
+
signal?.removeEventListener("abort", abort);
|
|
191
|
+
if (reader) { try { await reader.cancel(); } catch {} reader.releaseLock(); }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { ready, connectGeneration, setConversation, dispose, openPrivacySettings,
|
|
195
|
+
grant: choice => sponsor ? sponsor.grant(choice) : Promise.resolve({ status: "renderer_unavailable" }),
|
|
196
|
+
deny: () => sponsor ? sponsor.deny() : Promise.resolve({ status: "renderer_unavailable" }),
|
|
197
|
+
withdraw: () => sponsor ? sponsor.withdraw() : Promise.resolve({ status: "renderer_unavailable" }) };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function connectPublisherGeneration(url, init, consume) {
|
|
201
|
+
const ads = window.publisherAds;
|
|
202
|
+
if (!ads) return consume(await fetch(url, init), { onDelta() {} });
|
|
203
|
+
return ads.connectGeneration({
|
|
204
|
+
signal: init.signal ?? undefined,
|
|
205
|
+
request: values => {
|
|
206
|
+
const headers = new Headers(init.headers);
|
|
207
|
+
for (const [name, value] of Object.entries(values)) headers.set(name, value);
|
|
208
|
+
return fetch(url, { ...init, headers });
|
|
209
|
+
},
|
|
210
|
+
consume
|
|
211
|
+
});
|
|
212
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
function generationTransport({ request, run }) {
|
|
2
|
+
const sessionId = request.headers.get("x-wavebird-session-id") || "";
|
|
3
|
+
const generationId = request.headers.get("x-wavebird-generation-id") || "";
|
|
4
|
+
const validId = (value) => /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
|
5
|
+
if (!validId(sessionId) || !validId(generationId))
|
|
6
|
+
return run(request.signal);
|
|
7
|
+
const abort = new AbortController();
|
|
8
|
+
const stop = () => abort.abort(request.signal.reason);
|
|
9
|
+
request.signal.addEventListener("abort", stop, { once: true });
|
|
10
|
+
if (request.signal.aborted)
|
|
11
|
+
stop();
|
|
12
|
+
let reader = null;
|
|
13
|
+
let pending;
|
|
14
|
+
try {
|
|
15
|
+
pending = Promise.resolve(run(abort.signal));
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
pending = Promise.reject(error);
|
|
19
|
+
}
|
|
20
|
+
pending.catch(() => { });
|
|
21
|
+
const encoder = new TextEncoder();
|
|
22
|
+
const record = (value) => encoder.encode(JSON.stringify({
|
|
23
|
+
session_id: sessionId, generation_id: generationId, ...value
|
|
24
|
+
}) + "\n");
|
|
25
|
+
async function* records() {
|
|
26
|
+
try {
|
|
27
|
+
if (abort.signal.aborted)
|
|
28
|
+
throw new Error("generation_aborted");
|
|
29
|
+
yield record({ type: "started" });
|
|
30
|
+
const response = await pending;
|
|
31
|
+
if (!(response instanceof Response))
|
|
32
|
+
throw new Error("model_response_required");
|
|
33
|
+
if (abort.signal.aborted)
|
|
34
|
+
throw new Error("generation_aborted");
|
|
35
|
+
const headers = [...response.headers.entries()].filter(([name]) => !["set-cookie", "content-length", "content-encoding", "transfer-encoding"].includes(name.toLowerCase()));
|
|
36
|
+
if (response.headers.has("set-cookie"))
|
|
37
|
+
throw new Error("model_response_cookie_requires_outer_route");
|
|
38
|
+
yield record({ type: "response", status: response.status, status_text: response.statusText, headers });
|
|
39
|
+
reader = response.body?.getReader() || null;
|
|
40
|
+
if (reader) {
|
|
41
|
+
while (true) {
|
|
42
|
+
if (abort.signal.aborted)
|
|
43
|
+
throw new Error("generation_aborted");
|
|
44
|
+
const next = await reader.read();
|
|
45
|
+
if (abort.signal.aborted)
|
|
46
|
+
throw new Error("generation_aborted");
|
|
47
|
+
if (next.done)
|
|
48
|
+
break;
|
|
49
|
+
for (let offset = 0; offset < next.value.length; offset += 16384) {
|
|
50
|
+
yield record({ type: "bytes", data: Buffer.from(next.value.subarray(offset, offset + 16384)).toString("base64") });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
yield record({ type: response.ok ? "done" : "error" });
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
yield record({ type: abort.signal.aborted ? "abort" : "error" });
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
abort.abort();
|
|
61
|
+
if (reader) {
|
|
62
|
+
try {
|
|
63
|
+
await reader.cancel();
|
|
64
|
+
}
|
|
65
|
+
catch { }
|
|
66
|
+
reader.releaseLock();
|
|
67
|
+
}
|
|
68
|
+
request.signal.removeEventListener("abort", stop);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const iterator = records();
|
|
72
|
+
const body = new ReadableStream({
|
|
73
|
+
async pull(controller) {
|
|
74
|
+
try {
|
|
75
|
+
const next = await iterator.next();
|
|
76
|
+
if (next.done)
|
|
77
|
+
controller.close();
|
|
78
|
+
else
|
|
79
|
+
controller.enqueue(next.value);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
controller.error(error);
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
cancel(reason) {
|
|
86
|
+
abort.abort(reason);
|
|
87
|
+
void reader?.cancel(reason).catch(() => { });
|
|
88
|
+
void iterator.return(undefined).catch(() => { });
|
|
89
|
+
}
|
|
90
|
+
}, { highWaterMark: 1 });
|
|
91
|
+
return new Response(body, { headers: {
|
|
92
|
+
"Content-Type": "application/x-wavebird-generation+jsonl",
|
|
93
|
+
"Cache-Control": "no-store", "X-Wavebird-Generation-Transport": "1"
|
|
94
|
+
} });
|
|
95
|
+
}
|
|
96
|
+
export { generationTransport };
|
|
@@ -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/src/server.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
export type PublisherServerOptions = {
|
|
34
|
+
apiBaseUrl?: string;
|
|
35
|
+
clientId: string;
|
|
36
|
+
secretKey: string;
|
|
37
|
+
preferenceSecret: string;
|
|
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;
|
|
43
|
+
publicOrigins: string[];
|
|
44
|
+
surfaceId: string;
|
|
45
|
+
placementKey: string;
|
|
46
|
+
jobType: "chat" | "code" | "image" | "voice" | "agent";
|
|
47
|
+
locale: string;
|
|
48
|
+
timing: "before" | "during" | "after";
|
|
49
|
+
consentMode: "wavebird_consent" | "own_cmp";
|
|
50
|
+
gdprApplies: boolean;
|
|
51
|
+
publisher: { app_name: string; app_domain: string; categories: string[] };
|
|
52
|
+
allowedFormats: Array<"banner" | "native" | "clip">;
|
|
53
|
+
positionHint: "above" | "below" | "sidebar" | "between";
|
|
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"> }>;
|
|
58
|
+
responsiveSizeSelection?: boolean;
|
|
59
|
+
bidfloor?: number;
|
|
60
|
+
bidfloorCurrency?: string;
|
|
61
|
+
blockedCategories?: string[];
|
|
62
|
+
};
|
|
63
|
+
export type GenerationLifecycleEvent = {
|
|
64
|
+
type: "started" | "done" | "error" | "abort";
|
|
65
|
+
sessionId: string;
|
|
66
|
+
generationId: string;
|
|
67
|
+
};
|
|
68
|
+
export type PublisherServer = {
|
|
69
|
+
sessionGET(request: Request): Promise<Response>;
|
|
70
|
+
placementPOST(request: Request): Promise<Response>;
|
|
71
|
+
consentGET(request: Request): Promise<Response>;
|
|
72
|
+
consentPOST(request: Request): Promise<Response>;
|
|
73
|
+
withGeneration(options: { request: Request; run: (signal: AbortSignal) => Response | Promise<Response>; onLifecycle?: (event: GenerationLifecycleEvent) => void | Promise<void> }): Promise<Response>;
|
|
74
|
+
reportGeneration(jobId: string, event: "started" | "finished" | "failed"): Promise<void>;
|
|
75
|
+
};
|
|
76
|
+
export function createPublisherServer(options: PublisherServerOptions): PublisherServer;
|