mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
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:
12
.github/workflows/test.yml
vendored
12
.github/workflows/test.yml
vendored
@@ -21,6 +21,14 @@ jobs:
|
||||
working-directory: ./app
|
||||
run: bun install
|
||||
|
||||
- name: Run tests
|
||||
- name: Build
|
||||
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
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createApp, registries } from "../../src";
|
||||
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 () => {
|
||||
/**
|
||||
|
||||
@@ -3,8 +3,10 @@ import { OAuthStrategy } from "../../../src/auth/authenticate/strategies";
|
||||
|
||||
const ALL_TESTS = !!process.env.ALL_TESTS;
|
||||
|
||||
// @todo: add mock response
|
||||
describe("OAuthStrategy", async () => {
|
||||
const strategy = new OAuthStrategy({
|
||||
return;
|
||||
/*const strategy = new OAuthStrategy({
|
||||
type: "oidc",
|
||||
client: {
|
||||
client_id: process.env.OAUTH_CLIENT_ID!,
|
||||
@@ -21,6 +23,7 @@ describe("OAuthStrategy", async () => {
|
||||
|
||||
const server = Bun.serve({
|
||||
fetch: async (req) => {
|
||||
console.log("req", req.method, req.url);
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/auth/google/callback") {
|
||||
console.log("req", req);
|
||||
@@ -42,5 +45,5 @@ describe("OAuthStrategy", async () => {
|
||||
console.log("request", request);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100000));
|
||||
});
|
||||
});*/
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
15
app/__test__/core/cache/MemoryCache.spec.ts
vendored
15
app/__test__/core/cache/MemoryCache.spec.ts
vendored
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
84
app/__test__/core/cache/cache-test-suite.ts
vendored
84
app/__test__/core/cache/cache-test-suite.ts
vendored
@@ -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);
|
||||
});*/
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { BooleanField } from "../../../../src/data";
|
||||
import { runBaseFieldTests, transformPersist } from "./inc";
|
||||
import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
|
||||
|
||||
describe("[data] BooleanField", async () => {
|
||||
runBaseFieldTests(BooleanField, { defaultValue: true, schemaType: "boolean" });
|
||||
fieldTestSuite({ expect, test }, BooleanField, { defaultValue: true, schemaType: "boolean" });
|
||||
|
||||
test("transformRetrieve", async () => {
|
||||
const field = new BooleanField("test");
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { DateField } from "../../../../src/data";
|
||||
import { runBaseFieldTests } from "./inc";
|
||||
import { fieldTestSuite } from "data/fields/field-test-suite";
|
||||
|
||||
describe("[data] DateField", async () => {
|
||||
runBaseFieldTests(DateField, { defaultValue: new Date(), schemaType: "date" });
|
||||
fieldTestSuite({ expect, test }, DateField, { defaultValue: new Date(), schemaType: "date" });
|
||||
|
||||
// @todo: add datefield tests
|
||||
test("week", async () => {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { EnumField } from "../../../../src/data";
|
||||
import { runBaseFieldTests, transformPersist } from "./inc";
|
||||
import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
|
||||
|
||||
function options(strings: string[]) {
|
||||
return { type: "strings", values: strings };
|
||||
}
|
||||
|
||||
describe("[data] EnumField", async () => {
|
||||
runBaseFieldTests(
|
||||
fieldTestSuite(
|
||||
{ expect, test },
|
||||
// @ts-ignore
|
||||
EnumField,
|
||||
{ defaultValue: "a", schemaType: "text" },
|
||||
{ options: options(["a", "b", "c"]) },
|
||||
@@ -15,11 +17,13 @@ describe("[data] EnumField", async () => {
|
||||
|
||||
test("yields if default value is not a valid option", async () => {
|
||||
expect(
|
||||
// @ts-ignore
|
||||
() => new EnumField("test", { options: options(["a", "b"]), default_value: "c" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("transformPersist (config)", async () => {
|
||||
// @ts-ignore
|
||||
const field = new EnumField("test", { options: options(["a", "b", "c"]) });
|
||||
|
||||
expect(transformPersist(field, null)).resolves.toBeUndefined();
|
||||
@@ -29,6 +33,7 @@ describe("[data] EnumField", async () => {
|
||||
|
||||
test("transformRetrieve", async () => {
|
||||
const field = new EnumField("test", {
|
||||
// @ts-ignore
|
||||
options: options(["a", "b", "c"]),
|
||||
default_value: "a",
|
||||
required: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Default, stripMark } from "../../../../src/core/utils";
|
||||
import { baseFieldConfigSchema, Field } from "../../../../src/data/fields/Field";
|
||||
import { runBaseFieldTests } from "./inc";
|
||||
import { fieldTestSuite } from "data/fields/field-test-suite";
|
||||
|
||||
describe("[data] Field", async () => {
|
||||
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 () => {
|
||||
const config = Default(baseFieldConfigSchema, {});
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Type } from "../../../../src/core/utils";
|
||||
import {
|
||||
Entity,
|
||||
EntityIndex,
|
||||
type EntityManager,
|
||||
Field,
|
||||
type SchemaResponse,
|
||||
} from "../../../../src/data";
|
||||
import { Entity, EntityIndex, Field } from "../../../../src/data";
|
||||
|
||||
class TestField extends Field {
|
||||
protected getSchema(): any {
|
||||
return Type.Any();
|
||||
}
|
||||
|
||||
schema(em: EntityManager<any>): SchemaResponse {
|
||||
override schema() {
|
||||
return undefined as any;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { JsonField } from "../../../../src/data";
|
||||
import { runBaseFieldTests, transformPersist } from "./inc";
|
||||
import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
|
||||
|
||||
describe("[data] JsonField", async () => {
|
||||
const field = new JsonField("test");
|
||||
runBaseFieldTests(JsonField, {
|
||||
fieldTestSuite({ expect, test }, JsonField, {
|
||||
defaultValue: { a: 1 },
|
||||
sampleValues: ["string", { test: 1 }, 1],
|
||||
schemaType: "text",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { JsonSchemaField } from "../../../../src/data";
|
||||
import { runBaseFieldTests } from "./inc";
|
||||
import { fieldTestSuite } from "data/fields/field-test-suite";
|
||||
|
||||
describe("[data] JsonSchemaField", async () => {
|
||||
runBaseFieldTests(JsonSchemaField, { defaultValue: {}, schemaType: "text" });
|
||||
// @ts-ignore
|
||||
fieldTestSuite({ expect, test }, JsonSchemaField, { defaultValue: {}, schemaType: "text" });
|
||||
|
||||
// @todo: add JsonSchemaField tests
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { NumberField } from "../../../../src/data";
|
||||
import { runBaseFieldTests, transformPersist } from "./inc";
|
||||
import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
|
||||
|
||||
describe("[data] NumberField", async () => {
|
||||
test("transformPersist (config)", async () => {
|
||||
@@ -15,5 +15,5 @@ describe("[data] NumberField", async () => {
|
||||
expect(transformPersist(field2, 10000)).resolves.toBe(10000);
|
||||
});
|
||||
|
||||
runBaseFieldTests(NumberField, { defaultValue: 12, schemaType: "integer" });
|
||||
fieldTestSuite({ expect, test }, NumberField, { defaultValue: 12, schemaType: "integer" });
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { TextField } from "../../../../src/data";
|
||||
import { runBaseFieldTests, transformPersist } from "./inc";
|
||||
import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
|
||||
|
||||
describe("[data] TextField", async () => {
|
||||
test("transformPersist (config)", async () => {
|
||||
@@ -11,5 +11,5 @@ describe("[data] TextField", async () => {
|
||||
expect(transformPersist(field, "abc")).resolves.toBe("abc");
|
||||
});
|
||||
|
||||
runBaseFieldTests(TextField, { defaultValue: "abc", schemaType: "text" });
|
||||
fieldTestSuite({ expect, test }, TextField, { defaultValue: "abc", schemaType: "text" });
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { createApp, registries } from "../../src";
|
||||
import { mergeObject, randomString } from "../../src/core/utils";
|
||||
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";
|
||||
|
||||
beforeAll(() => {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import * as assert from "node:assert/strict";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { createWriteStream, readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
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
|
||||
console.log = async (message: any) => {
|
||||
@@ -10,25 +13,20 @@ console.log = async (message: any) => {
|
||||
return tty.write(`${msg}\n`);
|
||||
};
|
||||
|
||||
test("what", async () => {
|
||||
test("StorageR2Adapter", async () => {
|
||||
const mf = new Miniflare({
|
||||
modules: true,
|
||||
script: "export default { async fetch() { return new Response(null); } }",
|
||||
r2Buckets: ["BUCKET"],
|
||||
});
|
||||
|
||||
const bucket = await mf.getR2Bucket("BUCKET");
|
||||
console.log(await bucket.put("count", "1"));
|
||||
const bucket = (await mf.getR2Bucket("BUCKET")) as unknown as R2Bucket;
|
||||
const adapter = new StorageR2Adapter(bucket);
|
||||
|
||||
const object = await bucket.get("count");
|
||||
if (object) {
|
||||
/*const headers = new Headers();
|
||||
object.writeHttpMetadata(headers);
|
||||
headers.set("etag", object.httpEtag);*/
|
||||
console.log("yo -->", await object.text());
|
||||
|
||||
assert.strictEqual(await object.text(), "1");
|
||||
}
|
||||
const basePath = path.resolve(import.meta.dirname, "../_assets");
|
||||
const buffer = readFileSync(path.join(basePath, "image.png"));
|
||||
const file = new File([buffer], "image.png", { type: "image/png" });
|
||||
|
||||
await adapterTestSuite(nodeTestRunner, adapter, file);
|
||||
await mf.dispose();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createApp, registries } from "../../src";
|
||||
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 { moduleTestSuite } from "./module-test-suite";
|
||||
|
||||
|
||||
10
app/build.ts
10
app/build.ts
@@ -54,7 +54,7 @@ function banner(title: string) {
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -65,7 +65,13 @@ async function buildApi() {
|
||||
minify,
|
||||
sourcemap,
|
||||
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",
|
||||
external: [...external],
|
||||
metafile: true,
|
||||
|
||||
@@ -16,9 +16,13 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"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",
|
||||
"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: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:static": "vite build",
|
||||
"watch": "bun run build.ts --types --watch",
|
||||
@@ -101,6 +105,7 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tsc-alias": "^1.8.11",
|
||||
"tsup": "^8.4.0",
|
||||
"tsx": "^4.19.3",
|
||||
"vite": "^6.2.1",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"wouter": "^3.6.0"
|
||||
@@ -156,6 +161,11 @@
|
||||
"import": "./dist/cli/index.js",
|
||||
"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": {
|
||||
"types": "./dist/types/adapter/cloudflare/index.d.ts",
|
||||
"import": "./dist/adapter/cloudflare/index.js",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { registries } from "bknd";
|
||||
import { isDebug } from "bknd/core";
|
||||
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 { getBindings } from "./bindings";
|
||||
|
||||
@@ -47,8 +48,10 @@ export function registerMedia(env: Record<string, any>) {
|
||||
* Adapter for R2 storage
|
||||
* @todo: add tests (bun tests won't work, need node native tests)
|
||||
*/
|
||||
export class StorageR2Adapter implements StorageAdapter {
|
||||
constructor(private readonly bucket: R2Bucket) {}
|
||||
export class StorageR2Adapter extends StorageAdapter {
|
||||
constructor(private readonly bucket: R2Bucket) {
|
||||
super();
|
||||
}
|
||||
|
||||
getName(): string {
|
||||
return "r2";
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { registries } from "bknd";
|
||||
import {
|
||||
type LocalAdapterConfig,
|
||||
StorageLocalAdapter,
|
||||
} from "../../media/storage/adapters/StorageLocalAdapter";
|
||||
import { type LocalAdapterConfig, StorageLocalAdapter } from "./storage/StorageLocalAdapter";
|
||||
|
||||
export * from "./node.adapter";
|
||||
export { StorageLocalAdapter, type LocalAdapterConfig };
|
||||
export { nodeTestRunner } from "./test";
|
||||
|
||||
export function registerLocalMediaAdapter() {
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
14
app/src/adapter/node/storage/StorageLocalAdapter.spec.ts
Normal file
14
app/src/adapter/node/storage/StorageLocalAdapter.spec.ts
Normal 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);
|
||||
});
|
||||
@@ -1,13 +1,7 @@
|
||||
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { type Static, Type, isFile, parse } from "core/utils";
|
||||
import type {
|
||||
FileBody,
|
||||
FileListObject,
|
||||
FileMeta,
|
||||
FileUploadPayload,
|
||||
StorageAdapter,
|
||||
} from "../../Storage";
|
||||
import { guess } from "../../mime-types-tiny";
|
||||
import { type Static, Type, isFile, parse } from "bknd/utils";
|
||||
import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd/media";
|
||||
import { StorageAdapter, guessMimeType as guess } from "bknd/media";
|
||||
|
||||
export const localAdapterConfig = Type.Object(
|
||||
{
|
||||
@@ -17,10 +11,11 @@ export const localAdapterConfig = Type.Object(
|
||||
);
|
||||
export type LocalAdapterConfig = Static<typeof localAdapterConfig>;
|
||||
|
||||
export class StorageLocalAdapter implements StorageAdapter {
|
||||
export class StorageLocalAdapter extends StorageAdapter {
|
||||
private config: LocalAdapterConfig;
|
||||
|
||||
constructor(config: any) {
|
||||
super();
|
||||
this.config = parse(localAdapterConfig, config);
|
||||
}
|
||||
|
||||
75
app/src/adapter/node/test.ts
Normal file
75
app/src/adapter/node/test.ts
Normal 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),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
127
app/src/core/cache/adapters/CloudflareKvCache.ts
vendored
127
app/src/core/cache/adapters/CloudflareKvCache.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
139
app/src/core/cache/adapters/MemoryCache.ts
vendored
139
app/src/core/cache/adapters/MemoryCache.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
178
app/src/core/cache/cache-interface.ts
vendored
178
app/src/core/cache/cache-interface.ts
vendored
@@ -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>;
|
||||
}
|
||||
48
app/src/core/test/index.ts
Normal file
48
app/src/core/test/index.ts
Normal 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;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { BaseFieldConfig, Field, TActionContext } from "data";
|
||||
import type { ColumnDataType } from "kysely";
|
||||
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;
|
||||
|
||||
@@ -15,11 +15,13 @@ export function transformPersist(field: Field, value: any, context?: TActionCont
|
||||
return field.transformPersist(value, undefined as any, context as any);
|
||||
}
|
||||
|
||||
export function runBaseFieldTests(
|
||||
export function fieldTestSuite(
|
||||
testRunner: TestRunner,
|
||||
fieldClass: ConstructableField,
|
||||
config: FieldTestConfig,
|
||||
_requiredConfig: any = {},
|
||||
) {
|
||||
const { test, expect } = testRunner;
|
||||
const noConfigField = new fieldClass("no_config", _requiredConfig);
|
||||
const fillable = new fieldClass("fillable", { ..._requiredConfig, fillable: true });
|
||||
const required = new fieldClass("required", { ..._requiredConfig, required: true });
|
||||
@@ -76,9 +78,9 @@ export function runBaseFieldTests(
|
||||
const isPrimitive = (v) => ["string", "number"].includes(typeof v);
|
||||
for (const value of config.sampleValues!) {
|
||||
// "form"
|
||||
expect(isPrimitive(noConfigField.getValue(value, "form"))).toBeTrue();
|
||||
expect(isPrimitive(noConfigField.getValue(value, "form"))).toBe(true);
|
||||
// "table"
|
||||
expect(isPrimitive(noConfigField.getValue(value, "table"))).toBeTrue();
|
||||
expect(isPrimitive(noConfigField.getValue(value, "table"))).toBe(true);
|
||||
// "read"
|
||||
// "submit"
|
||||
}
|
||||
@@ -53,3 +53,5 @@ export const FieldClassMap = {
|
||||
json: { schema: jsonFieldConfigSchema, field: JsonField },
|
||||
jsonschema: { schema: jsonSchemaFieldConfigSchema, field: JsonSchemaField },
|
||||
} as const;
|
||||
|
||||
export { fieldTestSuite } from "./field-test-suite";
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import type { TObject, TString } from "@sinclair/typebox";
|
||||
import type { TObject } from "@sinclair/typebox";
|
||||
import { type Constructor, Registry } from "core";
|
||||
|
||||
//export { MIME_TYPES } from "./storage/mime-types";
|
||||
export { guess as guessMimeType } from "./storage/mime-types-tiny";
|
||||
export {
|
||||
Storage,
|
||||
type StorageAdapter,
|
||||
type FileMeta,
|
||||
type FileListObject,
|
||||
type StorageConfig,
|
||||
type FileBody,
|
||||
type FileUploadPayload,
|
||||
} from "./storage/Storage";
|
||||
import type { StorageAdapter } from "./storage/Storage";
|
||||
import { StorageAdapter } from "./storage/StorageAdapter";
|
||||
import {
|
||||
type CloudinaryConfig,
|
||||
StorageCloudinaryAdapter,
|
||||
} from "./storage/adapters/StorageCloudinaryAdapter";
|
||||
import { type S3AdapterConfig, StorageS3Adapter } from "./storage/adapters/StorageS3Adapter";
|
||||
} from "./storage/adapters/cloudinary/StorageCloudinaryAdapter";
|
||||
import { type S3AdapterConfig, StorageS3Adapter } from "./storage/adapters/s3/StorageS3Adapter";
|
||||
|
||||
export { StorageAdapter };
|
||||
export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig };
|
||||
|
||||
export * as StorageEvents from "./storage/events";
|
||||
@@ -45,3 +47,5 @@ export const Adapters = {
|
||||
schema: StorageCloudinaryAdapter.prototype.getSchema(),
|
||||
},
|
||||
} as const;
|
||||
|
||||
export { adapterTestSuite } from "./storage/adapters/adapter-test-suite";
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 * as StorageEvents from "./events";
|
||||
import type { FileUploadedEventData } from "./events";
|
||||
import { $console } from "core";
|
||||
import type { StorageAdapter } from "./StorageAdapter";
|
||||
|
||||
export type FileListObject = {
|
||||
key: string;
|
||||
@@ -19,24 +20,6 @@ export type FileUploadPayload = {
|
||||
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 = {
|
||||
body_max_size?: number;
|
||||
};
|
||||
|
||||
37
app/src/media/storage/StorageAdapter.ts
Normal file
37
app/src/media/storage/StorageAdapter.ts
Normal 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;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export {
|
||||
StorageLocalAdapter,
|
||||
type LocalAdapterConfig,
|
||||
localAdapterConfig,
|
||||
} from "./StorageLocalAdapter";
|
||||
79
app/src/media/storage/adapters/adapter-test-suite.ts
Normal file
79
app/src/media/storage/adapters/adapter-test-suite.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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 { FileBody, FileListObject, FileMeta, StorageAdapter } from "../Storage";
|
||||
import type { FileBody, FileListObject, FileMeta } from "../../Storage";
|
||||
import { StorageAdapter } from "../../StorageAdapter";
|
||||
|
||||
export const cloudinaryAdapterConfig = Type.Object(
|
||||
{
|
||||
@@ -53,10 +54,11 @@ type CloudinaryListObjectsResponse = {
|
||||
};
|
||||
|
||||
// @todo: add signed uploads
|
||||
export class StorageCloudinaryAdapter implements StorageAdapter {
|
||||
export class StorageCloudinaryAdapter extends StorageAdapter {
|
||||
private config: CloudinaryConfig;
|
||||
|
||||
constructor(config: CloudinaryConfig) {
|
||||
super();
|
||||
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[]> {
|
||||
const result = await fetch(
|
||||
`https://api.cloudinary.com/v1_1/${this.config.cloud_name}/resources/search`,
|
||||
@@ -133,6 +140,7 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Cache-Control": "no-cache",
|
||||
...this.getAuthorizationHeader(),
|
||||
},
|
||||
},
|
||||
@@ -143,18 +151,22 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
|
||||
}
|
||||
|
||||
const data = (await result.json()) as CloudinaryListObjectsResponse;
|
||||
return data.resources.map((item) => ({
|
||||
const items = data.resources.map((item) => ({
|
||||
key: item.public_id,
|
||||
last_modified: new Date(item.uploaded_at),
|
||||
size: item.bytes,
|
||||
}));
|
||||
return items;
|
||||
}
|
||||
|
||||
private async headObject(key: string) {
|
||||
const url = this.getObjectUrl(key);
|
||||
return await fetch(url, {
|
||||
method: "GET",
|
||||
method: "HEAD",
|
||||
headers: {
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
Pragma: "no-cache",
|
||||
Expires: "0",
|
||||
Range: "bytes=0-1",
|
||||
},
|
||||
});
|
||||
@@ -196,6 +208,22 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
|
||||
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> {
|
||||
const res = await fetch(this.getObjectUrl(key), {
|
||||
method: "GET",
|
||||
@@ -211,13 +239,30 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
|
||||
|
||||
async deleteObject(key: string): Promise<void> {
|
||||
const type = this.guessType(key) ?? "image";
|
||||
const formData = new FormData();
|
||||
formData.append("public_ids[]", key);
|
||||
const public_id = this.filenameToPublicId(key);
|
||||
const { timestamp, signature } = await this.generateSignature({
|
||||
public_id,
|
||||
});
|
||||
|
||||
await fetch(`https://res.cloudinary.com/${this.config.cloud_name}/${type}/upload/`, {
|
||||
method: "DELETE",
|
||||
const formData = new FormData();
|
||||
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,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to delete object: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
toJSON(secrets?: boolean) {
|
||||
50
app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts
Normal file
50
app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,8 @@ import type {
|
||||
import { AwsClient, isDebug } from "core";
|
||||
import { type Static, Type, isFile, parse, pickHeaders2 } from "core/utils";
|
||||
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(
|
||||
{
|
||||
@@ -32,11 +33,13 @@ export const s3AdapterConfig = Type.Object(
|
||||
|
||||
export type S3AdapterConfig = Static<typeof s3AdapterConfig>;
|
||||
|
||||
export class StorageS3Adapter extends AwsClient implements StorageAdapter {
|
||||
export class StorageS3Adapter extends StorageAdapter {
|
||||
readonly #config: S3AdapterConfig;
|
||||
readonly client: AwsClient;
|
||||
|
||||
constructor(config: S3AdapterConfig) {
|
||||
super(
|
||||
super();
|
||||
this.client = new AwsClient(
|
||||
{
|
||||
accessKeyId: config.access_key,
|
||||
secretAccessKey: config.secret_access_key,
|
||||
@@ -58,10 +61,10 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
|
||||
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);
|
||||
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 res = await this.fetchJson<{ ListBucketResult: ListObjectsV2Output }>(url, {
|
||||
const res = await this.client.fetchJson<{ ListBucketResult: ListObjectsV2Output }>(url, {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
@@ -115,7 +118,7 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
|
||||
params: Omit<PutObjectRequest, "Bucket" | "Key"> = {},
|
||||
) {
|
||||
const url = this.getUrl(key, {});
|
||||
const res = await this.fetch(url, {
|
||||
const res = await this.client.fetch(url, {
|
||||
method: "PUT",
|
||||
body,
|
||||
headers: isFile(body)
|
||||
@@ -139,7 +142,7 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
|
||||
params: Pick<HeadObjectRequest, "PartNumber" | "VersionId"> = {},
|
||||
) {
|
||||
const url = this.getUrl(key, {});
|
||||
return await this.fetch(url, {
|
||||
return await this.client.fetch(url, {
|
||||
method: "HEAD",
|
||||
headers: {
|
||||
Range: "bytes=0-1",
|
||||
@@ -175,7 +178,7 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
|
||||
*/
|
||||
async getObject(key: string, headers: Headers): Promise<Response> {
|
||||
const url = this.getUrl(key);
|
||||
const res = await this.fetch(url, {
|
||||
const res = await this.client.fetch(url, {
|
||||
method: "GET",
|
||||
headers: pickHeaders2(headers, [
|
||||
"if-none-match",
|
||||
@@ -201,7 +204,7 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
|
||||
params: Omit<DeleteObjectRequest, "Bucket" | "Key"> = {},
|
||||
): Promise<void> {
|
||||
const url = this.getUrl(key, params);
|
||||
const res = await this.fetch(url, {
|
||||
const res = await this.client.fetch(url, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
64
bun.lock
64
bun.lock
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"app": {
|
||||
"name": "bknd",
|
||||
"version": "0.10.2",
|
||||
"version": "0.10.3-rc.1",
|
||||
"bin": "./dist/cli/index.js",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^4.1.1",
|
||||
@@ -99,6 +99,7 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tsc-alias": "^1.8.11",
|
||||
"tsup": "^8.4.0",
|
||||
"tsx": "^4.19.3",
|
||||
"vite": "^6.2.1",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"wouter": "^3.6.0",
|
||||
@@ -125,7 +126,6 @@
|
||||
"version": "0.5.1",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"bknd": "workspace:*",
|
||||
"tsdx": "^0.14.1",
|
||||
"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/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=="],
|
||||
|
||||
@@ -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-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-value": ["get-value@2.0.6", "", {}, "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA=="],
|
||||
@@ -3102,6 +3104,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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/isobject": ["isobject@2.1.0", "", { "dependencies": { "isarray": "1.0.0" } }, "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA=="],
|
||||
|
||||
Reference in New Issue
Block a user