@lumavox/server 0.0.0-stage → 0.2.2
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 +134 -2
- package/dist/client.d.ts +59 -0
- package/dist/client.js +52 -0
- package/dist/errors.d.ts +86 -0
- package/dist/errors.js +138 -0
- package/dist/gen/index.d.ts +1 -0
- package/dist/gen/index.js +2 -0
- package/dist/gen/types.gen.d.ts +6563 -0
- package/dist/gen/types.gen.js +2 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +8 -0
- package/dist/paging.d.ts +34 -0
- package/dist/paging.js +41 -0
- package/dist/refuse.d.ts +11 -0
- package/dist/refuse.js +12 -0
- package/dist/resources/agents.d.ts +34 -0
- package/dist/resources/agents.js +33 -0
- package/dist/resources/assets.d.ts +59 -0
- package/dist/resources/assets.js +80 -0
- package/dist/resources/end-users.d.ts +73 -0
- package/dist/resources/end-users.js +139 -0
- package/dist/resources/ephemeral-keys.d.ts +65 -0
- package/dist/resources/ephemeral-keys.js +129 -0
- package/dist/resources/generations.d.ts +40 -0
- package/dist/resources/generations.js +55 -0
- package/dist/resources/knowledge.d.ts +58 -0
- package/dist/resources/knowledge.js +91 -0
- package/dist/resources/resource.d.ts +52 -0
- package/dist/resources/resource.js +68 -0
- package/dist/resources/speech.d.ts +80 -0
- package/dist/resources/speech.js +64 -0
- package/dist/resources/templates.d.ts +37 -0
- package/dist/resources/templates.js +50 -0
- package/dist/resources/usage.d.ts +15 -0
- package/dist/resources/usage.js +16 -0
- package/dist/resources/voices.d.ts +41 -0
- package/dist/resources/voices.js +53 -0
- package/dist/runtime.d.ts +22 -0
- package/dist/runtime.js +24 -0
- package/dist/stream.d.ts +6 -0
- package/dist/stream.js +37 -0
- package/dist/transport.d.ts +83 -0
- package/dist/transport.js +236 -0
- package/dist/upload.d.ts +37 -0
- package/dist/upload.js +34 -0
- package/dist/wait.d.ts +42 -0
- package/dist/wait.js +51 -0
- package/package.json +50 -4
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `client.templates`: the platform's frozen generations — `/v1/templates*`.
|
|
3
|
+
* Every tenant sees the same ones; a template fixes every parameter but
|
|
4
|
+
* the ones it `exposed`, so its price is known up front.
|
|
5
|
+
*
|
|
6
|
+
* - **`create(id, body)` is the route** (`POST …/generate`, 202, a
|
|
7
|
+
* `gen_…`), and **`generate(id, body)` waits** for the result, as
|
|
8
|
+
* `images.generate` does — one name, one meaning, across the SDK. The
|
|
9
|
+
* wait reads `/v1/generations/{id}`: a template makes an image, a video
|
|
10
|
+
* or a skybox, and the key that made it may read it.
|
|
11
|
+
* - **`file(id, name)` needs no key** and answers the bytes; a template's
|
|
12
|
+
* URLs already name each file and its `v`, which makes it cacheable.
|
|
13
|
+
*/
|
|
14
|
+
import type { GenerationInfo, TemplateGenerateRequest, TemplateInfo, TemplateList } from "../gen/index.js";
|
|
15
|
+
import { type RequestOptions, type Transport } from "../transport.js";
|
|
16
|
+
import type { Generations } from "./generations.js";
|
|
17
|
+
import { Readable } from "./resource.js";
|
|
18
|
+
/** A template's picture, as stored. */
|
|
19
|
+
export interface TemplateFile {
|
|
20
|
+
body: ArrayBuffer;
|
|
21
|
+
contentType: string;
|
|
22
|
+
}
|
|
23
|
+
/** `get(id)` answers one with its inputs, frozen parameters and price. */
|
|
24
|
+
export declare class Templates extends Readable<TemplateInfo> {
|
|
25
|
+
private readonly generations;
|
|
26
|
+
constructor(api: Transport, base: string, generations: Generations);
|
|
27
|
+
/** Every template this organisation may generate from. */
|
|
28
|
+
list(options?: RequestOptions): Promise<TemplateList>;
|
|
29
|
+
/** Generates from it, charged when accepted: the `gen_…` at once. */
|
|
30
|
+
create(id: string, body?: TemplateGenerateRequest, options?: RequestOptions): Promise<GenerationInfo>;
|
|
31
|
+
/** Generates from it and waits: the generation once it is final. */
|
|
32
|
+
generate(id: string, body?: TemplateGenerateRequest, options?: RequestOptions): Promise<GenerationInfo>;
|
|
33
|
+
/** A template's picture — `example`, or `input-<slot>` — with no key. */
|
|
34
|
+
file(id: string, name: string, params?: {
|
|
35
|
+
v?: string | null;
|
|
36
|
+
}, options?: RequestOptions): Promise<TemplateFile>;
|
|
37
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `client.templates`: the platform's frozen generations — `/v1/templates*`.
|
|
3
|
+
* Every tenant sees the same ones; a template fixes every parameter but
|
|
4
|
+
* the ones it `exposed`, so its price is known up front.
|
|
5
|
+
*
|
|
6
|
+
* - **`create(id, body)` is the route** (`POST …/generate`, 202, a
|
|
7
|
+
* `gen_…`), and **`generate(id, body)` waits** for the result, as
|
|
8
|
+
* `images.generate` does — one name, one meaning, across the SDK. The
|
|
9
|
+
* wait reads `/v1/generations/{id}`: a template makes an image, a video
|
|
10
|
+
* or a skybox, and the key that made it may read it.
|
|
11
|
+
* - **`file(id, name)` needs no key** and answers the bytes; a template's
|
|
12
|
+
* URLs already name each file and its `v`, which makes it cacheable.
|
|
13
|
+
*/
|
|
14
|
+
import { segment } from "../transport.js";
|
|
15
|
+
import { poll } from "../wait.js";
|
|
16
|
+
import { Readable } from "./resource.js";
|
|
17
|
+
/** `get(id)` answers one with its inputs, frozen parameters and price. */
|
|
18
|
+
export class Templates extends Readable {
|
|
19
|
+
constructor(api, base, generations) {
|
|
20
|
+
super(api, base);
|
|
21
|
+
this.generations = generations;
|
|
22
|
+
}
|
|
23
|
+
/** Every template this organisation may generate from. */
|
|
24
|
+
list(options) {
|
|
25
|
+
return this.send("GET", "", options);
|
|
26
|
+
}
|
|
27
|
+
/** Generates from it, charged when accepted: the `gen_…` at once. */
|
|
28
|
+
create(id, body = {}, options) {
|
|
29
|
+
return this.send("POST", this.at(id, "/generate"), options, body);
|
|
30
|
+
}
|
|
31
|
+
/** Generates from it and waits: the generation once it is final. */
|
|
32
|
+
async generate(id, body = {}, options) {
|
|
33
|
+
const job = await this.create(id, body, options);
|
|
34
|
+
return poll(() => this.generations.get(job.id, options), options, job);
|
|
35
|
+
}
|
|
36
|
+
/** A template's picture — `example`, or `input-<slot>` — with no key. */
|
|
37
|
+
async file(id, name, params = {}, options) {
|
|
38
|
+
const answer = await this.api.request({
|
|
39
|
+
method: "GET",
|
|
40
|
+
path: this.base + this.at(id, `/files/${segment(name)}`),
|
|
41
|
+
query: params,
|
|
42
|
+
read: "bytes",
|
|
43
|
+
open: true,
|
|
44
|
+
}, options);
|
|
45
|
+
return {
|
|
46
|
+
body: answer.data,
|
|
47
|
+
contentType: answer.headers.get("content-type") ?? "",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `client.usage`: what this organisation spent — `GET /v1/usage` over a
|
|
3
|
+
* window of days, and `GET /v1/plan`, its plan and balance (`null` with
|
|
4
|
+
* no subscription). Read-only, unmetered.
|
|
5
|
+
*/
|
|
6
|
+
import type { OrgPlan, UsageData, UsageResponse } from "../gen/index.js";
|
|
7
|
+
import type { RequestOptions } from "../transport.js";
|
|
8
|
+
import { Resource } from "./resource.js";
|
|
9
|
+
export type UsageParams = NonNullable<UsageData["query"]>;
|
|
10
|
+
export declare class Usage extends Resource {
|
|
11
|
+
/** Requests and seconds by capability, by day and by key: 30 days by default. */
|
|
12
|
+
get(params?: UsageParams, options?: RequestOptions): Promise<UsageResponse>;
|
|
13
|
+
/** The plan and the credits left; `null` on no plan. */
|
|
14
|
+
plan(options?: RequestOptions): Promise<OrgPlan | null>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `client.usage`: what this organisation spent — `GET /v1/usage` over a
|
|
3
|
+
* window of days, and `GET /v1/plan`, its plan and balance (`null` with
|
|
4
|
+
* no subscription). Read-only, unmetered.
|
|
5
|
+
*/
|
|
6
|
+
import { Resource } from "./resource.js";
|
|
7
|
+
export class Usage extends Resource {
|
|
8
|
+
/** Requests and seconds by capability, by day and by key: 30 days by default. */
|
|
9
|
+
get(params = {}, options) {
|
|
10
|
+
return this.send("GET", "/usage", options, params);
|
|
11
|
+
}
|
|
12
|
+
/** The plan and the credits left; `null` on no plan. */
|
|
13
|
+
plan(options) {
|
|
14
|
+
return this.send("GET", "/plan", options);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `client.voices`: the voices this organisation may speak with —
|
|
3
|
+
* `/v1/voices*` — the built-in one, the platform's, and its own, cloned
|
|
4
|
+
* from a recording or designed from a description.
|
|
5
|
+
*
|
|
6
|
+
* - **A voice of its own is a job**: `create`, `saveDesign` and `upload`
|
|
7
|
+
* answer it `pending`; `wait(id)` reads it until `ready` or `failed`.
|
|
8
|
+
* - **`upload(recording, fields)`** is the ticket, the `PUT` and the
|
|
9
|
+
* create in one (`upload.ts`); `consent: true` says you may clone it.
|
|
10
|
+
* - **`design(body)` answers previews, not a voice**: keep one with
|
|
11
|
+
* `saveDesign`. It is a provider call held open, and charged.
|
|
12
|
+
* - **`delete` answers `false` when there was no such voice**: done.
|
|
13
|
+
*/
|
|
14
|
+
import type { VoiceDesignRequest, VoiceDesignResult, VoiceDesignSaveRequest, VoiceDetail, VoiceInfo, VoiceUpdate, VoiceUpload, VoiceUploadRequest, VoiceUploadTicket } from "../gen/index.js";
|
|
15
|
+
import type { RequestOptions } from "../transport.js";
|
|
16
|
+
import { type UploadData, type UploadOptions } from "../upload.js";
|
|
17
|
+
import { Waitable } from "./resource.js";
|
|
18
|
+
/** A recording's voice: the create's fields, and the recording's type. */
|
|
19
|
+
export type VoiceUploadFields = Omit<VoiceUpload, "upload_id"> & {
|
|
20
|
+
/** A Blob's own type by default: `audio/wav`, `audio/mpeg`, … */
|
|
21
|
+
content_type?: string;
|
|
22
|
+
};
|
|
23
|
+
/** `update` renames a voice, or changes its description or defaults. */
|
|
24
|
+
export declare class Voices extends Waitable<VoiceDetail, VoiceUpdate, VoiceDetail, boolean> {
|
|
25
|
+
/** Every voice this organisation may speak with. */
|
|
26
|
+
list(options?: RequestOptions): Promise<VoiceInfo[]>;
|
|
27
|
+
/** Clones a recording already `PUT` to its ticket: `pending` at once. */
|
|
28
|
+
create(body: VoiceUpload, options?: RequestOptions): Promise<VoiceDetail>;
|
|
29
|
+
/** A signed URL to `PUT` a recording to, with exactly this type. */
|
|
30
|
+
uploadUrl(body: VoiceUploadRequest, options?: RequestOptions): Promise<VoiceUploadTicket>;
|
|
31
|
+
/** Uploads a recording and clones it: `uploadUrl`, the `PUT`, `create`. */
|
|
32
|
+
upload(recording: UploadData, fields: VoiceUploadFields, options?: UploadOptions): Promise<VoiceDetail>;
|
|
33
|
+
/** Deletes it: `true`, or `false` when there was none by that id (a 204 has no body). */
|
|
34
|
+
delete(id: string, options?: RequestOptions): Promise<boolean>;
|
|
35
|
+
/** Speaks its samples again, one per model. */
|
|
36
|
+
resample(id: string, options?: RequestOptions): Promise<VoiceDetail>;
|
|
37
|
+
/** Previews of a voice made from a description; charged. */
|
|
38
|
+
design(body: VoiceDesignRequest, options?: RequestOptions): Promise<VoiceDesignResult>;
|
|
39
|
+
/** Keeps one preview as a voice: `pending` at once. */
|
|
40
|
+
saveDesign(body: VoiceDesignSaveRequest, options?: RequestOptions): Promise<VoiceDetail>;
|
|
41
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `client.voices`: the voices this organisation may speak with —
|
|
3
|
+
* `/v1/voices*` — the built-in one, the platform's, and its own, cloned
|
|
4
|
+
* from a recording or designed from a description.
|
|
5
|
+
*
|
|
6
|
+
* - **A voice of its own is a job**: `create`, `saveDesign` and `upload`
|
|
7
|
+
* answer it `pending`; `wait(id)` reads it until `ready` or `failed`.
|
|
8
|
+
* - **`upload(recording, fields)`** is the ticket, the `PUT` and the
|
|
9
|
+
* create in one (`upload.ts`); `consent: true` says you may clone it.
|
|
10
|
+
* - **`design(body)` answers previews, not a voice**: keep one with
|
|
11
|
+
* `saveDesign`. It is a provider call held open, and charged.
|
|
12
|
+
* - **`delete` answers `false` when there was no such voice**: done.
|
|
13
|
+
*/
|
|
14
|
+
import { needType, put, } from "../upload.js";
|
|
15
|
+
import { Waitable } from "./resource.js";
|
|
16
|
+
/** `update` renames a voice, or changes its description or defaults. */
|
|
17
|
+
export class Voices extends Waitable {
|
|
18
|
+
/** Every voice this organisation may speak with. */
|
|
19
|
+
list(options) {
|
|
20
|
+
return this.send("GET", "", options);
|
|
21
|
+
}
|
|
22
|
+
/** Clones a recording already `PUT` to its ticket: `pending` at once. */
|
|
23
|
+
create(body, options) {
|
|
24
|
+
return this.send("POST", "", options, body);
|
|
25
|
+
}
|
|
26
|
+
/** A signed URL to `PUT` a recording to, with exactly this type. */
|
|
27
|
+
uploadUrl(body, options) {
|
|
28
|
+
return this.send("POST", "/upload-url", options, body);
|
|
29
|
+
}
|
|
30
|
+
/** Uploads a recording and clones it: `uploadUrl`, the `PUT`, `create`. */
|
|
31
|
+
async upload(recording, fields, options = {}) {
|
|
32
|
+
const { content_type, ...body } = fields;
|
|
33
|
+
const ticket = await this.uploadUrl({ content_type: needType(recording, content_type) }, options);
|
|
34
|
+
await put(this.api, ticket, recording, ticket.content_type, options);
|
|
35
|
+
return this.settled(await this.create({ ...body, upload_id: ticket.upload_id }, options), options);
|
|
36
|
+
}
|
|
37
|
+
/** Deletes it: `true`, or `false` when there was none by that id (a 204 has no body). */
|
|
38
|
+
async delete(id, options) {
|
|
39
|
+
return (await super.delete(id, options)) !== null;
|
|
40
|
+
}
|
|
41
|
+
/** Speaks its samples again, one per model. */
|
|
42
|
+
resample(id, options) {
|
|
43
|
+
return this.send("POST", this.at(id, "/sample"), options);
|
|
44
|
+
}
|
|
45
|
+
/** Previews of a voice made from a description; charged. */
|
|
46
|
+
design(body, options) {
|
|
47
|
+
return this.send("POST", "/design", options, body);
|
|
48
|
+
}
|
|
49
|
+
/** Keeps one preview as a voice: `pending` at once. */
|
|
50
|
+
saveDesign(body, options) {
|
|
51
|
+
return this.send("POST", "/design/save", options, body);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where this SDK refuses to run: anywhere an end user's device holds it.
|
|
3
|
+
*
|
|
4
|
+
* It carries the organisation's API key, which spends the whole balance
|
|
5
|
+
* and reads every end user. **The API cannot be the wall**: its CORS
|
|
6
|
+
* allows every origin (`apps/api/src/http/cors.ts`) and the console sends
|
|
7
|
+
* an org's key from the browser itself. So the SDK is: the package's
|
|
8
|
+
* `browser` and `react-native` export conditions resolve to `refuse.js`
|
|
9
|
+
* (a bundler fails), and `createClient` checks again here for a bundle
|
|
10
|
+
* that took the plain file anyway. **No escape hatch** — a device talks to
|
|
11
|
+
* agents with an ephemeral key, through `@lumavox/client`.
|
|
12
|
+
*/
|
|
13
|
+
export declare const REFUSAL: string;
|
|
14
|
+
interface Globals {
|
|
15
|
+
document?: unknown;
|
|
16
|
+
navigator?: {
|
|
17
|
+
product?: unknown;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** Why this runtime may not hold an API key — `null` on a server. */
|
|
21
|
+
export declare function refusedHere(globals?: Globals): string | null;
|
|
22
|
+
export {};
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where this SDK refuses to run: anywhere an end user's device holds it.
|
|
3
|
+
*
|
|
4
|
+
* It carries the organisation's API key, which spends the whole balance
|
|
5
|
+
* and reads every end user. **The API cannot be the wall**: its CORS
|
|
6
|
+
* allows every origin (`apps/api/src/http/cors.ts`) and the console sends
|
|
7
|
+
* an org's key from the browser itself. So the SDK is: the package's
|
|
8
|
+
* `browser` and `react-native` export conditions resolve to `refuse.js`
|
|
9
|
+
* (a bundler fails), and `createClient` checks again here for a bundle
|
|
10
|
+
* that took the plain file anyway. **No escape hatch** — a device talks to
|
|
11
|
+
* agents with an ephemeral key, through `@lumavox/client`.
|
|
12
|
+
*/
|
|
13
|
+
export const REFUSAL = "@lumavox/server holds your organisation's API key, so it runs only on " +
|
|
14
|
+
"your server: never in a browser or a React Native app. Mint an end " +
|
|
15
|
+
"user an ephemeral key on your server (ephemeralKeys.create) and use " +
|
|
16
|
+
"@lumavox/client on the device.";
|
|
17
|
+
/** Why this runtime may not hold an API key — `null` on a server. */
|
|
18
|
+
export function refusedHere(globals = globalThis) {
|
|
19
|
+
if (typeof globals.document !== "undefined")
|
|
20
|
+
return "a browser";
|
|
21
|
+
if (globals.navigator?.product === "ReactNative")
|
|
22
|
+
return "React Native";
|
|
23
|
+
return null;
|
|
24
|
+
}
|
package/dist/stream.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every line of `body`, parsed, as it arrives. Its chunks are typed as the
|
|
3
|
+
* decoder takes them — the DOM's types and Workers' disagree on what a
|
|
4
|
+
* `TextDecoderStream` writes from, and a `Uint8Array` is both.
|
|
5
|
+
*/
|
|
6
|
+
export declare function ndjson<T>(body: ReadableStream<BufferSource>, requestId: string): AsyncGenerator<T, void, undefined>;
|
package/dist/stream.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Newline-delimited JSON, as `POST /v1/stt` with `stream=true` answers it:
|
|
3
|
+
* one event a line, read as the lines arrive — a `segment` as each piece
|
|
4
|
+
* is cut, its `transcript` (or `error`) in order, and a `done` with the
|
|
5
|
+
* whole text.
|
|
6
|
+
*
|
|
7
|
+
* **An iterator left early hangs up**: `break` cancels the body, so the
|
|
8
|
+
* API sees its caller go, as it does any stream abandoned half way.
|
|
9
|
+
*/
|
|
10
|
+
import { parsed } from "./transport.js";
|
|
11
|
+
/**
|
|
12
|
+
* Every line of `body`, parsed, as it arrives. Its chunks are typed as the
|
|
13
|
+
* decoder takes them — the DOM's types and Workers' disagree on what a
|
|
14
|
+
* `TextDecoderStream` writes from, and a `Uint8Array` is both.
|
|
15
|
+
*/
|
|
16
|
+
export async function* ndjson(body, requestId) {
|
|
17
|
+
const reader = body.pipeThrough(new TextDecoderStream()).getReader();
|
|
18
|
+
let rest = "";
|
|
19
|
+
try {
|
|
20
|
+
for (;;) {
|
|
21
|
+
const { done, value } = await reader.read();
|
|
22
|
+
const lines = (rest + (value ?? "")).split("\n");
|
|
23
|
+
rest = done ? "" : lines.pop();
|
|
24
|
+
for (const line of lines) {
|
|
25
|
+
if (line.trim())
|
|
26
|
+
yield parsed(line, 200, requestId);
|
|
27
|
+
}
|
|
28
|
+
if (done)
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
await reader.cancel().catch(() => {
|
|
34
|
+
// swallowed: a body already ended or failed has nothing to cancel.
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** What `fetch` the client calls: the global one, or the caller's. */
|
|
2
|
+
export type Fetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
3
|
+
/** Options any call takes, in its last argument. */
|
|
4
|
+
export interface RequestOptions {
|
|
5
|
+
/** Per attempt; the client's `timeoutMs` (30 s) by default. */
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
/** Aborts the call, its retries and their waits. */
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
/** The client's `maxRetries` (2) by default; `0` sends once. */
|
|
10
|
+
maxRetries?: number;
|
|
11
|
+
/** Sent as `X-Request-Id` on every attempt; one is made when absent. */
|
|
12
|
+
requestId?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface TransportConfig {
|
|
15
|
+
apiKey: string;
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
timeoutMs: number;
|
|
18
|
+
maxRetries: number;
|
|
19
|
+
fetch: Fetch;
|
|
20
|
+
}
|
|
21
|
+
export type Query = Record<string, string | number | boolean | null | undefined>;
|
|
22
|
+
export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
23
|
+
/** One call, as a method describes it. */
|
|
24
|
+
export interface Call {
|
|
25
|
+
method: Method;
|
|
26
|
+
/** With its parameters already encoded (`segment`); or a whole URL. */
|
|
27
|
+
path: string;
|
|
28
|
+
query?: Query;
|
|
29
|
+
/** A JSON body. */
|
|
30
|
+
body?: unknown;
|
|
31
|
+
/** A body sent as it is, with `type` as its `Content-Type`. */
|
|
32
|
+
raw?: BodyInit;
|
|
33
|
+
type?: string;
|
|
34
|
+
headers?: Record<string, string>;
|
|
35
|
+
/** The answer's bytes, or its body unread; JSON by default. */
|
|
36
|
+
read?: "bytes" | "stream";
|
|
37
|
+
/** No credential: the open routes, and object storage's signed URLs. */
|
|
38
|
+
open?: boolean;
|
|
39
|
+
/** A POST that may be sent twice with the same effect. */
|
|
40
|
+
idempotent?: boolean;
|
|
41
|
+
/** Another bearer for this call: an end user's ephemeral key. */
|
|
42
|
+
token?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface Answer<T> {
|
|
45
|
+
data: T;
|
|
46
|
+
status: number;
|
|
47
|
+
headers: Headers;
|
|
48
|
+
requestId: string;
|
|
49
|
+
}
|
|
50
|
+
/** The longest `Retry-After` slept through; past it the 429 is thrown. */
|
|
51
|
+
export declare const MAX_RETRY_AFTER_S = 60;
|
|
52
|
+
/** A path parameter or a query's value, encoded: an `external_id` may hold `/` or `?`. */
|
|
53
|
+
export declare const segment: (value: string | number | boolean) => string;
|
|
54
|
+
/**
|
|
55
|
+
* A request id of the API's own shape: 16 hex. From `Math.random`, not
|
|
56
|
+
* `crypto`, which React Native lacks (this file is the client SDK's too):
|
|
57
|
+
* an id names a call in the logs and is no secret.
|
|
58
|
+
*/
|
|
59
|
+
export declare const newRequestId: () => string;
|
|
60
|
+
/**
|
|
61
|
+
* How long to wait before retry `attempt` (1, 2, …): the doubling backoff
|
|
62
|
+
* less up to a quarter, or `Retry-After`'s seconds when the API sent one.
|
|
63
|
+
*/
|
|
64
|
+
export declare function retryDelayMs(attempt: number, retryAfter: number | null, random?: () => number): number;
|
|
65
|
+
/** Waits `ms`, or rejects with the signal's reason when it aborts first. */
|
|
66
|
+
export declare function sleep(ms: number, signal: AbortSignal | undefined): Promise<void>;
|
|
67
|
+
/** A 2xx body: JSON, or nothing. Anything else is not the API answering. */
|
|
68
|
+
export declare function parsed<T>(text: string, status: number, requestId: string): T;
|
|
69
|
+
/** `promise`'s value, or `null` when it is a 404: what is not there is done. */
|
|
70
|
+
export declare const orNull: <T>(promise: Promise<T>) => Promise<T | null>;
|
|
71
|
+
export declare class Transport {
|
|
72
|
+
private readonly config;
|
|
73
|
+
constructor(config: TransportConfig);
|
|
74
|
+
/** A JSON call's answer: `data` is a GET's query and any other's body. */
|
|
75
|
+
send<T>(method: Method, path: string, options?: RequestOptions, data?: unknown): Promise<T>;
|
|
76
|
+
request<T>(call: Call, options?: RequestOptions): Promise<Answer<T>>;
|
|
77
|
+
private url;
|
|
78
|
+
/**
|
|
79
|
+
* One send and its body read, under this attempt's timer — which stops
|
|
80
|
+
* at the headers of a stream the caller reads (and of nothing else).
|
|
81
|
+
*/
|
|
82
|
+
private attempt;
|
|
83
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One HTTP call to the API, with everything every method shares: the
|
|
3
|
+
* credential, the request id, the timeout, the retries and the errors.
|
|
4
|
+
*
|
|
5
|
+
* - **One request id per call** (16 hex, as the API mints its own), sent
|
|
6
|
+
* as `X-Request-Id` and **reused on every retry**: the API keeps a valid
|
|
7
|
+
* one (`apps/api/src/http/request-id.ts`), so every attempt of a call
|
|
8
|
+
* shares one id in Workers Logs and in the error the caller sees.
|
|
9
|
+
* - **The timeout is per attempt**, a timer joined with the caller's
|
|
10
|
+
* `signal`, and it covers reading the body — **except a stream's**,
|
|
11
|
+
* whose timer stops at the headers: a transcript streamed for minutes
|
|
12
|
+
* is not a call that timed out. The caller's abort is theirs: it is
|
|
13
|
+
* thrown as their reason and never retried, and it still ends a stream.
|
|
14
|
+
* - **Not every body is JSON**: speech is bytes or a stream, `/v1/stt`
|
|
15
|
+
* takes the audio raw, and an upload's `PUT` goes to object storage's
|
|
16
|
+
* own URL with no credential (`open`) — the same attempts, retries and
|
|
17
|
+
* errors. A body that is a stream is sent once: it cannot be read twice.
|
|
18
|
+
* - **What is retried is what cannot have done anything twice.** GET, PUT,
|
|
19
|
+
* PATCH and DELETE: a connection that failed, a timeout, 429, 502, 503,
|
|
20
|
+
* 504. A POST: only a 429, which the API answers at admission before any
|
|
21
|
+
* work — unless the method says the POST is idempotent
|
|
22
|
+
* (`POST /v1/end-users` with an `external_id`). Never a 500: whatever
|
|
23
|
+
* failed may have half-happened.
|
|
24
|
+
* - **The wait** doubles from 0.5 s to 8 s, less up to a quarter at random
|
|
25
|
+
* so callers refused together do not return together; `Retry-After`
|
|
26
|
+
* wins, up to a minute. A longer one is thrown at once as a
|
|
27
|
+
* `RateLimitError` with its `retryAfter`: a call that sleeps for an hour
|
|
28
|
+
* is not one a caller can reason about.
|
|
29
|
+
* - **The client SDK runs this file too** (`sdks/client/src/transport.ts`,
|
|
30
|
+
* a copy its `test/copies.test.ts` holds identical), on React Native's
|
|
31
|
+
* Hermes as well: so no `crypto`, `URLSearchParams`, `AbortSignal.any`
|
|
32
|
+
* or global `ReadableStream` taken for granted, each of which it lacks.
|
|
33
|
+
*/
|
|
34
|
+
import { ConnectionError, errorFromResponse, NotFoundError, PlatformError, TimeoutError, } from "./errors.js";
|
|
35
|
+
const RETRIED_STATUS = new Set([429, 502, 503, 504]);
|
|
36
|
+
const BASE_DELAY_MS = 500;
|
|
37
|
+
const MAX_DELAY_MS = 8_000;
|
|
38
|
+
/** The longest `Retry-After` slept through; past it the 429 is thrown. */
|
|
39
|
+
export const MAX_RETRY_AFTER_S = 60;
|
|
40
|
+
/** A path parameter or a query's value, encoded: an `external_id` may hold `/` or `?`. */
|
|
41
|
+
export const segment = (value) => encodeURIComponent(value);
|
|
42
|
+
/**
|
|
43
|
+
* A request id of the API's own shape: 16 hex. From `Math.random`, not
|
|
44
|
+
* `crypto`, which React Native lacks (this file is the client SDK's too):
|
|
45
|
+
* an id names a call in the logs and is no secret.
|
|
46
|
+
*/
|
|
47
|
+
export const newRequestId = () => "x".repeat(16).replace(/x/g, () => ((Math.random() * 16) | 0).toString(16));
|
|
48
|
+
/**
|
|
49
|
+
* How long to wait before retry `attempt` (1, 2, …): the doubling backoff
|
|
50
|
+
* less up to a quarter, or `Retry-After`'s seconds when the API sent one.
|
|
51
|
+
*/
|
|
52
|
+
export function retryDelayMs(attempt, retryAfter, random = Math.random) {
|
|
53
|
+
if (retryAfter !== null)
|
|
54
|
+
return Math.ceil(retryAfter * 1000);
|
|
55
|
+
const full = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** (attempt - 1));
|
|
56
|
+
return Math.round(full * (1 - 0.25 * random()));
|
|
57
|
+
}
|
|
58
|
+
/** Waits `ms`, or rejects with the signal's reason when it aborts first. */
|
|
59
|
+
export function sleep(ms, signal) {
|
|
60
|
+
if (ms <= 0)
|
|
61
|
+
return Promise.resolve();
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
if (signal?.aborted)
|
|
64
|
+
return reject(signal.reason);
|
|
65
|
+
const done = () => {
|
|
66
|
+
signal?.removeEventListener("abort", stop);
|
|
67
|
+
resolve();
|
|
68
|
+
};
|
|
69
|
+
const timer = setTimeout(done, ms);
|
|
70
|
+
const stop = () => {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
reject(signal.reason);
|
|
73
|
+
};
|
|
74
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** A 2xx body: JSON, or nothing. Anything else is not the API answering. */
|
|
78
|
+
export function parsed(text, status, requestId) {
|
|
79
|
+
if (!text)
|
|
80
|
+
return undefined;
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(text);
|
|
83
|
+
}
|
|
84
|
+
catch (cause) {
|
|
85
|
+
throw new PlatformError({
|
|
86
|
+
status,
|
|
87
|
+
type: "invalid_response",
|
|
88
|
+
message: `HTTP ${status} with a body that is not JSON.`,
|
|
89
|
+
requestId,
|
|
90
|
+
cause,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** `promise`'s value, or `null` when it is a 404: what is not there is done. */
|
|
95
|
+
export const orNull = (promise) => promise.catch((error) => {
|
|
96
|
+
if (error instanceof NotFoundError)
|
|
97
|
+
return null;
|
|
98
|
+
throw error;
|
|
99
|
+
});
|
|
100
|
+
export class Transport {
|
|
101
|
+
constructor(config) {
|
|
102
|
+
this.config = config;
|
|
103
|
+
}
|
|
104
|
+
/** A JSON call's answer: `data` is a GET's query and any other's body. */
|
|
105
|
+
send(method, path, options, data) {
|
|
106
|
+
return this.request(method === "GET"
|
|
107
|
+
? { method, path, query: data }
|
|
108
|
+
: { method, path, body: data }, options).then((answer) => answer.data);
|
|
109
|
+
}
|
|
110
|
+
async request(call, options = {}) {
|
|
111
|
+
const requestId = options.requestId ?? newRequestId();
|
|
112
|
+
// A stream by its shape: not every runtime has a global `ReadableStream`.
|
|
113
|
+
const once = "getReader" in Object(call.raw);
|
|
114
|
+
const maxRetries = once
|
|
115
|
+
? 0
|
|
116
|
+
: (options.maxRetries ?? this.config.maxRetries);
|
|
117
|
+
const retriesAll = call.method !== "POST" || call.idempotent === true;
|
|
118
|
+
const url = this.url(call);
|
|
119
|
+
const headers = {
|
|
120
|
+
Accept: call.read ? "*/*" : "application/json",
|
|
121
|
+
"X-Request-Id": requestId,
|
|
122
|
+
...call.headers,
|
|
123
|
+
};
|
|
124
|
+
if (!call.open) {
|
|
125
|
+
headers.Authorization = `Bearer ${call.token ?? this.config.apiKey}`;
|
|
126
|
+
}
|
|
127
|
+
let body = call.raw;
|
|
128
|
+
if (call.type)
|
|
129
|
+
headers["Content-Type"] = call.type;
|
|
130
|
+
if (call.body !== undefined) {
|
|
131
|
+
headers["Content-Type"] = "application/json";
|
|
132
|
+
body = JSON.stringify(call.body);
|
|
133
|
+
}
|
|
134
|
+
const init = { method: call.method, headers, body };
|
|
135
|
+
// Node's fetch sends a stream only when told it is one-way.
|
|
136
|
+
if (once)
|
|
137
|
+
Object.assign(init, { duplex: "half" });
|
|
138
|
+
for (let attempt = 0;; attempt++) {
|
|
139
|
+
const outcome = await this.attempt(url, init, call.read, options);
|
|
140
|
+
const last = attempt >= maxRetries;
|
|
141
|
+
if (outcome.kind === "response") {
|
|
142
|
+
const { response, body: read } = outcome;
|
|
143
|
+
if (response.ok) {
|
|
144
|
+
return {
|
|
145
|
+
data: call.read
|
|
146
|
+
? read
|
|
147
|
+
: parsed(read, response.status, requestId),
|
|
148
|
+
status: response.status,
|
|
149
|
+
headers: response.headers,
|
|
150
|
+
requestId,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const error = errorFromResponse(response.status, response.headers, read, requestId);
|
|
154
|
+
const retried = response.status === 429 ||
|
|
155
|
+
(retriesAll && RETRIED_STATUS.has(response.status));
|
|
156
|
+
if (last || !retried || (error.retryAfter ?? 0) > MAX_RETRY_AFTER_S) {
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
await sleep(retryDelayMs(attempt + 1, error.retryAfter), options.signal);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (last || !retriesAll) {
|
|
163
|
+
throw outcome.kind === "timeout"
|
|
164
|
+
? new TimeoutError({
|
|
165
|
+
status: 0,
|
|
166
|
+
type: "timeout",
|
|
167
|
+
message: "The API did not answer in time.",
|
|
168
|
+
requestId,
|
|
169
|
+
})
|
|
170
|
+
: new ConnectionError({
|
|
171
|
+
status: 0,
|
|
172
|
+
type: "connection_error",
|
|
173
|
+
message: "Could not reach the API.",
|
|
174
|
+
requestId,
|
|
175
|
+
cause: outcome.error,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
await sleep(retryDelayMs(attempt + 1, null), options.signal);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
url(call) {
|
|
182
|
+
// By hand: React Native's `URLSearchParams` is partial.
|
|
183
|
+
let query = "";
|
|
184
|
+
for (const [key, value] of Object.entries(call.query ?? {})) {
|
|
185
|
+
if (value != null) {
|
|
186
|
+
query += `${query ? "&" : "?"}${key}=${segment(value)}`;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const base = /^https?:/.test(call.path) ? "" : this.config.baseUrl;
|
|
190
|
+
return base + call.path + query;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* One send and its body read, under this attempt's timer — which stops
|
|
194
|
+
* at the headers of a stream the caller reads (and of nothing else).
|
|
195
|
+
*/
|
|
196
|
+
async attempt(url, init, read, options) {
|
|
197
|
+
// The caller's signal joined to the timer by hand, its reason carried
|
|
198
|
+
// over: `AbortSignal.any` is not React Native's. Left joined for a
|
|
199
|
+
// stream, whose body the caller's abort still ends.
|
|
200
|
+
const { signal } = options;
|
|
201
|
+
const timer = new AbortController();
|
|
202
|
+
const stop = () => timer.abort(signal?.reason);
|
|
203
|
+
const clock = setTimeout(stop, options.timeoutMs ?? this.config.timeoutMs);
|
|
204
|
+
signal?.addEventListener("abort", stop);
|
|
205
|
+
// Already aborted: no event will come, and `fetch` sends nothing.
|
|
206
|
+
if (signal?.aborted)
|
|
207
|
+
stop();
|
|
208
|
+
try {
|
|
209
|
+
const response = await this.config.fetch(url, {
|
|
210
|
+
...init,
|
|
211
|
+
signal: timer.signal,
|
|
212
|
+
});
|
|
213
|
+
const body = !response.ok
|
|
214
|
+
? await response.text()
|
|
215
|
+
: read === "stream"
|
|
216
|
+
? response.body
|
|
217
|
+
: read === "bytes"
|
|
218
|
+
? await response.arrayBuffer()
|
|
219
|
+
: await response.text();
|
|
220
|
+
return { kind: "response", response, body };
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
// The caller's abort is the caller's, whatever else happened.
|
|
224
|
+
if (signal?.aborted)
|
|
225
|
+
throw signal.reason;
|
|
226
|
+
if (timer.signal.aborted)
|
|
227
|
+
return { kind: "timeout" };
|
|
228
|
+
return { kind: "network", error };
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
clearTimeout(clock);
|
|
232
|
+
if (read !== "stream")
|
|
233
|
+
signal?.removeEventListener("abort", stop);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|