@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.
@@ -10,8 +10,16 @@ export default function Page() {
10
10
  const [answer, setAnswer] = useState("");
11
11
  useEffect(() => {
12
12
  if (!slot.current) return;
13
- const current = createPublisherAds({
14
- target: slot.current,
13
+ const target = slot.current;
14
+ const controller = new AbortController();
15
+ let disposed = false;
16
+ let current: PublisherAds | null = null;
17
+ void fetch("/api/sponsor-session", { cache: "no-store", signal: controller.signal }).then(async response => {
18
+ if (!response.ok) return;
19
+ const sessionId = (await response.json()).session_id;
20
+ if (disposed || !/^[A-Za-z0-9_-]{1,128}$/.test(sessionId)) return;
21
+ current = createPublisherAds({
22
+ target,
15
23
  clientId: "wbproj_replace_from_dashboard",
16
24
  surfaceId: "chat_assistant",
17
25
  jobType: "chat",
@@ -19,13 +27,16 @@ export default function Page() {
19
27
  consentMode: "wavebird_consent",
20
28
  userId: "replace-with-authenticated-user-id",
21
29
  conversationId: "replace-with-current-conversation-id",
30
+ sessionId,
31
+ turnBinding: true,
22
32
  rendererUrl: "https://api.wavebird.ai/v1/render.js",
23
33
  placementEndpoint: "/api/sponsor-slot",
24
34
  consentEndpoint: "/api/sponsor-consent",
25
35
  privacySettingsTarget: "#privacy-settings"
26
- });
27
- ads.current = current;
28
- return () => { abort.current?.abort(); current.dispose(); ads.current = null; };
36
+ });
37
+ ads.current = current;
38
+ }).catch(() => {});
39
+ return () => { disposed = true; controller.abort(); abort.current?.abort(); current?.dispose(); ads.current = null; };
29
40
  }, []);
30
41
  async function generate() {
31
42
  if (!ads.current) return;
@@ -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
- const path = process.env.PUBLISHER_AUTH_CHECK_PATH;
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
- return publisher ??= createPublisherServer({
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.1",
3
+ "version": "0.1.0-beta.11",
4
4
  "private": false,
5
5
  "type": "module",
6
- "description": "Banner/Native publisher integration for the wavebird API",
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" },
@@ -0,0 +1,37 @@
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<Bound extends boolean = boolean> = {
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: Bound extends true
7
+ ? { userId: string; conversationId: string; sessionId: string }
8
+ : { userId: string; conversationId: string; sessionId?: string }): void;
9
+ dispose(): void;
10
+ openPrivacySettings(): void;
11
+ grant(choice?: unknown): Promise<unknown>;
12
+ deny(): Promise<unknown>;
13
+ withdraw(): Promise<unknown>;
14
+ };
15
+ type PublisherAdsOptionsBase = {
16
+ target: HTMLElement;
17
+ clientId: string;
18
+ surfaceId: string;
19
+ jobType: "chat" | "code" | "image" | "voice" | "agent";
20
+ userId: string;
21
+ conversationId: string;
22
+ timing?: "before" | "during" | "after";
23
+ consentMode?: "wavebird_consent" | "own_cmp";
24
+ /** Optional official renderer URL. Only https://api.wavebird.ai/v1/render.js is supported. */
25
+ rendererUrl?: string;
26
+ privacySettingsTarget?: string;
27
+ getFormat?: () => "banner" | "native" | "clip" | "auto";
28
+ placementEndpoint?: string;
29
+ consentEndpoint?: string;
30
+ };
31
+ export function createPublisherAds(options: PublisherAdsOptionsBase &
32
+ { turnBinding?: false; sessionId?: string }): PublisherAds<false>;
33
+ /** Opt-in successor: a trusted host session is required at mount and at each conversation change. */
34
+ export function createPublisherAds(options: PublisherAdsOptionsBase &
35
+ { turnBinding: true; sessionId: string }): PublisherAds<true>;
36
+ /** Pass the mount's ads instance to keep concurrent surfaces bound to their own generation. */
37
+ export function connectPublisherGeneration<T>(url: string, init: RequestInit, consume: GenerationConsumer<T>, ads?: PublisherAds): Promise<T>;
package/src/browser.js ADDED
@@ -0,0 +1,248 @@
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
+ const requiredText = value => typeof value === "string" && value.trim().length > 0;
24
+ if (!target || typeof target.append !== "function" || !requiredText(options.userId) || !requiredText(options.conversationId))
25
+ throw new Error("Application container and session are required");
26
+ if (!["chat", "code", "image", "voice", "agent"].includes(options.jobType)) throw new Error("publisher_job_invalid");
27
+ if (typeof options.clientId !== "string" || !options.clientId.startsWith("wbproj_"))
28
+ throw new Error("publisher_project_invalid");
29
+ if (typeof options.surfaceId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(options.surfaceId))
30
+ throw new Error("publisher_placement_invalid");
31
+ if (options.turnBinding !== undefined && typeof options.turnBinding !== "boolean")
32
+ throw new Error("publisher_turn_binding_invalid");
33
+ const turnBinding = options.turnBinding === true;
34
+ const validId = value => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
35
+ if (turnBinding && !validId(options.sessionId)) throw new Error("publisher_trusted_session_required");
36
+ const timing = options.timing === undefined ? "during" : options.timing;
37
+ const consentMode = options.consentMode === undefined ? "wavebird_consent" : options.consentMode;
38
+ if (!["before", "during", "after"].includes(timing) || !["wavebird_consent", "own_cmp"].includes(consentMode)) throw new Error("Invalid publisher configuration");
39
+ const validEndpoint = value => {
40
+ if (typeof value !== "string" || !value || value.trim() !== value) return false;
41
+ try {
42
+ const base = new URL(window.location?.href ?? "https://publisher.invalid/");
43
+ const endpoint = new URL(value, base);
44
+ return ["http:", "https:"].includes(endpoint.protocol) && endpoint.origin === base.origin &&
45
+ !endpoint.username && !endpoint.password && !endpoint.hash;
46
+ } catch { return false; }
47
+ };
48
+ if ((options.placementEndpoint !== undefined && !validEndpoint(options.placementEndpoint)) ||
49
+ (options.consentEndpoint !== undefined && !validEndpoint(options.consentEndpoint)))
50
+ throw new Error("publisher_endpoint_invalid");
51
+ const rendererUrl = options.rendererUrl === undefined ? "https://api.wavebird.ai/v1/render.js" : options.rendererUrl;
52
+ if (rendererUrl !== "https://api.wavebird.ai/v1/render.js") throw new Error("publisher_renderer_url_invalid");
53
+ if (options.getFormat !== undefined && typeof options.getFormat !== "function")
54
+ throw new Error("publisher_format_hook_invalid");
55
+ let privacyTarget = null;
56
+ if (consentMode === "wavebird_consent" && options.privacySettingsTarget !== undefined) {
57
+ if (typeof options.privacySettingsTarget !== "string" || !options.privacySettingsTarget.trim())
58
+ throw new Error("publisher_privacy_target_invalid");
59
+ try { privacyTarget = document.querySelector(options.privacySettingsTarget); }
60
+ catch { throw new Error("publisher_privacy_target_invalid"); }
61
+ }
62
+ let binding = { userId: options.userId, conversationId: options.conversationId,
63
+ sessionId: turnBinding ? options.sessionId : crypto.randomUUID() };
64
+ let sponsor = null, disposed = false, context = 0, active = null;
65
+ const slot = document.createElement("section");
66
+ slot.setAttribute("aria-label", "Sponsored placement");
67
+ slot.style.cssText = "width:100%;height:100%;overflow:hidden;margin:0 auto";
68
+ target.append(slot);
69
+ const openPrivacySettings = () => { if (!disposed) sponsor?.openPrivacySettings(); };
70
+ privacyTarget?.addEventListener("click", openPrivacySettings);
71
+ const notify = (method, id) => {
72
+ try { sponsor?.[method](id); }
73
+ catch {
74
+ try { sponsor?.dispose(); } catch {}
75
+ sponsor = null; slot.hidden = true; slot.replaceChildren();
76
+ }
77
+ };
78
+ const current = job => !disposed && !job.cancelled && job.context === context && active === job;
79
+ const event = (job, type) => {
80
+ if (!current(job)) return;
81
+ if (type === "started") {
82
+ if (job.state !== "pending") return;
83
+ job.state = "started"; notify("onStarted", job.id);
84
+ } else if (type === "progress") {
85
+ if (job.state === "started") { job.progressed = true; notify("onDelta", job.id); }
86
+ } else if (type === "no_result") {
87
+ if (job.state === "started") job.state = "no_result";
88
+ } else if (["done", "error", "abort"].includes(type)) {
89
+ if (job.state !== "started") return;
90
+ job.state = type;
91
+ notify(type === "done" ? "onDone" : type === "abort" ? "onAbort" : "onError", job.id);
92
+ }
93
+ };
94
+ const prepare = () => { if (!disposed && sponsor) void sponsor.prepare().catch(() => {}); };
95
+ function setConversation(next) {
96
+ if (disposed) return;
97
+ if (!requiredText(next?.userId) || !requiredText(next?.conversationId))
98
+ throw new Error("Application session is required");
99
+ if (turnBinding && !validId(next.sessionId)) throw new Error("publisher_trusted_session_required");
100
+ if (next.userId === binding.userId && next.conversationId === binding.conversationId &&
101
+ (!turnBinding || next.sessionId === binding.sessionId)) return;
102
+ if (turnBinding && next.sessionId === binding.sessionId) throw new Error("publisher_session_reuse");
103
+ if (active) event(active, "abort");
104
+ context++; active = null;
105
+ binding = { userId: next.userId, conversationId: next.conversationId,
106
+ sessionId: turnBinding ? next.sessionId : crypto.randomUUID() };
107
+ sponsor?.setConversation(binding); prepare();
108
+ }
109
+ function dispose() {
110
+ if (disposed) return;
111
+ if (active) event(active, "abort");
112
+ disposed = true; context++; active = null;
113
+ sponsor?.dispose(); sponsor = null; slot.remove();
114
+ privacyTarget?.removeEventListener("click", openPrivacySettings);
115
+ window.removeEventListener("pagehide", dispose);
116
+ }
117
+ window.addEventListener("pagehide", dispose);
118
+ const ready = loadRenderer(rendererUrl).then(() => {
119
+ if (disposed) return;
120
+ sponsor = window.wavebird.createChatSponsor({ target: slot, ...binding,
121
+ clientId: options.clientId, surfaceId: options.surfaceId, timing, consentMode,
122
+ cleanupAfterClip: true,
123
+ ...(turnBinding ? { turnBinding: true } : {}),
124
+ ...(options.getFormat === undefined ? {} : { getFormat: options.getFormat }),
125
+ ...(options.placementEndpoint === undefined ? {} : { placementEndpoint: options.placementEndpoint }),
126
+ ...(options.consentEndpoint === undefined ? {} : { consentEndpoint: options.consentEndpoint }) });
127
+ prepare();
128
+ if (active && current(active)) {
129
+ if (active.state === "started") {
130
+ notify("onStarted", active.id);
131
+ if (active.progressed) notify("onDelta", active.id);
132
+ } else if (timing === "after" && active.state === "done") {
133
+ notify("onStarted", active.id); notify("onDone", active.id);
134
+ }
135
+ }
136
+ }).catch(() => {});
137
+
138
+ async function connectGeneration({ request, consume, signal }) {
139
+ const job = { id: crypto.randomUUID(), sessionId: binding.sessionId, context, state: "pending", cancelled: false };
140
+ if (active) event(active, "abort");
141
+ active = job;
142
+ let reader = null, ended = false, buffer = "";
143
+ const decoder = new TextDecoder();
144
+ const abort = () => { event(job, "abort"); job.cancelled = true; void reader?.cancel().catch(() => {}); };
145
+ signal?.addEventListener("abort", abort, { once: true });
146
+ if (signal?.aborted) abort();
147
+ const headers = { "x-wavebird-session-id": job.sessionId, "x-wavebird-generation-id": job.id };
148
+ try {
149
+ if (signal?.aborted) throw new Error("generation_aborted");
150
+ const response = await request(headers);
151
+ if (!current(job)) throw new Error("generation_aborted");
152
+ if (response.headers.get("x-wavebird-generation-transport") !== "1") {
153
+ const result = await consume(response, { onDelta() {} });
154
+ if (!current(job)) throw new Error("generation_aborted");
155
+ return result;
156
+ }
157
+ if (!response.body) throw new Error("generation_transport_missing");
158
+ reader = response.body.getReader();
159
+ async function nextRecord() {
160
+ while (true) {
161
+ const boundary = buffer.indexOf("\n");
162
+ if (boundary >= 0) {
163
+ const line = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 1);
164
+ if (!line || line.length > 32768) throw new Error("generation_transport_invalid");
165
+ const record = JSON.parse(line);
166
+ if (record.session_id !== job.sessionId || record.generation_id !== job.id) throw new Error("generation_transport_context_mismatch");
167
+ return record;
168
+ }
169
+ if (buffer.length > 32768) throw new Error("generation_transport_limit");
170
+ const next = await reader.read();
171
+ if (signal?.aborted) throw new Error("generation_aborted");
172
+ if (next.done) throw new Error("generation_transport_truncated");
173
+ buffer += decoder.decode(next.value, { stream: true });
174
+ }
175
+ }
176
+ let metadata;
177
+ while (!metadata) {
178
+ const record = await nextRecord();
179
+ if (record.type === "started") event(job, "started");
180
+ else if (record.type === "response") metadata = record;
181
+ else throw new Error("generation_failed_before_response");
182
+ }
183
+ if (!Number.isInteger(metadata.status) || metadata.status < 200 || metadata.status > 599 || !Array.isArray(metadata.headers)) throw new Error("generation_response_invalid");
184
+ const output = new ReadableStream({
185
+ async pull(controller) {
186
+ try {
187
+ while (true) {
188
+ const record = await nextRecord();
189
+ if (record.type === "bytes") {
190
+ if (typeof record.data !== "string" || record.data.length > 21848 || !/^[A-Za-z0-9+/]*={0,2}$/.test(record.data)) throw new Error("generation_bytes_invalid");
191
+ const raw = atob(record.data), bytes = Uint8Array.from(raw, value => value.charCodeAt(0));
192
+ controller.enqueue(bytes);
193
+ if (bytes.length && timing !== "before" && options.jobType !== "image") event(job, "progress");
194
+ return;
195
+ }
196
+ if (["done", "error", "abort"].includes(record.type)) {
197
+ ended = true;
198
+ if (record.type !== "done" || timing !== "after") event(job, record.type);
199
+ if (record.type === "done" || !metadata.status.toString().startsWith("2")) controller.close();
200
+ else controller.error(new Error("generation_" + record.type));
201
+ return;
202
+ }
203
+ throw new Error("generation_transport_invalid");
204
+ }
205
+ } catch (error) { event(job, signal?.aborted ? "abort" : "error"); controller.error(error); }
206
+ },
207
+ cancel() { event(job, "abort"); void reader?.cancel().catch(() => {}); }
208
+ }, { highWaterMark: 0 });
209
+ if ([204, 205, 304].includes(metadata.status)) {
210
+ const record = await nextRecord();
211
+ if (!["done", "error", "abort"].includes(record.type)) throw new Error("generation_transport_invalid");
212
+ ended = true;
213
+ if (record.type !== "done" || timing !== "after") event(job, record.type);
214
+ }
215
+ const noBody = [204, 205, 304].includes(metadata.status);
216
+ const primary = new Response(noBody ? null : output, {
217
+ status: metadata.status, statusText: metadata.status_text || "", headers: metadata.headers
218
+ });
219
+ const result = await consume(primary, { onDelta: () => event(job, "progress") });
220
+ if (timing === "after" && ended) event(job, noBody ? "no_result" : "done");
221
+ return result;
222
+ } catch (error) {
223
+ event(job, signal?.aborted ? "abort" : "error");
224
+ throw error;
225
+ } finally {
226
+ if (!ended && job.state === "started") event(job, "abort");
227
+ signal?.removeEventListener("abort", abort);
228
+ if (reader) { try { await reader.cancel(); } catch {} reader.releaseLock(); }
229
+ }
230
+ }
231
+ return { ready, connectGeneration, setConversation, dispose, openPrivacySettings,
232
+ grant: choice => sponsor ? sponsor.grant(choice) : Promise.resolve({ status: "renderer_unavailable" }),
233
+ deny: () => sponsor ? sponsor.deny() : Promise.resolve({ status: "renderer_unavailable" }),
234
+ withdraw: () => sponsor ? sponsor.withdraw() : Promise.resolve({ status: "renderer_unavailable" }) };
235
+ }
236
+
237
+ export async function connectPublisherGeneration(url, init, consume, ads = window.publisherAds) {
238
+ if (!ads) return consume(await fetch(url, init), { onDelta() {} });
239
+ return ads.connectGeneration({
240
+ signal: init.signal ?? undefined,
241
+ request: values => {
242
+ const headers = new Headers(init.headers);
243
+ for (const [name, value] of Object.entries(values)) headers.set(name, value);
244
+ return fetch(url, { ...init, headers });
245
+ },
246
+ consume
247
+ });
248
+ }
@@ -0,0 +1,98 @@
1
+ import { Buffer } from "node:buffer";
2
+
3
+ function generationTransport({ request, run }) {
4
+ const sessionId = request.headers.get("x-wavebird-session-id") || "";
5
+ const generationId = request.headers.get("x-wavebird-generation-id") || "";
6
+ const validId = (value) => /^[A-Za-z0-9_-]{1,128}$/.test(value);
7
+ if (!validId(sessionId) || !validId(generationId))
8
+ return run(request.signal);
9
+ const abort = new AbortController();
10
+ const stop = () => abort.abort(request.signal.reason);
11
+ request.signal.addEventListener("abort", stop, { once: true });
12
+ if (request.signal.aborted)
13
+ stop();
14
+ let reader = null;
15
+ let pending;
16
+ try {
17
+ pending = Promise.resolve(run(abort.signal));
18
+ }
19
+ catch (error) {
20
+ pending = Promise.reject(error);
21
+ }
22
+ pending.catch(() => { });
23
+ const encoder = new TextEncoder();
24
+ const record = (value) => encoder.encode(JSON.stringify({
25
+ session_id: sessionId, generation_id: generationId, ...value
26
+ }) + "\n");
27
+ async function* records() {
28
+ try {
29
+ if (abort.signal.aborted)
30
+ throw new Error("generation_aborted");
31
+ yield record({ type: "started" });
32
+ const response = await pending;
33
+ if (!(response instanceof Response))
34
+ throw new Error("model_response_required");
35
+ if (abort.signal.aborted)
36
+ throw new Error("generation_aborted");
37
+ const headers = [...response.headers.entries()].filter(([name]) => !["set-cookie", "content-length", "content-encoding", "transfer-encoding"].includes(name.toLowerCase()));
38
+ if (response.headers.has("set-cookie"))
39
+ throw new Error("model_response_cookie_requires_outer_route");
40
+ yield record({ type: "response", status: response.status, status_text: response.statusText, headers });
41
+ reader = response.body?.getReader() || null;
42
+ if (reader) {
43
+ while (true) {
44
+ if (abort.signal.aborted)
45
+ throw new Error("generation_aborted");
46
+ const next = await reader.read();
47
+ if (abort.signal.aborted)
48
+ throw new Error("generation_aborted");
49
+ if (next.done)
50
+ break;
51
+ for (let offset = 0; offset < next.value.length; offset += 16384) {
52
+ yield record({ type: "bytes", data: Buffer.from(next.value.subarray(offset, offset + 16384)).toString("base64") });
53
+ }
54
+ }
55
+ }
56
+ yield record({ type: response.ok ? "done" : "error" });
57
+ }
58
+ catch {
59
+ yield record({ type: abort.signal.aborted ? "abort" : "error" });
60
+ }
61
+ finally {
62
+ abort.abort();
63
+ if (reader) {
64
+ try {
65
+ await reader.cancel();
66
+ }
67
+ catch { }
68
+ reader.releaseLock();
69
+ }
70
+ request.signal.removeEventListener("abort", stop);
71
+ }
72
+ }
73
+ const iterator = records();
74
+ const body = new ReadableStream({
75
+ async pull(controller) {
76
+ try {
77
+ const next = await iterator.next();
78
+ if (next.done)
79
+ controller.close();
80
+ else
81
+ controller.enqueue(next.value);
82
+ }
83
+ catch (error) {
84
+ controller.error(error);
85
+ }
86
+ },
87
+ cancel(reason) {
88
+ abort.abort(reason);
89
+ void reader?.cancel(reason).catch(() => { });
90
+ void iterator.return(undefined).catch(() => { });
91
+ }
92
+ }, { highWaterMark: 1 });
93
+ return new Response(body, { headers: {
94
+ "Content-Type": "application/x-wavebird-generation+jsonl",
95
+ "Cache-Control": "no-store", "X-Wavebird-Generation-Transport": "1"
96
+ } });
97
+ }
98
+ 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
+ }