Add data and marketplace API endpoints

This commit is contained in:
Arnaud 2024-08-15 12:08:51 +02:00
parent 945582bf57
commit f5dd224479
No known key found for this signature in database
GPG Key ID: 69D6CE281FCAE663
21 changed files with 691 additions and 121 deletions

View File

@ -4,6 +4,8 @@ The Codex SDK provides an API for interacting with the Codex decentralized stora
The SDK has a small bundle size and support tree shaking.
The SDK is currently under early development and the API can change at any time.
## Import
```js

2
package-lock.json generated
View File

@ -19,7 +19,7 @@
"typescript": "^5.5.4"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
},
"node_modules/@esbuild/aix-ppc64": {

View File

@ -7,12 +7,14 @@
"url": "https://github.com/codex-storage/codex-js"
},
"scripts": {
"prepack": "npm run build",
"prepack": "tsup tsup src/index.ts --format esm,cjs --dts",
"prebuild": "rm -Rf dist/*",
"build": "tsup tsup src/index.ts --format esm,cjs --dts",
"build": "tsc --p tsconfig.test.json",
"compile": "tsc --noEmit",
"pretest": "npm run build",
"pretest:only": "npm run build",
"test": "node --test",
"test:only": "node --test --test-only",
"watch": "tsc --watch",
"format": "prettier --write ./src"
},
@ -44,7 +46,7 @@
"readme": "README.md",
"license": "MIT",
"engines": {
"node": ">=18"
"node": ">=20"
},
"devDependencies": {
"@faker-js/faker": "^8.4.1",

19
src/chunks/chunks.ts Normal file
View File

@ -0,0 +1,19 @@
export const Chunks = {
async split(file: File) {
const totalSize = file.size;
const chunkSize = 1024 * 1024 * 3; // 10MB
const chunks = [] as Uint8Array[];
const amountOfChunks = Math.ceil(totalSize / chunkSize);
for (let index = 0; index < amountOfChunks; index++) {
const start = index * chunkSize;
const end = (index + 1) * chunkSize;
const chunk = await file.slice(start, end).arrayBuffer();
chunks.push(new Uint8Array(chunk));
}
return chunks;
},
};

197
src/data/data.ts Normal file
View File

@ -0,0 +1,197 @@
import { Api } from "../api/config";
import { Chunks } from "../chunks/chunks";
import { Fetch } from "../fetch-safe/fetch-safe";
import type { SafeValue } from "../values/values";
import type { CodexDataResponse, CodexNodeSpace } from "./types";
type UploadResponse = {
result: Promise<SafeValue<string>>;
abort: () => void;
};
export class Data {
readonly url: string;
constructor(url: string) {
this.url = url;
}
/**
* Lists manifest CIDs stored locally in node.
* TODO: remove the faker data part when the api is ready
*/
cids(): Promise<SafeValue<CodexDataResponse>> {
const url = this.url + Api.config.prefix + "/data";
return Fetch.safeJson<CodexDataResponse>(url, {
method: "GET",
}).then((data) => {
if (data.error) {
return data;
}
/* const mimetypes = [
"image/png",
"image/jpg",
"image/jpeg",
"audio/mp3",
"video/mp4",
"application/pdf",
"application/msdoc",
"text/plain",
];
*/
return {
error: false,
data: {
content: data.data.content /*.map((content) => {
const random = Math.trunc(Math.random() * (mimetypes.length - 1));
const mimetype = mimetypes[random];
const [, extension] = mimetype?.split("/") || [];
const filename = Array(5)
.fill("")
.map((_) => ((Math.random() * 36) | 0).toString(36))
.join("");
return {
cid: content.cid,
manifest: {
...content.manifest,
filename: `${filename}.${extension}`,
mimetype: mimetype || "",
uploadedAt: new Date().toJSON(),
},
};
})*/,
},
};
});
}
/**
* Gets a summary of the storage space allocation of the node.
*/
space() {
const url = this.url + Api.config.prefix + "/space";
return Fetch.safeJson<CodexNodeSpace>(url, {
method: "GET",
});
}
/**
* Upload a file in a streaming manner.
* Once completed, the file is stored in the node and can be retrieved by any node in the network using the returned CID.
* XMLHttpRequest is used instead of fetch for this case, to obtain progress information.
* A callback onProgress can be passed to receive upload progress data information.
*/
async upload(
file: File,
onProgress?: (loaded: number, total: number) => void
): Promise<UploadResponse> {
const url = this.url + Api.config.prefix + "/data";
const xhr = new XMLHttpRequest();
const promise = new Promise<SafeValue<string>>(async (resolve) => {
xhr.upload.onprogress = (evt) => {
if (evt.lengthComputable) {
onProgress?.(evt.loaded, evt.total);
}
};
xhr.open("POST", url, true);
await Chunks.split(file);
xhr.send(new Blob(await Chunks.split(file)));
xhr.onload = function () {
if (xhr.status != 200) {
resolve({
error: true,
data: {
code: xhr.status,
message: xhr.responseText,
},
});
} else {
resolve({ error: false, data: xhr.response });
}
};
xhr.onerror = function () {
resolve({
error: true,
data: {
message: "Something went wrong during the file upload.",
},
});
};
});
// const promise = Fetch.safe(url, {
// method: "POST",
// headers: { "Content-Type": "text/plain" },
// body: file.stream(),
// // @ts-ignore
// duplex: "half",
// })
// .then(async (res) => {
// console.info(res);
// return res.error
// ? res
// : { error: false as false, data: await res.data.text() };
// })
return {
result: promise,
abort: () => {
xhr.abort();
},
};
}
/**
* Download a file from the local node in a streaming manner.
* If the file is not available locally, a 404 is returned.
* There result is a readable stream.
*/
async localDownload(cid: string) {
const url = this.url + Api.config.prefix + "/data/" + cid;
const res = await Fetch.safe(url, {
method: "GET",
headers: {
"content-type": "application/octet-stream",
},
});
if (res.error) {
return res;
}
return res.data.body;
}
/**
* Download a file from the network in a streaming manner.
* If the file is not available locally, it will be retrieved from other nodes in the network if able.
*/
async networkDownload(cid: string) {
const url = this.url + Api.config.prefix + `/data/${cid}/network`;
const res = await Fetch.safe(url, {
method: "GET",
headers: {
"content-type": "application/octet-stream",
},
});
if (res.error) {
return res;
}
return res.data.body;
}
}

81
src/data/types.ts Normal file
View File

@ -0,0 +1,81 @@
export type CodexManifest = {
/**
* "Root hash of the content"
*/
// rootHash: string;
/**
* Length of original content in bytes
*/
// originalBytes: number;
/**
* Total size of all blocks
*/
datasetSize: number;
/**
* "Size of blocks"
*/
blockSize: number;
/**
* Indicates if content is protected by erasure-coding
*/
protected: boolean;
/**
* Root of the merkle tree
*/
treeCid: string;
/**
* Name of the name (not implemeted yet)
*/
filename: string;
/**
* Mimetype (not implemeted yet)
*/
mimetype: string;
/**
* Date of upload (not implemented at)
*/
uploadedAt: string;
};
export type CodexDataContent = {
/**
* Content Identifier as specified at https://github.com/multiformats/cid
*/
cid: string;
manifest: CodexManifest;
};
export type CodexDataResponse = {
content: CodexDataContent[];
};
export type CodexNodeSpace = {
/**
* Number of blocks stored by the node
*/
totalBlocks: number;
/**
* Maximum storage space used by the node
*/
quotaMaxBytes: number;
/**
* Amount of storage space currently in use
*/
quotaUsedBytes: number;
/**
* Amount of storage space reserved
*/
quotaReservedBytes: number;
};

40
src/debug/debug.test.ts Normal file
View File

@ -0,0 +1,40 @@
import assert from "assert";
import { describe, it } from "node:test";
import { Fetch } from "../fetch-safe/fetch-safe";
import { Debug } from "./debug";
import type { CodexLogLevel } from "./types";
describe("debug", () => {
const debug = new Debug("http://localhost:3000");
it("returns an error when trying to setup the log level with a bad value", async () => {
const response = await debug.setLogLevel("TEST" as CodexLogLevel);
assert.deepStrictEqual(response, {
error: true,
data: {
message: "Cannot validate the input",
errors: [
{
expected:
'"TRACE" | "DEBUG" | "INFO" | "NOTICE" | "WARN" | "ERROR" | "FATAL"',
message:
'Invalid type: Expected "TRACE" | "DEBUG" | "INFO" | "NOTICE" | "WARN" | "ERROR" | "FATAL" but received "TEST"',
path: undefined,
received: '"TEST"',
},
],
},
});
});
it("returns a success when trying to setup the log level with a correct value", async (t) => {
t.mock.method(Fetch, "safe", () =>
Promise.resolve({ error: false, data: true })
);
const response = await debug.setLogLevel("ERROR");
assert.deepStrictEqual(response, { error: false, data: true });
});
});

52
src/debug/debug.ts Normal file
View File

@ -0,0 +1,52 @@
import { Api } from "../api/config";
import { CodexValibotIssuesMap } from "../errors/errors";
import { Fetch } from "../fetch-safe/fetch-safe";
import { CodexLogLevel, type CodexDebugInfo } from "./types";
import * as v from "valibot";
export class Debug {
readonly url: string;
constructor(url: string) {
this.url = url;
}
/**
* Set log level at run time
*/
setLogLevel(level: CodexLogLevel) {
const result = v.safeParse(CodexLogLevel, level);
if (!result.success) {
return {
error: true,
data: {
message: "Cannot validate the input",
errors: CodexValibotIssuesMap(result.issues),
},
};
}
const url =
this.url +
Api.config.prefix +
"/debug/chronicles/loglevel?level=" +
level;
return Fetch.safe(url, {
method: "POST",
body: "",
});
}
/**
* Gets node information
*/
info() {
const url = this.url + Api.config.prefix + `/debug/info`;
return Fetch.safeJson<CodexDebugInfo>(url, {
method: "GET",
});
}
}

33
src/debug/types.ts Normal file
View File

@ -0,0 +1,33 @@
import * as v from "valibot";
export const CodexLogLevel = v.picklist([
"TRACE",
"DEBUG",
"INFO",
"NOTICE",
"WARN",
"ERROR",
"FATAL",
]);
export type CodexLogLevel = v.InferOutput<typeof CodexLogLevel>;
export type CodexDebugInfo = {
/**
* Peer Identity reference as specified at https://docs.libp2p.io/concepts/fundamentals/peers/
*/
id: string;
/**
* Address of node as specified by the multi-address specification https://multiformats.io/multiaddr/
*/
addrs: string[];
/**
* Path of the data repository where all nodes data are stored
*/
repo: "string";
// Signed Peer Record (libp2p)
spr: string;
};

View File

@ -1,19 +0,0 @@
import type { SafeValue } from "../values/values";
export class Disk {
readonly url: string;
constructor(url: string) {
this.url = url;
}
async available(): Promise<SafeValue<{ full: number; used: number }>> {
return {
error: false,
data: {
full: 500,
used: 200,
},
};
}
}

View File

@ -25,5 +25,5 @@ export const CodexValibotIssuesMap = (issues: InferIssue<any>[]) =>
expected: i.expected,
received: i.received,
message: i.message,
path: i.path.map((item: { key: string }) => item.key).join("."),
path: i.path?.map((item: { key: string }) => item.key).join("."),
}));

View File

@ -45,48 +45,45 @@ class MockResponse implements Response {
}
}
describe("fetch", () => {
describe.only("fetch", () => {
it("returns an error when the http call failed", async (t) => {
global.fetch = t.mock.fn(() =>
Promise.resolve(new MockResponse(false, 500, "error"))
Promise.resolve(new MockResponse(false, 500, "error")),
);
const result = await Fetch.safe("http://localhost:3000/some-url", {
const result = await Fetch.safeJson("http://localhost:3000/some-url", {
method: "GET",
});
const error = {
type: "api",
message: "error",
status: 500,
code: 500,
};
assert.deepStrictEqual(result, { error: true, data: error });
});
it("returns an error when the json parsing failed", async (t) => {
it.only("returns an error when the json parsing failed", async (t) => {
global.fetch = t.mock.fn(() =>
Promise.resolve(new MockResponse(true, 200, ""))
Promise.resolve(new MockResponse(true, 200, "")),
);
const result = await Fetch.safe("http://localhost:3000/some-url", {
const result = await Fetch.safeJson("http://localhost:3000/some-url", {
method: "GET",
});
const error = {
type: "error",
message: "Unexpected end of JSON input",
};
assert.deepStrictEqual(result, { error: true, data: error });
assert.ok(result.error);
assert.deepStrictEqual(result.data.message, "Unexpected end of JSON input");
});
it("returns the data when the fetch succeed", async (t) => {
global.fetch = t.mock.fn(() =>
Promise.resolve(
new MockResponse(true, 200, JSON.stringify({ hello: "world" }))
)
new MockResponse(true, 200, JSON.stringify({ hello: "world" })),
),
);
const result = await Fetch.safe("http://localhost:3000/some-url", {
const result = await Fetch.safeJson("http://localhost:3000/some-url", {
method: "GET",
});

View File

@ -1,37 +1,50 @@
import { Promises } from "../promise-safe/promise-safe";
import { type SafeValue } from "../values/values";
export const Fetch = {
async safe<T extends Object>(
async safe(url: string, init: RequestInit): Promise<SafeValue<Response>> {
const res = await Promises.safe(() => fetch(url, init));
if (res.error) {
return {
error: true,
data: {
message:
"The connection with the Codex node seems to be broken. Please check your node is running.",
code: 502,
},
};
}
if (!res.data.ok) {
const message = await Promises.safe(() => res.data.text());
if (message.error) {
return message;
}
return {
error: true,
data: {
message: message.data,
code: res.data.status,
},
};
}
return { error: false, data: res.data };
},
async safeJson<T extends Object>(
url: string,
init: RequestInit,
init: RequestInit
): Promise<SafeValue<T>> {
const res = await fetch(url, init);
const res = await this.safe(url, init);
if (!res.ok) {
const message = await res.text();
return {
error: true,
data: {
message,
code: res.status,
},
};
if (res.error) {
return res;
}
try {
const json = await res.json();
return { error: false, data: json };
} catch (e) {
const opts = e instanceof Error && e.stack ? { stack: e.stack } : {};
return {
error: true,
data: {
message: e instanceof Error ? e.message : "JSON parsing error :" + e,
...opts,
},
};
}
return Promises.safe(() => res.data.json());
},
};

View File

@ -1,18 +1,28 @@
import { Disk } from "./disk/disk";
import type { Data } from "./data/data";
import type { Node } from "./node/node";
import { Marketplace } from "./marketplace/marketplace";
import type { Debug } from "./debug/debug";
export * from "./fetch-safe/fetch-safe";
export * from "./marketplace/types";
export * from "./debug/types";
export * from "./data/types";
export * from "./values/values";
export * from "./errors/errors";
export class Codex {
readonly url: string;
private _marketplace: Marketplace | null;
readonly disk: Disk;
private _data: Data | null;
private _node: Node | null;
private _debug: Debug | null;
constructor(url: string) {
this.url = url;
this._marketplace = null;
this.disk = new Disk(url);
this._data = null;
this._node = null;
this._debug = null;
}
async marketplace() {
@ -24,6 +34,42 @@ export class Codex {
this._marketplace = new module.Marketplace(this.url);
return module.Marketplace;
return this._marketplace;
}
async data() {
if (this._data) {
return this._data;
}
const module = await import("./data/data");
this._data = new module.Data(this.url);
return this._data;
}
async node() {
if (this._node) {
return this._node;
}
const module = await import("./node/node");
this._node = new module.Node(this.url);
return this._node;
}
async debug() {
if (this._debug) {
return this._debug;
}
const module = await import("./debug/debug");
this._debug = new module.Debug(this.url);
return this._debug;
}
}

View File

@ -5,34 +5,34 @@ import { Fetch } from "../fetch-safe/fetch-safe";
import { Marketplace } from "./marketplace";
// function createSlot() {
// return {
// "id": faker.string.alphanumeric(64),
// "request": {
// return {
// "id": faker.string.alphanumeric(64),
// "request": {
// "id": faker.string.alphanumeric(64),
// "client": faker.finance.ethereumAddress(),
// "ask":
// {
// "slots": faker.number.int({ min: 0, max: 9 }),
// "slotSize": faker.number.float({ max: 10000 }).toString(),
// "duration": faker.number.int({ max: 300000 }).toString(),
// "proofProbability": faker.number.int({ max: 9 }),
// "reward": faker.number.float({ max: 1000 }).toString(),
// "maxSlotLoss": faker.number.int({ max: 9 })
// },
// "content": {
// "cid": faker.string.alphanumeric(64),
// "por": {
// "u": faker.string.alphanumeric(16),
// "publicKey": faker.string.alphanumeric(64),
// "name": faker.string.alphanumeric(16)
// }
// },
// "expiry": faker.number.int({ min: 2, max: 59 }) + " minutes",
// "nonce": faker.string.alphanumeric(64)
// },
// "slotIndex": faker.number.int({ min: 0, max: 9 })
// }
// "id": faker.string.alphanumeric(64),
// "client": faker.finance.ethereumAddress(),
// "ask":
// {
// "slots": faker.number.int({ min: 0, max: 9 }),
// "slotSize": faker.number.float({ max: 10000 }).toString(),
// "duration": faker.number.int({ max: 300000 }).toString(),
// "proofProbability": faker.number.int({ max: 9 }),
// "reward": faker.number.float({ max: 1000 }).toString(),
// "maxSlotLoss": faker.number.int({ max: 9 })
// },
// "content": {
// "cid": faker.string.alphanumeric(64),
// "por": {
// "u": faker.string.alphanumeric(16),
// "publicKey": faker.string.alphanumeric(64),
// "name": faker.string.alphanumeric(16)
// }
// },
// "expiry": faker.number.int({ min: 2, max: 59 }) + " minutes",
// "nonce": faker.string.alphanumeric(64)
// },
// "slotIndex": faker.number.int({ min: 0, max: 9 })
// }
// }
function createStorageRequest() {
@ -52,7 +52,6 @@ function missingNumberValidationError(field: string) {
return {
error: true,
data: {
type: "validation",
message: "Cannot validate the input",
errors: [
{
@ -70,7 +69,6 @@ function extraValidationError(field: string, value: unknown) {
return {
error: true,
data: {
type: "validation",
message: "Cannot validate the input",
errors: [
{
@ -88,7 +86,6 @@ function missingStringValidationError(field: string) {
return {
error: true,
data: {
type: "validation",
message: "Cannot validate the input",
errors: [
{
@ -106,7 +103,6 @@ function mistypeNumberValidationError(field: string, value: string) {
return {
error: true,
data: {
type: "validation",
message: "Cannot validate the input",
errors: [
{
@ -124,7 +120,6 @@ function minNumberValidationError(field: string, min: number) {
return {
error: true,
data: {
type: "validation",
message: "Cannot validate the input",
errors: [
{
@ -161,7 +156,7 @@ describe("marketplace", () => {
assert.deepStrictEqual(response, missingNumberValidationError("totalSize"));
});
it.only("returns an error when trying to create an availability with an invalid number valid", async () => {
it("returns an error when trying to create an availability with an invalid number valid", async () => {
const response = await marketplace.createAvailability({
duration: 3000,
maxCollateral: 1,
@ -193,8 +188,6 @@ describe("marketplace", () => {
minPrice: 100,
} as any);
console.info(response.error);
assert.deepStrictEqual(response, missingNumberValidationError("duration"));
});
@ -247,7 +240,9 @@ describe("marketplace", () => {
it("returns a response when the request succeed", async (t) => {
const data = { ...createAvailability(), freeSize: 1000 };
t.mock.method(Fetch, "safe", () => Promise.resolve({ error: false, data }));
t.mock.method(Fetch, "safeJson", () =>
Promise.resolve({ error: false, data })
);
const response = await marketplace.createAvailability({
maxCollateral: 1,
@ -262,7 +257,9 @@ describe("marketplace", () => {
it("returns a response when the create availability succeed", async (t) => {
const data = { ...createAvailability(), freeSize: 1000 };
t.mock.method(Fetch, "safe", () => Promise.resolve({ error: false, data }));
t.mock.method(Fetch, "safeJson", () =>
Promise.resolve({ error: false, data })
);
const response = await marketplace.createAvailability({
maxCollateral: 1,
@ -301,7 +298,9 @@ describe("marketplace", () => {
it("returns a response when the update availability succeed", async (t) => {
const data = createAvailability();
t.mock.method(Fetch, "safe", () => Promise.resolve({ error: false, data }));
t.mock.method(Fetch, "safeJson", () =>
Promise.resolve({ error: false, data })
);
const response = await marketplace.updateAvailability({
id: faker.string.alphanumeric(64),

View File

@ -28,7 +28,7 @@ export class Marketplace {
async activeSlots(): Promise<SafeValue<CodexSlot[]>> {
const url = this.url + Api.config.prefix + "/sales/slots";
return Fetch.safe<CodexSlot[]>(url, {
return Fetch.safeJson<CodexSlot[]>(url, {
method: "GET",
});
}
@ -39,7 +39,7 @@ export class Marketplace {
async activeSlot(slotId: string): Promise<SafeValue<CodexSlot>> {
const url = this.url + Api.config.prefix + "/sales/slots/" + slotId;
return Fetch.safe<CodexSlot>(url, {
return Fetch.safeJson<CodexSlot>(url, {
method: "GET",
});
}
@ -50,7 +50,7 @@ export class Marketplace {
async availabilities(): Promise<SafeValue<CodexAvailability[]>> {
const url = this.url + Api.config.prefix + "/sales/availability";
return Fetch.safe<CodexAvailability[]>(url, {
return Fetch.safeJson<CodexAvailability[]>(url, {
method: "GET",
});
}
@ -75,7 +75,7 @@ export class Marketplace {
const url = this.url + Api.config.prefix + "/sales/availability";
return Fetch.safe<CodexAvailabilityCreateResponse>(url, {
return Fetch.safeJson<CodexAvailabilityCreateResponse>(url, {
method: "POST",
headers: {
"content-type": "application/json",
@ -106,7 +106,7 @@ export class Marketplace {
const url =
this.url + Api.config.prefix + "/sales/availability/" + result.output.id;
return Fetch.safe<CodexAvailability>(url, {
return Fetch.safeJson<CodexAvailability>(url, {
method: "POST",
headers: {
"content-type": "application/json",
@ -126,7 +126,7 @@ export class Marketplace {
Api.config.prefix +
`/sales/availability/${availabilityId}/reservations`;
return Fetch.safe<CodexReservation[]>(url, {
return Fetch.safeJson<CodexReservation[]>(url, {
method: "GET",
});
}
@ -137,7 +137,7 @@ export class Marketplace {
async purchaseIds(): Promise<SafeValue<string[]>> {
const url = this.url + Api.config.prefix + `/storage/purchases`;
return Fetch.safe<string[]>(url, {
return Fetch.safeJson<string[]>(url, {
method: "GET",
});
}
@ -149,7 +149,7 @@ export class Marketplace {
const url =
this.url + Api.config.prefix + `/storage/purchases/` + purchaseId;
return Fetch.safe<CodexPurchase>(url, {
return Fetch.safeJson<CodexPurchase>(url, {
method: "GET",
});
}
@ -175,7 +175,7 @@ export class Marketplace {
const { cid, ...body } = result.output;
const url = this.url + Api.config.prefix + "/storage/request/" + cid;
return Fetch.safe<CodexCreateStorageRequestResponse>(url, {
return Fetch.safeJson<CodexCreateStorageRequestResponse>(url, {
method: "POST",
headers: {
"content-type": "application/json",

53
src/node/node.ts Normal file
View File

@ -0,0 +1,53 @@
import { Api } from "../api/config";
import { Fetch } from "../fetch-safe/fetch-safe";
export class Node {
readonly url: string;
constructor(url: string) {
this.url = url;
}
/**
* Connect to a peer
* TODO check result
*/
connect(peerId: string, addrs: string[] = []) {
const params = new URLSearchParams();
for (const addr of addrs) {
params.append("addrs", addr);
}
const url =
this.url + Api.config.prefix + `/connect/${peerId}?` + addrs.toString();
return Fetch.safe(url, {
method: "GET",
});
}
/**
* Get Node's SPR
* TODO check result
*/
spr() {
const url = this.url + Api.config.prefix + "/spr";
return Fetch.safe(url, {
method: "GET",
});
}
/**
* Get Node's PeerID
* TODO check result
*/
peerId() {
const url = this.url + Api.config.prefix + "/node/peerid";
return Fetch.safe(url, {
method: "GET",
});
}
}

View File

@ -0,0 +1,21 @@
import assert from "assert";
import { describe, it } from "node:test";
import { Promises } from "./promise-safe";
describe("promise safe", () => {
it("returns an error when the promise failed", async () => {
const result = await Promises.safe(
() => new Promise((_, reject) => reject("error")),
);
assert.deepStrictEqual(result, { error: true, data: { message: "error" } });
});
it("returns the value when the promise succeed", async () => {
const result = await Promises.safe(
() => new Promise((resolve) => resolve("ok")),
);
assert.deepStrictEqual(result, { error: false, data: "ok" });
});
});

View File

@ -0,0 +1,21 @@
import type { SafeValue } from "../values/values";
export const Promises = {
async safe<T>(promise: () => Promise<T>): Promise<SafeValue<T>> {
try {
const result = await promise();
return { error: false, data: result };
} catch (e) {
const opts = e instanceof Error && e.stack ? { stack: e.stack } : {};
return {
error: true,
data: {
message: e instanceof Error ? e.message : "" + e,
...opts,
},
};
}
},
};

View File

@ -13,7 +13,6 @@
"outDir": "./dist",
"module": "ESNext",
"moduleResolution": "Bundler",
"verbatimModuleSyntax": true,
},
"buildOptions": {}
"verbatimModuleSyntax": true
}
}

14
tsconfig.test.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "@tsconfig/strictest/tsconfig.json",
"include": [
"src"
],
"compilerOptions": {
"declaration": true,
"lib": [
"ES2020",
"DOM",
],
"outDir": "./dist"
}
}