@wavebirdai/api 0.0.0-stage → 0.1.0-beta.10
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/LICENSE +21 -0
- package/README.md +99 -2
- package/bin/wavebird-next.mjs +69 -0
- package/build.mjs +10 -0
- package/dist/browser.d.ts +31 -0
- package/dist/browser.js +220 -0
- package/dist/generation.js +98 -0
- package/dist/postgres-turn-storage.js +81 -0
- package/dist/server.d.ts +76 -0
- package/dist/server.js +471 -0
- package/dist/turn-journal.js +155 -0
- package/examples/next-js/.env.example +6 -0
- package/examples/next-js/app/api/generate/route.js +6 -0
- package/examples/next-js/app/api/sponsor-consent/route.js +4 -0
- package/examples/next-js/app/api/sponsor-session/route.js +5 -0
- package/examples/next-js/app/api/sponsor-slot/route.js +3 -0
- package/examples/next-js/app/layout.jsx +3 -0
- package/examples/next-js/app/page.jsx +70 -0
- package/examples/next-js/lib/auth.js +33 -0
- package/examples/next-js/lib/model.js +3 -0
- package/examples/next-js/lib/publisher.js +37 -0
- package/examples/next-js/lib/turn-store.js +9 -0
- package/examples/next-js/package.json +7 -0
- package/examples/next-ts/.env.example +6 -0
- package/examples/next-ts/app/api/generate/route.ts +6 -0
- package/examples/next-ts/app/api/sponsor-consent/route.ts +4 -0
- package/examples/next-ts/app/api/sponsor-session/route.ts +5 -0
- package/examples/next-ts/app/api/sponsor-slot/route.ts +3 -0
- package/examples/next-ts/app/layout.tsx +5 -0
- package/examples/next-ts/app/page.tsx +71 -0
- package/examples/next-ts/lib/auth.ts +33 -0
- package/examples/next-ts/lib/model.ts +3 -0
- package/examples/next-ts/lib/publisher.ts +37 -0
- package/examples/next-ts/lib/turn-store.ts +10 -0
- package/examples/next-ts/package.json +8 -0
- package/examples/next-ts/tsconfig.json +20 -0
- package/package.json +21 -4
- package/src/browser.d.ts +31 -0
- package/src/browser.js +220 -0
- package/src/generation.js +98 -0
- package/src/postgres-turn-storage.js +81 -0
- package/src/server.d.ts +76 -0
- package/src/server.js +471 -0
- package/src/turn-journal.js +155 -0
- package/templates/NEXT-SETUP.md +12 -0
- package/templates/WavebirdPlacement.jsx +41 -0
- package/templates/WavebirdPlacement.tsx +47 -0
- package/templates/model.js +3 -0
- package/templates/model.ts +3 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CSL
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,100 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @wavebirdai/api
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Add Wavebird Banner, Native, and Clip placements to an authenticated chat app. The package provides server handlers for placement, consent, and generation reporting, plus a browser adapter for the hosted renderer. Your server keeps the Wavebird key; your app keeps control of its login, model stream, layout, and consent choice.
|
|
4
|
+
|
|
5
|
+
## Integrate with Next.js
|
|
6
|
+
|
|
7
|
+
You need a saved Wavebird Test project and placement in Dashboard, a Server Test Key, an existing authenticated Next.js App Router app, and a durable turn store. Start with the placement's saved formats, sizes, timing, and consent mode. Test traffic is nonpayable.
|
|
8
|
+
|
|
9
|
+
1. Install the package and generate the route files for your app directory:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install @wavebirdai/api@0.1.0-beta.10
|
|
13
|
+
./node_modules/.bin/wavebird-next init --lang ts --root . --app-dir src/app
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Use `--lang js --app-dir app` for a JavaScript app with `app/` at the project root. The generator adds files without overwriting existing ones and writes `WAVEBIRD-SETUP.md` with the exact connection points.
|
|
17
|
+
|
|
18
|
+
2. Set the server-only values in your deployment environment:
|
|
19
|
+
|
|
20
|
+
```dotenv
|
|
21
|
+
WAVEBIRD_CLIENT_ID=<saved-project-id>
|
|
22
|
+
WAVEBIRD_SECRET_KEY=<server-test-key>
|
|
23
|
+
WAVEBIRD_PREFERENCE_SECRET=<stable-random-value-at-least-32-bytes>
|
|
24
|
+
WAVEBIRD_PUBLIC_ORIGINS=https://your-app.example
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`WAVEBIRD_PUBLIC_ORIGINS` must contain each exact app origin, including scheme and port. For local Server Test-key work, add the exact loopback origin to this list while keeping the Dashboard project domain at its real HTTPS host. Never expose the server key or preference secret through `NEXT_PUBLIC_`, client props, or source control.
|
|
28
|
+
|
|
29
|
+
3. Connect the generated files to your app: copy the **saved** placement settings into `lib/publisher`; have `lib/auth` validate the real logged-in request and return a stable, non-credential session ID; replace `lib/turn-store` with your persistent store; and connect `lib/model` to your existing generation stream. The supplied auth, store, and model hooks fail closed until you do this. If you use PostgreSQL, the provided adapter uses your existing pool and its `install()` belongs in your normal migration step.
|
|
30
|
+
|
|
31
|
+
The generated server route keeps the Wavebird key on the server and wraps your existing stream:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { getPublisher } from "../../../lib/publisher";
|
|
35
|
+
import { existingGeneration } from "../../../lib/model";
|
|
36
|
+
|
|
37
|
+
export async function POST(request: Request): Promise<Response> {
|
|
38
|
+
return getPublisher().withGeneration({ request, run: existingGeneration });
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
4. Mount the generated `WavebirdPlacement` beside your app's result area using real authenticated user and conversation IDs, then wrap the existing generation request:
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
<WavebirdPlacement
|
|
46
|
+
clientId={publicProjectId}
|
|
47
|
+
userId={authenticatedUserId}
|
|
48
|
+
conversationId={activeConversationId}
|
|
49
|
+
onReady={setAds}
|
|
50
|
+
/>
|
|
51
|
+
|
|
52
|
+
await ads.connectGeneration({
|
|
53
|
+
signal,
|
|
54
|
+
request: headers => fetch("/api/generate", { method: "POST", headers, signal }),
|
|
55
|
+
consume: async (response, lifecycle) => {
|
|
56
|
+
if (!response.ok || !response.body) throw new Error("generation_unavailable");
|
|
57
|
+
const reader = response.body.getReader();
|
|
58
|
+
while (true) {
|
|
59
|
+
const next = await reader.read();
|
|
60
|
+
if (next.done) break;
|
|
61
|
+
// Forward next.value to your existing stream consumer.
|
|
62
|
+
lifecycle.onDelta();
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The generated `WAVEBIRD-SETUP.md` shows where the component and routes go. Use Wavebird's consent dialog only when that matches the saved project; for `own_cmp`, connect your actual CMP lifecycle.
|
|
69
|
+
|
|
70
|
+
5. Validate in Test: confirm the saved format and size, consent and GPC behavior, No-Fill, Stop/abort, and late responses. The generation result must continue independently of the ad path. Move to another package version or Live only through your normal project approval process.
|
|
71
|
+
|
|
72
|
+
JavaScript and TypeScript use this same package. Complete Next.js JavaScript and TypeScript examples are included in `node_modules/@wavebirdai/api/examples/next-js` and `node_modules/@wavebirdai/api/examples/next-ts`. For other frameworks, use the explicit `@wavebirdai/api/server` and `@wavebirdai/api/browser` exports described below. The [REST reference](https://wavebird.ai/api/reference/endpoints) covers integrations without this package. The established [`wavebird`](https://www.npmjs.com/package/wavebird) SDK is a separate package.
|
|
73
|
+
|
|
74
|
+
## Server and browser boundary
|
|
75
|
+
|
|
76
|
+
`@wavebirdai/api/server` exports `createPublisherServer`. Configure it with server-only Project ID, Server Test or production Key, preference signing secret, the publisher's real request authorization function, exact `publicOrigins`, and the saved project/placement values shown in the dashboard. The origin list is required. The package compares the browser Origin with a configured public origin whose host matches the route Host, even when Next.js or a proxy gives the route an internal URL. `localhost` and `127.0.0.1` are separate origins, and each port and scheme must match exactly; HTTP loopback is available only with a Server Test Key. A production key also requires its separately approved project and Wavebird server authority. Omit `bidfloor` and `bidfloorCurrency` to inherit saved pricing. An explicit override supplies both a nonnegative amount, including zero, and a three-letter currency; the API validates it against the saved project. It returns authenticated `placementPOST`, `consentGET`, `consentPOST`, and `withGeneration` handlers, plus the server-only `reportGeneration(jobId, event)` method. The helper constructs the existing `/v1/placements`, `/v1/consent`, and `/v1/jobs/{id}/generation/{event}` requests; it does not introduce a new Wavebird protocol. The Wavebird API remains authoritative for saved scope, creative formats, duration, fallback, and financial classification.
|
|
77
|
+
|
|
78
|
+
Responsive size selection is enabled by default in this version. The browser adapter sends the measured reserved container size. The server chooses one of the saved `allowedSizes` that fits, or returns `no_fit` without an upstream placement request. Numeric `maxWidth` and `maxHeight` are optional advanced caps: when omitted, the saved sizes define the envelope; when supplied, they can only narrow eligible sizes. A size can carry `formats: ["banner"]`, `formats: ["native"]`, `formats: ["clip"]`, or an approved combination; this annotation remains on the publisher server and is stripped from the wavebird request. A browser format preference is accepted only when it is among the saved `allowedFormats` and compatible with the selected size. Set `responsiveSizeSelection: false` only for a deliberate fixed-size compatibility integration. Reserve enough height for the selected creative and its controls; do not stretch the returned creative or place it over the model result.
|
|
79
|
+
|
|
80
|
+
Create the server helper when a request arrives, as the included Next.js examples do. This lets Next.js build a publisher app without loading its server key into the build process; the helper still validates configuration before handling traffic.
|
|
81
|
+
|
|
82
|
+
`withGeneration({ request, run, onLifecycle })` accepts an optional server-side observer. It receives `{ type, sessionId, generationId }` for `started` and one terminal state: `done`, `error`, or `abort`. Use it to forward lifecycle events to your own generation reporter; observer failures do not change the model response. It runs only for authenticated, same-origin requests with valid generation transport IDs. The callback is not awaited, and it receives no prompt or model bytes.
|
|
83
|
+
|
|
84
|
+
With `sessionIdForRequest` and `turnJournal` configured, the package validates the browser non-credential session ID against the publisher authenticated request, binds one turn to its authorized placement job, and automatically retries ordered generation reports. The PostgreSQL adapter uses the publisher existing pool; run its `install()` in the normal migration step. An unavailable journal suppresses ad placement while an authenticated model request can still run. The store and HMAC key must survive worker restarts. A durable per-project key-version guard rejects a changed preference secret before accepting another turn; pending reports can still reconcile. Coordinate key rotation after active turns are drained rather than clearing the guard while turns remain. This opt-in path requires a package version that includes these exports; earlier published versions retain their original behavior.
|
|
85
|
+
|
|
86
|
+
Call `reportGeneration` only from trusted server code with a job ID returned by an authorized placement. Its events are `started`, `finished`, and `failed`. The server key scopes the request at wavebird; invalid IDs or events are rejected locally, and each report has a five-second deadline and a parallel request limit. Reporting errors reject the returned promise so the caller can handle them outside the model response path.
|
|
87
|
+
|
|
88
|
+
`@wavebirdai/api/browser` exports the existing tested `createPublisherAds` and `connectPublisherGeneration` lifecycle helper. It loads the hosted renderer and attaches only to a reserved container selected by the publisher. Initialize it with the current app user and conversation IDs; pass optional same-origin `placementEndpoint` and `consentEndpoint` paths when using the server routes in this package. The helper forwards those paths to the hosted renderer. Keep the Server Key and preference signing secret in the server configuration; never pass them as browser options. Use the real wavebird consent dialog or the publisher's actual CMP. Consent on an idle page does not request an ad. For `during`, the helper displays only during an active generation. Its generation request and response stream run without waiting for consent synchronization, the ad response, or renderer readiness. It clears late responses on completion, error, abort, conversation change, GPC, or withdrawal.
|
|
89
|
+
|
|
90
|
+
For `after`, resolve the generation consumer only after your app presents the result. The browser helper waits for both the transport finish and that consumer before starting the placement. If presentation fails, reject the consumer so the placement does not start.
|
|
91
|
+
|
|
92
|
+
## Next.js
|
|
93
|
+
|
|
94
|
+
The package contains equivalent JavaScript and TypeScript Next.js example files in `examples/next-js` and `examples/next-ts`. Each shows a publisher-owned reserved slot outside the input and scrolling transcript, same-origin consent and placement routes, the server-only configuration, and an existing generation stream wrapped by `withGeneration`. Their authorization hook fails closed until connected to the publisher's actual session validation. The sample generation stream is a local demonstration and is meant to be replaced by the publisher's existing model call.
|
|
95
|
+
|
|
96
|
+
The required server-only values are `WAVEBIRD_CLIENT_ID`, `WAVEBIRD_SECRET_KEY`, `WAVEBIRD_PREFERENCE_SECRET`, and `WAVEBIRD_PUBLIC_ORIGINS`. Keep secrets outside browser bundles and source control. Set project surface, placement, job type, timing, formats, locale, consent profile, identity/domain, and ad-space dimensions from the saved dashboard configuration. Reserve space for the selected creative and controls without distorting it. The publisher positions the section in its own UI.
|
|
97
|
+
|
|
98
|
+
## Test scope
|
|
99
|
+
|
|
100
|
+
The browser and generation transport sources are included in the package. Before use in a publisher app, test the server routes, browser placement, consent, and generation lifecycle against its saved Test project and supported browser and host. Test traffic must remain nonpayable; installing this package does not activate Live ads.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { access, copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
+
const usage = "Usage: wavebird-next init --lang js|ts --root <existing Next.js project> [--app-dir app|src/app]";
|
|
9
|
+
|
|
10
|
+
function options(argv) {
|
|
11
|
+
if (argv[0] !== "init") throw new Error(usage);
|
|
12
|
+
const out = { appDir: "app" };
|
|
13
|
+
for (let i = 1; i < argv.length; i += 2) {
|
|
14
|
+
const key = argv[i];
|
|
15
|
+
const value = argv[i + 1];
|
|
16
|
+
const field = key === "--app-dir" ? "appDir" : key.slice(2);
|
|
17
|
+
if (!value || !["--lang", "--root", "--app-dir"].includes(key) || (field !== "appDir" && out[field] !== undefined)) throw new Error(usage);
|
|
18
|
+
if (key === "--app-dir") out.appDir = value;
|
|
19
|
+
else out[key.slice(2)] = value;
|
|
20
|
+
}
|
|
21
|
+
if (!["js", "ts"].includes(out.lang) || !out.root || !["app", "src/app"].includes(out.appDir)) throw new Error(usage);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function plan(argv) {
|
|
26
|
+
const { lang, root, appDir } = options(argv);
|
|
27
|
+
const projectRoot = resolve(root);
|
|
28
|
+
await access(projectRoot, constants.R_OK | constants.W_OK);
|
|
29
|
+
const appPath = resolve(projectRoot, appDir);
|
|
30
|
+
await access(appPath, constants.R_OK);
|
|
31
|
+
const base = dirname(appPath);
|
|
32
|
+
const ext = lang === "ts" ? "ts" : "js";
|
|
33
|
+
const componentExt = lang === "ts" ? "tsx" : "jsx";
|
|
34
|
+
const example = resolve(packageRoot, "examples", `next-${lang}`);
|
|
35
|
+
const files = [
|
|
36
|
+
[join(example, "lib", `publisher.${ext}`), join(base, "lib", `publisher.${ext}`)],
|
|
37
|
+
[join(example, "lib", `auth.${ext}`), join(base, "lib", `auth.${ext}`)],
|
|
38
|
+
[join(example, "lib", `turn-store.${ext}`), join(base, "lib", `turn-store.${ext}`)],
|
|
39
|
+
[resolve(packageRoot, "templates", `model.${ext}`), join(base, "lib", `model.${ext}`)],
|
|
40
|
+
[join(example, "app", "api", "generate", `route.${ext}`), join(appPath, "api", "generate", `route.${ext}`)],
|
|
41
|
+
[join(example, "app", "api", "sponsor-slot", `route.${ext}`), join(appPath, "api", "sponsor-slot", `route.${ext}`)],
|
|
42
|
+
[join(example, "app", "api", "sponsor-consent", `route.${ext}`), join(appPath, "api", "sponsor-consent", `route.${ext}`)],
|
|
43
|
+
[join(example, "app", "api", "sponsor-session", `route.${ext}`), join(appPath, "api", "sponsor-session", `route.${ext}`)],
|
|
44
|
+
[resolve(packageRoot, "templates", `WavebirdPlacement.${componentExt}`), join(base, "components", `WavebirdPlacement.${componentExt}`)],
|
|
45
|
+
[resolve(packageRoot, "templates", "NEXT-SETUP.md"), join(projectRoot, "WAVEBIRD-SETUP.md")]
|
|
46
|
+
];
|
|
47
|
+
for (const [source, dest] of files) {
|
|
48
|
+
await access(source, constants.R_OK);
|
|
49
|
+
try { await access(dest); throw new Error(`Refusing to overwrite ${dest}`); }
|
|
50
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
51
|
+
}
|
|
52
|
+
return { lang, projectRoot, appDir, files };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function install(argv) {
|
|
56
|
+
const result = await plan(argv);
|
|
57
|
+
for (const [source, dest] of result.files) {
|
|
58
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
59
|
+
if (source.endsWith("NEXT-SETUP.md")) await writeFile(dest, await readFile(source, "utf8"), { flag: "wx" });
|
|
60
|
+
else await copyFile(source, dest, constants.COPYFILE_EXCL);
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
66
|
+
install(process.argv.slice(2)).then(result => {
|
|
67
|
+
process.stdout.write(`Installed ${result.lang.toUpperCase()} wavebird routes and connectors in ${result.projectRoot}. Read WAVEBIRD-SETUP.md.\n`);
|
|
68
|
+
}).catch(error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; });
|
|
69
|
+
}
|
package/build.mjs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { mkdir, copyFile } from "node:fs/promises";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
const root = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const dist = resolve(root, "dist");
|
|
7
|
+
await mkdir(dist, { recursive: true });
|
|
8
|
+
for (const file of ["generation.js", "turn-journal.js", "postgres-turn-storage.js", "browser.js", "server.js", "server.d.ts", "browser.d.ts"]) {
|
|
9
|
+
await copyFile(resolve(root, "src", file), resolve(dist, file));
|
|
10
|
+
}
|
|
@@ -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/dist/browser.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
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 || !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
|
+
const turnBinding = options.turnBinding === true;
|
|
32
|
+
const validId = value => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
|
33
|
+
if (turnBinding && !validId(options.sessionId)) throw new Error("publisher_trusted_session_required");
|
|
34
|
+
const timing = options.timing === undefined ? "during" : options.timing;
|
|
35
|
+
const consentMode = options.consentMode === undefined ? "wavebird_consent" : options.consentMode;
|
|
36
|
+
if (!["before", "during", "after"].includes(timing) || !["wavebird_consent", "own_cmp"].includes(consentMode)) throw new Error("Invalid publisher configuration");
|
|
37
|
+
let binding = { userId: options.userId, conversationId: options.conversationId,
|
|
38
|
+
sessionId: turnBinding ? options.sessionId : crypto.randomUUID() };
|
|
39
|
+
let sponsor = null, disposed = false, context = 0, active = null;
|
|
40
|
+
const slot = document.createElement("section");
|
|
41
|
+
slot.setAttribute("aria-label", "Sponsored placement");
|
|
42
|
+
slot.style.cssText = "width:100%;height:100%;overflow:hidden;margin:0 auto";
|
|
43
|
+
target.append(slot);
|
|
44
|
+
const privacyTarget = consentMode === "wavebird_consent" && options.privacySettingsTarget
|
|
45
|
+
? document.querySelector(options.privacySettingsTarget) : null;
|
|
46
|
+
const openPrivacySettings = () => { if (!disposed) sponsor?.openPrivacySettings(); };
|
|
47
|
+
privacyTarget?.addEventListener("click", openPrivacySettings);
|
|
48
|
+
const notify = (method, id) => {
|
|
49
|
+
try { sponsor?.[method](id); }
|
|
50
|
+
catch {
|
|
51
|
+
try { sponsor?.dispose(); } catch {}
|
|
52
|
+
sponsor = null; slot.hidden = true; slot.replaceChildren();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const current = job => !disposed && !job.cancelled && job.context === context && active === job;
|
|
56
|
+
const event = (job, type) => {
|
|
57
|
+
if (!current(job)) return;
|
|
58
|
+
if (type === "started") {
|
|
59
|
+
if (job.state !== "pending") return;
|
|
60
|
+
job.state = "started"; notify("onStarted", job.id);
|
|
61
|
+
} else if (type === "progress") {
|
|
62
|
+
if (job.state === "started") { job.progressed = true; notify("onDelta", job.id); }
|
|
63
|
+
} else if (type === "no_result") {
|
|
64
|
+
if (job.state === "started") job.state = "no_result";
|
|
65
|
+
} else if (["done", "error", "abort"].includes(type)) {
|
|
66
|
+
if (job.state !== "started") return;
|
|
67
|
+
job.state = type;
|
|
68
|
+
notify(type === "done" ? "onDone" : type === "abort" ? "onAbort" : "onError", job.id);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const prepare = () => { if (!disposed && sponsor) void sponsor.prepare().catch(() => {}); };
|
|
72
|
+
function setConversation(next) {
|
|
73
|
+
if (disposed) return;
|
|
74
|
+
if (!requiredText(next?.userId) || !requiredText(next?.conversationId))
|
|
75
|
+
throw new Error("Application session is required");
|
|
76
|
+
if (turnBinding && !validId(next.sessionId)) throw new Error("publisher_trusted_session_required");
|
|
77
|
+
if (next.userId === binding.userId && next.conversationId === binding.conversationId &&
|
|
78
|
+
(!turnBinding || next.sessionId === binding.sessionId)) return;
|
|
79
|
+
if (turnBinding && next.sessionId === binding.sessionId) throw new Error("publisher_session_reuse");
|
|
80
|
+
if (active) event(active, "abort");
|
|
81
|
+
context++; active = null;
|
|
82
|
+
binding = { userId: next.userId, conversationId: next.conversationId,
|
|
83
|
+
sessionId: turnBinding ? next.sessionId : crypto.randomUUID() };
|
|
84
|
+
sponsor?.setConversation(binding); prepare();
|
|
85
|
+
}
|
|
86
|
+
function dispose() {
|
|
87
|
+
if (disposed) return;
|
|
88
|
+
if (active) event(active, "abort");
|
|
89
|
+
disposed = true; context++; active = null;
|
|
90
|
+
sponsor?.dispose(); sponsor = null; slot.remove();
|
|
91
|
+
privacyTarget?.removeEventListener("click", openPrivacySettings);
|
|
92
|
+
window.removeEventListener("pagehide", dispose);
|
|
93
|
+
}
|
|
94
|
+
window.addEventListener("pagehide", dispose);
|
|
95
|
+
const ready = loadRenderer(options.rendererUrl || "https://api.wavebird.ai/v1/render.js").then(() => {
|
|
96
|
+
if (disposed) return;
|
|
97
|
+
sponsor = window.wavebird.createChatSponsor({ target: slot, ...binding,
|
|
98
|
+
clientId: options.clientId, surfaceId: options.surfaceId, timing, consentMode,
|
|
99
|
+
cleanupAfterClip: true,
|
|
100
|
+
...(turnBinding ? { turnBinding: true } : {}),
|
|
101
|
+
...(options.getFormat === undefined ? {} : { getFormat: options.getFormat }),
|
|
102
|
+
...(options.placementEndpoint === undefined ? {} : { placementEndpoint: options.placementEndpoint }),
|
|
103
|
+
...(options.consentEndpoint === undefined ? {} : { consentEndpoint: options.consentEndpoint }) });
|
|
104
|
+
prepare();
|
|
105
|
+
if (active && current(active)) {
|
|
106
|
+
if (active.state === "started") {
|
|
107
|
+
notify("onStarted", active.id);
|
|
108
|
+
if (active.progressed) notify("onDelta", active.id);
|
|
109
|
+
} else if (timing === "after" && active.state === "done") {
|
|
110
|
+
notify("onStarted", active.id); notify("onDone", active.id);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}).catch(() => {});
|
|
114
|
+
|
|
115
|
+
async function connectGeneration({ request, consume, signal }) {
|
|
116
|
+
const job = { id: crypto.randomUUID(), sessionId: binding.sessionId, context, state: "pending", cancelled: false };
|
|
117
|
+
if (active) event(active, "abort");
|
|
118
|
+
active = job;
|
|
119
|
+
let reader = null, ended = false, buffer = "";
|
|
120
|
+
const decoder = new TextDecoder();
|
|
121
|
+
const abort = () => { event(job, "abort"); job.cancelled = true; void reader?.cancel().catch(() => {}); };
|
|
122
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
123
|
+
if (signal?.aborted) abort();
|
|
124
|
+
const headers = { "x-wavebird-session-id": job.sessionId, "x-wavebird-generation-id": job.id };
|
|
125
|
+
try {
|
|
126
|
+
const response = await request(headers);
|
|
127
|
+
if (response.headers.get("x-wavebird-generation-transport") !== "1") return await consume(response);
|
|
128
|
+
if (!response.body) throw new Error("generation_transport_missing");
|
|
129
|
+
reader = response.body.getReader();
|
|
130
|
+
async function nextRecord() {
|
|
131
|
+
while (true) {
|
|
132
|
+
const boundary = buffer.indexOf("\n");
|
|
133
|
+
if (boundary >= 0) {
|
|
134
|
+
const line = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 1);
|
|
135
|
+
if (!line || line.length > 32768) throw new Error("generation_transport_invalid");
|
|
136
|
+
const record = JSON.parse(line);
|
|
137
|
+
if (record.session_id !== job.sessionId || record.generation_id !== job.id) throw new Error("generation_transport_context_mismatch");
|
|
138
|
+
return record;
|
|
139
|
+
}
|
|
140
|
+
if (buffer.length > 32768) throw new Error("generation_transport_limit");
|
|
141
|
+
const next = await reader.read();
|
|
142
|
+
if (signal?.aborted) throw new Error("generation_aborted");
|
|
143
|
+
if (next.done) throw new Error("generation_transport_truncated");
|
|
144
|
+
buffer += decoder.decode(next.value, { stream: true });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
let metadata;
|
|
148
|
+
while (!metadata) {
|
|
149
|
+
const record = await nextRecord();
|
|
150
|
+
if (record.type === "started") event(job, "started");
|
|
151
|
+
else if (record.type === "response") metadata = record;
|
|
152
|
+
else throw new Error("generation_failed_before_response");
|
|
153
|
+
}
|
|
154
|
+
if (!Number.isInteger(metadata.status) || metadata.status < 200 || metadata.status > 599 || !Array.isArray(metadata.headers)) throw new Error("generation_response_invalid");
|
|
155
|
+
const output = new ReadableStream({
|
|
156
|
+
async pull(controller) {
|
|
157
|
+
try {
|
|
158
|
+
while (true) {
|
|
159
|
+
const record = await nextRecord();
|
|
160
|
+
if (record.type === "bytes") {
|
|
161
|
+
if (typeof record.data !== "string" || record.data.length > 21848 || !/^[A-Za-z0-9+/]*={0,2}$/.test(record.data)) throw new Error("generation_bytes_invalid");
|
|
162
|
+
const raw = atob(record.data), bytes = Uint8Array.from(raw, value => value.charCodeAt(0));
|
|
163
|
+
controller.enqueue(bytes);
|
|
164
|
+
if (timing !== "before" && options.jobType !== "image") event(job, "progress");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (["done", "error", "abort"].includes(record.type)) {
|
|
168
|
+
ended = true;
|
|
169
|
+
if (record.type !== "done" || timing !== "after") event(job, record.type);
|
|
170
|
+
if (record.type === "done" || !metadata.status.toString().startsWith("2")) controller.close();
|
|
171
|
+
else controller.error(new Error("generation_" + record.type));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
throw new Error("generation_transport_invalid");
|
|
175
|
+
}
|
|
176
|
+
} catch (error) { event(job, signal?.aborted ? "abort" : "error"); controller.error(error); }
|
|
177
|
+
},
|
|
178
|
+
cancel() { event(job, "abort"); void reader?.cancel().catch(() => {}); }
|
|
179
|
+
}, { highWaterMark: 0 });
|
|
180
|
+
if ([204, 205, 304].includes(metadata.status)) {
|
|
181
|
+
const record = await nextRecord();
|
|
182
|
+
if (!["done", "error", "abort"].includes(record.type)) throw new Error("generation_transport_invalid");
|
|
183
|
+
ended = true;
|
|
184
|
+
if (record.type !== "done" || timing !== "after") event(job, record.type);
|
|
185
|
+
}
|
|
186
|
+
const noBody = [204, 205, 304].includes(metadata.status);
|
|
187
|
+
const primary = new Response(noBody ? null : output, {
|
|
188
|
+
status: metadata.status, statusText: metadata.status_text || "", headers: metadata.headers
|
|
189
|
+
});
|
|
190
|
+
const result = await consume(primary, { onDelta: () => event(job, "progress") });
|
|
191
|
+
if (timing === "after" && ended) event(job, noBody ? "no_result" : "done");
|
|
192
|
+
return result;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
event(job, signal?.aborted ? "abort" : "error");
|
|
195
|
+
throw error;
|
|
196
|
+
} finally {
|
|
197
|
+
if (!ended && job.state === "started") event(job, "abort");
|
|
198
|
+
signal?.removeEventListener("abort", abort);
|
|
199
|
+
if (reader) { try { await reader.cancel(); } catch {} reader.releaseLock(); }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { ready, connectGeneration, setConversation, dispose, openPrivacySettings,
|
|
203
|
+
grant: choice => sponsor ? sponsor.grant(choice) : Promise.resolve({ status: "renderer_unavailable" }),
|
|
204
|
+
deny: () => sponsor ? sponsor.deny() : Promise.resolve({ status: "renderer_unavailable" }),
|
|
205
|
+
withdraw: () => sponsor ? sponsor.withdraw() : Promise.resolve({ status: "renderer_unavailable" }) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function connectPublisherGeneration(url, init, consume) {
|
|
209
|
+
const ads = window.publisherAds;
|
|
210
|
+
if (!ads) return consume(await fetch(url, init), { onDelta() {} });
|
|
211
|
+
return ads.connectGeneration({
|
|
212
|
+
signal: init.signal ?? undefined,
|
|
213
|
+
request: values => {
|
|
214
|
+
const headers = new Headers(init.headers);
|
|
215
|
+
for (const [name, value] of Object.entries(values)) headers.set(name, value);
|
|
216
|
+
return fetch(url, { ...init, headers });
|
|
217
|
+
},
|
|
218
|
+
consume
|
|
219
|
+
});
|
|
220
|
+
}
|
|
@@ -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
|
+
}
|