abysshub 0.0.0-stage → 0.0.1-dev.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 +143 -2
- package/dist/client.d.ts +76 -0
- package/dist/client.js +359 -0
- package/dist/error.d.ts +26 -0
- package/dist/error.js +23 -0
- package/dist/files.d.ts +23 -0
- package/dist/files.js +90 -0
- package/dist/http.d.ts +19 -0
- package/dist/http.js +57 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/node.d.ts +16 -0
- package/dist/node.js +11 -0
- package/dist/run.d.ts +33 -0
- package/dist/run.js +96 -0
- package/dist/types.d.ts +73 -0
- package/dist/types.js +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +37 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Abyss
|
|
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,144 @@
|
|
|
1
|
-
#
|
|
1
|
+
# abysshub
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The official JavaScript library for the [Abyss](https://abysshub.com) API.
|
|
4
|
+
|
|
5
|
+
Pre-release: the Abyss API opens soon.
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { Abyss, AbyssError } from "abysshub";
|
|
9
|
+
|
|
10
|
+
const abyss = new Abyss(); // reads ABYSS_API_KEY
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const run = await abyss.run("widget_131", { amount: 250000 });
|
|
14
|
+
console.log(run.result);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (error instanceof AbyssError) console.error(error.code, error.param, error.request_id, error.run);
|
|
17
|
+
else throw error;
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`run()` presses the Widget and waits for the run to end, however long it takes, then returns the succeeded run. A refusal or a failed run raises `AbyssError`.
|
|
22
|
+
|
|
23
|
+
It runs on Node 20+, Bun and Deno, with no dependencies. An API key is a server-side secret, so in a browser `new Abyss()` throws.
|
|
24
|
+
|
|
25
|
+
## The client
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
const abyss = new Abyss({ apiKey, baseURL, maxRetries, timeout });
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- **`apiKey`:** defaults to `ABYSS_API_KEY`. Without a key, `new Abyss()` raises `AbyssError` with `code: "invalid_api_key"`.
|
|
32
|
+
- **`baseURL`:** defaults to `ABYSS_BASE_URL`, else `https://api.abysshub.com`. A key starting `abyss_sk_dev_` goes to `https://api.dev.abysshub.com`.
|
|
33
|
+
- **`maxRetries`:** how many times a request is sent again (see [Retries](#retries)). Defaults to 2.
|
|
34
|
+
- **`timeout`:** the seconds every call waits at most, unless it sets its own (see [Timeouts](#timeouts)). None by default.
|
|
35
|
+
|
|
36
|
+
Every request sends `Authorization: Bearer <key>` and `User-Agent: abysshub-js/<version> (<runtime> <version>)`.
|
|
37
|
+
|
|
38
|
+
## Running a Widget
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
const run = await abyss.run(widget, input, { maxPrice, idempotencyKey, timeout, signal });
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- **`widget`** is `widget_<id>`, or an unlisted Widget's private token.
|
|
45
|
+
- **`input`** is the Widget's Fields by name (`GET /v1/widgets/{widget}` lists them as `input_schema`). A file Field takes a file (see [Files](#files)).
|
|
46
|
+
- **`maxPrice`:** the most the run may cost, in Byssium. A higher price is refused with `price_above_max`, and nothing is charged.
|
|
47
|
+
- **`idempotencyKey`:** each call presses with a fresh UUID `Idempotency-Key` unless you give one. Retries send the same one, so a run is never made or charged twice.
|
|
48
|
+
|
|
49
|
+
The press holds until the run ends. When the hold ends early (a deploy cuts it, the API's holds are all taken, or an hour passes), `run()` re-attaches with `GET <Location>?wait=true` until the run ends. Re-attaching has no limit and is not a retry. Connecting has 10 s, and 30 s of silence counts as a cut.
|
|
50
|
+
|
|
51
|
+
A **run** has `/v1`'s fields: `id`, `widget`, `status` (`queued`, `running`, `succeeded` or `failed`), `price`, `result`, `output_files`, `error`, `created_at`, `started_at` and `ended_at`.
|
|
52
|
+
|
|
53
|
+
## The routes
|
|
54
|
+
|
|
55
|
+
Under `run()` there is one method per route. Each takes `timeout` and `signal` in its last argument.
|
|
56
|
+
|
|
57
|
+
| Method | Route | Answers |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `abyss.runs.create(widget, input, { maxPrice?, idempotencyKey? })` | `POST /v1/widgets/{widget}/runs` with `"wait": false` | the run, `queued`, at once |
|
|
60
|
+
| `abyss.runs.get(id, { wait? })` | `GET /v1/runs/{id}`, with `?wait=true` when `wait` | the run |
|
|
61
|
+
| `abyss.runs.list({ limit?, startingAfter? })` | `GET /v1/runs?limit=&starting_after=` | `{ data, has_more }`, newest first |
|
|
62
|
+
| `abyss.widgets.get(widget)` | `GET /v1/widgets/{widget}` | `{ id, name, description, url, price, free_runs, input_schema }` |
|
|
63
|
+
| `abyss.uploads.create(widget, { field, filename, size? })` | `POST /v1/widgets/{widget}/uploads` | `{ id, upload: { url, fields } }` |
|
|
64
|
+
| `abyss.key()` | `GET /v1/key` | `{ name, key, user, spend_cap, spent_this_month, created_at }` |
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
const queued = await abyss.runs.create("widget_131", { amount: 250000 });
|
|
68
|
+
// … later, or in another process:
|
|
69
|
+
const run = await abyss.runs.get(queued.id, { wait: true });
|
|
70
|
+
|
|
71
|
+
let page = await abyss.runs.list({ limit: 100 });
|
|
72
|
+
while (page.has_more) page = await abyss.runs.list({ limit: 100, startingAfter: page.data.at(-1).id });
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
- **`runs.create()`** uploads the input's files like `run()`, then answers without waiting.
|
|
76
|
+
- **`runs.get()`** answers the run as it is, failed or not: only `run()` raises a failed run. With `wait`, it holds until the run ends, reading again when the hold is cut. A hold answers after an hour, or at once when the API's holds are all taken, so read again while `status` is `queued` or `running`.
|
|
77
|
+
- **`runs.list()`** answers each of `data` as a run, with `save()` and `output_files`.
|
|
78
|
+
- **`uploads.create()`** only asks for the grant. To upload by hand, POST every `fields` entry, then the file as `file`, to `url`, and put `id` in the input.
|
|
79
|
+
|
|
80
|
+
## Files
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
import { Abyss, file } from "abysshub";
|
|
84
|
+
|
|
85
|
+
const run = await abyss.run("widget_1660", {
|
|
86
|
+
report: file("report.pdf"),
|
|
87
|
+
data: [file("jan.csv"), new File([csv], "feb.csv"), "https://example.com/sales/mar.csv"],
|
|
88
|
+
});
|
|
89
|
+
const paths = await run.save("output"); // ["output/charts/a.png", "output/output.json"]
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- **`file(path)`** is a file on disk, read when it is uploaded. A file Field also takes a `Blob` or `File`, a `Buffer` or `Uint8Array`, or a stream (a web `ReadableStream`, or any async iterable such as `fs.createReadStream()`). `file(data, { filename })` names data. A file without a name uploads under its Field's name.
|
|
93
|
+
- **A string passes through** as an `https` URL or an upload id. A multi-file Field takes a list, which may mix all three kinds.
|
|
94
|
+
- **Before the press,** each file is uploaded with `POST /v1/widgets/{widget}/uploads`, then sent to storage, in parallel, and its id goes in the input. An upload refusal (`held_input_*`) raises before any press. A retried press reuses the ids; it never uploads again.
|
|
95
|
+
- **`await run.save(dir)`** writes every output file under `dir` at its `path`, making folders, and answers the paths it wrote.
|
|
96
|
+
- **Each of `run.output_files`** has `await f.save(path)` and `await f.read()`, which answers a `Uint8Array`. An expired URL is refreshed once, by reading the run again with `GET /v1/runs/{id}`.
|
|
97
|
+
|
|
98
|
+
## Errors
|
|
99
|
+
|
|
100
|
+
A refusal, a failed run, a timeout and a lost connection all raise one class, `AbyssError`. There is no class per status: branch on `code`.
|
|
101
|
+
|
|
102
|
+
| Field | |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `code` | `/v1`'s error code, a failed run's `error.code`, or a library-only code below |
|
|
105
|
+
| `message` | what to do, for a person |
|
|
106
|
+
| `status` | the HTTP status of a refusal, else `null` |
|
|
107
|
+
| `param` | the Field, body key, query key or header at fault, else `null` |
|
|
108
|
+
| `doc_url` | the code on the API access page, else `null` |
|
|
109
|
+
| `request_id` | quote it to support, else `null` |
|
|
110
|
+
| `run` | the run, for a failed run or a timeout, else `null` |
|
|
111
|
+
|
|
112
|
+
- **Refusals:** `invalid_api_key` (401), `not_found` (404), `invalid_request`, `invalid_input`, `held_input_*`, `input_unreachable` and `idempotency_key_reused` (422), `insufficient_funds` (402), `spend_cap_reached` and `price_above_max` (400), `idempotency_key_in_use` (409), `rate_limited` (429), `server_error` (500).
|
|
113
|
+
- **A failed run:** `widget_fault`, `platform_fault` or `budget_exceeded`, with the run as `run`.
|
|
114
|
+
- **Library-only codes:** `timeout` (see [Timeouts](#timeouts)); `connection_error`, when the connection still drops before any answer after the retries, with the cause as `cause`; and `unexpected_response`, when an answer is not what the route sends.
|
|
115
|
+
|
|
116
|
+
## Retries
|
|
117
|
+
|
|
118
|
+
A request is sent again, at most `maxRetries` times (2 by default), only after:
|
|
119
|
+
|
|
120
|
+
- **a drop before any headers arrived:** a short backoff, then the same request, with the same `Idempotency-Key` for a press;
|
|
121
|
+
- **`429 rate_limited` and `409 idempotency_key_in_use`:** after the `Retry-After` seconds;
|
|
122
|
+
- **a `5xx`:** a short backoff (0.5 s, then 1 s).
|
|
123
|
+
|
|
124
|
+
When the last try fails too, it raises. Every other refusal raises at once, and re-attaching never counts as a retry.
|
|
125
|
+
|
|
126
|
+
## Timeouts
|
|
127
|
+
|
|
128
|
+
There is no timeout by default: `run()` waits as long as the run takes. `timeout` (seconds) and `signal` (an `AbortSignal`) stop the waiting, never the run.
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
try {
|
|
132
|
+
const run = await abyss.run("widget_131", input, { timeout: 60, signal: AbortSignal.timeout(90_000) });
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error instanceof AbyssError && error.code === "timeout") {
|
|
135
|
+
// The run goes on. error.run is the run as last seen, or null when none was seen yet.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Either one raises `AbyssError` with `code: "timeout"` and `run`, the run as last seen. During the press's first hold the run has not been seen yet, so `run` is `null`; to keep its `id` whatever happens, press with `runs.create()` and wait with `runs.get(id, { wait: true, timeout })`.
|
|
141
|
+
|
|
142
|
+
## Names
|
|
143
|
+
|
|
144
|
+
Returned data keeps `/v1`'s names: `output_files`, `has_more`, `request_id`. The library's own options are camelCase: `maxPrice`, `startingAfter`. A Field's name is never converted: `input` is sent as you write it.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Run } from "./run.js";
|
|
2
|
+
import type { Input, Key, RunList, Upload, Widget } from "./types.js";
|
|
3
|
+
export interface AbyssOptions {
|
|
4
|
+
/** Defaults to `ABYSS_API_KEY`. */
|
|
5
|
+
apiKey?: string;
|
|
6
|
+
/** Defaults to `ABYSS_BASE_URL`, else the address the key belongs to. */
|
|
7
|
+
baseURL?: string;
|
|
8
|
+
/** How many times a request is sent again after a drop before any headers, a `429`, a `409` or a `5xx`. Defaults to 2. */
|
|
9
|
+
maxRetries?: number;
|
|
10
|
+
/** The seconds every call waits at most, unless it sets its own. None by default. */
|
|
11
|
+
timeout?: number;
|
|
12
|
+
}
|
|
13
|
+
/** What every call takes. Stopping the waiting never stops the run. */
|
|
14
|
+
export interface CallOptions {
|
|
15
|
+
/** The seconds to wait at most, then raise `AbyssError` with `code: "timeout"`. */
|
|
16
|
+
timeout?: number;
|
|
17
|
+
/** Aborting it stops the waiting, as `timeout` does. */
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
export interface RunOptions extends CallOptions {
|
|
21
|
+
/** The most this run may cost, in Byssium. */
|
|
22
|
+
maxPrice?: number;
|
|
23
|
+
/** Defaults to a fresh UUID for each call. */
|
|
24
|
+
idempotencyKey?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface GetRunOptions extends CallOptions {
|
|
27
|
+
/** Holds until the run ends, as the press holds. */
|
|
28
|
+
wait?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface ListRunsOptions extends CallOptions {
|
|
31
|
+
/** How many runs to answer, 1 to 100; the API's default is 20. */
|
|
32
|
+
limit?: number;
|
|
33
|
+
/** The `id` of the last run on the page you have. */
|
|
34
|
+
startingAfter?: string;
|
|
35
|
+
}
|
|
36
|
+
/** What `uploads.create` asks for: the file Field, the file's name and its size in bytes. */
|
|
37
|
+
export interface UploadRequest {
|
|
38
|
+
field: string;
|
|
39
|
+
filename: string;
|
|
40
|
+
size?: number;
|
|
41
|
+
}
|
|
42
|
+
/** `/v1`'s run routes, one method each. */
|
|
43
|
+
export interface Runs {
|
|
44
|
+
/** Uploads the input's files and presses with `"wait": false`: answers the run at once, `queued`. */
|
|
45
|
+
create(widget: string, input: Input, options?: RunOptions): Promise<Run>;
|
|
46
|
+
/** Reads a run as it is, or with `wait` holds until it ends. A failed run is answered, not raised. */
|
|
47
|
+
get(id: string, options?: GetRunOptions): Promise<Run>;
|
|
48
|
+
/** Answers a page of runs, newest first. */
|
|
49
|
+
list(options?: ListRunsOptions): Promise<RunList<Run>>;
|
|
50
|
+
}
|
|
51
|
+
export interface Widgets {
|
|
52
|
+
/** Reads a Widget: its price, Free Runs and `input_schema`. */
|
|
53
|
+
get(widget: string, options?: CallOptions): Promise<Widget>;
|
|
54
|
+
}
|
|
55
|
+
export interface Uploads {
|
|
56
|
+
/** Asks for an upload's grant. `run()` and `runs.create()` upload files by themselves. */
|
|
57
|
+
create(widget: string, upload: UploadRequest, options?: CallOptions): Promise<Upload>;
|
|
58
|
+
}
|
|
59
|
+
export declare class Abyss {
|
|
60
|
+
#private;
|
|
61
|
+
readonly baseURL: string;
|
|
62
|
+
readonly maxRetries: number;
|
|
63
|
+
readonly timeout: number | null;
|
|
64
|
+
readonly runs: Runs;
|
|
65
|
+
readonly widgets: Widgets;
|
|
66
|
+
readonly uploads: Uploads;
|
|
67
|
+
constructor(options?: AbyssOptions);
|
|
68
|
+
/**
|
|
69
|
+
* Uploads the input's files, presses a Widget and waits for the run's ending,
|
|
70
|
+
* re-attaching through `Location` whenever the hold ends early. Returns the succeeded
|
|
71
|
+
* run; raises `AbyssError` on a refusal or a failed run.
|
|
72
|
+
*/
|
|
73
|
+
run(widget: string, input: Input, options?: RunOptions): Promise<Run>;
|
|
74
|
+
/** Answers the API Key making the call, with `GET /v1/key`. */
|
|
75
|
+
key(options?: CallOptions): Promise<Key>;
|
|
76
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { AbyssError } from "./error.js";
|
|
2
|
+
import { uploadFiles } from "./files.js";
|
|
3
|
+
import { request } from "./http.js";
|
|
4
|
+
import { Run } from "./run.js";
|
|
5
|
+
import { VERSION } from "./version.js";
|
|
6
|
+
const API = "https://api.abysshub.com";
|
|
7
|
+
const DEV_API = "https://api.dev.abysshub.com";
|
|
8
|
+
const DEV_KEY_PREFIX = "abyss_sk_dev_";
|
|
9
|
+
/** The pause between two re-attaches that both ended without the run's ending. */
|
|
10
|
+
const REATTACH_PAUSE_MS = 1_000;
|
|
11
|
+
/** The first pause before a retry after a drop or a 5xx; it doubles with each retry. */
|
|
12
|
+
const RETRY_PAUSE_MS = 500;
|
|
13
|
+
const BROWSER_REFUSAL = "An Abyss API key is a server-side secret. Call the API from your server.";
|
|
14
|
+
const USER_AGENT = `abysshub-js/${VERSION} (${runtime()})`;
|
|
15
|
+
export class Abyss {
|
|
16
|
+
baseURL;
|
|
17
|
+
maxRetries;
|
|
18
|
+
timeout;
|
|
19
|
+
runs;
|
|
20
|
+
widgets;
|
|
21
|
+
uploads;
|
|
22
|
+
#apiKey;
|
|
23
|
+
constructor(options = {}) {
|
|
24
|
+
if (inBrowser())
|
|
25
|
+
throw new Error(BROWSER_REFUSAL);
|
|
26
|
+
const apiKey = options.apiKey ?? env("ABYSS_API_KEY");
|
|
27
|
+
if (!apiKey) {
|
|
28
|
+
throw new AbyssError({
|
|
29
|
+
code: "invalid_api_key",
|
|
30
|
+
message: "No Abyss API key: pass apiKey, or set ABYSS_API_KEY.",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
this.#apiKey = apiKey;
|
|
34
|
+
const baseURL = options.baseURL ?? env("ABYSS_BASE_URL") ?? (apiKey.startsWith(DEV_KEY_PREFIX) ? DEV_API : API);
|
|
35
|
+
this.baseURL = baseURL.replace(/\/+$/, "");
|
|
36
|
+
this.maxRetries = options.maxRetries ?? 2;
|
|
37
|
+
this.timeout = options.timeout ?? null;
|
|
38
|
+
this.runs = {
|
|
39
|
+
create: (widget, input, options = {}) => this.#call(options, async (call) => {
|
|
40
|
+
const press = await this.#press(call, widget, input, options, false);
|
|
41
|
+
return this.#run(expect(press, isRun, "The press answered without a run."));
|
|
42
|
+
}),
|
|
43
|
+
get: (id, options = {}) => this.#call(options, async (call) => this.#run(await this.#getRun(call, id, options.wait ?? false))),
|
|
44
|
+
list: (options = {}) => this.#call(options, (call) => this.#listRuns(call, options)),
|
|
45
|
+
};
|
|
46
|
+
this.widgets = {
|
|
47
|
+
get: (widget, options = {}) => this.#call(options, (call) => this.#get(call, this.#widgetURL(widget), isWidget, "The Widget answered without an id.")),
|
|
48
|
+
};
|
|
49
|
+
this.uploads = {
|
|
50
|
+
create: (widget, upload, options = {}) => this.#call(options, (call) => this.#grant(call, this.#widgetURL(widget), upload)),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Uploads the input's files, presses a Widget and waits for the run's ending,
|
|
55
|
+
* re-attaching through `Location` whenever the hold ends early. Returns the succeeded
|
|
56
|
+
* run; raises `AbyssError` on a refusal or a failed run.
|
|
57
|
+
*/
|
|
58
|
+
run(widget, input, options = {}) {
|
|
59
|
+
return this.#call(options, async (call) => {
|
|
60
|
+
const press = await this.#press(call, widget, input, options, true);
|
|
61
|
+
const reply = hasEnded(press) ? press : await this.#hold(call, this.#waitURL(press), hasEnded);
|
|
62
|
+
const run = this.#run(reply.run);
|
|
63
|
+
if (run.status === "failed") {
|
|
64
|
+
throw new AbyssError({
|
|
65
|
+
code: run.error?.code ?? "platform_fault",
|
|
66
|
+
message: run.error?.message ?? "The run failed.",
|
|
67
|
+
request_id: reply.requestId,
|
|
68
|
+
run,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return run;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/** Answers the API Key making the call, with `GET /v1/key`. */
|
|
75
|
+
key(options = {}) {
|
|
76
|
+
return this.#call(options, (call) => this.#get(call, `${this.baseURL}/v1/key`, isKey, "The key answered without a key."));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Runs one call under its `timeout` and `signal`. When either stops the waiting, the
|
|
80
|
+
* call raises `AbyssError` with `code: "timeout"` and the run as last seen.
|
|
81
|
+
*/
|
|
82
|
+
async #call(options, work) {
|
|
83
|
+
const timeout = options.timeout ?? this.timeout;
|
|
84
|
+
const stop = new AbortController();
|
|
85
|
+
let message = "The signal stopped the waiting. The run goes on.";
|
|
86
|
+
const abort = () => stop.abort();
|
|
87
|
+
const timer = timeout === null
|
|
88
|
+
? undefined
|
|
89
|
+
: setTimeout(() => {
|
|
90
|
+
message = `Stopped waiting after ${timeout} s. The run goes on.`;
|
|
91
|
+
abort();
|
|
92
|
+
}, timeout * 1_000);
|
|
93
|
+
if (options.signal?.aborted)
|
|
94
|
+
abort();
|
|
95
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
96
|
+
const call = { signal: stop.signal, seen: null };
|
|
97
|
+
try {
|
|
98
|
+
return await work(call);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (!stop.signal.aborted)
|
|
102
|
+
throw error;
|
|
103
|
+
throw new AbyssError({ code: "timeout", message, run: call.seen && this.#run(call.seen) });
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
options.signal?.removeEventListener("abort", abort);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Uploads the input's files and sends the press, with `"wait": false` unless it holds.
|
|
112
|
+
* Its retries send the same body and `Idempotency-Key`, so the uploads are never redone.
|
|
113
|
+
*/
|
|
114
|
+
async #press(call, widget, input, options, hold) {
|
|
115
|
+
const widgetURL = this.#widgetURL(widget);
|
|
116
|
+
const body = {
|
|
117
|
+
input: await uploadFiles(input, (field, filename, data) => this.#upload(call, widgetURL, field, filename, data)),
|
|
118
|
+
};
|
|
119
|
+
if (options.maxPrice !== undefined)
|
|
120
|
+
body.max_price = options.maxPrice;
|
|
121
|
+
if (!hold)
|
|
122
|
+
body.wait = false;
|
|
123
|
+
return this.#send(call, "POST", `${widgetURL}/runs`, {
|
|
124
|
+
"Content-Type": "application/json",
|
|
125
|
+
"Idempotency-Key": options.idempotencyKey ?? crypto.randomUUID(),
|
|
126
|
+
}, JSON.stringify(body));
|
|
127
|
+
}
|
|
128
|
+
/** Asks for an upload with `POST /v1/widgets/{widget}/uploads`. */
|
|
129
|
+
async #grant(call, widgetURL, { field, filename, size }) {
|
|
130
|
+
// JSON.stringify leaves `size` out when it is undefined.
|
|
131
|
+
const grant = await this.#send(call, "POST", `${widgetURL}/uploads`, { "Content-Type": "application/json" }, JSON.stringify({ field, filename, size }));
|
|
132
|
+
return expect(grant, isUpload, "The upload answered without an id and a form.");
|
|
133
|
+
}
|
|
134
|
+
/** Asks for an upload, sends the file to storage and answers its id. */
|
|
135
|
+
async #upload(call, widgetURL, field, filename, data) {
|
|
136
|
+
const grant = await this.#grant(call, widgetURL, { field, filename, size: data.size });
|
|
137
|
+
const form = new FormData();
|
|
138
|
+
for (const [name, value] of Object.entries(grant.upload.fields))
|
|
139
|
+
form.append(name, value);
|
|
140
|
+
form.append("file", data, filename);
|
|
141
|
+
const stored = await fetch(grant.upload.url, { method: "POST", body: form, signal: call.signal });
|
|
142
|
+
await stored.body?.cancel();
|
|
143
|
+
if (!stored.ok) {
|
|
144
|
+
throw new AbyssError({
|
|
145
|
+
code: "unexpected_response",
|
|
146
|
+
message: `Storage answered ${stored.status} to the upload of ${filename}.`,
|
|
147
|
+
status: stored.status,
|
|
148
|
+
param: field,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return grant.id;
|
|
152
|
+
}
|
|
153
|
+
/** Reads a run with `GET /v1/runs/{id}`, holding with `?wait=true`. A cut hold is read again. */
|
|
154
|
+
async #getRun(call, id, wait) {
|
|
155
|
+
const url = new URL(`${this.baseURL}/v1/runs/${encodeURIComponent(id)}`);
|
|
156
|
+
if (wait)
|
|
157
|
+
url.searchParams.set("wait", "true");
|
|
158
|
+
const reply = await this.#hold(call, url.href, (reply) => !reply.cut);
|
|
159
|
+
return expect(reply, isRun, "The read answered without a run.");
|
|
160
|
+
}
|
|
161
|
+
async #listRuns(call, options) {
|
|
162
|
+
const url = new URL(`${this.baseURL}/v1/runs`);
|
|
163
|
+
if (options.limit !== undefined)
|
|
164
|
+
url.searchParams.set("limit", String(options.limit));
|
|
165
|
+
if (options.startingAfter !== undefined)
|
|
166
|
+
url.searchParams.set("starting_after", options.startingAfter);
|
|
167
|
+
const page = await this.#get(call, url.href, isRunList, "The run list answered without data.");
|
|
168
|
+
return { data: page.data.map((data) => this.#run(data)), has_more: page.has_more };
|
|
169
|
+
}
|
|
170
|
+
/** Reads `url` once and answers its body, when it is what the route answers. */
|
|
171
|
+
async #get(call, url, is, message) {
|
|
172
|
+
return expect(await this.#send(call, "GET", url), is, message);
|
|
173
|
+
}
|
|
174
|
+
/** Reads `url` until `done`. Re-attaching has no limit and is not a retry. */
|
|
175
|
+
async #hold(call, url, done) {
|
|
176
|
+
let reply = await this.#send(call, "GET", url);
|
|
177
|
+
while (!done(reply)) {
|
|
178
|
+
await sleep(REATTACH_PAUSE_MS, call.signal);
|
|
179
|
+
reply = await this.#send(call, "GET", url);
|
|
180
|
+
}
|
|
181
|
+
return reply;
|
|
182
|
+
}
|
|
183
|
+
#run(data) {
|
|
184
|
+
return new Run(data, () => this.#getRun({ signal: null, seen: null }, data.id, false));
|
|
185
|
+
}
|
|
186
|
+
#widgetURL(widget) {
|
|
187
|
+
return `${this.baseURL}/v1/widgets/${encodeURIComponent(widget)}`;
|
|
188
|
+
}
|
|
189
|
+
#waitURL(reply) {
|
|
190
|
+
const location = reply.location ?? (reply.run ? `/v1/runs/${reply.run.id}` : null);
|
|
191
|
+
if (!location) {
|
|
192
|
+
throw new AbyssError({
|
|
193
|
+
code: "unexpected_response",
|
|
194
|
+
message: "The press answered without a Location to read the run at.",
|
|
195
|
+
request_id: reply.requestId,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
const url = new URL(location, `${this.baseURL}/`);
|
|
199
|
+
url.searchParams.set("wait", "true");
|
|
200
|
+
return url.href;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Sends one request to `/v1`. A drop before any headers, a `429`, a `409` and a `5xx`
|
|
204
|
+
* are sent again, at most `maxRetries` times; every other refusal raises at once.
|
|
205
|
+
*/
|
|
206
|
+
async #send(call, method, url, headers = {}, body) {
|
|
207
|
+
const init = {
|
|
208
|
+
method,
|
|
209
|
+
headers: { Authorization: `Bearer ${this.#apiKey}`, Accept: "application/json", "User-Agent": USER_AGENT, ...headers },
|
|
210
|
+
signal: call.signal,
|
|
211
|
+
...(body === undefined ? {} : { body }),
|
|
212
|
+
};
|
|
213
|
+
for (let attempt = 0;; attempt++) {
|
|
214
|
+
let answer;
|
|
215
|
+
try {
|
|
216
|
+
answer = await request(url, init);
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
if (call.signal?.aborted)
|
|
220
|
+
throw error;
|
|
221
|
+
if (attempt >= this.maxRetries) {
|
|
222
|
+
throw new AbyssError({
|
|
223
|
+
code: "connection_error",
|
|
224
|
+
message: "The connection dropped, or timed out, before the API answered.",
|
|
225
|
+
cause: error,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
await sleep(backoff(attempt), call.signal);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const requestId = answer.headers.get("request-id");
|
|
232
|
+
const parsed = parse(answer.text);
|
|
233
|
+
if (answer.status === 200 || answer.status === 202) {
|
|
234
|
+
const run = isRun(parsed) ? parsed : null;
|
|
235
|
+
if (run)
|
|
236
|
+
call.seen = run;
|
|
237
|
+
return { body: parsed, run, cut: answer.text === null, location: answer.headers.get("location"), requestId };
|
|
238
|
+
}
|
|
239
|
+
const pause = retryPause(answer, attempt);
|
|
240
|
+
if (pause === null || attempt >= this.maxRetries)
|
|
241
|
+
throw refusal(answer.status, parsed, requestId);
|
|
242
|
+
await sleep(pause, call.signal);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** How long to wait before sending a refused request again, or null when it is not retried. */
|
|
247
|
+
function retryPause(answer, attempt) {
|
|
248
|
+
if (answer.status === 429 || answer.status === 409) {
|
|
249
|
+
const after = answer.headers.get("retry-after");
|
|
250
|
+
const seconds = after === null ? Number.NaN : Number(after);
|
|
251
|
+
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1_000 : backoff(attempt);
|
|
252
|
+
}
|
|
253
|
+
return answer.status >= 500 ? backoff(attempt) : null;
|
|
254
|
+
}
|
|
255
|
+
function backoff(attempt) {
|
|
256
|
+
return RETRY_PAUSE_MS * 2 ** attempt;
|
|
257
|
+
}
|
|
258
|
+
/** The reply's body when it is what the route answers, else `unexpected_response`. */
|
|
259
|
+
function expect(reply, is, message) {
|
|
260
|
+
if (is(reply.body))
|
|
261
|
+
return reply.body;
|
|
262
|
+
throw new AbyssError({ code: "unexpected_response", message, request_id: reply.requestId });
|
|
263
|
+
}
|
|
264
|
+
function refusal(status, body, requestId) {
|
|
265
|
+
const error = isObject(body) && isObject(body.error) ? body.error : null;
|
|
266
|
+
if (!error || typeof error.code !== "string") {
|
|
267
|
+
return new AbyssError({
|
|
268
|
+
code: "unexpected_response",
|
|
269
|
+
message: `The API answered ${status} without an error body.`,
|
|
270
|
+
status,
|
|
271
|
+
request_id: requestId,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return new AbyssError({
|
|
275
|
+
code: error.code,
|
|
276
|
+
message: typeof error.message === "string" ? error.message : error.code,
|
|
277
|
+
status,
|
|
278
|
+
param: typeof error.param === "string" ? error.param : null,
|
|
279
|
+
doc_url: typeof error.doc_url === "string" ? error.doc_url : null,
|
|
280
|
+
request_id: isObject(body) && typeof body.request_id === "string" ? body.request_id : requestId,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
function parse(text) {
|
|
284
|
+
if (text === null)
|
|
285
|
+
return undefined;
|
|
286
|
+
try {
|
|
287
|
+
return JSON.parse(text);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function isObject(value) {
|
|
294
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
295
|
+
}
|
|
296
|
+
function isRun(value) {
|
|
297
|
+
return isObject(value) && typeof value.id === "string" && typeof value.status === "string";
|
|
298
|
+
}
|
|
299
|
+
function isRunList(value) {
|
|
300
|
+
return isObject(value) && Array.isArray(value.data) && value.data.every(isRun) && typeof value.has_more === "boolean";
|
|
301
|
+
}
|
|
302
|
+
function isWidget(value) {
|
|
303
|
+
return isObject(value) && typeof value.id === "string";
|
|
304
|
+
}
|
|
305
|
+
function isKey(value) {
|
|
306
|
+
return isObject(value) && typeof value.key === "string";
|
|
307
|
+
}
|
|
308
|
+
function isUpload(value) {
|
|
309
|
+
return (isObject(value) &&
|
|
310
|
+
typeof value.id === "string" &&
|
|
311
|
+
isObject(value.upload) &&
|
|
312
|
+
typeof value.upload.url === "string" &&
|
|
313
|
+
isObject(value.upload.fields));
|
|
314
|
+
}
|
|
315
|
+
/** Whether the reply carries a run that has succeeded or failed. */
|
|
316
|
+
function hasEnded(reply) {
|
|
317
|
+
return reply.run !== null && (reply.run.status === "succeeded" || reply.run.status === "failed");
|
|
318
|
+
}
|
|
319
|
+
function env(name) {
|
|
320
|
+
try {
|
|
321
|
+
const { process } = globalThis;
|
|
322
|
+
return process?.env?.[name] || undefined;
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/** Whether this runs in a browser, where an API key would be exposed. */
|
|
329
|
+
function inBrowser() {
|
|
330
|
+
const scope = globalThis;
|
|
331
|
+
return scope.window !== undefined && scope.document !== undefined;
|
|
332
|
+
}
|
|
333
|
+
/** The runtime and its version, for the User-Agent. Bun and Deno also serve `process`, so they are asked first. */
|
|
334
|
+
function runtime() {
|
|
335
|
+
const scope = globalThis;
|
|
336
|
+
if (scope.Bun?.version)
|
|
337
|
+
return `bun ${scope.Bun.version}`;
|
|
338
|
+
if (scope.Deno?.version?.deno)
|
|
339
|
+
return `deno ${scope.Deno.version.deno}`;
|
|
340
|
+
if (scope.process?.versions?.node)
|
|
341
|
+
return `node ${scope.process.versions.node}`;
|
|
342
|
+
return "unknown";
|
|
343
|
+
}
|
|
344
|
+
/** Waits `ms`, or rejects as soon as `signal` aborts. */
|
|
345
|
+
function sleep(ms, signal) {
|
|
346
|
+
return new Promise((resolve, reject) => {
|
|
347
|
+
if (signal?.aborted)
|
|
348
|
+
return reject(signal.reason);
|
|
349
|
+
const stop = () => {
|
|
350
|
+
clearTimeout(timer);
|
|
351
|
+
reject(signal?.reason);
|
|
352
|
+
};
|
|
353
|
+
const timer = setTimeout(() => {
|
|
354
|
+
signal?.removeEventListener("abort", stop);
|
|
355
|
+
resolve();
|
|
356
|
+
}, ms);
|
|
357
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
358
|
+
});
|
|
359
|
+
}
|
package/dist/error.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Run } from "./run.js";
|
|
2
|
+
export interface AbyssErrorFields {
|
|
3
|
+
code: string;
|
|
4
|
+
message: string;
|
|
5
|
+
status?: number | null;
|
|
6
|
+
param?: string | null;
|
|
7
|
+
doc_url?: string | null;
|
|
8
|
+
request_id?: string | null;
|
|
9
|
+
run?: Run | null;
|
|
10
|
+
/** What a `connection_error` was caused by. */
|
|
11
|
+
cause?: unknown;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The one error the library raises: a refusal from `/v1`, or a run that failed.
|
|
15
|
+
* Branch on `code`. A failed run carries the run as `run`.
|
|
16
|
+
*/
|
|
17
|
+
export declare class AbyssError extends Error {
|
|
18
|
+
readonly code: string;
|
|
19
|
+
/** The HTTP status of a refusal; null for a failed run. */
|
|
20
|
+
readonly status: number | null;
|
|
21
|
+
readonly param: string | null;
|
|
22
|
+
readonly doc_url: string | null;
|
|
23
|
+
readonly request_id: string | null;
|
|
24
|
+
readonly run: Run | null;
|
|
25
|
+
constructor(fields: AbyssErrorFields);
|
|
26
|
+
}
|
package/dist/error.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one error the library raises: a refusal from `/v1`, or a run that failed.
|
|
3
|
+
* Branch on `code`. A failed run carries the run as `run`.
|
|
4
|
+
*/
|
|
5
|
+
export class AbyssError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
/** The HTTP status of a refusal; null for a failed run. */
|
|
8
|
+
status;
|
|
9
|
+
param;
|
|
10
|
+
doc_url;
|
|
11
|
+
request_id;
|
|
12
|
+
run;
|
|
13
|
+
constructor(fields) {
|
|
14
|
+
super(fields.message, fields.cause === undefined ? undefined : { cause: fields.cause });
|
|
15
|
+
this.name = "AbyssError";
|
|
16
|
+
this.code = fields.code;
|
|
17
|
+
this.status = fields.status ?? null;
|
|
18
|
+
this.param = fields.param ?? null;
|
|
19
|
+
this.doc_url = fields.doc_url ?? null;
|
|
20
|
+
this.request_id = fields.request_id ?? null;
|
|
21
|
+
this.run = fields.run ?? null;
|
|
22
|
+
}
|
|
23
|
+
}
|
package/dist/files.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Input } from "./types.js";
|
|
2
|
+
/** File data held in memory or streamed: a `Blob` or `File`, a `Buffer` or `Uint8Array`, or a stream. */
|
|
3
|
+
export type FileData = Blob | Uint8Array | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array | string>;
|
|
4
|
+
export interface FileOptions {
|
|
5
|
+
/** The name to upload under. Defaults to the path's or the `File`'s name, else the Field's name. */
|
|
6
|
+
filename?: string;
|
|
7
|
+
}
|
|
8
|
+
/** A file for a file Field: a path, read when it is uploaded, or data. Made by `file()`. */
|
|
9
|
+
export declare class InputFile {
|
|
10
|
+
readonly source: string | FileData;
|
|
11
|
+
readonly filename: string | undefined;
|
|
12
|
+
constructor(source: string | FileData, options?: FileOptions);
|
|
13
|
+
}
|
|
14
|
+
/** A file for a file Field. A string is a path, read when the file is uploaded. */
|
|
15
|
+
export declare function file(source: string | FileData, options?: FileOptions): InputFile;
|
|
16
|
+
/** Uploads one file for a Field and answers the upload's id. */
|
|
17
|
+
export type Uploader = (field: string, filename: string, data: Blob) => Promise<string>;
|
|
18
|
+
/**
|
|
19
|
+
* Uploads every file value in the input, in parallel, and answers the input with each
|
|
20
|
+
* one replaced by its upload's id. Strings pass through as an `https` URL or an upload
|
|
21
|
+
* id, and a list may mix all kinds. The caller's input is never changed.
|
|
22
|
+
*/
|
|
23
|
+
export declare function uploadFiles(input: Input, upload: Uploader): Promise<Input>;
|
package/dist/files.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { nodeFS } from "./node.js";
|
|
2
|
+
/** A file for a file Field: a path, read when it is uploaded, or data. Made by `file()`. */
|
|
3
|
+
export class InputFile {
|
|
4
|
+
source;
|
|
5
|
+
filename;
|
|
6
|
+
constructor(source, options = {}) {
|
|
7
|
+
this.source = source;
|
|
8
|
+
this.filename = options.filename;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** A file for a file Field. A string is a path, read when the file is uploaded. */
|
|
12
|
+
export function file(source, options = {}) {
|
|
13
|
+
return new InputFile(source, options);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Uploads every file value in the input, in parallel, and answers the input with each
|
|
17
|
+
* one replaced by its upload's id. Strings pass through as an `https` URL or an upload
|
|
18
|
+
* id, and a list may mix all kinds. The caller's input is never changed.
|
|
19
|
+
*/
|
|
20
|
+
export async function uploadFiles(input, upload) {
|
|
21
|
+
const uploaded = { ...input };
|
|
22
|
+
const uploads = [];
|
|
23
|
+
const uploadOne = async (field, value) => {
|
|
24
|
+
const { filename, data } = await load(value, field);
|
|
25
|
+
return upload(field, filename, data);
|
|
26
|
+
};
|
|
27
|
+
for (const [field, value] of Object.entries(input)) {
|
|
28
|
+
if (isFile(value)) {
|
|
29
|
+
uploads.push(uploadOne(field, value).then((id) => void (uploaded[field] = id)));
|
|
30
|
+
}
|
|
31
|
+
else if (Array.isArray(value) && value.some(isFile)) {
|
|
32
|
+
const list = [...value];
|
|
33
|
+
uploaded[field] = list;
|
|
34
|
+
list.forEach((item, index) => {
|
|
35
|
+
if (isFile(item))
|
|
36
|
+
uploads.push(uploadOne(field, item).then((id) => void (list[index] = id)));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const failed = (await Promise.allSettled(uploads)).find((settled) => settled.status === "rejected");
|
|
41
|
+
if (failed)
|
|
42
|
+
throw failed.reason;
|
|
43
|
+
return uploaded;
|
|
44
|
+
}
|
|
45
|
+
function isFile(value) {
|
|
46
|
+
return (value instanceof InputFile ||
|
|
47
|
+
value instanceof Blob ||
|
|
48
|
+
value instanceof Uint8Array ||
|
|
49
|
+
value instanceof ReadableStream ||
|
|
50
|
+
(typeof value === "object" && value !== null && Symbol.asyncIterator in value));
|
|
51
|
+
}
|
|
52
|
+
async function load(value, field) {
|
|
53
|
+
const given = value instanceof InputFile ? value : new InputFile(value);
|
|
54
|
+
const { source } = given;
|
|
55
|
+
if (typeof source === "string") {
|
|
56
|
+
const fs = await nodeFS();
|
|
57
|
+
return { filename: given.filename ?? basename(source), data: new Blob([blobPart(await fs.readFile(source))]) };
|
|
58
|
+
}
|
|
59
|
+
const data = source instanceof Blob ? source : source instanceof Uint8Array ? new Blob([blobPart(source)]) : await drain(source);
|
|
60
|
+
return { filename: given.filename ?? nameOf(source) ?? field, data };
|
|
61
|
+
}
|
|
62
|
+
/** The name a `File` or a file stream (Node's `fs.createReadStream`) carries. */
|
|
63
|
+
function nameOf(source) {
|
|
64
|
+
const named = source;
|
|
65
|
+
if (source instanceof Blob)
|
|
66
|
+
return typeof named.name === "string" && named.name ? named.name : undefined;
|
|
67
|
+
return typeof named.path === "string" ? basename(named.path) : undefined;
|
|
68
|
+
}
|
|
69
|
+
async function drain(stream) {
|
|
70
|
+
const parts = [];
|
|
71
|
+
const encoder = new TextEncoder();
|
|
72
|
+
const add = (chunk) => parts.push(typeof chunk === "string" ? encoder.encode(chunk) : blobPart(chunk));
|
|
73
|
+
if (stream instanceof ReadableStream) {
|
|
74
|
+
const reader = stream.getReader();
|
|
75
|
+
for (let read = await reader.read(); !read.done; read = await reader.read())
|
|
76
|
+
add(read.value);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
for await (const chunk of stream)
|
|
80
|
+
add(chunk);
|
|
81
|
+
}
|
|
82
|
+
return new Blob(parts);
|
|
83
|
+
}
|
|
84
|
+
/** The bytes as a `Blob` takes them, on a plain `ArrayBuffer`: copied only when they are on a shared one. */
|
|
85
|
+
function blobPart(bytes) {
|
|
86
|
+
return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
87
|
+
}
|
|
88
|
+
function basename(path) {
|
|
89
|
+
return path.split(/[\\/]/).pop() || path;
|
|
90
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface Limits {
|
|
2
|
+
/** How long to wait for the status and headers. */
|
|
3
|
+
connectMs: number;
|
|
4
|
+
/** How long the body may stay silent before it counts as a cut. */
|
|
5
|
+
silenceMs: number;
|
|
6
|
+
}
|
|
7
|
+
export declare const LIMITS: Limits;
|
|
8
|
+
export interface Answer {
|
|
9
|
+
status: number;
|
|
10
|
+
headers: Headers;
|
|
11
|
+
/** The whole body, or null when the connection was cut or went silent after the headers. */
|
|
12
|
+
text: string | null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Sends one request and reads its whole body. A failure before the headers rejects;
|
|
16
|
+
* a cut after them answers `text: null`, so the caller can re-attach. `init.signal`
|
|
17
|
+
* stops the request at any point, and then it always rejects.
|
|
18
|
+
*/
|
|
19
|
+
export declare function request(url: string, init: RequestInit, limits?: Limits): Promise<Answer>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export const LIMITS = { connectMs: 10_000, silenceMs: 30_000 };
|
|
2
|
+
/**
|
|
3
|
+
* Sends one request and reads its whole body. A failure before the headers rejects;
|
|
4
|
+
* a cut after them answers `text: null`, so the caller can re-attach. `init.signal`
|
|
5
|
+
* stops the request at any point, and then it always rejects.
|
|
6
|
+
*/
|
|
7
|
+
export async function request(url, init, limits = LIMITS) {
|
|
8
|
+
const stop = init.signal;
|
|
9
|
+
stop?.throwIfAborted();
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
const abort = () => controller.abort();
|
|
12
|
+
stop?.addEventListener("abort", abort, { once: true });
|
|
13
|
+
const connect = setTimeout(abort, limits.connectMs);
|
|
14
|
+
try {
|
|
15
|
+
let response;
|
|
16
|
+
try {
|
|
17
|
+
response = await fetch(url, { ...init, signal: controller.signal });
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
clearTimeout(connect);
|
|
21
|
+
}
|
|
22
|
+
const text = await readBody(response, controller, limits.silenceMs);
|
|
23
|
+
stop?.throwIfAborted();
|
|
24
|
+
return { status: response.status, headers: response.headers, text };
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
stop?.removeEventListener("abort", abort);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function readBody(response, controller, silenceMs) {
|
|
31
|
+
if (!response.body)
|
|
32
|
+
return "";
|
|
33
|
+
const reader = response.body.getReader();
|
|
34
|
+
const decoder = new TextDecoder();
|
|
35
|
+
let text = "";
|
|
36
|
+
let silence;
|
|
37
|
+
const listen = () => {
|
|
38
|
+
clearTimeout(silence);
|
|
39
|
+
silence = setTimeout(() => controller.abort(), silenceMs);
|
|
40
|
+
};
|
|
41
|
+
try {
|
|
42
|
+
listen();
|
|
43
|
+
for (;;) {
|
|
44
|
+
const { done, value } = await reader.read();
|
|
45
|
+
if (done)
|
|
46
|
+
return text + decoder.decode();
|
|
47
|
+
listen();
|
|
48
|
+
text += decoder.decode(value, { stream: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
clearTimeout(silence);
|
|
56
|
+
}
|
|
57
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { Abyss, type AbyssOptions, type CallOptions, type GetRunOptions, type ListRunsOptions, type RunOptions, type Runs, type UploadRequest, type Uploads, type Widgets, } from "./client.js";
|
|
2
|
+
export { AbyssError, type AbyssErrorFields } from "./error.js";
|
|
3
|
+
export { file, type FileData, type FileOptions, type InputFile } from "./files.js";
|
|
4
|
+
export type { Run, RunFile } from "./run.js";
|
|
5
|
+
export type { Input, Key, RunError, RunList, RunStatus, Upload, Widget } from "./types.js";
|
package/dist/index.js
ADDED
package/dist/node.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface NodeFS {
|
|
2
|
+
readFile(path: string): Promise<Uint8Array>;
|
|
3
|
+
writeFile(path: string, data: Uint8Array): Promise<void>;
|
|
4
|
+
mkdir(path: string, options: {
|
|
5
|
+
recursive: true;
|
|
6
|
+
}): Promise<unknown>;
|
|
7
|
+
}
|
|
8
|
+
export interface NodePath {
|
|
9
|
+
join(...paths: string[]): string;
|
|
10
|
+
dirname(path: string): string;
|
|
11
|
+
resolve(...paths: string[]): string;
|
|
12
|
+
relative(from: string, to: string): string;
|
|
13
|
+
isAbsolute(path: string): boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function nodeFS(): Promise<NodeFS>;
|
|
16
|
+
export declare function nodePath(): Promise<NodePath>;
|
package/dist/node.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Node's fs and path modules, which Node, Bun and Deno all serve. They are imported
|
|
2
|
+
// by a non-literal specifier so the library needs no @types/node, and loads in
|
|
3
|
+
// places that have neither.
|
|
4
|
+
const FS = "node:fs/promises";
|
|
5
|
+
const PATH = "node:path";
|
|
6
|
+
export function nodeFS() {
|
|
7
|
+
return import(FS);
|
|
8
|
+
}
|
|
9
|
+
export function nodePath() {
|
|
10
|
+
return import(PATH);
|
|
11
|
+
}
|
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { RunData, RunError, RunFileData, RunStatus } from "./types.js";
|
|
2
|
+
/** Reads the run again with `GET /v1/runs/{id}`. */
|
|
3
|
+
export type Reread = () => Promise<RunData>;
|
|
4
|
+
/** A run, with `/v1`'s names. */
|
|
5
|
+
export declare class Run {
|
|
6
|
+
#private;
|
|
7
|
+
id: string;
|
|
8
|
+
widget: string;
|
|
9
|
+
status: RunStatus;
|
|
10
|
+
price: number;
|
|
11
|
+
result: unknown;
|
|
12
|
+
output_files: RunFile[];
|
|
13
|
+
error: RunError | null;
|
|
14
|
+
created_at: string;
|
|
15
|
+
started_at: string | null;
|
|
16
|
+
ended_at: string | null;
|
|
17
|
+
constructor(data: RunData, reread: Reread);
|
|
18
|
+
/** Saves every output file under `dir`, at its `path`, and answers the paths it wrote. */
|
|
19
|
+
save(dir: string): Promise<string[]>;
|
|
20
|
+
}
|
|
21
|
+
/** One file a run wrote. Its `url` is signed fresh on every read of the run, and expires. */
|
|
22
|
+
export declare class RunFile {
|
|
23
|
+
#private;
|
|
24
|
+
path: string;
|
|
25
|
+
size: number;
|
|
26
|
+
content_type: string;
|
|
27
|
+
url: string;
|
|
28
|
+
constructor(data: RunFileData, refresh: () => Promise<void>);
|
|
29
|
+
/** Downloads the file. An expired `url` is refreshed once, by reading the run again. */
|
|
30
|
+
read(): Promise<Uint8Array>;
|
|
31
|
+
/** Downloads the file to `path`, making its folders, and answers `path`. */
|
|
32
|
+
save(path: string): Promise<string>;
|
|
33
|
+
}
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { AbyssError } from "./error.js";
|
|
2
|
+
import { nodeFS, nodePath } from "./node.js";
|
|
3
|
+
/** A run, with `/v1`'s names. */
|
|
4
|
+
export class Run {
|
|
5
|
+
id;
|
|
6
|
+
widget;
|
|
7
|
+
status;
|
|
8
|
+
price;
|
|
9
|
+
result;
|
|
10
|
+
output_files;
|
|
11
|
+
error;
|
|
12
|
+
created_at;
|
|
13
|
+
started_at;
|
|
14
|
+
ended_at;
|
|
15
|
+
#reread;
|
|
16
|
+
constructor(data, reread) {
|
|
17
|
+
this.id = data.id;
|
|
18
|
+
this.widget = data.widget;
|
|
19
|
+
this.status = data.status;
|
|
20
|
+
this.price = data.price;
|
|
21
|
+
this.result = data.result;
|
|
22
|
+
this.error = data.error;
|
|
23
|
+
this.created_at = data.created_at;
|
|
24
|
+
this.started_at = data.started_at;
|
|
25
|
+
this.ended_at = data.ended_at;
|
|
26
|
+
this.#reread = reread;
|
|
27
|
+
this.output_files = data.output_files.map((output) => new RunFile(output, () => this.#refreshURLs()));
|
|
28
|
+
}
|
|
29
|
+
/** Reads the run again and gives every output file its freshly signed `url`. */
|
|
30
|
+
async #refreshURLs() {
|
|
31
|
+
const fresh = await this.#reread();
|
|
32
|
+
for (const output of this.output_files) {
|
|
33
|
+
const url = fresh.output_files.find((candidate) => candidate.path === output.path)?.url;
|
|
34
|
+
if (url)
|
|
35
|
+
output.url = url;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Saves every output file under `dir`, at its `path`, and answers the paths it wrote. */
|
|
39
|
+
async save(dir) {
|
|
40
|
+
const path = await nodePath();
|
|
41
|
+
const saved = [];
|
|
42
|
+
for (const output of this.output_files) {
|
|
43
|
+
const target = path.join(dir, output.path);
|
|
44
|
+
const inside = path.relative(path.resolve(dir), path.resolve(target));
|
|
45
|
+
if (!inside || inside.startsWith("..") || path.isAbsolute(inside)) {
|
|
46
|
+
throw new AbyssError({
|
|
47
|
+
code: "unexpected_response",
|
|
48
|
+
message: `The output file ${output.path} would be saved outside ${dir}.`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
saved.push(await output.save(target));
|
|
52
|
+
}
|
|
53
|
+
return saved;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** One file a run wrote. Its `url` is signed fresh on every read of the run, and expires. */
|
|
57
|
+
export class RunFile {
|
|
58
|
+
path;
|
|
59
|
+
size;
|
|
60
|
+
content_type;
|
|
61
|
+
url;
|
|
62
|
+
#refresh;
|
|
63
|
+
constructor(data, refresh) {
|
|
64
|
+
this.path = data.path;
|
|
65
|
+
this.size = data.size;
|
|
66
|
+
this.content_type = data.content_type;
|
|
67
|
+
this.url = data.url;
|
|
68
|
+
this.#refresh = refresh;
|
|
69
|
+
}
|
|
70
|
+
/** Downloads the file. An expired `url` is refreshed once, by reading the run again. */
|
|
71
|
+
async read() {
|
|
72
|
+
let response = await fetch(this.url);
|
|
73
|
+
if (response.status === 403) {
|
|
74
|
+
await response.body?.cancel();
|
|
75
|
+
await this.#refresh();
|
|
76
|
+
response = await fetch(this.url);
|
|
77
|
+
}
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
await response.body?.cancel();
|
|
80
|
+
throw new AbyssError({
|
|
81
|
+
code: "unexpected_response",
|
|
82
|
+
message: `The download of ${this.path} answered ${response.status}.`,
|
|
83
|
+
status: response.status,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
87
|
+
}
|
|
88
|
+
/** Downloads the file to `path`, making its folders, and answers `path`. */
|
|
89
|
+
async save(path) {
|
|
90
|
+
const [fs, { dirname }] = await Promise.all([nodeFS(), nodePath()]);
|
|
91
|
+
const bytes = await this.read();
|
|
92
|
+
await fs.mkdir(dirname(path), { recursive: true });
|
|
93
|
+
await fs.writeFile(path, bytes);
|
|
94
|
+
return path;
|
|
95
|
+
}
|
|
96
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** A run's place: `succeeded` and `failed` are final. */
|
|
2
|
+
export type RunStatus = "queued" | "running" | "succeeded" | "failed";
|
|
3
|
+
/** Why a run failed: `widget_fault`, `platform_fault` or `budget_exceeded`. */
|
|
4
|
+
export interface RunError {
|
|
5
|
+
code: string;
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
/** One file a run wrote, as `/v1` sends it. */
|
|
9
|
+
export interface RunFileData {
|
|
10
|
+
path: string;
|
|
11
|
+
size: number;
|
|
12
|
+
content_type: string;
|
|
13
|
+
url: string;
|
|
14
|
+
}
|
|
15
|
+
/** A run, as `/v1` sends it. */
|
|
16
|
+
export interface RunData {
|
|
17
|
+
id: string;
|
|
18
|
+
widget: string;
|
|
19
|
+
status: RunStatus;
|
|
20
|
+
price: number;
|
|
21
|
+
result: unknown;
|
|
22
|
+
output_files: RunFileData[];
|
|
23
|
+
error: RunError | null;
|
|
24
|
+
created_at: string;
|
|
25
|
+
started_at: string | null;
|
|
26
|
+
ended_at: string | null;
|
|
27
|
+
}
|
|
28
|
+
/** A Widget's input: its Fields by name, never converted. */
|
|
29
|
+
export type Input = Record<string, unknown>;
|
|
30
|
+
/** A page of runs, newest first, as `GET /v1/runs` sends it. */
|
|
31
|
+
export interface RunList<R = RunData> {
|
|
32
|
+
data: R[];
|
|
33
|
+
/** Whether a page `starting_after` the last run here would find more. */
|
|
34
|
+
has_more: boolean;
|
|
35
|
+
}
|
|
36
|
+
/** A Widget, as the caller about to press it sees it. */
|
|
37
|
+
export interface Widget {
|
|
38
|
+
id: string;
|
|
39
|
+
name: string | null;
|
|
40
|
+
description: string | null;
|
|
41
|
+
url: string;
|
|
42
|
+
/** The one price a run costs, Call Price included. */
|
|
43
|
+
price: number;
|
|
44
|
+
/** The caller's Free Runs on this Widget; null for their own Widget and for a free one. */
|
|
45
|
+
free_runs: {
|
|
46
|
+
limit: number;
|
|
47
|
+
remaining: number;
|
|
48
|
+
} | null;
|
|
49
|
+
/** The JSON Schema the input must match. */
|
|
50
|
+
input_schema: Record<string, unknown>;
|
|
51
|
+
}
|
|
52
|
+
/** An upload's grant: its `id` goes in the input once the file is in storage. */
|
|
53
|
+
export interface Upload {
|
|
54
|
+
id: string;
|
|
55
|
+
/** POST every `fields` entry, then the file as `file`, to `url`. */
|
|
56
|
+
upload: {
|
|
57
|
+
url: string;
|
|
58
|
+
fields: Record<string, string>;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** The API Key making the call, as `GET /v1/key` sends it. Never its secret. */
|
|
62
|
+
export interface Key {
|
|
63
|
+
name: string;
|
|
64
|
+
/** The key's display form: its prefix and last four characters. */
|
|
65
|
+
key: string;
|
|
66
|
+
user: {
|
|
67
|
+
id: number;
|
|
68
|
+
name: string;
|
|
69
|
+
};
|
|
70
|
+
spend_cap: number | null;
|
|
71
|
+
spent_this_month: number;
|
|
72
|
+
created_at: string;
|
|
73
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "abysshub",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1-dev.10",
|
|
4
|
+
"description": "The official JavaScript library for the Abyss API.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/Abysshub/abysshub-sdk/tree/main/js#readme",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Abysshub/abysshub-sdk.git",
|
|
10
|
+
"directory": "js"
|
|
11
|
+
},
|
|
12
|
+
"bugs": "https://github.com/Abysshub/abysshub-sdk/issues",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "node scripts/version.js && tsc",
|
|
31
|
+
"prepack": "npm run build",
|
|
32
|
+
"test": "npm run build && node --test test/*.test.js"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"ajv": "^8.20.0",
|
|
36
|
+
"ajv-formats": "^3.0.1",
|
|
37
|
+
"typescript": "^5.9.3"
|
|
38
|
+
}
|
|
39
|
+
}
|