@duckviz/sdk 0.1.0
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 +135 -0
- package/dist/chunk-U3PJRXFJ.cjs +260 -0
- package/dist/chunk-WYWQAALZ.js +251 -0
- package/dist/express.cjs +10 -0
- package/dist/express.d.cts +16 -0
- package/dist/express.d.ts +16 -0
- package/dist/express.js +8 -0
- package/dist/index.cjs +226 -0
- package/dist/index.d.cts +401 -0
- package/dist/index.d.ts +401 -0
- package/dist/index.js +195 -0
- package/dist/next.cjs +80 -0
- package/dist/next.d.cts +51 -0
- package/dist/next.d.ts +51 -0
- package/dist/next.js +78 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# @duckviz/sdk
|
|
2
|
+
|
|
3
|
+
Node-only SDK for DuckViz AI endpoints. Composable classes for widget recommendations, log format detection, report generation, and credit management — with built-in retry, auth, and error handling.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @duckviz/sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
### Next.js App Router (recommended)
|
|
14
|
+
|
|
15
|
+
The fastest way to connect your app to DuckViz AI. Four lines — token stays server-side.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// app/api/duckviz/[...route]/route.ts
|
|
19
|
+
import { createDuckvizHandlers } from "@duckviz/sdk/next";
|
|
20
|
+
|
|
21
|
+
export const { POST, GET } = createDuckvizHandlers({
|
|
22
|
+
token: process.env.DUCKVIZ_TOKEN!,
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
This creates a catch-all proxy that forwards `/api/duckviz/*` requests to DuckViz Cloud with your token attached. SSE responses stream through without buffering.
|
|
27
|
+
|
|
28
|
+
Wire it up with `@duckviz/explorer`:
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
// app/page.tsx
|
|
32
|
+
import { Explorer } from "@duckviz/explorer";
|
|
33
|
+
|
|
34
|
+
const customFetch = (input, init) => {
|
|
35
|
+
if (typeof input === "string" && input.startsWith("/api/")) {
|
|
36
|
+
input = input.replace(/^\/api\//, "/api/duckviz/");
|
|
37
|
+
}
|
|
38
|
+
return fetch(input, init);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
<Explorer datasets={datasets} customFetch={customFetch} authenticated={true} />;
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Programmatic usage
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { DuckvizWidgetFlow, DuckvizCredits } from "@duckviz/sdk";
|
|
48
|
+
|
|
49
|
+
// Generate widget recommendations
|
|
50
|
+
const flow = new DuckvizWidgetFlow({
|
|
51
|
+
token: process.env.DUCKVIZ_TOKEN!,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const widgets = await flow.invoke({
|
|
55
|
+
schema: [
|
|
56
|
+
{ name: "date", type: "DATE" },
|
|
57
|
+
{ name: "revenue", type: "DOUBLE" },
|
|
58
|
+
{ name: "region", type: "VARCHAR" },
|
|
59
|
+
],
|
|
60
|
+
tableName: "t_sales",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Check credit balance
|
|
64
|
+
const credits = new DuckvizCredits({
|
|
65
|
+
token: process.env.DUCKVIZ_TOKEN!,
|
|
66
|
+
});
|
|
67
|
+
const { balance, plan } = await credits.invoke();
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Streaming (SSE)
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
const flow = new DuckvizWidgetFlow({
|
|
74
|
+
token: process.env.DUCKVIZ_TOKEN!,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
for await (const event of flow.stream({
|
|
78
|
+
schema,
|
|
79
|
+
tableName: "t_logs",
|
|
80
|
+
})) {
|
|
81
|
+
console.log(event.event, event.data);
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## API
|
|
86
|
+
|
|
87
|
+
### Next.js handler
|
|
88
|
+
|
|
89
|
+
| Export | Description |
|
|
90
|
+
| ------------------------- | ---------------------------------------------------------- |
|
|
91
|
+
| `createDuckvizHandlers()` | Creates `{ POST, GET }` handlers for a catch-all API route |
|
|
92
|
+
|
|
93
|
+
Options: `token` (required), `baseUrl` (override, defaults to `https://app.duckviz.com`), `onRequest` (optional hook).
|
|
94
|
+
|
|
95
|
+
### Endpoint classes
|
|
96
|
+
|
|
97
|
+
| Class | Description |
|
|
98
|
+
| -------------------------- | ------------------------------------------- |
|
|
99
|
+
| `DuckvizWidgetFlow` | AI widget recommendations from table schema |
|
|
100
|
+
| `DuckvizLogFormatDetector` | Detect log format from sample lines |
|
|
101
|
+
| `DuckvizReports` | Generate structured reports from widgets |
|
|
102
|
+
| `DuckvizCredits` | Check credit balance |
|
|
103
|
+
| `DuckvizUsage` | Query usage history |
|
|
104
|
+
|
|
105
|
+
All classes accept `{ token, baseUrl?, maxRetries?, timeout?, fetch? }`.
|
|
106
|
+
|
|
107
|
+
### Error types
|
|
108
|
+
|
|
109
|
+
| Error | Status | Description |
|
|
110
|
+
| --------------------------- | ------ | ------------------------ |
|
|
111
|
+
| `DuckvizAuthError` | 401 | Invalid or missing token |
|
|
112
|
+
| `DuckvizQuotaExceededError` | 402 | Insufficient credits |
|
|
113
|
+
| `DuckvizRateLimitError` | 429 | Rate limit exceeded |
|
|
114
|
+
| `DuckvizServerError` | 5xx | Server error |
|
|
115
|
+
| `DuckvizNetworkError` | — | Network/timeout failure |
|
|
116
|
+
|
|
117
|
+
### Dashboard config validation
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { validateDashboardConfig } from "@duckviz/sdk";
|
|
121
|
+
|
|
122
|
+
const config = validateDashboardConfig(untrustedInput);
|
|
123
|
+
// Throws DashboardConfigError with path info on invalid input
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Environment variables
|
|
127
|
+
|
|
128
|
+
| Variable | Required | Description |
|
|
129
|
+
| ------------------ | -------- | ---------------------------------------------- |
|
|
130
|
+
| `DUCKVIZ_TOKEN` | Yes | Personal access token (`dvz_live_...`) |
|
|
131
|
+
| `DUCKVIZ_BASE_URL` | No | Override API base URL (defaults to production) |
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/errors.ts
|
|
4
|
+
var DuckvizError = class extends Error {
|
|
5
|
+
constructor(message, status) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.name = "DuckvizError";
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
var DuckvizAuthError = class extends DuckvizError {
|
|
12
|
+
constructor(message = "Invalid or missing DuckViz token") {
|
|
13
|
+
super(message, 401);
|
|
14
|
+
this.name = "DuckvizAuthError";
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
var DuckvizQuotaExceededError = class extends DuckvizError {
|
|
18
|
+
constructor(message = "Insufficient credits", required) {
|
|
19
|
+
super(message, 402);
|
|
20
|
+
this.required = required;
|
|
21
|
+
this.name = "DuckvizQuotaExceededError";
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
var DuckvizRateLimitError = class extends DuckvizError {
|
|
25
|
+
constructor(message = "Rate limit exceeded", retryAfterMs) {
|
|
26
|
+
super(message, 429);
|
|
27
|
+
this.retryAfterMs = retryAfterMs;
|
|
28
|
+
this.name = "DuckvizRateLimitError";
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
var DuckvizServerError = class extends DuckvizError {
|
|
32
|
+
constructor(message, status) {
|
|
33
|
+
super(message, status);
|
|
34
|
+
this.name = "DuckvizServerError";
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
var DuckvizNetworkError = class extends DuckvizError {
|
|
38
|
+
constructor(message, cause) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.cause = cause;
|
|
41
|
+
this.name = "DuckvizNetworkError";
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// src/base.ts
|
|
46
|
+
var DEFAULT_BASE_URL = "https://app.duckviz.com";
|
|
47
|
+
var DuckvizEndpoint = class {
|
|
48
|
+
token;
|
|
49
|
+
baseUrl;
|
|
50
|
+
fetchImpl;
|
|
51
|
+
maxRetries;
|
|
52
|
+
timeout;
|
|
53
|
+
constructor(fields) {
|
|
54
|
+
if (!fields.token) {
|
|
55
|
+
throw new DuckvizAuthError("token is required");
|
|
56
|
+
}
|
|
57
|
+
this.token = fields.token;
|
|
58
|
+
this.baseUrl = (fields.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
59
|
+
this.fetchImpl = fields.fetch ?? globalThis.fetch;
|
|
60
|
+
this.maxRetries = fields.maxRetries ?? 2;
|
|
61
|
+
this.timeout = fields.timeout ?? 6e4;
|
|
62
|
+
}
|
|
63
|
+
// ─── Shared transport helpers (used by subclasses) ──────────────────────
|
|
64
|
+
buildHeaders() {
|
|
65
|
+
return new Headers({
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
Authorization: `Bearer ${this.token}`
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* POST a JSON body and parse a JSON response. Retries transient failures.
|
|
72
|
+
* Throws a typed `DuckvizError` subclass on non-2xx responses.
|
|
73
|
+
*/
|
|
74
|
+
async postJson(path, body, options) {
|
|
75
|
+
const res = await this.requestWithRetry(path, body, options, "POST");
|
|
76
|
+
return await res.json();
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* GET a JSON response. Query params are passed as a plain object and
|
|
80
|
+
* serialized into the URL — values coerce to strings, arrays repeat the
|
|
81
|
+
* key, `undefined` / `null` are skipped.
|
|
82
|
+
*/
|
|
83
|
+
async getJson(path, params, options) {
|
|
84
|
+
let finalPath = path;
|
|
85
|
+
if (params) {
|
|
86
|
+
const qs = new URLSearchParams();
|
|
87
|
+
for (const [k, v] of Object.entries(params)) {
|
|
88
|
+
if (v === void 0 || v === null) continue;
|
|
89
|
+
qs.append(k, String(v));
|
|
90
|
+
}
|
|
91
|
+
const q = qs.toString();
|
|
92
|
+
if (q) finalPath = `${path}?${q}`;
|
|
93
|
+
}
|
|
94
|
+
const res = await this.requestWithRetry(
|
|
95
|
+
finalPath,
|
|
96
|
+
void 0,
|
|
97
|
+
options,
|
|
98
|
+
"GET"
|
|
99
|
+
);
|
|
100
|
+
return await res.json();
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* POST a JSON body and stream back an SSE response, one parsed message at
|
|
104
|
+
* a time. The raw DuckViz event wire format is
|
|
105
|
+
* data: {"event":"...","data":...}\n\n
|
|
106
|
+
* — this helper yields the parsed `{event, data}` objects directly.
|
|
107
|
+
*/
|
|
108
|
+
async *postSSE(path, body, options) {
|
|
109
|
+
const res = await this.requestWithRetry(path, body, options, "POST");
|
|
110
|
+
if (!res.body) return;
|
|
111
|
+
for await (const msg of parseSSEStream(res.body)) {
|
|
112
|
+
yield msg;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async requestWithRetry(path, body, options, method) {
|
|
116
|
+
const url = `${this.baseUrl}${path}`;
|
|
117
|
+
const payload = method === "POST" ? JSON.stringify(body) : void 0;
|
|
118
|
+
let attempt = 0;
|
|
119
|
+
while (true) {
|
|
120
|
+
const attemptController = new AbortController();
|
|
121
|
+
const timeoutId = setTimeout(
|
|
122
|
+
() => attemptController.abort(),
|
|
123
|
+
this.timeout
|
|
124
|
+
);
|
|
125
|
+
const mergedSignal = mergeSignals(
|
|
126
|
+
options?.signal,
|
|
127
|
+
attemptController.signal
|
|
128
|
+
);
|
|
129
|
+
let res;
|
|
130
|
+
try {
|
|
131
|
+
const init = {
|
|
132
|
+
method,
|
|
133
|
+
headers: this.buildHeaders(),
|
|
134
|
+
signal: mergedSignal
|
|
135
|
+
};
|
|
136
|
+
if (payload !== void 0) init.body = payload;
|
|
137
|
+
res = await this.fetchImpl(url, init);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
clearTimeout(timeoutId);
|
|
140
|
+
if (options?.signal?.aborted) {
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
if (attempt >= this.maxRetries) {
|
|
144
|
+
throw new DuckvizNetworkError(
|
|
145
|
+
err instanceof Error ? err.message : String(err),
|
|
146
|
+
err
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
await sleep(backoffMs(attempt));
|
|
150
|
+
attempt++;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
clearTimeout(timeoutId);
|
|
154
|
+
if (res.ok) return res;
|
|
155
|
+
const errBody = await safeReadJson(res);
|
|
156
|
+
const message = (errBody && typeof errBody === "object" && "error" in errBody ? String(errBody.error) : null) ?? `Request failed (${res.status})`;
|
|
157
|
+
if (res.status === 401) throw new DuckvizAuthError(message);
|
|
158
|
+
if (res.status === 402) {
|
|
159
|
+
const required = errBody && typeof errBody === "object" && "required" in errBody ? Number(errBody.required) : void 0;
|
|
160
|
+
throw new DuckvizQuotaExceededError(
|
|
161
|
+
message,
|
|
162
|
+
Number.isFinite(required) ? required : void 0
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (res.status === 429) {
|
|
166
|
+
const retryAfterMs = parseRetryAfter(res.headers.get("retry-after"));
|
|
167
|
+
if (attempt < this.maxRetries) {
|
|
168
|
+
await sleep(retryAfterMs ?? backoffMs(attempt));
|
|
169
|
+
attempt++;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
throw new DuckvizRateLimitError(message, retryAfterMs);
|
|
173
|
+
}
|
|
174
|
+
if (res.status >= 500 && res.status < 600) {
|
|
175
|
+
if (attempt < this.maxRetries) {
|
|
176
|
+
await sleep(backoffMs(attempt));
|
|
177
|
+
attempt++;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
throw new DuckvizServerError(message, res.status);
|
|
181
|
+
}
|
|
182
|
+
throw new DuckvizError(message, res.status);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
function backoffMs(attempt) {
|
|
187
|
+
const base = 250 * Math.pow(2, attempt);
|
|
188
|
+
const jitter = base * 0.2 * (Math.random() * 2 - 1);
|
|
189
|
+
return Math.max(0, Math.round(base + jitter));
|
|
190
|
+
}
|
|
191
|
+
function sleep(ms) {
|
|
192
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
193
|
+
}
|
|
194
|
+
function parseRetryAfter(header) {
|
|
195
|
+
if (!header) return void 0;
|
|
196
|
+
const seconds = Number(header);
|
|
197
|
+
if (Number.isFinite(seconds)) return seconds * 1e3;
|
|
198
|
+
const date = Date.parse(header);
|
|
199
|
+
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
|
|
200
|
+
return void 0;
|
|
201
|
+
}
|
|
202
|
+
async function safeReadJson(res) {
|
|
203
|
+
try {
|
|
204
|
+
return await res.json();
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function mergeSignals(...signals) {
|
|
210
|
+
const real = signals.filter((s) => !!s);
|
|
211
|
+
if (real.length === 0) return void 0;
|
|
212
|
+
if (real.length === 1) return real[0];
|
|
213
|
+
const any = AbortSignal.any;
|
|
214
|
+
if (typeof any === "function") return any.call(AbortSignal, real);
|
|
215
|
+
const controller = new AbortController();
|
|
216
|
+
for (const s of real) {
|
|
217
|
+
if (s.aborted) {
|
|
218
|
+
controller.abort(s.reason);
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
s.addEventListener("abort", () => controller.abort(s.reason), {
|
|
222
|
+
once: true
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return controller.signal;
|
|
226
|
+
}
|
|
227
|
+
async function* parseSSEStream(body) {
|
|
228
|
+
const reader = body.getReader();
|
|
229
|
+
const decoder = new TextDecoder();
|
|
230
|
+
let buffer = "";
|
|
231
|
+
try {
|
|
232
|
+
while (true) {
|
|
233
|
+
const { done, value } = await reader.read();
|
|
234
|
+
if (done) break;
|
|
235
|
+
buffer += decoder.decode(value, { stream: true });
|
|
236
|
+
const lines = buffer.split("\n");
|
|
237
|
+
buffer = lines.pop() ?? "";
|
|
238
|
+
for (const line of lines) {
|
|
239
|
+
if (!line.startsWith("data: ")) continue;
|
|
240
|
+
const raw = line.slice(6).trim();
|
|
241
|
+
if (!raw) continue;
|
|
242
|
+
try {
|
|
243
|
+
yield JSON.parse(raw);
|
|
244
|
+
} catch {
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
} finally {
|
|
249
|
+
reader.releaseLock();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
exports.DEFAULT_BASE_URL = DEFAULT_BASE_URL;
|
|
254
|
+
exports.DuckvizAuthError = DuckvizAuthError;
|
|
255
|
+
exports.DuckvizEndpoint = DuckvizEndpoint;
|
|
256
|
+
exports.DuckvizError = DuckvizError;
|
|
257
|
+
exports.DuckvizNetworkError = DuckvizNetworkError;
|
|
258
|
+
exports.DuckvizQuotaExceededError = DuckvizQuotaExceededError;
|
|
259
|
+
exports.DuckvizRateLimitError = DuckvizRateLimitError;
|
|
260
|
+
exports.DuckvizServerError = DuckvizServerError;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var DuckvizError = class extends Error {
|
|
3
|
+
constructor(message, status) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.status = status;
|
|
6
|
+
this.name = "DuckvizError";
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
var DuckvizAuthError = class extends DuckvizError {
|
|
10
|
+
constructor(message = "Invalid or missing DuckViz token") {
|
|
11
|
+
super(message, 401);
|
|
12
|
+
this.name = "DuckvizAuthError";
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
var DuckvizQuotaExceededError = class extends DuckvizError {
|
|
16
|
+
constructor(message = "Insufficient credits", required) {
|
|
17
|
+
super(message, 402);
|
|
18
|
+
this.required = required;
|
|
19
|
+
this.name = "DuckvizQuotaExceededError";
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
var DuckvizRateLimitError = class extends DuckvizError {
|
|
23
|
+
constructor(message = "Rate limit exceeded", retryAfterMs) {
|
|
24
|
+
super(message, 429);
|
|
25
|
+
this.retryAfterMs = retryAfterMs;
|
|
26
|
+
this.name = "DuckvizRateLimitError";
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var DuckvizServerError = class extends DuckvizError {
|
|
30
|
+
constructor(message, status) {
|
|
31
|
+
super(message, status);
|
|
32
|
+
this.name = "DuckvizServerError";
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var DuckvizNetworkError = class extends DuckvizError {
|
|
36
|
+
constructor(message, cause) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.cause = cause;
|
|
39
|
+
this.name = "DuckvizNetworkError";
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// src/base.ts
|
|
44
|
+
var DEFAULT_BASE_URL = "https://app.duckviz.com";
|
|
45
|
+
var DuckvizEndpoint = class {
|
|
46
|
+
token;
|
|
47
|
+
baseUrl;
|
|
48
|
+
fetchImpl;
|
|
49
|
+
maxRetries;
|
|
50
|
+
timeout;
|
|
51
|
+
constructor(fields) {
|
|
52
|
+
if (!fields.token) {
|
|
53
|
+
throw new DuckvizAuthError("token is required");
|
|
54
|
+
}
|
|
55
|
+
this.token = fields.token;
|
|
56
|
+
this.baseUrl = (fields.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
57
|
+
this.fetchImpl = fields.fetch ?? globalThis.fetch;
|
|
58
|
+
this.maxRetries = fields.maxRetries ?? 2;
|
|
59
|
+
this.timeout = fields.timeout ?? 6e4;
|
|
60
|
+
}
|
|
61
|
+
// ─── Shared transport helpers (used by subclasses) ──────────────────────
|
|
62
|
+
buildHeaders() {
|
|
63
|
+
return new Headers({
|
|
64
|
+
"Content-Type": "application/json",
|
|
65
|
+
Authorization: `Bearer ${this.token}`
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* POST a JSON body and parse a JSON response. Retries transient failures.
|
|
70
|
+
* Throws a typed `DuckvizError` subclass on non-2xx responses.
|
|
71
|
+
*/
|
|
72
|
+
async postJson(path, body, options) {
|
|
73
|
+
const res = await this.requestWithRetry(path, body, options, "POST");
|
|
74
|
+
return await res.json();
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* GET a JSON response. Query params are passed as a plain object and
|
|
78
|
+
* serialized into the URL — values coerce to strings, arrays repeat the
|
|
79
|
+
* key, `undefined` / `null` are skipped.
|
|
80
|
+
*/
|
|
81
|
+
async getJson(path, params, options) {
|
|
82
|
+
let finalPath = path;
|
|
83
|
+
if (params) {
|
|
84
|
+
const qs = new URLSearchParams();
|
|
85
|
+
for (const [k, v] of Object.entries(params)) {
|
|
86
|
+
if (v === void 0 || v === null) continue;
|
|
87
|
+
qs.append(k, String(v));
|
|
88
|
+
}
|
|
89
|
+
const q = qs.toString();
|
|
90
|
+
if (q) finalPath = `${path}?${q}`;
|
|
91
|
+
}
|
|
92
|
+
const res = await this.requestWithRetry(
|
|
93
|
+
finalPath,
|
|
94
|
+
void 0,
|
|
95
|
+
options,
|
|
96
|
+
"GET"
|
|
97
|
+
);
|
|
98
|
+
return await res.json();
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* POST a JSON body and stream back an SSE response, one parsed message at
|
|
102
|
+
* a time. The raw DuckViz event wire format is
|
|
103
|
+
* data: {"event":"...","data":...}\n\n
|
|
104
|
+
* — this helper yields the parsed `{event, data}` objects directly.
|
|
105
|
+
*/
|
|
106
|
+
async *postSSE(path, body, options) {
|
|
107
|
+
const res = await this.requestWithRetry(path, body, options, "POST");
|
|
108
|
+
if (!res.body) return;
|
|
109
|
+
for await (const msg of parseSSEStream(res.body)) {
|
|
110
|
+
yield msg;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async requestWithRetry(path, body, options, method) {
|
|
114
|
+
const url = `${this.baseUrl}${path}`;
|
|
115
|
+
const payload = method === "POST" ? JSON.stringify(body) : void 0;
|
|
116
|
+
let attempt = 0;
|
|
117
|
+
while (true) {
|
|
118
|
+
const attemptController = new AbortController();
|
|
119
|
+
const timeoutId = setTimeout(
|
|
120
|
+
() => attemptController.abort(),
|
|
121
|
+
this.timeout
|
|
122
|
+
);
|
|
123
|
+
const mergedSignal = mergeSignals(
|
|
124
|
+
options?.signal,
|
|
125
|
+
attemptController.signal
|
|
126
|
+
);
|
|
127
|
+
let res;
|
|
128
|
+
try {
|
|
129
|
+
const init = {
|
|
130
|
+
method,
|
|
131
|
+
headers: this.buildHeaders(),
|
|
132
|
+
signal: mergedSignal
|
|
133
|
+
};
|
|
134
|
+
if (payload !== void 0) init.body = payload;
|
|
135
|
+
res = await this.fetchImpl(url, init);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
clearTimeout(timeoutId);
|
|
138
|
+
if (options?.signal?.aborted) {
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
if (attempt >= this.maxRetries) {
|
|
142
|
+
throw new DuckvizNetworkError(
|
|
143
|
+
err instanceof Error ? err.message : String(err),
|
|
144
|
+
err
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
await sleep(backoffMs(attempt));
|
|
148
|
+
attempt++;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
clearTimeout(timeoutId);
|
|
152
|
+
if (res.ok) return res;
|
|
153
|
+
const errBody = await safeReadJson(res);
|
|
154
|
+
const message = (errBody && typeof errBody === "object" && "error" in errBody ? String(errBody.error) : null) ?? `Request failed (${res.status})`;
|
|
155
|
+
if (res.status === 401) throw new DuckvizAuthError(message);
|
|
156
|
+
if (res.status === 402) {
|
|
157
|
+
const required = errBody && typeof errBody === "object" && "required" in errBody ? Number(errBody.required) : void 0;
|
|
158
|
+
throw new DuckvizQuotaExceededError(
|
|
159
|
+
message,
|
|
160
|
+
Number.isFinite(required) ? required : void 0
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (res.status === 429) {
|
|
164
|
+
const retryAfterMs = parseRetryAfter(res.headers.get("retry-after"));
|
|
165
|
+
if (attempt < this.maxRetries) {
|
|
166
|
+
await sleep(retryAfterMs ?? backoffMs(attempt));
|
|
167
|
+
attempt++;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
throw new DuckvizRateLimitError(message, retryAfterMs);
|
|
171
|
+
}
|
|
172
|
+
if (res.status >= 500 && res.status < 600) {
|
|
173
|
+
if (attempt < this.maxRetries) {
|
|
174
|
+
await sleep(backoffMs(attempt));
|
|
175
|
+
attempt++;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
throw new DuckvizServerError(message, res.status);
|
|
179
|
+
}
|
|
180
|
+
throw new DuckvizError(message, res.status);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
function backoffMs(attempt) {
|
|
185
|
+
const base = 250 * Math.pow(2, attempt);
|
|
186
|
+
const jitter = base * 0.2 * (Math.random() * 2 - 1);
|
|
187
|
+
return Math.max(0, Math.round(base + jitter));
|
|
188
|
+
}
|
|
189
|
+
function sleep(ms) {
|
|
190
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
191
|
+
}
|
|
192
|
+
function parseRetryAfter(header) {
|
|
193
|
+
if (!header) return void 0;
|
|
194
|
+
const seconds = Number(header);
|
|
195
|
+
if (Number.isFinite(seconds)) return seconds * 1e3;
|
|
196
|
+
const date = Date.parse(header);
|
|
197
|
+
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
|
|
198
|
+
return void 0;
|
|
199
|
+
}
|
|
200
|
+
async function safeReadJson(res) {
|
|
201
|
+
try {
|
|
202
|
+
return await res.json();
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function mergeSignals(...signals) {
|
|
208
|
+
const real = signals.filter((s) => !!s);
|
|
209
|
+
if (real.length === 0) return void 0;
|
|
210
|
+
if (real.length === 1) return real[0];
|
|
211
|
+
const any = AbortSignal.any;
|
|
212
|
+
if (typeof any === "function") return any.call(AbortSignal, real);
|
|
213
|
+
const controller = new AbortController();
|
|
214
|
+
for (const s of real) {
|
|
215
|
+
if (s.aborted) {
|
|
216
|
+
controller.abort(s.reason);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
s.addEventListener("abort", () => controller.abort(s.reason), {
|
|
220
|
+
once: true
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return controller.signal;
|
|
224
|
+
}
|
|
225
|
+
async function* parseSSEStream(body) {
|
|
226
|
+
const reader = body.getReader();
|
|
227
|
+
const decoder = new TextDecoder();
|
|
228
|
+
let buffer = "";
|
|
229
|
+
try {
|
|
230
|
+
while (true) {
|
|
231
|
+
const { done, value } = await reader.read();
|
|
232
|
+
if (done) break;
|
|
233
|
+
buffer += decoder.decode(value, { stream: true });
|
|
234
|
+
const lines = buffer.split("\n");
|
|
235
|
+
buffer = lines.pop() ?? "";
|
|
236
|
+
for (const line of lines) {
|
|
237
|
+
if (!line.startsWith("data: ")) continue;
|
|
238
|
+
const raw = line.slice(6).trim();
|
|
239
|
+
if (!raw) continue;
|
|
240
|
+
try {
|
|
241
|
+
yield JSON.parse(raw);
|
|
242
|
+
} catch {
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} finally {
|
|
247
|
+
reader.releaseLock();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export { DEFAULT_BASE_URL, DuckvizAuthError, DuckvizEndpoint, DuckvizError, DuckvizNetworkError, DuckvizQuotaExceededError, DuckvizRateLimitError, DuckvizServerError };
|
package/dist/express.cjs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/express.ts
|
|
4
|
+
function duckvizExpressMiddleware() {
|
|
5
|
+
throw new Error(
|
|
6
|
+
"@duckviz/sdk/express is not implemented yet. Use @duckviz/sdk/next for Next.js App Router, or open an issue if you need Express support."
|
|
7
|
+
);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
exports.duckvizExpressMiddleware = duckvizExpressMiddleware;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @duckviz/sdk/express — placeholder.
|
|
3
|
+
*
|
|
4
|
+
* Deferred to a later session. The Next.js handler (`./next`) covers the
|
|
5
|
+
* main target use case (consumer platform plan Stream 5). Express support
|
|
6
|
+
* needs a different plumbing strategy because the Node http ServerResponse
|
|
7
|
+
* doesn't speak Web Streams natively, so SSE pass-through has to use
|
|
8
|
+
* `res.write` in a loop over the upstream body reader. Not hard, just not
|
|
9
|
+
* blocking Stream 5 v1.
|
|
10
|
+
*
|
|
11
|
+
* Importing this subpath throws so the failure is loud rather than a
|
|
12
|
+
* silently-broken `{}` export.
|
|
13
|
+
*/
|
|
14
|
+
declare function duckvizExpressMiddleware(): never;
|
|
15
|
+
|
|
16
|
+
export { duckvizExpressMiddleware };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @duckviz/sdk/express — placeholder.
|
|
3
|
+
*
|
|
4
|
+
* Deferred to a later session. The Next.js handler (`./next`) covers the
|
|
5
|
+
* main target use case (consumer platform plan Stream 5). Express support
|
|
6
|
+
* needs a different plumbing strategy because the Node http ServerResponse
|
|
7
|
+
* doesn't speak Web Streams natively, so SSE pass-through has to use
|
|
8
|
+
* `res.write` in a loop over the upstream body reader. Not hard, just not
|
|
9
|
+
* blocking Stream 5 v1.
|
|
10
|
+
*
|
|
11
|
+
* Importing this subpath throws so the failure is loud rather than a
|
|
12
|
+
* silently-broken `{}` export.
|
|
13
|
+
*/
|
|
14
|
+
declare function duckvizExpressMiddleware(): never;
|
|
15
|
+
|
|
16
|
+
export { duckvizExpressMiddleware };
|
package/dist/express.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// src/express.ts
|
|
2
|
+
function duckvizExpressMiddleware() {
|
|
3
|
+
throw new Error(
|
|
4
|
+
"@duckviz/sdk/express is not implemented yet. Use @duckviz/sdk/next for Next.js App Router, or open an issue if you need Express support."
|
|
5
|
+
);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export { duckvizExpressMiddleware };
|