abysshub 0.0.1-dev.5 → 0.0.1-dev.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,4 +22,24 @@ try {
22
22
 
23
23
  - **`new Abyss({ apiKey?, baseURL?, maxRetries? })`:** the key defaults to `ABYSS_API_KEY`. A key starting `abyss_sk_dev_` goes to `https://api.dev.abysshub.com`. `ABYSS_BASE_URL` or `baseURL` overrides the address.
24
24
  - **`abyss.run(widget, input, { maxPrice?, idempotencyKey? })`:** each call presses with a fresh UUID `Idempotency-Key` unless you give one.
25
+ - **A run** has `/v1`'s fields: `id`, `widget`, `status`, `price`, `result`, `output_files`, `error`, `created_at`, `started_at` and `ended_at`.
26
+ - **A drop before any headers** presses again, at most `maxRetries` times, with the same body and `Idempotency-Key`.
25
27
  - **Names:** returned data keeps the API's names (`output_files`, `request_id`). Options are camelCase. Field names are never converted.
28
+
29
+ ## Files
30
+
31
+ ```js
32
+ import { Abyss, file } from "abysshub";
33
+
34
+ const run = await abyss.run("widget_1660", {
35
+ report: file("report.pdf"),
36
+ data: [file("jan.csv"), new File([csv], "feb.csv"), "https://example.com/sales/mar.csv"],
37
+ });
38
+ const paths = await run.save("output"); // ["output/charts/a.png", "output/output.json"]
39
+ ```
40
+
41
+ - **`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.
42
+ - **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.
43
+ - **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.
44
+ - **`await run.save(dir)`** writes every output file under `dir` at its `path`, making folders, and answers the paths it wrote.
45
+ - **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}`.
package/dist/client.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Input, Run } from "./types.js";
1
+ import { Run } from "./run.js";
2
+ import type { Input } from "./types.js";
2
3
  export interface AbyssOptions {
3
4
  /** Defaults to `ABYSS_API_KEY`. */
4
5
  apiKey?: string;
@@ -18,9 +19,9 @@ export declare class Abyss {
18
19
  readonly maxRetries: number;
19
20
  constructor(options?: AbyssOptions);
20
21
  /**
21
- * Presses a Widget and waits for the run's ending, re-attaching through `Location`
22
- * whenever the hold ends early. Returns the succeeded run; raises `AbyssError` on a
23
- * refusal or a failed run.
22
+ * Uploads the input's files, presses a Widget and waits for the run's ending,
23
+ * re-attaching through `Location` whenever the hold ends early. Returns the succeeded
24
+ * run; raises `AbyssError` on a refusal or a failed run.
24
25
  */
25
26
  run(widget: string, input: Input, options?: RunOptions): Promise<Run>;
26
27
  }
package/dist/client.js CHANGED
@@ -1,10 +1,14 @@
1
1
  import { AbyssError } from "./error.js";
2
+ import { uploadFiles } from "./files.js";
2
3
  import { request } from "./http.js";
4
+ import { Run } from "./run.js";
3
5
  const API = "https://api.abysshub.com";
4
6
  const DEV_API = "https://api.dev.abysshub.com";
5
7
  const DEV_KEY_PREFIX = "abyss_sk_dev_";
6
8
  /** The pause between two re-attaches that both ended without the run's ending. */
7
9
  const REATTACH_PAUSE_MS = 1_000;
10
+ /** The pause before pressing again after a drop before any headers. */
11
+ const RETRY_PAUSE_MS = 500;
8
12
  export class Abyss {
9
13
  baseURL;
10
14
  maxRetries;
@@ -23,25 +27,83 @@ export class Abyss {
23
27
  this.maxRetries = options.maxRetries ?? 2;
24
28
  }
25
29
  /**
26
- * Presses a Widget and waits for the run's ending, re-attaching through `Location`
27
- * whenever the hold ends early. Returns the succeeded run; raises `AbyssError` on a
28
- * refusal or a failed run.
30
+ * Uploads the input's files, presses a Widget and waits for the run's ending,
31
+ * re-attaching through `Location` whenever the hold ends early. Returns the succeeded
32
+ * run; raises `AbyssError` on a refusal or a failed run.
29
33
  */
30
34
  async run(widget, input, options = {}) {
31
- const body = { input };
35
+ const widgetURL = `${this.baseURL}/v1/widgets/${encodeURIComponent(widget)}`;
36
+ const body = {
37
+ input: await uploadFiles(input, (field, filename, data) => this.#upload(widgetURL, field, filename, data)),
38
+ };
32
39
  if (options.maxPrice !== undefined)
33
40
  body.max_price = options.maxPrice;
34
- const press = await this.#send("POST", `${this.baseURL}/v1/widgets/${encodeURIComponent(widget)}/runs`, {
41
+ const press = await this.#press(`${widgetURL}/runs`, {
35
42
  "Content-Type": "application/json",
36
43
  "Idempotency-Key": options.idempotencyKey ?? crypto.randomUUID(),
37
44
  }, JSON.stringify(body));
38
45
  const reply = hasEnded(press) ? press : await this.#reattach(press);
39
- if (reply.run.status === "failed") {
46
+ const run = new Run(reply.run, () => this.#read(reply.run.id));
47
+ if (run.status === "failed") {
40
48
  throw new AbyssError({
41
- code: reply.run.error?.code ?? "platform_fault",
42
- message: reply.run.error?.message ?? "The run failed.",
49
+ code: run.error?.code ?? "platform_fault",
50
+ message: run.error?.message ?? "The run failed.",
51
+ request_id: reply.requestId,
52
+ run,
53
+ });
54
+ }
55
+ return run;
56
+ }
57
+ /**
58
+ * Sends the press. A drop before any headers presses again, at most `maxRetries`
59
+ * times, with the same body and `Idempotency-Key`, so the uploads are never redone.
60
+ */
61
+ async #press(url, headers, body) {
62
+ for (let attempt = 0;; attempt++) {
63
+ try {
64
+ return await this.#send("POST", url, headers, body);
65
+ }
66
+ catch (error) {
67
+ if (error instanceof AbyssError || attempt >= this.maxRetries)
68
+ throw error;
69
+ await sleep(RETRY_PAUSE_MS);
70
+ }
71
+ }
72
+ }
73
+ /** Asks for an upload with `POST /v1/widgets/{widget}/uploads`, sends the file to storage and answers its id. */
74
+ async #upload(widgetURL, field, filename, data) {
75
+ const grant = await this.#send("POST", `${widgetURL}/uploads`, { "Content-Type": "application/json" }, JSON.stringify({ field, filename, size: data.size }));
76
+ if (!isUpload(grant.body)) {
77
+ throw new AbyssError({
78
+ code: "unexpected_response",
79
+ message: "The upload answered without an id and a form.",
80
+ request_id: grant.requestId,
81
+ });
82
+ }
83
+ const form = new FormData();
84
+ for (const [name, value] of Object.entries(grant.body.upload.fields))
85
+ form.append(name, value);
86
+ form.append("file", data, filename);
87
+ const stored = await fetch(grant.body.upload.url, { method: "POST", body: form });
88
+ await stored.body?.cancel();
89
+ if (!stored.ok) {
90
+ throw new AbyssError({
91
+ code: "unexpected_response",
92
+ message: `Storage answered ${stored.status} to the upload of ${filename}.`,
93
+ status: stored.status,
94
+ param: field,
95
+ });
96
+ }
97
+ return grant.body.id;
98
+ }
99
+ /** Reads a run at once with `GET /v1/runs/{id}`. */
100
+ async #read(id) {
101
+ const reply = await this.#send("GET", `${this.baseURL}/v1/runs/${encodeURIComponent(id)}`);
102
+ if (!reply.run) {
103
+ throw new AbyssError({
104
+ code: "unexpected_response",
105
+ message: "The read answered without a run.",
43
106
  request_id: reply.requestId,
44
- run: reply.run,
45
107
  });
46
108
  }
47
109
  return reply.run;
@@ -78,7 +140,7 @@ export class Abyss {
78
140
  const requestId = answer.headers.get("request-id");
79
141
  const parsed = parse(answer.text);
80
142
  if (answer.status === 200 || answer.status === 202) {
81
- return { run: isRun(parsed) ? parsed : null, location: answer.headers.get("location"), requestId };
143
+ return { body: parsed, run: isRun(parsed) ? parsed : null, location: answer.headers.get("location"), requestId };
82
144
  }
83
145
  throw refusal(answer.status, parsed, requestId);
84
146
  }
@@ -118,6 +180,13 @@ function isObject(value) {
118
180
  function isRun(value) {
119
181
  return isObject(value) && typeof value.id === "string" && typeof value.status === "string";
120
182
  }
183
+ function isUpload(value) {
184
+ return (isObject(value) &&
185
+ typeof value.id === "string" &&
186
+ isObject(value.upload) &&
187
+ typeof value.upload.url === "string" &&
188
+ isObject(value.upload.fields));
189
+ }
121
190
  /** Whether the reply carries a run that has succeeded or failed. */
122
191
  function hasEnded(reply) {
123
192
  return reply.run !== null && (reply.run.status === "succeeded" || reply.run.status === "failed");
package/dist/error.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Run } from "./types.js";
1
+ import type { Run } from "./run.js";
2
2
  export interface AbyssErrorFields {
3
3
  code: string;
4
4
  message: string;
@@ -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/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export { Abyss, type AbyssOptions, type RunOptions } from "./client.js";
2
2
  export { AbyssError, type AbyssErrorFields } from "./error.js";
3
- export type { Input, Run, RunError, RunFile, RunStatus } from "./types.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, RunError, RunStatus } from "./types.js";
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { Abyss } from "./client.js";
2
2
  export { AbyssError } from "./error.js";
3
+ export { file } from "./files.js";
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 CHANGED
@@ -1,25 +1,25 @@
1
1
  /** A run's place: `succeeded` and `failed` are final. */
2
2
  export type RunStatus = "queued" | "running" | "succeeded" | "failed";
3
- /** One file a run wrote. Its `url` is signed fresh on every read, and expires. */
4
- export interface RunFile {
5
- path: string;
6
- size: number;
7
- content_type: string;
8
- url: string;
9
- }
10
3
  /** Why a run failed: `widget_fault`, `platform_fault` or `budget_exceeded`. */
11
4
  export interface RunError {
12
5
  code: string;
13
6
  message: string;
14
7
  }
15
- /** A run, with `/v1`'s names. */
16
- export interface Run {
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
17
  id: string;
18
18
  widget: string;
19
19
  status: RunStatus;
20
20
  price: number;
21
21
  result: unknown;
22
- output_files: RunFile[];
22
+ output_files: RunFileData[];
23
23
  error: RunError | null;
24
24
  created_at: string;
25
25
  started_at: string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abysshub",
3
- "version": "0.0.1-dev.5",
3
+ "version": "0.0.1-dev.6",
4
4
  "description": "The official JavaScript library for the Abyss API.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/Abysshub/abysshub-sdk/tree/main/js#readme",