reorganized storage adapter and added test suites for adapter and fields (#124)

* reorganized storage adapter and added test suites for adapter and fields

* added build command in ci pipeline

* updated workflow to also run node tests

* updated workflow: try with separate tasks

* updated workflow: try with separate tasks

* updated workflow: added tsx as dev dependency

* updated workflow: try with find instead of glob
This commit is contained in:
dswbx
2025-03-27 20:41:42 +01:00
committed by GitHub
parent 40c9ef9d90
commit 9e3c081e50
45 changed files with 605 additions and 940 deletions

View File

@@ -21,6 +21,14 @@ jobs:
working-directory: ./app working-directory: ./app
run: bun install run: bun install
- name: Run tests - name: Build
working-directory: ./app working-directory: ./app
run: bun run test run: bun run build:ci
- name: Run Bun tests
working-directory: ./app
run: bun run test:bun
- name: Run Node tests
working-directory: ./app
run: npm run test:node

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src"; import { createApp, registries } from "../../src";
import * as proto from "../../src/data/prototype"; import * as proto from "../../src/data/prototype";
import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
describe("repros", async () => { describe("repros", async () => {
/** /**

View File

@@ -3,8 +3,10 @@ import { OAuthStrategy } from "../../../src/auth/authenticate/strategies";
const ALL_TESTS = !!process.env.ALL_TESTS; const ALL_TESTS = !!process.env.ALL_TESTS;
// @todo: add mock response
describe("OAuthStrategy", async () => { describe("OAuthStrategy", async () => {
const strategy = new OAuthStrategy({ return;
/*const strategy = new OAuthStrategy({
type: "oidc", type: "oidc",
client: { client: {
client_id: process.env.OAUTH_CLIENT_ID!, client_id: process.env.OAUTH_CLIENT_ID!,
@@ -21,6 +23,7 @@ describe("OAuthStrategy", async () => {
const server = Bun.serve({ const server = Bun.serve({
fetch: async (req) => { fetch: async (req) => {
console.log("req", req.method, req.url);
const url = new URL(req.url); const url = new URL(req.url);
if (url.pathname === "/auth/google/callback") { if (url.pathname === "/auth/google/callback") {
console.log("req", req); console.log("req", req);
@@ -42,5 +45,5 @@ describe("OAuthStrategy", async () => {
console.log("request", request); console.log("request", request);
await new Promise((resolve) => setTimeout(resolve, 100000)); await new Promise((resolve) => setTimeout(resolve, 100000));
}); });*/
}); });

View File

@@ -1,57 +0,0 @@
import * as assert from "node:assert/strict";
import { createWriteStream } from "node:fs";
import { after, beforeEach, describe, test } from "node:test";
import { Miniflare } from "miniflare";
import {
CloudflareKVCacheItem,
CloudflareKVCachePool,
} from "../../../src/core/cache/adapters/CloudflareKvCache";
import { runTests } from "./cache-test-suite";
// https://github.com/nodejs/node/issues/44372#issuecomment-1736530480
console.log = async (message: any) => {
const tty = createWriteStream("/dev/tty");
const msg = typeof message === "string" ? message : JSON.stringify(message, null, 2);
return tty.write(`${msg}\n`);
};
describe("CloudflareKv", async () => {
let mf: Miniflare;
runTests({
createCache: async () => {
if (mf) {
await mf.dispose();
}
mf = new Miniflare({
modules: true,
script: "export default { async fetch() { return new Response(null); } }",
kvNamespaces: ["TEST"],
});
const kv = await mf.getKVNamespace("TEST");
return new CloudflareKVCachePool(kv as any);
},
createItem: (key, value) => new CloudflareKVCacheItem(key, value),
tester: {
test,
beforeEach,
expect: (actual?: any) => {
return {
toBe(expected: any) {
assert.equal(actual, expected);
},
toEqual(expected: any) {
assert.deepEqual(actual, expected);
},
toBeUndefined() {
assert.equal(actual, undefined);
},
};
},
},
});
after(async () => {
await mf?.dispose();
});
});

View File

@@ -1,15 +0,0 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { MemoryCache, MemoryCacheItem } from "../../../src/core/cache/adapters/MemoryCache";
import { runTests } from "./cache-test-suite";
describe("MemoryCache", () => {
runTests({
createCache: async () => new MemoryCache(),
createItem: (key, value) => new MemoryCacheItem(key, value),
tester: {
test,
beforeEach,
expect,
},
});
});

View File

@@ -1,84 +0,0 @@
//import { beforeEach as bunBeforeEach, expect as bunExpect, test as bunTest } from "bun:test";
import type { ICacheItem, ICachePool } from "../../../src/core/cache/cache-interface";
export type TestOptions = {
createCache: () => Promise<ICachePool>;
createItem: (key: string, value: any) => ICacheItem;
tester: {
test: (name: string, fn: () => Promise<void>) => void;
beforeEach: (fn: () => Promise<void>) => void;
expect: (actual?: any) => {
toBe(expected: any): void;
toEqual(expected: any): void;
toBeUndefined(): void;
};
};
};
export function runTests({ createCache, createItem, tester }: TestOptions) {
let cache: ICachePool<string>;
const { test, beforeEach, expect } = tester;
beforeEach(async () => {
cache = await createCache();
});
test("getItem returns correct item", async () => {
const item = createItem("key1", "value1");
await cache.save(item);
const retrievedItem = await cache.get("key1");
expect(retrievedItem.value()).toEqual(item.value());
});
test("getItem returns new item when key does not exist", async () => {
const retrievedItem = await cache.get("key1");
expect(retrievedItem.key()).toEqual("key1");
expect(retrievedItem.value()).toBeUndefined();
});
test("getItems returns correct items", async () => {
const item1 = createItem("key1", "value1");
const item2 = createItem("key2", "value2");
await cache.save(item1);
await cache.save(item2);
const retrievedItems = await cache.getMany(["key1", "key2"]);
expect(retrievedItems.get("key1")?.value()).toEqual(item1.value());
expect(retrievedItems.get("key2")?.value()).toEqual(item2.value());
});
test("hasItem returns true when item exists and is a hit", async () => {
const item = createItem("key1", "value1");
await cache.save(item);
expect(await cache.has("key1")).toBe(true);
});
test("clear and deleteItem correctly clear the cache and delete items", async () => {
const item = createItem("key1", "value1");
await cache.save(item);
if (cache.supports().clear) {
await cache.clear();
} else {
await cache.delete("key1");
}
expect(await cache.has("key1")).toBe(false);
});
test("save correctly saves items to the cache", async () => {
const item = createItem("key1", "value1");
await cache.save(item);
expect(await cache.has("key1")).toBe(true);
});
test("putItem correctly puts items in the cache ", async () => {
await cache.put("key1", "value1", { ttl: 60 });
const item = await cache.get("key1");
expect(item.value()).toEqual("value1");
expect(item.hit()).toBe(true);
});
/*test("commit returns true", async () => {
expect(await cache.commit()).toBe(true);
});*/
}

View File

@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { BooleanField } from "../../../../src/data"; import { BooleanField } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc"; import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
describe("[data] BooleanField", async () => { describe("[data] BooleanField", async () => {
runBaseFieldTests(BooleanField, { defaultValue: true, schemaType: "boolean" }); fieldTestSuite({ expect, test }, BooleanField, { defaultValue: true, schemaType: "boolean" });
test("transformRetrieve", async () => { test("transformRetrieve", async () => {
const field = new BooleanField("test"); const field = new BooleanField("test");

View File

@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { DateField } from "../../../../src/data"; import { DateField } from "../../../../src/data";
import { runBaseFieldTests } from "./inc"; import { fieldTestSuite } from "data/fields/field-test-suite";
describe("[data] DateField", async () => { describe("[data] DateField", async () => {
runBaseFieldTests(DateField, { defaultValue: new Date(), schemaType: "date" }); fieldTestSuite({ expect, test }, DateField, { defaultValue: new Date(), schemaType: "date" });
// @todo: add datefield tests // @todo: add datefield tests
test("week", async () => { test("week", async () => {

View File

@@ -1,13 +1,15 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { EnumField } from "../../../../src/data"; import { EnumField } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc"; import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
function options(strings: string[]) { function options(strings: string[]) {
return { type: "strings", values: strings }; return { type: "strings", values: strings };
} }
describe("[data] EnumField", async () => { describe("[data] EnumField", async () => {
runBaseFieldTests( fieldTestSuite(
{ expect, test },
// @ts-ignore
EnumField, EnumField,
{ defaultValue: "a", schemaType: "text" }, { defaultValue: "a", schemaType: "text" },
{ options: options(["a", "b", "c"]) }, { options: options(["a", "b", "c"]) },
@@ -15,11 +17,13 @@ describe("[data] EnumField", async () => {
test("yields if default value is not a valid option", async () => { test("yields if default value is not a valid option", async () => {
expect( expect(
// @ts-ignore
() => new EnumField("test", { options: options(["a", "b"]), default_value: "c" }), () => new EnumField("test", { options: options(["a", "b"]), default_value: "c" }),
).toThrow(); ).toThrow();
}); });
test("transformPersist (config)", async () => { test("transformPersist (config)", async () => {
// @ts-ignore
const field = new EnumField("test", { options: options(["a", "b", "c"]) }); const field = new EnumField("test", { options: options(["a", "b", "c"]) });
expect(transformPersist(field, null)).resolves.toBeUndefined(); expect(transformPersist(field, null)).resolves.toBeUndefined();
@@ -29,6 +33,7 @@ describe("[data] EnumField", async () => {
test("transformRetrieve", async () => { test("transformRetrieve", async () => {
const field = new EnumField("test", { const field = new EnumField("test", {
// @ts-ignore
options: options(["a", "b", "c"]), options: options(["a", "b", "c"]),
default_value: "a", default_value: "a",
required: true, required: true,

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Default, stripMark } from "../../../../src/core/utils"; import { Default, stripMark } from "../../../../src/core/utils";
import { baseFieldConfigSchema, Field } from "../../../../src/data/fields/Field"; import { baseFieldConfigSchema, Field } from "../../../../src/data/fields/Field";
import { runBaseFieldTests } from "./inc"; import { fieldTestSuite } from "data/fields/field-test-suite";
describe("[data] Field", async () => { describe("[data] Field", async () => {
class FieldSpec extends Field { class FieldSpec extends Field {
@@ -19,7 +19,7 @@ describe("[data] Field", async () => {
}); });
}); });
runBaseFieldTests(FieldSpec, { defaultValue: "test", schemaType: "text" }); fieldTestSuite({ expect, test }, FieldSpec, { defaultValue: "test", schemaType: "text" });
test("default config", async () => { test("default config", async () => {
const config = Default(baseFieldConfigSchema, {}); const config = Default(baseFieldConfigSchema, {});

View File

@@ -1,19 +1,13 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Type } from "../../../../src/core/utils"; import { Type } from "../../../../src/core/utils";
import { import { Entity, EntityIndex, Field } from "../../../../src/data";
Entity,
EntityIndex,
type EntityManager,
Field,
type SchemaResponse,
} from "../../../../src/data";
class TestField extends Field { class TestField extends Field {
protected getSchema(): any { protected getSchema(): any {
return Type.Any(); return Type.Any();
} }
schema(em: EntityManager<any>): SchemaResponse { override schema() {
return undefined as any; return undefined as any;
} }
} }

View File

@@ -1,10 +1,10 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { JsonField } from "../../../../src/data"; import { JsonField } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc"; import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
describe("[data] JsonField", async () => { describe("[data] JsonField", async () => {
const field = new JsonField("test"); const field = new JsonField("test");
runBaseFieldTests(JsonField, { fieldTestSuite({ expect, test }, JsonField, {
defaultValue: { a: 1 }, defaultValue: { a: 1 },
sampleValues: ["string", { test: 1 }, 1], sampleValues: ["string", { test: 1 }, 1],
schemaType: "text", schemaType: "text",

View File

@@ -1,9 +1,10 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { JsonSchemaField } from "../../../../src/data"; import { JsonSchemaField } from "../../../../src/data";
import { runBaseFieldTests } from "./inc"; import { fieldTestSuite } from "data/fields/field-test-suite";
describe("[data] JsonSchemaField", async () => { describe("[data] JsonSchemaField", async () => {
runBaseFieldTests(JsonSchemaField, { defaultValue: {}, schemaType: "text" }); // @ts-ignore
fieldTestSuite({ expect, test }, JsonSchemaField, { defaultValue: {}, schemaType: "text" });
// @todo: add JsonSchemaField tests // @todo: add JsonSchemaField tests
}); });

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { NumberField } from "../../../../src/data"; import { NumberField } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc"; import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
describe("[data] NumberField", async () => { describe("[data] NumberField", async () => {
test("transformPersist (config)", async () => { test("transformPersist (config)", async () => {
@@ -15,5 +15,5 @@ describe("[data] NumberField", async () => {
expect(transformPersist(field2, 10000)).resolves.toBe(10000); expect(transformPersist(field2, 10000)).resolves.toBe(10000);
}); });
runBaseFieldTests(NumberField, { defaultValue: 12, schemaType: "integer" }); fieldTestSuite({ expect, test }, NumberField, { defaultValue: 12, schemaType: "integer" });
}); });

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { TextField } from "../../../../src/data"; import { TextField } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc"; import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
describe("[data] TextField", async () => { describe("[data] TextField", async () => {
test("transformPersist (config)", async () => { test("transformPersist (config)", async () => {
@@ -11,5 +11,5 @@ describe("[data] TextField", async () => {
expect(transformPersist(field, "abc")).resolves.toBe("abc"); expect(transformPersist(field, "abc")).resolves.toBe("abc");
}); });
runBaseFieldTests(TextField, { defaultValue: "abc", schemaType: "text" }); fieldTestSuite({ expect, test }, TextField, { defaultValue: "abc", schemaType: "text" });
}); });

View File

@@ -4,7 +4,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src"; import { createApp, registries } from "../../src";
import { mergeObject, randomString } from "../../src/core/utils"; import { mergeObject, randomString } from "../../src/core/utils";
import type { TAppMediaConfig } from "../../src/media/media-schema"; import type { TAppMediaConfig } from "../../src/media/media-schema";
import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper"; import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper";
beforeAll(() => { beforeAll(() => {

View File

@@ -1,7 +1,10 @@
import * as assert from "node:assert/strict"; import { createWriteStream, readFileSync } from "node:fs";
import { createWriteStream } from "node:fs";
import { test } from "node:test"; import { test } from "node:test";
import { Miniflare } from "miniflare"; import { Miniflare } from "miniflare";
import { StorageR2Adapter } from "adapter/cloudflare/StorageR2Adapter";
import { adapterTestSuite } from "media";
import { nodeTestRunner } from "adapter/node";
import path from "node:path";
// https://github.com/nodejs/node/issues/44372#issuecomment-1736530480 // https://github.com/nodejs/node/issues/44372#issuecomment-1736530480
console.log = async (message: any) => { console.log = async (message: any) => {
@@ -10,25 +13,20 @@ console.log = async (message: any) => {
return tty.write(`${msg}\n`); return tty.write(`${msg}\n`);
}; };
test("what", async () => { test("StorageR2Adapter", async () => {
const mf = new Miniflare({ const mf = new Miniflare({
modules: true, modules: true,
script: "export default { async fetch() { return new Response(null); } }", script: "export default { async fetch() { return new Response(null); } }",
r2Buckets: ["BUCKET"], r2Buckets: ["BUCKET"],
}); });
const bucket = await mf.getR2Bucket("BUCKET"); const bucket = (await mf.getR2Bucket("BUCKET")) as unknown as R2Bucket;
console.log(await bucket.put("count", "1")); const adapter = new StorageR2Adapter(bucket);
const object = await bucket.get("count"); const basePath = path.resolve(import.meta.dirname, "../_assets");
if (object) { const buffer = readFileSync(path.join(basePath, "image.png"));
/*const headers = new Headers(); const file = new File([buffer], "image.png", { type: "image/png" });
object.writeHttpMetadata(headers);
headers.set("etag", object.httpEtag);*/
console.log("yo -->", await object.text());
assert.strictEqual(await object.text(), "1");
}
await adapterTestSuite(nodeTestRunner, adapter, file);
await mf.dispose(); await mf.dispose();
}); });

View File

@@ -1,63 +0,0 @@
import { describe, expect, test } from "bun:test";
import { randomString } from "../../../src/core/utils";
import { StorageCloudinaryAdapter } from "../../../src/media";
import { config } from "dotenv";
const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` });
const {
CLOUDINARY_CLOUD_NAME,
CLOUDINARY_API_KEY,
CLOUDINARY_API_SECRET,
CLOUDINARY_UPLOAD_PRESET,
} = dotenvOutput.parsed!;
const ALL_TESTS = !!process.env.ALL_TESTS;
describe.skipIf(ALL_TESTS)("StorageCloudinaryAdapter", () => {
if (ALL_TESTS) return;
const adapter = new StorageCloudinaryAdapter({
cloud_name: CLOUDINARY_CLOUD_NAME as string,
api_key: CLOUDINARY_API_KEY as string,
api_secret: CLOUDINARY_API_SECRET as string,
upload_preset: CLOUDINARY_UPLOAD_PRESET as string,
});
const file = Bun.file(`${import.meta.dir}/icon.png`);
const _filename = randomString(10);
const filename = `${_filename}.png`;
test("object exists", async () => {
expect(await adapter.objectExists("7fCTBi6L8c.png")).toBeTrue();
process.exit();
});
test("puts object", async () => {
expect(await adapter.objectExists(filename)).toBeFalse();
const result = await adapter.putObject(filename, file);
console.log("result", result);
expect(result).toBeDefined();
expect(result?.name).toBe(filename);
});
test("object exists", async () => {
await Bun.sleep(10000);
const one = await adapter.objectExists(_filename);
const two = await adapter.objectExists(filename);
expect(await adapter.objectExists(filename)).toBeTrue();
});
test("object meta", async () => {
const result = await adapter.getObjectMeta(filename);
console.log("objectMeta:result", result);
expect(result).toBeDefined();
expect(result.type).toBe("image/png");
expect(result.size).toBeGreaterThan(0);
});
test("list objects", async () => {
const result = await adapter.listObjects();
console.log("listObjects:result", result);
});
});

View File

@@ -1,47 +0,0 @@
import { describe, expect, test } from "bun:test";
import { randomString } from "../../../src/core/utils";
import { StorageLocalAdapter } from "../../../src/media/storage/adapters/StorageLocalAdapter";
import { assetsPath, assetsTmpPath } from "../../helper";
describe("StorageLocalAdapter", () => {
const adapter = new StorageLocalAdapter({
path: assetsTmpPath,
});
const file = Bun.file(`${assetsPath}/image.png`);
const _filename = randomString(10);
const filename = `${_filename}.png`;
let objects = 0;
test("puts an object", async () => {
objects = (await adapter.listObjects()).length;
expect(await adapter.putObject(filename, file as unknown as File)).toBeString();
});
test("lists objects", async () => {
expect((await adapter.listObjects()).length).toBe(objects + 1);
});
test("file exists", async () => {
expect(await adapter.objectExists(filename)).toBeTrue();
});
test("gets an object", async () => {
const res = await adapter.getObject(filename, new Headers());
expect(res.ok).toBeTrue();
// @todo: check the content
});
test("gets object meta", async () => {
expect(await adapter.getObjectMeta(filename)).toEqual({
type: file.type, // image/png
size: file.size,
});
});
test("deletes an object", async () => {
expect(await adapter.deleteObject(filename)).toBeUndefined();
expect(await adapter.objectExists(filename)).toBeFalse();
});
});

View File

@@ -1,109 +0,0 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { randomString } from "../../../src/core/utils";
import { StorageS3Adapter } from "../../../src/media";
import { config } from "dotenv";
//import { enableFetchLogging } from "../../helper";
const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` });
const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_URL } =
dotenvOutput.parsed!;
// @todo: mock r2/s3 responses for faster tests
const ALL_TESTS = !!process.env.ALL_TESTS;
console.log("ALL_TESTS?", ALL_TESTS);
/*
// @todo: preparation to mock s3 calls + replace fast-xml-parser
let cleanup: () => void;
beforeAll(async () => {
cleanup = await enableFetchLogging();
});
afterAll(() => {
cleanup();
}); */
describe.skipIf(ALL_TESTS)("StorageS3Adapter", async () => {
if (ALL_TESTS) return;
const versions = [
[
"r2",
new StorageS3Adapter({
access_key: R2_ACCESS_KEY as string,
secret_access_key: R2_SECRET_ACCESS_KEY as string,
url: R2_URL as string,
}),
],
[
"s3",
new StorageS3Adapter({
access_key: AWS_ACCESS_KEY as string,
secret_access_key: AWS_SECRET_KEY as string,
url: AWS_S3_URL as string,
}),
],
] as const;
const _conf = {
adapters: ["r2", "s3"],
tests: [
"listObjects",
"putObject",
"objectExists",
"getObject",
"deleteObject",
"getObjectMeta",
],
};
const file = Bun.file(`${import.meta.dir}/icon.png`);
const filename = `${randomString(10)}.png`;
// single (dev)
//_conf = { adapters: [/*"r2",*/ "s3"], tests: [/*"putObject",*/ "listObjects"] };
function disabled(test: (typeof _conf.tests)[number]) {
return !_conf.tests.includes(test);
}
// @todo: add mocked fetch for faster tests
describe.each(versions)("StorageS3Adapter for %s", async (name, adapter) => {
if (!_conf.adapters.includes(name) || ALL_TESTS) {
console.log("Skipping", name);
return;
}
let objects = 0;
test.skipIf(disabled("putObject"))("puts an object", async () => {
objects = (await adapter.listObjects()).length;
expect(await adapter.putObject(filename, file as any)).toBeString();
});
test.skipIf(disabled("listObjects"))("lists objects", async () => {
expect((await adapter.listObjects()).length).toBe(objects + 1);
});
test.skipIf(disabled("objectExists"))("file exists", async () => {
expect(await adapter.objectExists(filename)).toBeTrue();
});
test.skipIf(disabled("getObject"))("gets an object", async () => {
const res = await adapter.getObject(filename, new Headers());
expect(res.ok).toBeTrue();
// @todo: check the content
});
test.skipIf(disabled("getObjectMeta"))("gets object meta", async () => {
expect(await adapter.getObjectMeta(filename)).toEqual({
type: file.type, // image/png
size: file.size,
});
});
test.skipIf(disabled("deleteObject"))("deletes an object", async () => {
expect(await adapter.deleteObject(filename)).toBeUndefined();
expect(await adapter.objectExists(filename)).toBeFalse();
});
});
});

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src"; import { createApp, registries } from "../../src";
import { em, entity, text } from "../../src/data"; import { em, entity, text } from "../../src/data";
import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { AppMedia } from "../../src/modules"; import { AppMedia } from "../../src/modules";
import { moduleTestSuite } from "./module-test-suite"; import { moduleTestSuite } from "./module-test-suite";

View File

@@ -54,7 +54,7 @@ function banner(title: string) {
} }
// collection of always-external packages // collection of always-external packages
const external = ["bun:test", "@libsql/client"] as const; const external = ["bun:test", "node:test", "node:assert/strict", "@libsql/client"] as const;
/** /**
* Building backend and general API * Building backend and general API
@@ -65,7 +65,13 @@ async function buildApi() {
minify, minify,
sourcemap, sourcemap,
watch, watch,
entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"], entry: [
"src/index.ts",
"src/core/index.ts",
"src/core/utils/index.ts",
"src/data/index.ts",
"src/media/index.ts",
],
outDir: "dist", outDir: "dist",
external: [...external], external: [...external],
metafile: true, metafile: true,

View File

@@ -16,9 +16,13 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"test": "ALL_TESTS=1 bun test --bail", "test": "ALL_TESTS=1 bun test --bail",
"test:all": "bun run test && bun run test:node",
"test:bun": "ALL_TESTS=1 bun test --bail",
"test:node": "tsx --test $(find . -type f -name '*.native-spec.ts')",
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage", "test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
"build": "NODE_ENV=production bun run build.ts --minify --types", "build": "NODE_ENV=production bun run build.ts --minify --types",
"build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli", "build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
"build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts",
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --env PUBLIC_* --minify", "build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --env PUBLIC_* --minify",
"build:static": "vite build", "build:static": "vite build",
"watch": "bun run build.ts --types --watch", "watch": "bun run build.ts --types --watch",
@@ -101,6 +105,7 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tsc-alias": "^1.8.11", "tsc-alias": "^1.8.11",
"tsup": "^8.4.0", "tsup": "^8.4.0",
"tsx": "^4.19.3",
"vite": "^6.2.1", "vite": "^6.2.1",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"wouter": "^3.6.0" "wouter": "^3.6.0"
@@ -156,6 +161,11 @@
"import": "./dist/cli/index.js", "import": "./dist/cli/index.js",
"require": "./dist/cli/index.cjs" "require": "./dist/cli/index.cjs"
}, },
"./media": {
"types": "./dist/types/media/index.d.ts",
"import": "./dist/media/index.js",
"require": "./dist/media/index.cjs"
},
"./adapter/cloudflare": { "./adapter/cloudflare": {
"types": "./dist/types/adapter/cloudflare/index.d.ts", "types": "./dist/types/adapter/cloudflare/index.d.ts",
"import": "./dist/adapter/cloudflare/index.js", "import": "./dist/adapter/cloudflare/index.js",

View File

@@ -1,7 +1,8 @@
import { registries } from "bknd"; import { registries } from "bknd";
import { isDebug } from "bknd/core"; import { isDebug } from "bknd/core";
import { StringEnum, Type } from "bknd/utils"; import { StringEnum, Type } from "bknd/utils";
import type { FileBody, StorageAdapter } from "media/storage/Storage"; import type { FileBody } from "media/storage/Storage";
import { StorageAdapter } from "media/storage/StorageAdapter";
import { guess } from "media/storage/mime-types-tiny"; import { guess } from "media/storage/mime-types-tiny";
import { getBindings } from "./bindings"; import { getBindings } from "./bindings";
@@ -47,8 +48,10 @@ export function registerMedia(env: Record<string, any>) {
* Adapter for R2 storage * Adapter for R2 storage
* @todo: add tests (bun tests won't work, need node native tests) * @todo: add tests (bun tests won't work, need node native tests)
*/ */
export class StorageR2Adapter implements StorageAdapter { export class StorageR2Adapter extends StorageAdapter {
constructor(private readonly bucket: R2Bucket) {} constructor(private readonly bucket: R2Bucket) {
super();
}
getName(): string { getName(): string {
return "r2"; return "r2";

View File

@@ -1,11 +1,9 @@
import { registries } from "bknd"; import { registries } from "bknd";
import { import { type LocalAdapterConfig, StorageLocalAdapter } from "./storage/StorageLocalAdapter";
type LocalAdapterConfig,
StorageLocalAdapter,
} from "../../media/storage/adapters/StorageLocalAdapter";
export * from "./node.adapter"; export * from "./node.adapter";
export { StorageLocalAdapter, type LocalAdapterConfig }; export { StorageLocalAdapter, type LocalAdapterConfig };
export { nodeTestRunner } from "./test";
export function registerLocalMediaAdapter() { export function registerLocalMediaAdapter() {
registries.media.register("local", StorageLocalAdapter); registries.media.register("local", StorageLocalAdapter);

View File

@@ -0,0 +1,17 @@
import { describe } from "node:test";
import { StorageLocalAdapter, nodeTestRunner } from "adapter/node";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import { readFileSync } from "node:fs";
import path from "node:path";
describe("StorageLocalAdapter (node)", async () => {
const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets");
const buffer = readFileSync(path.join(basePath, "image.png"));
const file = new File([buffer], "image.png", { type: "image/png" });
const adapter = new StorageLocalAdapter({
path: path.join(basePath, "tmp"),
});
await adapterTestSuite(nodeTestRunner, adapter, file);
});

View File

@@ -0,0 +1,14 @@
import { describe, test, expect } from "bun:test";
import { StorageLocalAdapter } from "./StorageLocalAdapter";
// @ts-ignore
import { assetsPath, assetsTmpPath } from "../../../../__test__/helper";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
describe("StorageLocalAdapter (bun)", async () => {
const adapter = new StorageLocalAdapter({
path: assetsTmpPath,
});
const file = Bun.file(`${assetsPath}/image.png`);
await adapterTestSuite({ test, expect }, adapter, file);
});

View File

@@ -1,13 +1,7 @@
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
import { type Static, Type, isFile, parse } from "core/utils"; import { type Static, Type, isFile, parse } from "bknd/utils";
import type { import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd/media";
FileBody, import { StorageAdapter, guessMimeType as guess } from "bknd/media";
FileListObject,
FileMeta,
FileUploadPayload,
StorageAdapter,
} from "../../Storage";
import { guess } from "../../mime-types-tiny";
export const localAdapterConfig = Type.Object( export const localAdapterConfig = Type.Object(
{ {
@@ -17,10 +11,11 @@ export const localAdapterConfig = Type.Object(
); );
export type LocalAdapterConfig = Static<typeof localAdapterConfig>; export type LocalAdapterConfig = Static<typeof localAdapterConfig>;
export class StorageLocalAdapter implements StorageAdapter { export class StorageLocalAdapter extends StorageAdapter {
private config: LocalAdapterConfig; private config: LocalAdapterConfig;
constructor(config: any) { constructor(config: any) {
super();
this.config = parse(localAdapterConfig, config); this.config = parse(localAdapterConfig, config);
} }

View File

@@ -0,0 +1,75 @@
import nodeAssert from "node:assert/strict";
import { test } from "node:test";
import type { Matcher, Test, TestFn, TestRunner } from "core/test";
const nodeTestMatcher = <T = unknown>(actual: T, parentFailMsg?: string) =>
({
toEqual: (expected: T, failMsg = parentFailMsg) => {
nodeAssert.deepEqual(actual, expected, failMsg);
},
toBe: (expected: T, failMsg = parentFailMsg) => {
nodeAssert.strictEqual(actual, expected, failMsg);
},
toBeString: (failMsg = parentFailMsg) => {
nodeAssert.strictEqual(typeof actual, "string", failMsg);
},
toBeUndefined: (failMsg = parentFailMsg) => {
nodeAssert.strictEqual(actual, undefined, failMsg);
},
toBeDefined: (failMsg = parentFailMsg) => {
nodeAssert.notStrictEqual(actual, undefined, failMsg);
},
toBeOneOf: (expected: T | Array<T> | Iterable<T>, failMsg = parentFailMsg) => {
const e = Array.isArray(expected) ? expected : [expected];
nodeAssert.ok(e.includes(actual), failMsg);
},
}) satisfies Matcher<T>;
const nodeTestResolverProxy = <T = unknown>(
actual: Promise<T>,
handler: { resolve?: any; reject?: any },
) => {
return new Proxy(
{},
{
get: (_, prop) => {
if (prop === "then") {
return actual.then(handler.resolve, handler.reject);
}
return actual;
},
},
) as Matcher<Awaited<T>>;
};
function nodeTest(label: string, fn: TestFn, options?: any) {
return test(label, fn as any);
}
nodeTest.if = (condition: boolean): Test => {
if (condition) {
return nodeTest;
}
return (() => {}) as any;
};
nodeTest.skip = (label: string, fn: TestFn) => {
return test.skip(label, fn as any);
};
nodeTest.skipIf = (condition: boolean): Test => {
if (condition) {
return (() => {}) as any;
}
return nodeTest;
};
export const nodeTestRunner: TestRunner = {
test: nodeTest,
expect: <T = unknown>(actual?: T, failMsg?: string) => ({
...nodeTestMatcher(actual, failMsg),
resolves: nodeTestResolverProxy(actual as Promise<T>, {
resolve: (r) => nodeTestMatcher(r, failMsg),
}),
rejects: nodeTestResolverProxy(actual as Promise<T>, {
reject: (r) => nodeTestMatcher(r, failMsg),
}),
}),
};

View File

@@ -1,127 +0,0 @@
import type { ICacheItem, ICachePool } from "../cache-interface";
export class CloudflareKVCachePool<Data = any> implements ICachePool<Data> {
constructor(private namespace: KVNamespace) {}
supports = () => ({
metadata: true,
clear: false,
});
async get(key: string): Promise<ICacheItem<Data>> {
const result = await this.namespace.getWithMetadata<any>(key);
const hit = result.value !== null && typeof result.value !== "undefined";
// Assuming metadata is not supported directly;
// you may adjust if Cloudflare KV supports it in future.
return new CloudflareKVCacheItem(key, result.value ?? undefined, hit, result.metadata) as any;
}
async getMany(keys: string[] = []): Promise<Map<string, ICacheItem<Data>>> {
const items = new Map<string, ICacheItem<Data>>();
await Promise.all(
keys.map(async (key) => {
const item = await this.get(key);
items.set(key, item);
}),
);
return items;
}
async has(key: string): Promise<boolean> {
const data = await this.namespace.get(key);
return data !== null;
}
async clear(): Promise<boolean> {
// Cloudflare KV does not support clearing all keys in one operation
return false;
}
async delete(key: string): Promise<boolean> {
await this.namespace.delete(key);
return true;
}
async deleteMany(keys: string[]): Promise<boolean> {
const results = await Promise.all(keys.map((key) => this.delete(key)));
return results.every((result) => result);
}
async save(item: CloudflareKVCacheItem<Data>): Promise<boolean> {
await this.namespace.put(item.key(), (await item.value()) as string, {
expirationTtl: item._expirationTtl,
metadata: item.metadata(),
});
return true;
}
async put(
key: string,
value: any,
options?: { ttl?: number; expiresAt?: Date; metadata?: Record<string, string> },
): Promise<boolean> {
const item = new CloudflareKVCacheItem(key, value, true, options?.metadata);
if (options?.expiresAt) item.expiresAt(options.expiresAt);
if (options?.ttl) item.expiresAfter(options.ttl);
return await this.save(item);
}
}
export class CloudflareKVCacheItem<Data = any> implements ICacheItem<Data> {
_expirationTtl: number | undefined;
constructor(
private _key: string,
private data: Data | undefined,
private _hit: boolean = false,
private _metadata: Record<string, string> = {},
) {}
key(): string {
return this._key;
}
value(): Data | undefined {
if (this.data) {
try {
return JSON.parse(this.data as string);
} catch (e) {}
}
return this.data ?? undefined;
}
metadata(): Record<string, string> {
return this._metadata;
}
hit(): boolean {
return this._hit;
}
set(value: Data, metadata: Record<string, string> = {}): this {
this.data = value;
this._metadata = metadata;
return this;
}
expiresAt(expiration: Date | null): this {
// Cloudflare KV does not support specific date expiration; calculate ttl instead.
if (expiration) {
const now = new Date();
const ttl = (expiration.getTime() - now.getTime()) / 1000;
return this.expiresAfter(Math.max(0, Math.floor(ttl)));
}
return this.expiresAfter(null);
}
expiresAfter(time: number | null): this {
// Dummy implementation as Cloudflare KV requires setting expiration during PUT operation.
// This method will be effectively implemented in the Cache Pool save methods.
this._expirationTtl = time ?? undefined;
return this;
}
}

View File

@@ -1,139 +0,0 @@
import type { ICacheItem, ICachePool } from "../cache-interface";
export class MemoryCache<Data = any> implements ICachePool<Data> {
private cache: Map<string, MemoryCacheItem<Data>> = new Map();
private maxSize?: number;
constructor(options?: { maxSize?: number }) {
this.maxSize = options?.maxSize;
}
supports = () => ({
metadata: true,
clear: true,
});
async get(key: string): Promise<MemoryCacheItem<Data>> {
if (!this.cache.has(key)) {
// use undefined to denote a miss initially
return new MemoryCacheItem<Data>(key, undefined!);
}
return this.cache.get(key)!;
}
async getMany(keys: string[] = []): Promise<Map<string, MemoryCacheItem<Data>>> {
const items = new Map<string, MemoryCacheItem<Data>>();
for (const key of keys) {
items.set(key, await this.get(key));
}
return items;
}
async has(key: string): Promise<boolean> {
return this.cache.has(key) && this.cache.get(key)!.hit();
}
async clear(): Promise<boolean> {
this.cache.clear();
return true;
}
async delete(key: string): Promise<boolean> {
return this.cache.delete(key);
}
async deleteMany(keys: string[]): Promise<boolean> {
let success = true;
for (const key of keys) {
if (!this.delete(key)) {
success = false;
}
}
return success;
}
async save(item: MemoryCacheItem<Data>): Promise<boolean> {
this.checkSizeAndPurge();
this.cache.set(item.key(), item);
return true;
}
async put(
key: string,
value: Data,
options: { expiresAt?: Date; ttl?: number; metadata?: Record<string, string> } = {},
): Promise<boolean> {
const item = await this.get(key);
item.set(value, options.metadata || {});
if (options.expiresAt) {
item.expiresAt(options.expiresAt);
} else if (typeof options.ttl === "number") {
item.expiresAfter(options.ttl);
}
return this.save(item);
}
private checkSizeAndPurge(): void {
if (!this.maxSize) return;
if (this.cache.size >= this.maxSize) {
// Implement logic to purge items, e.g., LRU (Least Recently Used)
// For simplicity, clear the oldest item inserted
const keyToDelete = this.cache.keys().next().value;
this.cache.delete(keyToDelete!);
}
}
}
export class MemoryCacheItem<Data = any> implements ICacheItem<Data> {
private _key: string;
private _value: Data | undefined;
private expiration: Date | null = null;
private _metadata: Record<string, string> = {};
constructor(key: string, value: Data, metadata: Record<string, string> = {}) {
this._key = key;
this.set(value, metadata);
}
key(): string {
return this._key;
}
metadata(): Record<string, string> {
return this._metadata;
}
value(): Data | undefined {
return this._value;
}
hit(): boolean {
if (this.expiration !== null && new Date() > this.expiration) {
return false;
}
return this.value() !== undefined;
}
set(value: Data, metadata: Record<string, string> = {}): this {
this._value = value;
this._metadata = metadata;
return this;
}
expiresAt(expiration: Date | null): this {
this.expiration = expiration;
return this;
}
expiresAfter(time: number | null): this {
if (typeof time === "number") {
const expirationDate = new Date();
expirationDate.setSeconds(expirationDate.getSeconds() + time);
this.expiration = expirationDate;
} else {
this.expiration = null;
}
return this;
}
}

View File

@@ -1,178 +0,0 @@
/**
* CacheItem defines an interface for interacting with objects inside a cache.
* based on https://www.php-fig.org/psr/psr-6/
*/
export interface ICacheItem<Data = any> {
/**
* Returns the key for the current cache item.
*
* The key is loaded by the Implementing Library, but should be available to
* the higher level callers when needed.
*
* @returns The key string for this cache item.
*/
key(): string;
/**
* Retrieves the value of the item from the cache associated with this object's key.
*
* The value returned must be identical to the value originally stored by set().
*
* If isHit() returns false, this method MUST return null. Note that null
* is a legitimate cached value, so the isHit() method SHOULD be used to
* differentiate between "null value was found" and "no value was found."
*
* @returns The value corresponding to this cache item's key, or undefined if not found.
*/
value(): Data | undefined;
/**
* Retrieves the metadata of the item from the cache associated with this object's key.
*/
metadata(): Record<string, string>;
/**
* Confirms if the cache item lookup resulted in a cache hit.
*
* Note: This method MUST NOT have a race condition between calling isHit()
* and calling get().
*
* @returns True if the request resulted in a cache hit. False otherwise.
*/
hit(): boolean;
/**
* Sets the value represented by this cache item.
*
* The value argument may be any item that can be serialized by PHP,
* although the method of serialization is left up to the Implementing
* Library.
*
* @param value The serializable value to be stored.
* @param metadata The metadata to be associated with the item.
* @returns The invoked object.
*/
set(value: Data, metadata?: Record<string, string>): this;
/**
* Sets the expiration time for this cache item.
*
* @param expiration The point in time after which the item MUST be considered expired.
* If null is passed explicitly, a default value MAY be used. If none is set,
* the value should be stored permanently or for as long as the
* implementation allows.
* @returns The called object.
*/
expiresAt(expiration: Date | null): this;
/**
* Sets the expiration time for this cache item.
*
* @param time The period of time from the present after which the item MUST be considered
* expired. An integer parameter is understood to be the time in seconds until
* expiration. If null is passed explicitly, a default value MAY be used.
* If none is set, the value should be stored permanently or for as long as the
* implementation allows.
* @returns The called object.
*/
expiresAfter(time: number | null): this;
}
/**
* CachePool generates CacheItem objects.
* based on https://www.php-fig.org/psr/psr-6/
*/
export interface ICachePool<Data = any> {
supports(): {
metadata: boolean;
clear: boolean;
};
/**
* Returns a Cache Item representing the specified key.
* This method must always return a CacheItemInterface object, even in case of
* a cache miss. It MUST NOT return null.
*
* @param key The key for which to return the corresponding Cache Item.
* @throws Error If the key string is not a legal value an Error MUST be thrown.
* @returns The corresponding Cache Item.
*/
get(key: string): Promise<ICacheItem<Data>>;
/**
* Returns a traversable set of cache items.
*
* @param keys An indexed array of keys of items to retrieve.
* @throws Error If any of the keys in keys are not a legal value an Error MUST be thrown.
* @returns A traversable collection of Cache Items keyed by the cache keys of
* each item. A Cache item will be returned for each key, even if that
* key is not found. However, if no keys are specified then an empty
* traversable MUST be returned instead.
*/
getMany(keys?: string[]): Promise<Map<string, ICacheItem<Data>>>;
/**
* Confirms if the cache contains specified cache item.
*
* Note: This method MAY avoid retrieving the cached value for performance reasons.
* This could result in a race condition with CacheItemInterface.get(). To avoid
* such situation use CacheItemInterface.isHit() instead.
*
* @param key The key for which to check existence.
* @throws Error If the key string is not a legal value an Error MUST be thrown.
* @returns True if item exists in the cache, false otherwise.
*/
has(key: string): Promise<boolean>;
/**
* Deletes all items in the pool.
* @returns True if the pool was successfully cleared. False if there was an error.
*/
clear(): Promise<boolean>;
/**
* Removes the item from the pool.
*
* @param key The key to delete.
* @throws Error If the key string is not a legal value an Error MUST be thrown.
* @returns True if the item was successfully removed. False if there was an error.
*/
delete(key: string): Promise<boolean>;
/**
* Removes multiple items from the pool.
*
* @param keys An array of keys that should be removed from the pool.
* @throws Error If any of the keys in keys are not a legal value an Error MUST be thrown.
* @returns True if the items were successfully removed. False if there was an error.
*/
deleteMany(keys: string[]): Promise<boolean>;
/**
* Persists a cache item immediately.
*
* @param item The cache item to save.
* @returns True if the item was successfully persisted. False if there was an error.
*/
save(item: ICacheItem<Data>): Promise<boolean>;
/**
* Persists any deferred cache items.
* @returns True if all not-yet-saved items were successfully saved or there were none. False otherwise.
*/
put(
key: string,
value: any,
options?: { expiresAt?: Date; metadata?: Record<string, string> },
): Promise<boolean>;
put(
key: string,
value: any,
options?: { ttl?: number; metadata?: Record<string, string> },
): Promise<boolean>;
put(
key: string,
value: any,
options?: ({ ttl?: number } | { expiresAt?: Date }) & { metadata?: Record<string, string> },
): Promise<boolean>;
}

View File

@@ -0,0 +1,48 @@
export type Matcher<T = unknown> = {
toEqual: (expected: T, failMsg?: string) => void;
toBe: (expected: T, failMsg?: string) => void;
toBeUndefined: (failMsg?: string) => void;
toBeString: (failMsg?: string) => void;
toBeOneOf: (expected: T | Array<T> | Iterable<T>, failMsg?: string) => void;
toBeDefined: (failMsg?: string) => void;
};
export type TestFn = (() => void | Promise<unknown>) | ((done: (err?: unknown) => void) => void);
export interface Test {
(label: string, fn: TestFn, options?: any): void;
if: (condition: boolean) => (label: string, fn: TestFn, options?: any) => void;
skip: (label: string, fn: () => void) => void;
skipIf: (condition: boolean) => (label: string, fn: TestFn) => void;
}
export type TestRunner = {
test: Test;
expect: <T = unknown>(
actual?: T,
failMsg?: string,
) => Matcher<T> & {
resolves: Matcher<Awaited<T>>;
rejects: Matcher<Awaited<T>>;
};
};
export async function retry<T>(
fn: () => Promise<T>,
condition: (result: T) => boolean,
retries: number,
delay: number,
): Promise<T> {
let lastError: Error | null = null;
for (let i = 0; i < retries; i++) {
try {
const result = await fn();
if (condition(result)) {
return result;
} else {
await new Promise((resolve) => setTimeout(resolve, delay));
}
} catch (error) {
lastError = error as Error;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError;
}

View File

@@ -1,7 +1,7 @@
import { expect, test } from "bun:test"; import type { BaseFieldConfig, Field, TActionContext } from "data";
import type { ColumnDataType } from "kysely"; import type { ColumnDataType } from "kysely";
import { omit } from "lodash-es"; import { omit } from "lodash-es";
import type { BaseFieldConfig, Field, TActionContext } from "../../../../src/data"; import type { TestRunner } from "core/test";
type ConstructableField = new (name: string, config?: Partial<BaseFieldConfig>) => Field; type ConstructableField = new (name: string, config?: Partial<BaseFieldConfig>) => Field;
@@ -15,11 +15,13 @@ export function transformPersist(field: Field, value: any, context?: TActionCont
return field.transformPersist(value, undefined as any, context as any); return field.transformPersist(value, undefined as any, context as any);
} }
export function runBaseFieldTests( export function fieldTestSuite(
testRunner: TestRunner,
fieldClass: ConstructableField, fieldClass: ConstructableField,
config: FieldTestConfig, config: FieldTestConfig,
_requiredConfig: any = {}, _requiredConfig: any = {},
) { ) {
const { test, expect } = testRunner;
const noConfigField = new fieldClass("no_config", _requiredConfig); const noConfigField = new fieldClass("no_config", _requiredConfig);
const fillable = new fieldClass("fillable", { ..._requiredConfig, fillable: true }); const fillable = new fieldClass("fillable", { ..._requiredConfig, fillable: true });
const required = new fieldClass("required", { ..._requiredConfig, required: true }); const required = new fieldClass("required", { ..._requiredConfig, required: true });
@@ -76,9 +78,9 @@ export function runBaseFieldTests(
const isPrimitive = (v) => ["string", "number"].includes(typeof v); const isPrimitive = (v) => ["string", "number"].includes(typeof v);
for (const value of config.sampleValues!) { for (const value of config.sampleValues!) {
// "form" // "form"
expect(isPrimitive(noConfigField.getValue(value, "form"))).toBeTrue(); expect(isPrimitive(noConfigField.getValue(value, "form"))).toBe(true);
// "table" // "table"
expect(isPrimitive(noConfigField.getValue(value, "table"))).toBeTrue(); expect(isPrimitive(noConfigField.getValue(value, "table"))).toBe(true);
// "read" // "read"
// "submit" // "submit"
} }

View File

@@ -53,3 +53,5 @@ export const FieldClassMap = {
json: { schema: jsonFieldConfigSchema, field: JsonField }, json: { schema: jsonFieldConfigSchema, field: JsonField },
jsonschema: { schema: jsonSchemaFieldConfigSchema, field: JsonSchemaField }, jsonschema: { schema: jsonSchemaFieldConfigSchema, field: JsonSchemaField },
} as const; } as const;
export { fieldTestSuite } from "./field-test-suite";

View File

@@ -1,22 +1,24 @@
import type { TObject, TString } from "@sinclair/typebox"; import type { TObject } from "@sinclair/typebox";
import { type Constructor, Registry } from "core"; import { type Constructor, Registry } from "core";
//export { MIME_TYPES } from "./storage/mime-types"; //export { MIME_TYPES } from "./storage/mime-types";
export { guess as guessMimeType } from "./storage/mime-types-tiny"; export { guess as guessMimeType } from "./storage/mime-types-tiny";
export { export {
Storage, Storage,
type StorageAdapter,
type FileMeta, type FileMeta,
type FileListObject, type FileListObject,
type StorageConfig, type StorageConfig,
type FileBody,
type FileUploadPayload,
} from "./storage/Storage"; } from "./storage/Storage";
import type { StorageAdapter } from "./storage/Storage"; import { StorageAdapter } from "./storage/StorageAdapter";
import { import {
type CloudinaryConfig, type CloudinaryConfig,
StorageCloudinaryAdapter, StorageCloudinaryAdapter,
} from "./storage/adapters/StorageCloudinaryAdapter"; } from "./storage/adapters/cloudinary/StorageCloudinaryAdapter";
import { type S3AdapterConfig, StorageS3Adapter } from "./storage/adapters/StorageS3Adapter"; import { type S3AdapterConfig, StorageS3Adapter } from "./storage/adapters/s3/StorageS3Adapter";
export { StorageAdapter };
export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig }; export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig };
export * as StorageEvents from "./storage/events"; export * as StorageEvents from "./storage/events";
@@ -45,3 +47,5 @@ export const Adapters = {
schema: StorageCloudinaryAdapter.prototype.getSchema(), schema: StorageCloudinaryAdapter.prototype.getSchema(),
}, },
} as const; } as const;
export { adapterTestSuite } from "./storage/adapters/adapter-test-suite";

View File

@@ -1,9 +1,10 @@
import { type EmitsEvents, EventManager } from "core/events"; import { type EmitsEvents, EventManager } from "core/events";
import { type TSchema, isFile, detectImageDimensions } from "core/utils"; import { isFile, detectImageDimensions } from "core/utils";
import { isMimeType } from "media/storage/mime-types-tiny"; import { isMimeType } from "media/storage/mime-types-tiny";
import * as StorageEvents from "./events"; import * as StorageEvents from "./events";
import type { FileUploadedEventData } from "./events"; import type { FileUploadedEventData } from "./events";
import { $console } from "core"; import { $console } from "core";
import type { StorageAdapter } from "./StorageAdapter";
export type FileListObject = { export type FileListObject = {
key: string; key: string;
@@ -19,24 +20,6 @@ export type FileUploadPayload = {
etag: string; etag: string;
}; };
export interface StorageAdapter {
/**
* The unique name of the storage adapter
*/
getName(): string;
// @todo: method requires limit/offset parameters
listObjects(prefix?: string): Promise<FileListObject[]>;
putObject(key: string, body: FileBody): Promise<string | FileUploadPayload | undefined>;
deleteObject(key: string): Promise<void>;
objectExists(key: string): Promise<boolean>;
getObject(key: string, headers: Headers): Promise<Response>;
getObjectUrl(key: string): string;
getObjectMeta(key: string): Promise<FileMeta>;
getSchema(): TSchema | undefined;
toJSON(secrets?: boolean): any;
}
export type StorageConfig = { export type StorageConfig = {
body_max_size?: number; body_max_size?: number;
}; };

View File

@@ -0,0 +1,37 @@
import type { FileListObject, FileMeta } from "media";
import type { FileBody, FileUploadPayload } from "media/storage/Storage";
import type { TSchema } from "@sinclair/typebox";
const SYMBOL = Symbol.for("bknd:storage");
export abstract class StorageAdapter {
constructor() {
this[SYMBOL] = true;
}
/**
* This is a helper function to manage Connection classes
* coming from different places
* @param conn
*/
static isAdapter(conn: unknown): conn is StorageAdapter {
if (!conn) return false;
return conn[SYMBOL] === true;
}
/**
* The unique name of the storage adapter
*/
abstract getName(): string;
// @todo: method requires limit/offset parameters
abstract listObjects(prefix?: string): Promise<FileListObject[]>;
abstract putObject(key: string, body: FileBody): Promise<string | FileUploadPayload | undefined>;
abstract deleteObject(key: string): Promise<void>;
abstract objectExists(key: string): Promise<boolean>;
abstract getObject(key: string, headers: Headers): Promise<Response>;
abstract getObjectUrl(key: string): string;
abstract getObjectMeta(key: string): Promise<FileMeta>;
abstract getSchema(): TSchema | undefined;
abstract toJSON(secrets?: boolean): any;
}

View File

@@ -1,5 +0,0 @@
export {
StorageLocalAdapter,
type LocalAdapterConfig,
localAdapterConfig,
} from "./StorageLocalAdapter";

View File

@@ -0,0 +1,79 @@
import { retry, type TestRunner } from "core/test";
import type { StorageAdapter } from "media";
import { randomString } from "core/utils";
import type { BunFile } from "bun";
export async function adapterTestSuite(
testRunner: TestRunner,
adapter: StorageAdapter,
file: File | BunFile,
opts?: {
retries?: number;
retryTimeout?: number;
skipExistsAfterDelete?: boolean;
},
) {
const { test, expect } = testRunner;
const options = {
retries: opts?.retries ?? 1,
retryTimeout: opts?.retryTimeout ?? 1000,
};
let objects = 0;
const _filename = randomString(10);
const filename = `${_filename}.png`;
await test("puts an object", async () => {
objects = (await adapter.listObjects()).length;
const result = await adapter.putObject(filename, file as unknown as File);
expect(result).toBeDefined();
const type = typeof result;
expect(type).toBeOneOf(["string", "object"]);
if (typeof result === "object") {
expect(Object.keys(result).sort()).toEqual(["etag", "meta", "name"]);
expect(result.meta.type).toBe(file.type);
}
});
await test("lists objects", async () => {
const length = await retry(
() => adapter.listObjects().then((res) => res.length),
(length) => length > objects,
options.retries,
options.retryTimeout,
);
expect(length).toBe(objects + 1);
});
await test("file exists", async () => {
expect(await adapter.objectExists(filename)).toBe(true);
});
await test("gets an object", async () => {
const res = await adapter.getObject(filename, new Headers());
expect(res.ok).toBe(true);
// @todo: check the content
});
await test("gets object meta", async () => {
expect(await adapter.getObjectMeta(filename)).toEqual({
type: file.type, // image/png
size: file.size,
});
});
await test("deletes an object", async () => {
expect(await adapter.deleteObject(filename)).toBeUndefined();
if (opts?.skipExistsAfterDelete !== true) {
const exists = await retry(
() => adapter.objectExists(filename),
(res) => res === false,
options.retries,
options.retryTimeout,
);
expect(exists).toBe(false);
}
});
}

View File

@@ -0,0 +1,53 @@
import { describe, expect, test } from "bun:test";
import { StorageCloudinaryAdapter } from "./StorageCloudinaryAdapter";
import { config } from "dotenv";
// @ts-ignore
import { assetsPath, assetsTmpPath } from "../../../../../__test__/helper";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
const dotenvOutput = config({ path: `${import.meta.dir}/.env` });
const {
CLOUDINARY_CLOUD_NAME,
CLOUDINARY_API_KEY,
CLOUDINARY_API_SECRET,
CLOUDINARY_UPLOAD_PRESET,
} = dotenvOutput.parsed!;
const ALL_TESTS = !!process.env.ALL_TESTS;
describe.skipIf(ALL_TESTS)("StorageCloudinaryAdapter", async () => {
if (ALL_TESTS) return;
const adapter = new StorageCloudinaryAdapter({
cloud_name: CLOUDINARY_CLOUD_NAME as string,
api_key: CLOUDINARY_API_KEY as string,
api_secret: CLOUDINARY_API_SECRET as string,
upload_preset: CLOUDINARY_UPLOAD_PRESET as string,
});
const file = Bun.file(`${assetsPath}/image.png`) as unknown as File;
test("hash", async () => {
expect(
await adapter.generateSignature(
{
eager: "w_400,h_300,c_pad|w_260,h_200,c_crop",
public_id: "sample_image",
timestamp: 1315060510,
},
"abcd",
),
).toEqual({
signature: "bfd09f95f331f558cbd1320e67aa8d488770583e",
timestamp: 1315060510,
});
});
await adapterTestSuite({ test, expect }, adapter, file, {
// eventual consistency
retries: 20,
retryTimeout: 1000,
// result is cached from cloudinary
skipExistsAfterDelete: true,
});
});

View File

@@ -1,6 +1,7 @@
import { pickHeaders } from "core/utils"; import { hash, pickHeaders } from "core/utils";
import { type Static, Type, parse } from "core/utils"; import { type Static, Type, parse } from "core/utils";
import type { FileBody, FileListObject, FileMeta, StorageAdapter } from "../Storage"; import type { FileBody, FileListObject, FileMeta } from "../../Storage";
import { StorageAdapter } from "../../StorageAdapter";
export const cloudinaryAdapterConfig = Type.Object( export const cloudinaryAdapterConfig = Type.Object(
{ {
@@ -53,10 +54,11 @@ type CloudinaryListObjectsResponse = {
}; };
// @todo: add signed uploads // @todo: add signed uploads
export class StorageCloudinaryAdapter implements StorageAdapter { export class StorageCloudinaryAdapter extends StorageAdapter {
private config: CloudinaryConfig; private config: CloudinaryConfig;
constructor(config: CloudinaryConfig) { constructor(config: CloudinaryConfig) {
super();
this.config = parse(cloudinaryAdapterConfig, config); this.config = parse(cloudinaryAdapterConfig, config);
} }
@@ -126,6 +128,11 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
}; };
} }
/**
* https://cloudinary.com/documentation/admin_api#search_for_resources
* Cloudinary implements eventual consistency: Search results reflect any changes made to assets within a few seconds after the change
* @param prefix
*/
async listObjects(prefix?: string): Promise<FileListObject[]> { async listObjects(prefix?: string): Promise<FileListObject[]> {
const result = await fetch( const result = await fetch(
`https://api.cloudinary.com/v1_1/${this.config.cloud_name}/resources/search`, `https://api.cloudinary.com/v1_1/${this.config.cloud_name}/resources/search`,
@@ -133,6 +140,7 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
method: "GET", method: "GET",
headers: { headers: {
Accept: "application/json", Accept: "application/json",
"Cache-Control": "no-cache",
...this.getAuthorizationHeader(), ...this.getAuthorizationHeader(),
}, },
}, },
@@ -143,18 +151,22 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
} }
const data = (await result.json()) as CloudinaryListObjectsResponse; const data = (await result.json()) as CloudinaryListObjectsResponse;
return data.resources.map((item) => ({ const items = data.resources.map((item) => ({
key: item.public_id, key: item.public_id,
last_modified: new Date(item.uploaded_at), last_modified: new Date(item.uploaded_at),
size: item.bytes, size: item.bytes,
})); }));
return items;
} }
private async headObject(key: string) { private async headObject(key: string) {
const url = this.getObjectUrl(key); const url = this.getObjectUrl(key);
return await fetch(url, { return await fetch(url, {
method: "GET", method: "HEAD",
headers: { headers: {
"Cache-Control": "no-cache, no-store, must-revalidate",
Pragma: "no-cache",
Expires: "0",
Range: "bytes=0-1", Range: "bytes=0-1",
}, },
}); });
@@ -196,6 +208,22 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
return objectUrl; return objectUrl;
} }
async generateSignature(params: Record<string, string | number>, secret?: string) {
const timestamp = params.timestamp ?? Math.floor(Date.now() / 1000);
const content = Object.entries({ ...params, timestamp })
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
.map(([key, value]) => `${key}=${value}`)
.join("&");
const signature = await hash.sha1(content + (secret ?? this.config.api_secret));
return { signature, timestamp };
}
// get public_id as everything before the last "."
filenameToPublicId(key: string): string {
return key.split(".").slice(0, -1).join(".");
}
async getObject(key: string, headers: Headers): Promise<Response> { async getObject(key: string, headers: Headers): Promise<Response> {
const res = await fetch(this.getObjectUrl(key), { const res = await fetch(this.getObjectUrl(key), {
method: "GET", method: "GET",
@@ -211,13 +239,30 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
async deleteObject(key: string): Promise<void> { async deleteObject(key: string): Promise<void> {
const type = this.guessType(key) ?? "image"; const type = this.guessType(key) ?? "image";
const formData = new FormData(); const public_id = this.filenameToPublicId(key);
formData.append("public_ids[]", key); const { timestamp, signature } = await this.generateSignature({
public_id,
});
await fetch(`https://res.cloudinary.com/${this.config.cloud_name}/${type}/upload/`, { const formData = new FormData();
method: "DELETE", formData.append("public_id", public_id);
formData.append("timestamp", String(timestamp));
formData.append("signature", signature);
formData.append("api_key", this.config.api_key);
const url = `https://api.cloudinary.com/v1_1/${this.config.cloud_name}/${type}/destroy`;
const res = await fetch(url, {
headers: {
Accept: "application/json",
"Cache-Control": "no-cache",
...this.getAuthorizationHeader(),
},
method: "POST",
body: formData, body: formData,
}); });
if (!res.ok) {
throw new Error(`Failed to delete object: ${res.status} ${res.statusText}`);
}
} }
toJSON(secrets?: boolean) { toJSON(secrets?: boolean) {

View File

@@ -0,0 +1,50 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { StorageS3Adapter } from "./StorageS3Adapter";
import { config } from "dotenv";
import { adapterTestSuite } from "media";
import { assetsPath } from "../../../../../__test__/helper";
//import { enableFetchLogging } from "../../helper";
const dotenvOutput = config({ path: `${import.meta.dir}/.env` });
const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_URL } =
dotenvOutput.parsed!;
const ALL_TESTS = !!process.env.ALL_TESTS;
/*
// @todo: preparation to mock s3 calls + replace fast-xml-parser
let cleanup: () => void;
beforeAll(async () => {
cleanup = await enableFetchLogging();
});
afterAll(() => {
cleanup();
}); */
describe.skipIf(ALL_TESTS)("StorageS3Adapter", async () => {
if (ALL_TESTS) return;
const versions = [
[
"r2",
new StorageS3Adapter({
access_key: R2_ACCESS_KEY as string,
secret_access_key: R2_SECRET_ACCESS_KEY as string,
url: R2_URL as string,
}),
],
[
"s3",
new StorageS3Adapter({
access_key: AWS_ACCESS_KEY as string,
secret_access_key: AWS_SECRET_KEY as string,
url: AWS_S3_URL as string,
}),
],
] as const;
const file = Bun.file(`${assetsPath}/image.png`) as unknown as File;
describe.each(versions)("%s", async (_name, adapter) => {
await adapterTestSuite({ test, expect }, adapter, file);
});
});

View File

@@ -9,7 +9,8 @@ import type {
import { AwsClient, isDebug } from "core"; import { AwsClient, isDebug } from "core";
import { type Static, Type, isFile, parse, pickHeaders2 } from "core/utils"; import { type Static, Type, isFile, parse, pickHeaders2 } from "core/utils";
import { transform } from "lodash-es"; import { transform } from "lodash-es";
import type { FileBody, FileListObject, StorageAdapter } from "../Storage"; import type { FileBody, FileListObject } from "../../Storage";
import { StorageAdapter } from "../../StorageAdapter";
export const s3AdapterConfig = Type.Object( export const s3AdapterConfig = Type.Object(
{ {
@@ -32,11 +33,13 @@ export const s3AdapterConfig = Type.Object(
export type S3AdapterConfig = Static<typeof s3AdapterConfig>; export type S3AdapterConfig = Static<typeof s3AdapterConfig>;
export class StorageS3Adapter extends AwsClient implements StorageAdapter { export class StorageS3Adapter extends StorageAdapter {
readonly #config: S3AdapterConfig; readonly #config: S3AdapterConfig;
readonly client: AwsClient;
constructor(config: S3AdapterConfig) { constructor(config: S3AdapterConfig) {
super( super();
this.client = new AwsClient(
{ {
accessKeyId: config.access_key, accessKeyId: config.access_key,
secretAccessKey: config.secret_access_key, secretAccessKey: config.secret_access_key,
@@ -58,10 +61,10 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
return s3AdapterConfig; return s3AdapterConfig;
} }
override getUrl(path: string = "", searchParamsObj: Record<string, any> = {}): string { getUrl(path: string = "", searchParamsObj: Record<string, any> = {}): string {
let url = this.getObjectUrl("").slice(0, -1); let url = this.getObjectUrl("").slice(0, -1);
if (path.length > 0) url += `/${path}`; if (path.length > 0) url += `/${path}`;
return super.getUrl(url, searchParamsObj); return this.client.getUrl(url, searchParamsObj);
} }
/** /**
@@ -82,7 +85,7 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
}; };
const url = this.getUrl("", params); const url = this.getUrl("", params);
const res = await this.fetchJson<{ ListBucketResult: ListObjectsV2Output }>(url, { const res = await this.client.fetchJson<{ ListBucketResult: ListObjectsV2Output }>(url, {
method: "GET", method: "GET",
}); });
@@ -115,7 +118,7 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
params: Omit<PutObjectRequest, "Bucket" | "Key"> = {}, params: Omit<PutObjectRequest, "Bucket" | "Key"> = {},
) { ) {
const url = this.getUrl(key, {}); const url = this.getUrl(key, {});
const res = await this.fetch(url, { const res = await this.client.fetch(url, {
method: "PUT", method: "PUT",
body, body,
headers: isFile(body) headers: isFile(body)
@@ -139,7 +142,7 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
params: Pick<HeadObjectRequest, "PartNumber" | "VersionId"> = {}, params: Pick<HeadObjectRequest, "PartNumber" | "VersionId"> = {},
) { ) {
const url = this.getUrl(key, {}); const url = this.getUrl(key, {});
return await this.fetch(url, { return await this.client.fetch(url, {
method: "HEAD", method: "HEAD",
headers: { headers: {
Range: "bytes=0-1", Range: "bytes=0-1",
@@ -175,7 +178,7 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
*/ */
async getObject(key: string, headers: Headers): Promise<Response> { async getObject(key: string, headers: Headers): Promise<Response> {
const url = this.getUrl(key); const url = this.getUrl(key);
const res = await this.fetch(url, { const res = await this.client.fetch(url, {
method: "GET", method: "GET",
headers: pickHeaders2(headers, [ headers: pickHeaders2(headers, [
"if-none-match", "if-none-match",
@@ -201,7 +204,7 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
params: Omit<DeleteObjectRequest, "Bucket" | "Key"> = {}, params: Omit<DeleteObjectRequest, "Bucket" | "Key"> = {},
): Promise<void> { ): Promise<void> {
const url = this.getUrl(key, params); const url = this.getUrl(key, params);
const res = await this.fetch(url, { const res = await this.client.fetch(url, {
method: "DELETE", method: "DELETE",
}); });
} }

View File

@@ -27,7 +27,7 @@
}, },
"app": { "app": {
"name": "bknd", "name": "bknd",
"version": "0.10.2", "version": "0.10.3-rc.1",
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"dependencies": { "dependencies": {
"@cfworker/json-schema": "^4.1.1", "@cfworker/json-schema": "^4.1.1",
@@ -99,6 +99,7 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tsc-alias": "^1.8.11", "tsc-alias": "^1.8.11",
"tsup": "^8.4.0", "tsup": "^8.4.0",
"tsx": "^4.19.3",
"vite": "^6.2.1", "vite": "^6.2.1",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"wouter": "^3.6.0", "wouter": "^3.6.0",
@@ -125,7 +126,6 @@
"version": "0.5.1", "version": "0.5.1",
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"bknd": "workspace:*",
"tsdx": "^0.14.1", "tsdx": "^0.14.1",
"typescript": "^5.0.0", "typescript": "^5.0.0",
}, },
@@ -1202,7 +1202,7 @@
"@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="], "@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="],
"@types/bun": ["@types/bun@1.2.6", "", { "dependencies": { "bun-types": "1.2.6" } }, "sha512-fY9CAmTdJH1Llx7rugB0FpgWK2RKuHCs3g2cFDYXUutIy1QGiPQxKkGY8owhfZ4MXWNfxwIbQLChgH5gDsY7vw=="], "@types/bun": ["@types/bun@1.2.8", "", { "dependencies": { "bun-types": "1.2.7" } }, "sha512-t8L1RvJVUghW5V+M/fL3Thbxcs0HwNsXsnTEBEfEVqGteiJToOlZ/fyOEaR1kZsNqnu+3XA4RI/qmnX4w6+S+w=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
@@ -2130,6 +2130,8 @@
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
"get-tsconfig": ["get-tsconfig@4.10.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A=="],
"get-uri": ["get-uri@6.0.4", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ=="], "get-uri": ["get-uri@6.0.4", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ=="],
"get-value": ["get-value@2.0.6", "", {}, "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA=="], "get-value": ["get-value@2.0.6", "", {}, "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA=="],
@@ -3102,6 +3104,8 @@
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"resolve-url": ["resolve-url@0.2.1", "", {}, "sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg=="], "resolve-url": ["resolve-url@0.2.1", "", {}, "sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg=="],
"resq": ["resq@1.11.0", "", { "dependencies": { "fast-deep-equal": "^2.0.1" } }, "sha512-G10EBz+zAAy3zUd/CDoBbXRL6ia9kOo3xRHrMDsHljI0GDkhYlyjwoCx5+3eCC4swi1uCoZQhskuJkj7Gp57Bw=="], "resq": ["resq@1.11.0", "", { "dependencies": { "fast-deep-equal": "^2.0.1" } }, "sha512-G10EBz+zAAy3zUd/CDoBbXRL6ia9kOo3xRHrMDsHljI0GDkhYlyjwoCx5+3eCC4swi1uCoZQhskuJkj7Gp57Bw=="],
@@ -3466,6 +3470,8 @@
"tsutils": ["tsutils@3.21.0", "", { "dependencies": { "tslib": "^1.8.1" }, "peerDependencies": { "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" } }, "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA=="], "tsutils": ["tsutils@3.21.0", "", { "dependencies": { "tslib": "^1.8.1" }, "peerDependencies": { "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" } }, "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA=="],
"tsx": ["tsx@4.19.3", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ=="],
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"turbo-stream": ["turbo-stream@2.4.0", "", {}, "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g=="], "turbo-stream": ["turbo-stream@2.4.0", "", {}, "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g=="],
@@ -4006,7 +4012,7 @@
"@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], "@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"@types/bun/bun-types": ["bun-types@1.2.6", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-FbCKyr5KDiPULUzN/nm5oqQs9nXCHD8dVc64BArxJadCvbNzAI6lUWGh9fSJZWeDIRD38ikceBU8Kj/Uh+53oQ=="], "@types/bun/bun-types": ["bun-types@1.2.7", "", { "dependencies": { "@types/node": "*", "@types/ws": "*" } }, "sha512-P4hHhk7kjF99acXqKvltyuMQ2kf/rzIw3ylEDpCxDS9Xa0X0Yp/gJu/vDCucmWpiur5qJ0lwB2bWzOXa2GlHqA=="],
"@types/jest/jest-diff": ["jest-diff@25.5.0", "", { "dependencies": { "chalk": "^3.0.0", "diff-sequences": "^25.2.6", "jest-get-type": "^25.2.6", "pretty-format": "^25.5.0" } }, "sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A=="], "@types/jest/jest-diff": ["jest-diff@25.5.0", "", { "dependencies": { "chalk": "^3.0.0", "diff-sequences": "^25.2.6", "jest-get-type": "^25.2.6", "pretty-format": "^25.5.0" } }, "sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A=="],
@@ -4592,6 +4598,8 @@
"tsup/esbuild": ["esbuild@0.25.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.1", "@esbuild/android-arm": "0.25.1", "@esbuild/android-arm64": "0.25.1", "@esbuild/android-x64": "0.25.1", "@esbuild/darwin-arm64": "0.25.1", "@esbuild/darwin-x64": "0.25.1", "@esbuild/freebsd-arm64": "0.25.1", "@esbuild/freebsd-x64": "0.25.1", "@esbuild/linux-arm": "0.25.1", "@esbuild/linux-arm64": "0.25.1", "@esbuild/linux-ia32": "0.25.1", "@esbuild/linux-loong64": "0.25.1", "@esbuild/linux-mips64el": "0.25.1", "@esbuild/linux-ppc64": "0.25.1", "@esbuild/linux-riscv64": "0.25.1", "@esbuild/linux-s390x": "0.25.1", "@esbuild/linux-x64": "0.25.1", "@esbuild/netbsd-arm64": "0.25.1", "@esbuild/netbsd-x64": "0.25.1", "@esbuild/openbsd-arm64": "0.25.1", "@esbuild/openbsd-x64": "0.25.1", "@esbuild/sunos-x64": "0.25.1", "@esbuild/win32-arm64": "0.25.1", "@esbuild/win32-ia32": "0.25.1", "@esbuild/win32-x64": "0.25.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ=="], "tsup/esbuild": ["esbuild@0.25.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.1", "@esbuild/android-arm": "0.25.1", "@esbuild/android-arm64": "0.25.1", "@esbuild/android-x64": "0.25.1", "@esbuild/darwin-arm64": "0.25.1", "@esbuild/darwin-x64": "0.25.1", "@esbuild/freebsd-arm64": "0.25.1", "@esbuild/freebsd-x64": "0.25.1", "@esbuild/linux-arm": "0.25.1", "@esbuild/linux-arm64": "0.25.1", "@esbuild/linux-ia32": "0.25.1", "@esbuild/linux-loong64": "0.25.1", "@esbuild/linux-mips64el": "0.25.1", "@esbuild/linux-ppc64": "0.25.1", "@esbuild/linux-riscv64": "0.25.1", "@esbuild/linux-s390x": "0.25.1", "@esbuild/linux-x64": "0.25.1", "@esbuild/netbsd-arm64": "0.25.1", "@esbuild/netbsd-x64": "0.25.1", "@esbuild/openbsd-arm64": "0.25.1", "@esbuild/openbsd-x64": "0.25.1", "@esbuild/sunos-x64": "0.25.1", "@esbuild/win32-arm64": "0.25.1", "@esbuild/win32-ia32": "0.25.1", "@esbuild/win32-x64": "0.25.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ=="],
"tsx/esbuild": ["esbuild@0.25.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.1", "@esbuild/android-arm": "0.25.1", "@esbuild/android-arm64": "0.25.1", "@esbuild/android-x64": "0.25.1", "@esbuild/darwin-arm64": "0.25.1", "@esbuild/darwin-x64": "0.25.1", "@esbuild/freebsd-arm64": "0.25.1", "@esbuild/freebsd-x64": "0.25.1", "@esbuild/linux-arm": "0.25.1", "@esbuild/linux-arm64": "0.25.1", "@esbuild/linux-ia32": "0.25.1", "@esbuild/linux-loong64": "0.25.1", "@esbuild/linux-mips64el": "0.25.1", "@esbuild/linux-ppc64": "0.25.1", "@esbuild/linux-riscv64": "0.25.1", "@esbuild/linux-s390x": "0.25.1", "@esbuild/linux-x64": "0.25.1", "@esbuild/netbsd-arm64": "0.25.1", "@esbuild/netbsd-x64": "0.25.1", "@esbuild/openbsd-arm64": "0.25.1", "@esbuild/openbsd-x64": "0.25.1", "@esbuild/sunos-x64": "0.25.1", "@esbuild/win32-arm64": "0.25.1", "@esbuild/win32-ia32": "0.25.1", "@esbuild/win32-x64": "0.25.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ=="],
"unenv/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "unenv/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"union-value/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], "union-value/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
@@ -4996,6 +5004,54 @@
"tsup/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg=="], "tsup/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg=="],
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ=="],
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.1", "", { "os": "android", "cpu": "arm" }, "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q=="],
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.1", "", { "os": "android", "cpu": "arm64" }, "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA=="],
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.1", "", { "os": "android", "cpu": "x64" }, "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw=="],
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ=="],
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA=="],
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A=="],
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww=="],
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.1", "", { "os": "linux", "cpu": "arm" }, "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ=="],
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ=="],
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ=="],
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.1", "", { "os": "linux", "cpu": "none" }, "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg=="],
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.1", "", { "os": "linux", "cpu": "none" }, "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg=="],
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg=="],
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.1", "", { "os": "linux", "cpu": "none" }, "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ=="],
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ=="],
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA=="],
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.1", "", { "os": "none", "cpu": "x64" }, "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA=="],
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg=="],
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw=="],
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg=="],
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ=="],
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A=="],
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg=="],
"unset-value/has-value/has-values": ["has-values@0.1.4", "", {}, "sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ=="], "unset-value/has-value/has-values": ["has-values@0.1.4", "", {}, "sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ=="],
"unset-value/has-value/isobject": ["isobject@2.1.0", "", { "dependencies": { "isarray": "1.0.0" } }, "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA=="], "unset-value/has-value/isobject": ["isobject@2.1.0", "", { "dependencies": { "isarray": "1.0.0" } }, "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA=="],