@voiflow/sdk 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +82 -2
- package/dist/cjs/client.js +43 -0
- package/dist/cjs/errors.js +49 -0
- package/dist/cjs/http.js +280 -0
- package/dist/cjs/index.js +17 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/pagination.js +15 -0
- package/dist/cjs/resources.generated.js +573 -0
- package/dist/cjs/sse.js +46 -0
- package/dist/cjs/webhooks.js +46 -0
- package/dist/client.d.ts +14 -0
- package/dist/client.js +18 -0
- package/dist/errors.d.ts +37 -0
- package/dist/errors.js +43 -0
- package/dist/http.d.ts +66 -0
- package/dist/http.js +276 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/pagination.d.ts +7 -0
- package/dist/pagination.js +12 -0
- package/dist/resources.generated.d.ts +1999 -0
- package/dist/resources.generated.js +544 -0
- package/dist/sse.d.ts +7 -0
- package/dist/sse.js +43 -0
- package/dist/webhooks.d.ts +26 -0
- package/dist/webhooks.js +40 -0
- package/package.json +45 -4
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WebhookSignatureError = exports.SIGNATURE_HEADER = void 0;
|
|
4
|
+
exports.verifyWebhookSignature = verifyWebhookSignature;
|
|
5
|
+
exports.parseWebhookEvent = parseWebhookEvent;
|
|
6
|
+
const node_crypto_1 = require("node:crypto");
|
|
7
|
+
exports.SIGNATURE_HEADER = "VoiFlow-Signature";
|
|
8
|
+
class WebhookSignatureError extends Error {
|
|
9
|
+
}
|
|
10
|
+
exports.WebhookSignatureError = WebhookSignatureError;
|
|
11
|
+
/**
|
|
12
|
+
* Verifies `VoiFlow-Signature: t=<unix_seconds>,v1=<hex_hmac_sha256>` against the raw request
|
|
13
|
+
* body bytes. Signed payload is the UTF-8 timestamp, a literal period, then the raw bytes.
|
|
14
|
+
*/
|
|
15
|
+
function verifyWebhookSignature(rawBody, signatureHeader, secret, options = {}) {
|
|
16
|
+
const tolerance = options.toleranceSeconds ?? 300;
|
|
17
|
+
const now = options.now ?? (() => Date.now() / 1000);
|
|
18
|
+
const parts = Object.fromEntries(signatureHeader.split(",").map((part) => {
|
|
19
|
+
const [key, value] = part.split("=", 2);
|
|
20
|
+
return [key, value];
|
|
21
|
+
}));
|
|
22
|
+
const timestamp = parts["t"];
|
|
23
|
+
const signature = parts["v1"];
|
|
24
|
+
if (!timestamp || !signature) {
|
|
25
|
+
throw new WebhookSignatureError("Malformed VoiFlow-Signature header.");
|
|
26
|
+
}
|
|
27
|
+
const timestampSeconds = Number(timestamp);
|
|
28
|
+
if (!Number.isFinite(timestampSeconds) || Math.abs(now() - timestampSeconds) > tolerance) {
|
|
29
|
+
throw new WebhookSignatureError("Webhook timestamp is outside the allowed tolerance.");
|
|
30
|
+
}
|
|
31
|
+
const signedPayload = Buffer.concat([
|
|
32
|
+
Buffer.from(timestamp, "utf8"),
|
|
33
|
+
Buffer.from(".", "utf8"),
|
|
34
|
+
Buffer.from(rawBody),
|
|
35
|
+
]);
|
|
36
|
+
const expected = (0, node_crypto_1.createHmac)("sha256", secret).update(signedPayload).digest("hex");
|
|
37
|
+
const expectedBuf = Buffer.from(expected, "utf8");
|
|
38
|
+
const actualBuf = Buffer.from(signature, "utf8");
|
|
39
|
+
if (expectedBuf.length !== actualBuf.length || !(0, node_crypto_1.timingSafeEqual)(expectedBuf, actualBuf)) {
|
|
40
|
+
throw new WebhookSignatureError("Webhook signature does not match.");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Parses the verified webhook body. Call verifyWebhookSignature first. */
|
|
44
|
+
function parseWebhookEvent(rawBody) {
|
|
45
|
+
return JSON.parse(Buffer.from(rawBody).toString("utf8"));
|
|
46
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ClientOptions, HttpClient } from "./http.js";
|
|
2
|
+
import { GeneratedResources } from "./resources.generated.js";
|
|
3
|
+
/** Server-side client. Every published /v1 resource group is a property (`client.agents`, `client.aiWork`, ...). */
|
|
4
|
+
export declare class VoiFlowClient extends GeneratedResources {
|
|
5
|
+
private readonly transport;
|
|
6
|
+
constructor(options: ClientOptions);
|
|
7
|
+
get environment(): "live" | "test";
|
|
8
|
+
}
|
|
9
|
+
export { HttpClient, ClientOptions };
|
|
10
|
+
export * from "./resources.generated.js";
|
|
11
|
+
export { VoiFlowError, VoiFlowConnectionError, VoiFlowOperationPending } from "./errors.js";
|
|
12
|
+
export { verifyWebhookSignature, parseWebhookEvent, WebhookSignatureError, SIGNATURE_HEADER } from "./webhooks.js";
|
|
13
|
+
export type { VoiFlowEvent } from "./webhooks.js";
|
|
14
|
+
export type { SSEEvent } from "./sse.js";
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { HttpClient } from "./http.js";
|
|
2
|
+
import { GeneratedResources } from "./resources.generated.js";
|
|
3
|
+
/** Server-side client. Every published /v1 resource group is a property (`client.agents`, `client.aiWork`, ...). */
|
|
4
|
+
export class VoiFlowClient extends GeneratedResources {
|
|
5
|
+
transport;
|
|
6
|
+
constructor(options) {
|
|
7
|
+
const http = new HttpClient(options);
|
|
8
|
+
super(http);
|
|
9
|
+
this.transport = http;
|
|
10
|
+
}
|
|
11
|
+
get environment() {
|
|
12
|
+
return this.transport.environment;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export { HttpClient };
|
|
16
|
+
export * from "./resources.generated.js";
|
|
17
|
+
export { VoiFlowError, VoiFlowConnectionError, VoiFlowOperationPending } from "./errors.js";
|
|
18
|
+
export { verifyWebhookSignature, parseWebhookEvent, WebhookSignatureError, SIGNATURE_HEADER } from "./webhooks.js";
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface VoiFlowErrorBody {
|
|
2
|
+
code: string;
|
|
3
|
+
message: string;
|
|
4
|
+
request_id?: string;
|
|
5
|
+
details?: unknown;
|
|
6
|
+
/** Present on some errors (per the published error schema) to point at a pending operation. */
|
|
7
|
+
operation_id?: string;
|
|
8
|
+
/** Present on some errors (per the published error schema) with a resumable action link. */
|
|
9
|
+
status_url?: string;
|
|
10
|
+
}
|
|
11
|
+
/** Thrown for any non-2xx API response or a request that could not be sent. */
|
|
12
|
+
export declare class VoiFlowError extends Error {
|
|
13
|
+
readonly code: string;
|
|
14
|
+
readonly status: number;
|
|
15
|
+
readonly requestId?: string;
|
|
16
|
+
readonly details?: unknown;
|
|
17
|
+
readonly operationId?: string;
|
|
18
|
+
readonly statusUrl?: string;
|
|
19
|
+
/** The Idempotency-Key the failed call used; pass it back to retry the same logical operation. */
|
|
20
|
+
idempotencyKey?: string;
|
|
21
|
+
constructor(status: number, body: VoiFlowErrorBody);
|
|
22
|
+
}
|
|
23
|
+
/** The request never reached the server or the response could not be parsed (unknown outcome). */
|
|
24
|
+
export declare class VoiFlowConnectionError extends Error {
|
|
25
|
+
readonly cause?: unknown;
|
|
26
|
+
/** The Idempotency-Key the call used; pass it back to retry without repeating the action. */
|
|
27
|
+
idempotencyKey?: string;
|
|
28
|
+
constructor(message: string, cause?: unknown);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* HTTP 202: the server accepted the request but its outcome is not yet known. The action may still
|
|
32
|
+
* complete, so do not repeat it under a new Idempotency-Key — poll `operations.retrieve(operationId)`
|
|
33
|
+
* or retry with the same `idempotencyKey`.
|
|
34
|
+
*/
|
|
35
|
+
export declare class VoiFlowOperationPending extends VoiFlowError {
|
|
36
|
+
constructor(body: VoiFlowErrorBody);
|
|
37
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Thrown for any non-2xx API response or a request that could not be sent. */
|
|
2
|
+
export class VoiFlowError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
status;
|
|
5
|
+
requestId;
|
|
6
|
+
details;
|
|
7
|
+
operationId;
|
|
8
|
+
statusUrl;
|
|
9
|
+
/** The Idempotency-Key the failed call used; pass it back to retry the same logical operation. */
|
|
10
|
+
idempotencyKey;
|
|
11
|
+
constructor(status, body) {
|
|
12
|
+
super(body.message);
|
|
13
|
+
this.name = "VoiFlowError";
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.code = body.code;
|
|
16
|
+
this.requestId = body.request_id;
|
|
17
|
+
this.details = body.details;
|
|
18
|
+
this.operationId = body.operation_id;
|
|
19
|
+
this.statusUrl = body.status_url;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** The request never reached the server or the response could not be parsed (unknown outcome). */
|
|
23
|
+
export class VoiFlowConnectionError extends Error {
|
|
24
|
+
cause;
|
|
25
|
+
/** The Idempotency-Key the call used; pass it back to retry without repeating the action. */
|
|
26
|
+
idempotencyKey;
|
|
27
|
+
constructor(message, cause) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "VoiFlowConnectionError";
|
|
30
|
+
this.cause = cause;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* HTTP 202: the server accepted the request but its outcome is not yet known. The action may still
|
|
35
|
+
* complete, so do not repeat it under a new Idempotency-Key — poll `operations.retrieve(operationId)`
|
|
36
|
+
* or retry with the same `idempotencyKey`.
|
|
37
|
+
*/
|
|
38
|
+
export class VoiFlowOperationPending extends VoiFlowError {
|
|
39
|
+
constructor(body) {
|
|
40
|
+
super(202, body);
|
|
41
|
+
this.name = "VoiFlowOperationPending";
|
|
42
|
+
}
|
|
43
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface ClientOptions {
|
|
2
|
+
/** Server secret key: vf_live_... or vf_test_.... Never use a browser key here. */
|
|
3
|
+
apiKey: string;
|
|
4
|
+
baseUrl?: string;
|
|
5
|
+
/** Request timeout in ms, default 30000. */
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
/** Max automatic retries for safe/idempotent cases, default 2. */
|
|
8
|
+
maxRetries?: number;
|
|
9
|
+
fetch?: typeof fetch;
|
|
10
|
+
}
|
|
11
|
+
export interface RequestOptions {
|
|
12
|
+
query?: Record<string, string | number | boolean | undefined>;
|
|
13
|
+
body?: unknown;
|
|
14
|
+
/** State-changing operations: the caller's key, or (when `idempotent`) a generated one. */
|
|
15
|
+
idempotencyKey?: string;
|
|
16
|
+
/** Marks a state-changing operation: a stable key is generated once per call if none is given. */
|
|
17
|
+
idempotent?: boolean;
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
export interface VoiFlowResponse<T> {
|
|
21
|
+
data: T;
|
|
22
|
+
requestId: string | undefined;
|
|
23
|
+
status: number;
|
|
24
|
+
}
|
|
25
|
+
export interface BinaryResponse {
|
|
26
|
+
data: Uint8Array;
|
|
27
|
+
contentType: string;
|
|
28
|
+
requestId: string | undefined;
|
|
29
|
+
status: number;
|
|
30
|
+
}
|
|
31
|
+
export declare class HttpClient {
|
|
32
|
+
private readonly apiKey;
|
|
33
|
+
private readonly baseUrl;
|
|
34
|
+
private readonly timeoutMs;
|
|
35
|
+
private readonly maxRetries;
|
|
36
|
+
private readonly fetchImpl;
|
|
37
|
+
constructor(options: ClientOptions);
|
|
38
|
+
get environment(): "live" | "test";
|
|
39
|
+
resolvedUrl(path: string, query?: RequestOptions["query"]): URL;
|
|
40
|
+
/** Opens an authenticated text/event-stream GET request and returns its raw body. */
|
|
41
|
+
openStream(path: string, query: RequestOptions["query"], options?: {
|
|
42
|
+
signal?: AbortSignal;
|
|
43
|
+
lastEventId?: string;
|
|
44
|
+
}): Promise<ReadableStream<Uint8Array>>;
|
|
45
|
+
/** A successful binary response (e.g. a recording or document download) is raw bytes with an
|
|
46
|
+
* arbitrary content-type, not the {data, request_id} JSON envelope — an error response still is. */
|
|
47
|
+
requestBinary(method: string, path: string, options?: RequestOptions): Promise<BinaryResponse>;
|
|
48
|
+
/** Uploads raw bytes with an explicit content-type instead of JSON-serialising an object —
|
|
49
|
+
* the gateway forwards the body/content-type verbatim for upload routes. */
|
|
50
|
+
uploadBinary(method: string, path: string, content: Uint8Array, contentType: string, options?: {
|
|
51
|
+
query?: RequestOptions["query"];
|
|
52
|
+
idempotencyKey?: string;
|
|
53
|
+
signal?: AbortSignal;
|
|
54
|
+
}): Promise<VoiFlowResponse<unknown>>;
|
|
55
|
+
/** Multipart/form-data upload (a document plus optional JSON `metadata`). Idempotent: a stable key is generated once per call. */
|
|
56
|
+
uploadMultipart(method: string, path: string, file: Blob | Uint8Array, options?: {
|
|
57
|
+
filename?: string;
|
|
58
|
+
contentType?: string;
|
|
59
|
+
metadata?: Record<string, unknown>;
|
|
60
|
+
businessId?: string;
|
|
61
|
+
idempotencyKey?: string;
|
|
62
|
+
}): Promise<VoiFlowResponse<unknown>>;
|
|
63
|
+
request<T>(method: string, path: string, options?: RequestOptions): Promise<VoiFlowResponse<T>>;
|
|
64
|
+
private parseJsonResponse;
|
|
65
|
+
private runWithRetry;
|
|
66
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { VoiFlowConnectionError, VoiFlowError, VoiFlowOperationPending } from "./errors.js";
|
|
3
|
+
const DEFAULT_BASE_URL = "https://api.voiflow.ai/v1";
|
|
4
|
+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
5
|
+
const REQUEST_ID_HEADER = "VoiFlow-Request-Id";
|
|
6
|
+
function isKeyValid(key) {
|
|
7
|
+
return key.startsWith("vf_live_") || key.startsWith("vf_test_");
|
|
8
|
+
}
|
|
9
|
+
function assertServerRuntime() {
|
|
10
|
+
// This SDK holds a secret vf_live_/vf_test_ key. A browser runtime (window + document) means
|
|
11
|
+
// the key would ship to every visitor; refuse to construct the client at all in that case.
|
|
12
|
+
const g = globalThis;
|
|
13
|
+
if (typeof g.window !== "undefined" && typeof g.document !== "undefined") {
|
|
14
|
+
throw new Error("The VoiFlow SDK holds a secret server credential and must not run in a browser. " +
|
|
15
|
+
"Use it from your backend and give the browser a short-lived session token instead.");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class HttpClient {
|
|
19
|
+
apiKey;
|
|
20
|
+
baseUrl;
|
|
21
|
+
timeoutMs;
|
|
22
|
+
maxRetries;
|
|
23
|
+
fetchImpl;
|
|
24
|
+
constructor(options) {
|
|
25
|
+
assertServerRuntime();
|
|
26
|
+
if (!isKeyValid(options.apiKey)) {
|
|
27
|
+
throw new Error("apiKey must start with vf_live_ or vf_test_; browser/public keys are not supported here.");
|
|
28
|
+
}
|
|
29
|
+
this.apiKey = options.apiKey;
|
|
30
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
31
|
+
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
32
|
+
this.maxRetries = options.maxRetries ?? 2;
|
|
33
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
34
|
+
}
|
|
35
|
+
get environment() {
|
|
36
|
+
return this.apiKey.startsWith("vf_live_") ? "live" : "test";
|
|
37
|
+
}
|
|
38
|
+
resolvedUrl(path, query) {
|
|
39
|
+
const url = new URL(this.baseUrl + path);
|
|
40
|
+
if (query) {
|
|
41
|
+
for (const [key, value] of Object.entries(query)) {
|
|
42
|
+
if (value !== undefined)
|
|
43
|
+
url.searchParams.set(key, String(value));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return url;
|
|
47
|
+
}
|
|
48
|
+
/** Opens an authenticated text/event-stream GET request and returns its raw body. */
|
|
49
|
+
async openStream(path, query, options = {}) {
|
|
50
|
+
const url = this.resolvedUrl(path, query);
|
|
51
|
+
const headers = {
|
|
52
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
53
|
+
Accept: "text/event-stream",
|
|
54
|
+
};
|
|
55
|
+
if (options.lastEventId)
|
|
56
|
+
headers["Last-Event-ID"] = options.lastEventId;
|
|
57
|
+
const response = await this.fetchImpl(url, { headers, signal: options.signal });
|
|
58
|
+
if (!response.ok || !response.body) {
|
|
59
|
+
const body = await parseErrorBody(response);
|
|
60
|
+
throw new VoiFlowError(response.status, body);
|
|
61
|
+
}
|
|
62
|
+
return response.body;
|
|
63
|
+
}
|
|
64
|
+
/** A successful binary response (e.g. a recording or document download) is raw bytes with an
|
|
65
|
+
* arbitrary content-type, not the {data, request_id} JSON envelope — an error response still is. */
|
|
66
|
+
async requestBinary(method, path, options = {}) {
|
|
67
|
+
const url = this.resolvedUrl(path, options.query);
|
|
68
|
+
const headers = { Authorization: `Bearer ${this.apiKey}` };
|
|
69
|
+
const response = await this.runWithRetry(method, url, headers, undefined, options);
|
|
70
|
+
const requestId = readRequestId(response, undefined);
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
throw new VoiFlowError(response.status, await parseErrorBody(response));
|
|
73
|
+
}
|
|
74
|
+
const data = new Uint8Array(await response.arrayBuffer());
|
|
75
|
+
return { data, contentType: response.headers.get("content-type") ?? "application/octet-stream", requestId, status: response.status };
|
|
76
|
+
}
|
|
77
|
+
/** Uploads raw bytes with an explicit content-type instead of JSON-serialising an object —
|
|
78
|
+
* the gateway forwards the body/content-type verbatim for upload routes. */
|
|
79
|
+
async uploadBinary(method, path, content, contentType, options = {}) {
|
|
80
|
+
const url = this.resolvedUrl(path, options.query);
|
|
81
|
+
const headers = { Authorization: `Bearer ${this.apiKey}`, "Content-Type": contentType, Accept: "application/json" };
|
|
82
|
+
if (options.idempotencyKey)
|
|
83
|
+
headers["Idempotency-Key"] = options.idempotencyKey;
|
|
84
|
+
const response = await this.runWithRetry(method, url, headers, content, { idempotencyKey: options.idempotencyKey, signal: options.signal });
|
|
85
|
+
return this.parseJsonResponse(response);
|
|
86
|
+
}
|
|
87
|
+
/** Multipart/form-data upload (a document plus optional JSON `metadata`). Idempotent: a stable key is generated once per call. */
|
|
88
|
+
async uploadMultipart(method, path, file, options = {}) {
|
|
89
|
+
const idempotencyKey = options.idempotencyKey ?? randomUUID();
|
|
90
|
+
const form = new FormData();
|
|
91
|
+
const blob = file instanceof Blob ? file : new Blob([file], { type: options.contentType ?? "application/octet-stream" });
|
|
92
|
+
form.append("file", blob, options.filename ?? "upload");
|
|
93
|
+
if (options.metadata)
|
|
94
|
+
form.append("metadata", JSON.stringify(options.metadata));
|
|
95
|
+
const url = this.resolvedUrl(path, { business_id: options.businessId });
|
|
96
|
+
// No Content-Type here: fetch sets multipart/form-data with the boundary itself.
|
|
97
|
+
const headers = {
|
|
98
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
99
|
+
Accept: "application/json",
|
|
100
|
+
"Idempotency-Key": idempotencyKey,
|
|
101
|
+
};
|
|
102
|
+
try {
|
|
103
|
+
const response = await this.runWithRetry(method, url, headers, form, { idempotencyKey });
|
|
104
|
+
return await this.parseJsonResponse(response);
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
if (err instanceof VoiFlowError || err instanceof VoiFlowConnectionError)
|
|
108
|
+
err.idempotencyKey = idempotencyKey;
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async request(method, path, options = {}) {
|
|
113
|
+
const idempotencyKey = options.idempotencyKey ?? (options.idempotent ? randomUUID() : undefined);
|
|
114
|
+
const url = this.resolvedUrl(path, options.query);
|
|
115
|
+
const headers = {
|
|
116
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
117
|
+
Accept: "application/json",
|
|
118
|
+
};
|
|
119
|
+
if (idempotencyKey)
|
|
120
|
+
headers["Idempotency-Key"] = idempotencyKey;
|
|
121
|
+
let payload;
|
|
122
|
+
if (options.body !== undefined) {
|
|
123
|
+
headers["Content-Type"] = "application/json";
|
|
124
|
+
payload = JSON.stringify(options.body);
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const response = await this.runWithRetry(method, url, headers, payload, { ...options, idempotencyKey });
|
|
128
|
+
return await this.parseJsonResponse(response);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
if (idempotencyKey && (err instanceof VoiFlowError || err instanceof VoiFlowConnectionError)) {
|
|
132
|
+
err.idempotencyKey = idempotencyKey;
|
|
133
|
+
}
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async parseJsonResponse(response) {
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
throw new VoiFlowError(response.status, await parseErrorBody(response));
|
|
140
|
+
}
|
|
141
|
+
if (response.status === 204 || response.headers.get("content-length") === "0") {
|
|
142
|
+
return { data: undefined, requestId: readRequestId(response, undefined), status: response.status };
|
|
143
|
+
}
|
|
144
|
+
const parsed = (await response.json());
|
|
145
|
+
if (response.status === 202 && parsed.error && !("data" in parsed)) {
|
|
146
|
+
throw new VoiFlowOperationPending({ ...parsed.error, request_id: parsed.error.request_id ?? readRequestId(response, undefined) });
|
|
147
|
+
}
|
|
148
|
+
const requestId = readRequestId(response, parsed?.request_id);
|
|
149
|
+
return { data: parsed, requestId, status: response.status };
|
|
150
|
+
}
|
|
151
|
+
async runWithRetry(method, url, headers, body, options) {
|
|
152
|
+
const isSafeRetry = method === "GET" || options.idempotencyKey !== undefined;
|
|
153
|
+
let attempt = 0;
|
|
154
|
+
let lastError;
|
|
155
|
+
while (attempt <= this.maxRetries) {
|
|
156
|
+
if (options.signal?.aborted) {
|
|
157
|
+
throw new VoiFlowConnectionError("Request was cancelled.", options.signal.reason);
|
|
158
|
+
}
|
|
159
|
+
const controller = new AbortController();
|
|
160
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
161
|
+
const cleanupAbortLink = options.signal ? linkSignals(options.signal, controller) : undefined;
|
|
162
|
+
try {
|
|
163
|
+
const response = await this.fetchImpl(url, { method, headers, body, signal: controller.signal });
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
cleanupAbortLink?.();
|
|
166
|
+
if (RETRYABLE_STATUS.has(response.status) && isSafeRetry && attempt < this.maxRetries) {
|
|
167
|
+
const waited = await waitForRetry(retryDelayMs(response, attempt), options.signal);
|
|
168
|
+
if (!waited)
|
|
169
|
+
throw new VoiFlowConnectionError("Request was cancelled.", options.signal?.reason);
|
|
170
|
+
attempt += 1;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
return response;
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
cleanupAbortLink?.();
|
|
178
|
+
if (err instanceof VoiFlowError || err instanceof VoiFlowConnectionError)
|
|
179
|
+
throw err;
|
|
180
|
+
if (options.signal?.aborted) {
|
|
181
|
+
throw new VoiFlowConnectionError("Request was cancelled.", options.signal.reason);
|
|
182
|
+
}
|
|
183
|
+
lastError = err;
|
|
184
|
+
if (isSafeRetry && attempt < this.maxRetries) {
|
|
185
|
+
const waited = await waitForRetry(retryDelayMs(undefined, attempt), options.signal);
|
|
186
|
+
if (!waited)
|
|
187
|
+
throw new VoiFlowConnectionError("Request was cancelled.", options.signal?.reason);
|
|
188
|
+
attempt += 1;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
throw new VoiFlowConnectionError("VoiFlow API request failed; outcome is unknown.", err);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
throw new VoiFlowConnectionError("VoiFlow API request exhausted retries.", lastError);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function readRequestId(response, fromBody) {
|
|
198
|
+
// VoiFlow-Request-Id is a real response header on every /v1 response (success and error
|
|
199
|
+
// alike) — the only reliable source for binary/204 responses, which carry no JSON body.
|
|
200
|
+
return fromBody ?? response.headers.get(REQUEST_ID_HEADER) ?? undefined;
|
|
201
|
+
}
|
|
202
|
+
// The curated contract's error envelope is {error:{code,message,request_id,details?}}, but some
|
|
203
|
+
// routes still raise a plain FastAPI HTTPException ({detail:{code,message}} or {detail:"..."}),
|
|
204
|
+
// and parameter validation failures use FastAPI's own {detail:[{loc,msg,type},...]} shape. All
|
|
205
|
+
// three are observed on the real gateway; handle all three rather than assuming the curated one.
|
|
206
|
+
async function parseErrorBody(response) {
|
|
207
|
+
const requestId = response.headers.get(REQUEST_ID_HEADER) ?? undefined;
|
|
208
|
+
try {
|
|
209
|
+
const json = (await response.json());
|
|
210
|
+
if (json.error) {
|
|
211
|
+
return {
|
|
212
|
+
code: json.error.code,
|
|
213
|
+
message: json.error.message,
|
|
214
|
+
request_id: json.error.request_id ?? requestId,
|
|
215
|
+
details: json.error.details,
|
|
216
|
+
operation_id: json.error.operation_id,
|
|
217
|
+
status_url: json.error.status_url,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (typeof json.detail === "string") {
|
|
221
|
+
return { code: "http_error", message: json.detail, request_id: requestId };
|
|
222
|
+
}
|
|
223
|
+
if (Array.isArray(json.detail)) {
|
|
224
|
+
return { code: "unprocessable", message: "the request was not valid", request_id: requestId, details: json.detail };
|
|
225
|
+
}
|
|
226
|
+
if (json.detail) {
|
|
227
|
+
return {
|
|
228
|
+
code: json.detail.code ?? "http_error",
|
|
229
|
+
message: json.detail.message ?? `Request failed with status ${response.status}`,
|
|
230
|
+
request_id: requestId,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// body was not JSON; fall through to generic error below
|
|
236
|
+
}
|
|
237
|
+
return { code: "unknown_error", message: `Request failed with status ${response.status}`, request_id: requestId };
|
|
238
|
+
}
|
|
239
|
+
function retryDelayMs(response, attempt) {
|
|
240
|
+
const retryAfter = response?.headers.get("Retry-After");
|
|
241
|
+
if (retryAfter) {
|
|
242
|
+
const seconds = Number(retryAfter);
|
|
243
|
+
if (!Number.isNaN(seconds))
|
|
244
|
+
return seconds * 1000;
|
|
245
|
+
const whenMs = Date.parse(retryAfter);
|
|
246
|
+
if (!Number.isNaN(whenMs))
|
|
247
|
+
return Math.max(0, whenMs - Date.now());
|
|
248
|
+
}
|
|
249
|
+
return 250 * 2 ** attempt;
|
|
250
|
+
}
|
|
251
|
+
/** Resolves true after the delay, or false immediately if the signal aborts first — so a
|
|
252
|
+
* cancelled request doesn't sit out a retry backoff it will never use. */
|
|
253
|
+
function waitForRetry(ms, signal) {
|
|
254
|
+
return new Promise((resolve) => {
|
|
255
|
+
if (signal?.aborted) {
|
|
256
|
+
resolve(false);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const timer = setTimeout(() => {
|
|
260
|
+
signal?.removeEventListener("abort", onAbort);
|
|
261
|
+
resolve(true);
|
|
262
|
+
}, ms);
|
|
263
|
+
const onAbort = () => {
|
|
264
|
+
clearTimeout(timer);
|
|
265
|
+
resolve(false);
|
|
266
|
+
};
|
|
267
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
/** Aborts `controller` when `signal` aborts, and always removes its own listener afterward —
|
|
271
|
+
* each attempt gets its own link instead of accumulating listeners across retries. */
|
|
272
|
+
function linkSignals(signal, controller) {
|
|
273
|
+
const onAbort = () => controller.abort(signal.reason);
|
|
274
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
275
|
+
return () => signal.removeEventListener("abort", onAbort);
|
|
276
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./client.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./client.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface CursorPage<T> {
|
|
2
|
+
data: T[];
|
|
3
|
+
next_cursor: string;
|
|
4
|
+
request_id?: string;
|
|
5
|
+
}
|
|
6
|
+
/** Lazily walks every page of a cursor-paginated list endpoint. */
|
|
7
|
+
export declare function paginate<T>(fetchPage: (cursor: string | undefined) => Promise<CursorPage<T>>): AsyncGenerator<T, void, void>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Lazily walks every page of a cursor-paginated list endpoint. */
|
|
2
|
+
export async function* paginate(fetchPage) {
|
|
3
|
+
let cursor;
|
|
4
|
+
while (true) {
|
|
5
|
+
const page = await fetchPage(cursor);
|
|
6
|
+
for (const item of page.data)
|
|
7
|
+
yield item;
|
|
8
|
+
if (!page.next_cursor)
|
|
9
|
+
return;
|
|
10
|
+
cursor = page.next_cursor;
|
|
11
|
+
}
|
|
12
|
+
}
|