From 9d122da4b9048547776c8143c8432cf994cff5ad Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 26 Mar 2025 10:30:47 +0100 Subject: [PATCH 01/27] cli create: improve cloudflare (create d1, r2) --- app/package.json | 5 +- .../commands/create/templates/cloudflare.ts | 73 ++++++++++++--- bun.lock | 92 ++++++++++++++++++- 3 files changed, 155 insertions(+), 15 deletions(-) diff --git a/app/package.json b/app/package.json index 361acc9..3c517a0 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.10.2", + "version": "0.10.3-rc.1", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "homepage": "https://bknd.io", "repository": { @@ -58,7 +58,8 @@ "object-path-immutable": "^4.1.2", "picocolors": "^1.1.1", "radix-ui": "^1.1.3", - "swr": "^2.3.3" + "swr": "^2.3.3", + "wrangler": "^4.4.1" }, "devDependencies": { "@aws-sdk/client-s3": "^3.758.0", diff --git a/app/src/cli/commands/create/templates/cloudflare.ts b/app/src/cli/commands/create/templates/cloudflare.ts index a099608..0e4dee8 100644 --- a/app/src/cli/commands/create/templates/cloudflare.ts +++ b/app/src/cli/commands/create/templates/cloudflare.ts @@ -4,6 +4,7 @@ import { typewriter, wait } from "cli/utils/cli"; import { uuid } from "core/utils"; import c from "picocolors"; import type { Template, TemplateSetupCtx } from "."; +import { exec } from "cli/utils/sys"; const WRANGLER_FILE = "wrangler.json"; @@ -56,6 +57,8 @@ export const cloudflare = { "Couldn't add database. You can add it manually later. Error: " + c.red(message), ); } + + await createR2(ctx); }, } as const satisfies Template; @@ -75,6 +78,13 @@ async function createD1(ctx: TemplateSetupCtx) { process.exit(1); } + exec(`npx wrangler d1 create ${name}`); + await $p.stream.info( + (async function* () { + yield* typewriter("Please update your wrangler configuration with the output above."); + })(), + ); + await overrideJson( WRANGLER_FILE, (json) => ({ @@ -89,17 +99,6 @@ async function createD1(ctx: TemplateSetupCtx) { }), { dir: ctx.dir }, ); - - await $p.stream.info( - (async function* () { - yield* typewriter(`Database added to ${c.cyan("wrangler.json")}`); - await wait(); - yield* typewriter( - `\nNote that if you deploy, you have to create a real database using ${c.cyan("npx wrangler d1 create ")} and update your wrangler configuration.`, - c.dim, - ); - })(), - ); } async function createLibsql(ctx: TemplateSetupCtx) { @@ -142,3 +141,55 @@ async function createLibsql(ctx: TemplateSetupCtx) { })(), ); } + +async function createR2(ctx: TemplateSetupCtx) { + const create = await $p.confirm({ + message: "Do you want to use a R2 bucket?", + initialValue: true, + }); + if ($p.isCancel(create)) { + process.exit(1); + } + if (!create) { + await overrideJson( + WRANGLER_FILE, + (json) => ({ + ...json, + r2_buckets: undefined, + }), + { dir: ctx.dir }, + ); + return; + } + + const name = await $p.text({ + message: "Enter bucket name", + initialValue: "bucket", + placeholder: "bucket", + validate: (v) => { + if (!v) { + return "Invalid name"; + } + return; + }, + }); + if ($p.isCancel(name)) { + process.exit(1); + } + + exec(`npx wrangler r2 bucket create ${name}`); + + await overrideJson( + WRANGLER_FILE, + (json) => ({ + ...json, + r2_buckets: [ + { + binding: "BUCKET", + bucket_name: name, + }, + ], + }), + { dir: ctx.dir }, + ); +} diff --git a/bun.lock b/bun.lock index 76abf42..f0fda3c 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "app": { "name": "bknd", - "version": "0.10.0-rc.5", + "version": "0.10.2", "bin": "./dist/cli/index.js", "dependencies": { "@cfworker/json-schema": "^4.1.1", @@ -57,6 +57,7 @@ "picocolors": "^1.1.1", "radix-ui": "^1.1.3", "swr": "^2.3.3", + "wrangler": "^4.4.1", }, "devDependencies": { "@aws-sdk/client-s3": "^3.758.0", @@ -124,6 +125,7 @@ "version": "0.5.1", "devDependencies": { "@types/bun": "latest", + "bknd": "workspace:*", "tsdx": "^0.14.1", "typescript": "^5.0.0", }, @@ -500,6 +502,8 @@ "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.3.4", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q=="], + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.3.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.15", "workerd": "^1.20250311.0" }, "optionalPeers": ["workerd"] }, "sha512-AaKYnbFpHaVDZGh3Hjy3oLYd12+LZw9aupAOudYJ+tjekahxcIqlSAr0zK9kPOdtgn10tzaqH7QJFUWcLE+k7g=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250224.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-sBbaAF2vgQ9+T50ik1ihekdepStBp0w4fvNghBfXIw1iWqfNWnypcjDMmi/7JhXJt2uBxBrSlXCvE5H7Gz+kbw=="], "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250224.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-naetGefgjAaDbEacpwaVruJXNwxmRRL7v3ppStgEiqAlPmTpQ/Edjn2SQ284QwOw3MvaVPHrWcaTBupUpkqCyg=="], @@ -1198,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.5", "", { "dependencies": { "bun-types": "1.2.5" } }, "sha512-w2OZTzrZTVtbnJew1pdFmgV99H0/L+Pvw+z1P67HaR18MHOzYnTYOi6qzErhK8HyT+DB782ADVPPE92Xu2/Opg=="], + "@types/bun": ["@types/bun@1.2.6", "", { "dependencies": { "bun-types": "1.2.6" } }, "sha512-fY9CAmTdJH1Llx7rugB0FpgWK2RKuHCs3g2cFDYXUutIy1QGiPQxKkGY8owhfZ4MXWNfxwIbQLChgH5gDsY7vw=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], @@ -2000,6 +2004,8 @@ "express-rate-limit": ["express-rate-limit@5.5.1", "", {}, "sha512-MTjE2eIbHv5DyfuFz4zLYWxpqVhEhkTiwFGuB74Q9CSou2WHO52nlE5y3Zlg6SIsiYUIPj6ifFxnkPz6O3sIUg=="], + "exsolve": ["exsolve@1.0.4", "", {}, "sha512-xsZH6PXaER4XoV+NiT7JHp1bJodJVT+cxeSH1G0f0tlT0lJqYuHUP3bUx2HtfTDvOagMINYp8rsqusxud3RXhw=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extend-shallow": ["extend-shallow@3.0.2", "", { "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" } }, "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q=="], @@ -3794,10 +3800,14 @@ "@babel/runtime/regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="], + "@bknd/postgres/@types/bun": ["@types/bun@1.2.5", "", { "dependencies": { "bun-types": "1.2.5" } }, "sha512-w2OZTzrZTVtbnJew1pdFmgV99H0/L+Pvw+z1P67HaR18MHOzYnTYOi6qzErhK8HyT+DB782ADVPPE92Xu2/Opg=="], + "@bundled-es-modules/cookie/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "@bundled-es-modules/tough-cookie/tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="], + "@cloudflare/unenv-preset/unenv": ["unenv@2.0.0-rc.15", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.4", "ohash": "^2.0.11", "pathe": "^2.0.3", "ufo": "^1.5.4" } }, "sha512-J/rEIZU8w6FOfLNz/hNKsnY+fFHWnu9MH4yRbSZF3xbbGHovcetXPs7sD+9p8L6CeNC//I9bhRYAOsBt2u7/OA=="], + "@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@inquirer/core/cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], @@ -3996,6 +4006,8 @@ "@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/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/pretty-format": ["pretty-format@25.5.0", "", { "dependencies": { "@jest/types": "^25.5.0", "ansi-regex": "^5.0.0", "ansi-styles": "^4.0.0", "react-is": "^16.12.0" } }, "sha512-kbo/kq2LQ/A/is0PQwsEHM7Ca6//bGPPvU6UnsdDRSKTWxT/ru/xb88v4BJf6a69H+uTytOEsTusT9ksd/1iWQ=="], @@ -4086,6 +4098,8 @@ "base/pascalcase": ["pascalcase@0.1.1", "", {}, "sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw=="], + "bknd/wrangler": ["wrangler@4.4.1", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.3.0", "blake3-wasm": "2.1.5", "esbuild": "0.24.2", "miniflare": "4.20250321.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.15", "workerd": "1.20250321.0" }, "optionalDependencies": { "fsevents": "~2.3.2", "sharp": "^0.33.5" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250321.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-EFwr7hiVeAmPOuOGQ7HFfeaLKLxEXQMJ86kyn6RFB8pGjMEUtvZMsVa9cPubKkKgNi3WcDEFeFLalclGyq+tGA=="], + "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "browser-resolve/resolve": ["resolve@1.1.7", "", {}, "sha512-9znBF0vBcaSN3W2j7wKvdERPwqTxSpCq+if5C0WoTCyV9n24rua28jeuQ2pL/HOf+yUe/Mef+H/5p60K0Id3bg=="], @@ -4642,6 +4656,8 @@ "@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="], + "@cloudflare/unenv-preset/unenv/ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], @@ -4718,6 +4734,16 @@ "babel-plugin-istanbul/istanbul-lib-instrument/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "bknd/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.0", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA=="], + + "bknd/wrangler/esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="], + + "bknd/wrangler/miniflare": ["miniflare@4.20250321.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "stoppable": "1.1.0", "undici": "^5.28.5", "workerd": "1.20250321.0", "ws": "8.18.0", "youch": "3.2.3", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-os+NJA7Eqi00BJHdVhzIa+3PMotnCtZg3hiUIRYcsZF5W7He8SK2EkV8csAb+npZq3jZ4SNpDebO01swM5dcWw=="], + + "bknd/wrangler/unenv": ["unenv@2.0.0-rc.15", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.4", "ohash": "^2.0.11", "pathe": "^2.0.3", "ufo": "^1.5.4" } }, "sha512-J/rEIZU8w6FOfLNz/hNKsnY+fFHWnu9MH4yRbSZF3xbbGHovcetXPs7sD+9p8L6CeNC//I9bhRYAOsBt2u7/OA=="], + + "bknd/wrangler/workerd": ["workerd@1.20250321.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20250321.0", "@cloudflare/workerd-darwin-arm64": "1.20250321.0", "@cloudflare/workerd-linux-64": "1.20250321.0", "@cloudflare/workerd-linux-arm64": "1.20250321.0", "@cloudflare/workerd-windows-64": "1.20250321.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-vyuz9pdJ+7o1lC79vQ2UVRLXPARa2Lq94PbTfqEcYQeSxeR9X+YqhNq2yysv8Zs5vpokmexLCtMniPp9u+2LVQ=="], + "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "class-utils/define-property/is-descriptor": ["is-descriptor@0.1.7", "", { "dependencies": { "is-accessor-descriptor": "^1.0.1", "is-data-descriptor": "^1.0.1" } }, "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg=="], @@ -5126,6 +5152,68 @@ "@verdaccio/middleware/express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "bknd/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="], + + "bknd/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="], + + "bknd/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="], + + "bknd/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.24.2", "", { "os": "android", "cpu": "x64" }, "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="], + + "bknd/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="], + + "bknd/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="], + + "bknd/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="], + + "bknd/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="], + + "bknd/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="], + + "bknd/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="], + + "bknd/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="], + + "bknd/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="], + + "bknd/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="], + + "bknd/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="], + + "bknd/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="], + + "bknd/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="], + + "bknd/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="], + + "bknd/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="], + + "bknd/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.2", "", { "os": "none", "cpu": "x64" }, "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="], + + "bknd/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="], + + "bknd/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="], + + "bknd/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="], + + "bknd/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="], + + "bknd/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="], + + "bknd/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="], + + "bknd/wrangler/unenv/ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + + "bknd/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250321.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-y273GfLaNCxkL8hTfo0c8FZKkOPdq+CPZAKJXPWB+YpS1JCOULu6lNTptpD7ZtF14dTYPkn5Weug31TTlviJmw=="], + + "bknd/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250321.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qvf7/gkkQq7fAsoMlntJSimN/WfwQqxi2oL0aWZMGodTvs/yRHO2I4oE0eOihVdK1BXyBHJXNxEvNDBjF0+Yuw=="], + + "bknd/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20250321.0", "", { "os": "linux", "cpu": "x64" }, "sha512-AEp3xjWFrNPO/h0StCOgOb0bWCcNThnkESpy91Wf4mfUF2p7tOCdp37Nk/1QIRqSxnfv4Hgxyi7gcWud9cJuMw=="], + + "bknd/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20250321.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-wRWyMIoPIS1UBXCisW0FYTgGsfZD4AVS0hXA5nuLc0c21CvzZpmmTjqEWMcwPFenwy/MNL61NautVOC4qJqQ3Q=="], + + "bknd/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250321.0", "", { "os": "win32", "cpu": "x64" }, "sha512-8vYP3QYO0zo2faUDfWl88jjfUvz7Si9GS3mUYaTh/TR9LcAUtsO7muLxPamqEyoxNFtbQgy08R4rTid94KRi3w=="], + "eslint/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "eslint/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], From 9407f3d212c034f86ba1a5f59abaaaaff5882eed Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 27 Mar 2025 09:21:58 +0100 Subject: [PATCH 02/27] add image dimension detection for most common formats --- app/__test__/_assets/image.jpg | Bin 0 -> 16796 bytes app/__test__/core/utils.spec.ts | 124 +++++---- app/__test__/media/mime-types.spec.ts | 8 +- app/src/Api.ts | 4 + app/src/core/utils/file.ts | 239 ++++++++++++++++++ app/src/core/utils/reqres.ts | 180 ------------- app/src/core/utils/runtime.ts | 6 + app/src/media/AppMedia.ts | 9 +- app/src/media/storage/Storage.ts | 20 +- .../storage/adapters/StorageS3Adapter.ts | 3 +- 10 files changed, 352 insertions(+), 241 deletions(-) create mode 100644 app/__test__/_assets/image.jpg create mode 100644 app/src/core/utils/file.ts diff --git a/app/__test__/_assets/image.jpg b/app/__test__/_assets/image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0f7890d93503af3b3a5e3f9b1de33b034c0341d6 GIT binary patch literal 16796 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 { @@ -75,57 +76,6 @@ describe("Core Utils", async () => { const result3 = utils.encodeSearch(obj3, { encode: true }); expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D"); }); - - describe("guards", () => { - const types = { - blob: new Blob(), - file: new File([""], "file.txt"), - stream: new ReadableStream(), - arrayBuffer: new ArrayBuffer(10), - arrayBufferView: new Uint8Array(new ArrayBuffer(10)), - }; - - const fns = [ - [utils.isReadableStream, "stream"], - [utils.isBlob, "blob", ["stream", "arrayBuffer", "arrayBufferView"]], - [utils.isFile, "file", ["stream", "arrayBuffer", "arrayBufferView"]], - [utils.isArrayBuffer, "arrayBuffer"], - [utils.isArrayBufferView, "arrayBufferView"], - ] as const; - - const additional = [0, 0.0, "", null, undefined, {}, []]; - - for (const [fn, type, _to_test] of fns) { - test(`is${ucFirst(type)}`, () => { - const to_test = _to_test ?? (Object.keys(types) as string[]); - for (const key of to_test) { - const value = types[key as keyof typeof types]; - const result = fn(value); - expect(result).toBe(key === type); - } - - for (const value of additional) { - const result = fn(value); - expect(result).toBe(false); - } - }); - } - }); - - test("getContentName", () => { - const name = "test.json"; - const text = "attachment; filename=" + name; - const headers = new Headers({ - "Content-Disposition": text, - }); - const request = new Request("http://example.com", { - headers, - }); - - expect(utils.getContentName(text)).toBe(name); - expect(utils.getContentName(headers)).toBe(name); - expect(utils.getContentName(request)).toBe(name); - }); }); describe("perf", async () => { @@ -246,6 +196,76 @@ describe("Core Utils", async () => { }); }); + describe("file", async () => { + describe("type guards", () => { + const types = { + blob: new Blob(), + file: new File([""], "file.txt"), + stream: new ReadableStream(), + arrayBuffer: new ArrayBuffer(10), + arrayBufferView: new Uint8Array(new ArrayBuffer(10)), + }; + + const fns = [ + [utils.isReadableStream, "stream"], + [utils.isBlob, "blob", ["stream", "arrayBuffer", "arrayBufferView"]], + [utils.isFile, "file", ["stream", "arrayBuffer", "arrayBufferView"]], + [utils.isArrayBuffer, "arrayBuffer"], + [utils.isArrayBufferView, "arrayBufferView"], + ] as const; + + const additional = [0, 0.0, "", null, undefined, {}, []]; + + for (const [fn, type, _to_test] of fns) { + test(`is${ucFirst(type)}`, () => { + const to_test = _to_test ?? (Object.keys(types) as string[]); + for (const key of to_test) { + const value = types[key as keyof typeof types]; + const result = fn(value); + expect(result).toBe(key === type); + } + + for (const value of additional) { + const result = fn(value); + expect(result).toBe(false); + } + }); + } + }); + + test("getContentName", () => { + const name = "test.json"; + const text = "attachment; filename=" + name; + const headers = new Headers({ + "Content-Disposition": text, + }); + const request = new Request("http://example.com", { + headers, + }); + + expect(utils.getContentName(text)).toBe(name); + expect(utils.getContentName(headers)).toBe(name); + expect(utils.getContentName(request)).toBe(name); + }); + + test.only("detectImageDimensions", async () => { + // wrong + // @ts-expect-error + expect(utils.detectImageDimensions(new ArrayBuffer(), "text/plain")).rejects.toThrow(); + + // successful ones + const getFile = (name: string): File => Bun.file(`${assetsPath}/${name}`) as any; + expect(await utils.detectImageDimensions(getFile("image.png"))).toEqual({ + width: 362, + height: 387, + }); + expect(await utils.detectImageDimensions(getFile("image.jpg"))).toEqual({ + width: 453, + height: 512, + }); + }); + }); + describe("dates", () => { test.only("formats local time", () => { expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25"); diff --git a/app/__test__/media/mime-types.spec.ts b/app/__test__/media/mime-types.spec.ts index 6c51fab..37f29a8 100644 --- a/app/__test__/media/mime-types.spec.ts +++ b/app/__test__/media/mime-types.spec.ts @@ -5,7 +5,9 @@ import { getRandomizedFilename } from "../../src/media/utils"; describe("media/mime-types", () => { test("tiny resolves", () => { - const tests = [[".mp4", "video/mp4", ".jpg", "image/jpeg", ".zip", "application/zip"]]; + const tests = [ + [".mp4", "video/mp4", ".jpg", "image/jpeg", ".zip", "application/zip"], + ] as const; for (const [ext, mime] of tests) { expect(tiny.guess(ext)).toBe(mime); @@ -69,7 +71,7 @@ describe("media/mime-types", () => { ["application/zip", "zip"], ["text/tab-separated-values", "tsv"], ["application/zip", "zip"], - ]; + ] as const; for (const [mime, ext] of tests) { expect(tiny.extension(mime), `extension(): ${mime} should be ${ext}`).toBe(ext); @@ -86,7 +88,7 @@ describe("media/mime-types", () => { ["image.jpeg", "jpeg"], ["-473Wx593H-466453554-black-MODEL.jpg", "jpg"], ["-473Wx593H-466453554-black-MODEL.avif", "avif"], - ]; + ] as const; for (const [filename, ext] of tests) { expect( diff --git a/app/src/Api.ts b/app/src/Api.ts index 70cbd13..593979e 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -78,6 +78,10 @@ export class Api { this.buildApis(); } + get fetcher() { + return this.options.fetcher ?? fetch; + } + get baseUrl() { return this.options.host ?? "http://localhost"; } diff --git a/app/src/core/utils/file.ts b/app/src/core/utils/file.ts new file mode 100644 index 0000000..96970a7 --- /dev/null +++ b/app/src/core/utils/file.ts @@ -0,0 +1,239 @@ +import { extension, guess, isMimeType } from "media/storage/mime-types-tiny"; +import { randomString } from "core/utils/strings"; +import type { Context } from "hono"; +import { invariant } from "core/utils/runtime"; + +export function getContentName(request: Request): string | undefined; +export function getContentName(contentDisposition: string): string | undefined; +export function getContentName(headers: Headers): string | undefined; +export function getContentName(ctx: Headers | Request | string): string | undefined { + let c: string = ""; + + if (typeof ctx === "string") { + c = ctx; + } else if (ctx instanceof Headers) { + c = ctx.get("Content-Disposition") || ""; + } else if (ctx instanceof Request) { + c = ctx.headers.get("Content-Disposition") || ""; + } + + const match = c.match(/filename\*?=(?:UTF-8'')?("?)([^";]+)\1/); + return match ? match[2] : undefined; +} + +export function isReadableStream(value: unknown): value is ReadableStream { + return ( + typeof value === "object" && + value !== null && + typeof (value as ReadableStream).getReader === "function" + ); +} + +export function isBlob(value: unknown): value is Blob { + return ( + typeof value === "object" && + value !== null && + typeof (value as Blob).arrayBuffer === "function" && + typeof (value as Blob).type === "string" + ); +} + +export function isFile(value: unknown): value is File { + return ( + isBlob(value) && + typeof (value as File).name === "string" && + typeof (value as File).lastModified === "number" + ); +} + +export function isArrayBuffer(value: unknown): value is ArrayBuffer { + return ( + typeof value === "object" && + value !== null && + Object.prototype.toString.call(value) === "[object ArrayBuffer]" + ); +} + +export function isArrayBufferView(value: unknown): value is ArrayBufferView { + return typeof value === "object" && value !== null && ArrayBuffer.isView(value); +} + +const FILE_SIGNATURES: Record = { + "89504E47": "image/png", + FFD8FF: "image/jpeg", + "47494638": "image/gif", + "49492A00": "image/tiff", // Little Endian TIFF + "4D4D002A": "image/tiff", // Big Endian TIFF + "52494646????57454250": "image/webp", // WEBP (RIFF....WEBP) + "504B0304": "application/zip", + "25504446": "application/pdf", + "00000020667479706D70": "video/mp4", + "000001BA": "video/mpeg", + "000001B3": "video/mpeg", + "1A45DFA3": "video/webm", + "4F676753": "audio/ogg", + "494433": "audio/mpeg", // MP3 with ID3 header + FFF1: "audio/aac", + FFF9: "audio/aac", + "52494646????41564920": "audio/wav", + "52494646????57415645": "audio/wave", + "52494646????415550": "audio/aiff", +}; + +async function detectMimeType( + input: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | File | null, +): Promise { + if (!input) return; + + let buffer: Uint8Array; + + if (isReadableStream(input)) { + const reader = input.getReader(); + const { value } = await reader.read(); + if (!value) return; + buffer = new Uint8Array(value); + } else if (isBlob(input) || isFile(input)) { + buffer = new Uint8Array(await input.slice(0, 12).arrayBuffer()); + } else if (isArrayBuffer(input)) { + buffer = new Uint8Array(input); + } else if (isArrayBufferView(input)) { + buffer = new Uint8Array(input.buffer, input.byteOffset, input.byteLength); + } else if (typeof input === "string") { + buffer = new TextEncoder().encode(input); + } else { + return; + } + + const hex = Array.from(buffer.slice(0, 12)) + .map((b) => b.toString(16).padStart(2, "0").toUpperCase()) + .join(""); + + for (const [signature, mime] of Object.entries(FILE_SIGNATURES)) { + const regex = new RegExp("^" + signature.replace(/\?\?/g, "..")); + if (regex.test(hex)) return mime; + } + + return; +} + +export async function getFileFromContext(c: Context): Promise { + const contentType = c.req.header("Content-Type") ?? "application/octet-stream"; + + if ( + contentType?.startsWith("multipart/form-data") || + contentType?.startsWith("application/x-www-form-urlencoded") + ) { + try { + const f = await c.req.formData(); + if ([...f.values()].length > 0) { + const v = [...f.values()][0]; + return await blobToFile(v); + } + } catch (e) { + console.warn("Error parsing form data", e); + } + } else { + try { + const blob = await c.req.blob(); + if (isFile(blob)) { + return blob; + } else if (isBlob(blob)) { + return await blobToFile(blob, { name: getContentName(c.req.raw), type: contentType }); + } + } catch (e) { + console.warn("Error parsing blob", e); + } + } + + throw new Error("No file found in request"); +} + +export async function getBodyFromContext(c: Context): Promise { + const contentType = c.req.header("Content-Type") ?? "application/octet-stream"; + + if ( + !contentType?.startsWith("multipart/form-data") && + !contentType?.startsWith("application/x-www-form-urlencoded") + ) { + const body = c.req.raw.body; + if (body) { + return body; + } + } + + return getFileFromContext(c); +} + +type ImageDim = { width: number; height: number }; +export async function detectImageDimensions( + input: ArrayBuffer, + type: `image/${string}`, +): Promise; +export async function detectImageDimensions(input: File): Promise; +export async function detectImageDimensions( + input: File | ArrayBuffer, + _type?: `image/${string}`, +): Promise { + // Only process images + const is_file = isFile(input); + const type = is_file ? input.type : _type!; + + invariant(type && typeof type === "string" && type.startsWith("image/"), "type must be image/*"); + + const buffer = is_file ? await input.arrayBuffer() : input; + invariant(buffer.byteLength >= 128, "Buffer must be at least 128 bytes"); + + const dataView = new DataView(buffer); + + if (type === "image/jpeg") { + let offset = 2; + while (offset < dataView.byteLength) { + const marker = dataView.getUint16(offset); + offset += 2; + if (marker === 0xffc0 || marker === 0xffc2) { + return { + width: dataView.getUint16(offset + 5), + height: dataView.getUint16(offset + 3), + }; + } + offset += dataView.getUint16(offset); + } + } else if (type === "image/png") { + return { + width: dataView.getUint32(16), + height: dataView.getUint32(20), + }; + } else if (type === "image/gif") { + return { + width: dataView.getUint16(6), + height: dataView.getUint16(8), + }; + } else if (type === "image/tiff") { + const isLittleEndian = dataView.getUint16(0) === 0x4949; + const offset = dataView.getUint32(4, isLittleEndian); + const width = dataView.getUint32(offset + 18, isLittleEndian); + const height = dataView.getUint32(offset + 10, isLittleEndian); + return { width, height }; + } + + throw new Error("Unsupported image format"); +} + +export async function blobToFile( + blob: Blob | File | unknown, + overrides: FilePropertyBag & { name?: string } = {}, +): Promise { + if (isFile(blob)) return blob; + if (!isBlob(blob)) throw new Error("Not a Blob"); + + const type = isMimeType(overrides.type, ["application/octet-stream"]) + ? overrides.type + : await detectMimeType(blob); + const ext = type ? extension(type) : ""; + const name = overrides.name || [randomString(16), ext].filter(Boolean).join("."); + + return new File([blob], name, { + type: type || guess(name), + lastModified: Date.now(), + }); +} diff --git a/app/src/core/utils/reqres.ts b/app/src/core/utils/reqres.ts index 9890334..3e9ec12 100644 --- a/app/src/core/utils/reqres.ts +++ b/app/src/core/utils/reqres.ts @@ -97,186 +97,6 @@ export function decodeSearch(str) { return out; } -export function isReadableStream(value: unknown): value is ReadableStream { - return ( - typeof value === "object" && - value !== null && - typeof (value as ReadableStream).getReader === "function" - ); -} - -export function isBlob(value: unknown): value is Blob { - return ( - typeof value === "object" && - value !== null && - typeof (value as Blob).arrayBuffer === "function" && - typeof (value as Blob).type === "string" - ); -} - -export function isFile(value: unknown): value is File { - return ( - isBlob(value) && - typeof (value as File).name === "string" && - typeof (value as File).lastModified === "number" - ); -} - -export function isArrayBuffer(value: unknown): value is ArrayBuffer { - return ( - typeof value === "object" && - value !== null && - Object.prototype.toString.call(value) === "[object ArrayBuffer]" - ); -} - -export function isArrayBufferView(value: unknown): value is ArrayBufferView { - return typeof value === "object" && value !== null && ArrayBuffer.isView(value); -} - -export function getContentName(request: Request): string | undefined; -export function getContentName(contentDisposition: string): string | undefined; -export function getContentName(headers: Headers): string | undefined; -export function getContentName(ctx: Headers | Request | string): string | undefined { - let c: string = ""; - - if (typeof ctx === "string") { - c = ctx; - } else if (ctx instanceof Headers) { - c = ctx.get("Content-Disposition") || ""; - } else if (ctx instanceof Request) { - c = ctx.headers.get("Content-Disposition") || ""; - } - - const match = c.match(/filename\*?=(?:UTF-8'')?("?)([^";]+)\1/); - return match ? match[2] : undefined; -} - -const FILE_SIGNATURES: Record = { - "89504E47": "image/png", - FFD8FF: "image/jpeg", - "47494638": "image/gif", - "49492A00": "image/tiff", // Little Endian TIFF - "4D4D002A": "image/tiff", // Big Endian TIFF - "52494646????57454250": "image/webp", // WEBP (RIFF....WEBP) - "504B0304": "application/zip", - "25504446": "application/pdf", - "00000020667479706D70": "video/mp4", - "000001BA": "video/mpeg", - "000001B3": "video/mpeg", - "1A45DFA3": "video/webm", - "4F676753": "audio/ogg", - "494433": "audio/mpeg", // MP3 with ID3 header - FFF1: "audio/aac", - FFF9: "audio/aac", - "52494646????41564920": "audio/wav", - "52494646????57415645": "audio/wave", - "52494646????415550": "audio/aiff", -}; - -async function detectMimeType( - input: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | File | null, -): Promise { - if (!input) return; - - let buffer: Uint8Array; - - if (isReadableStream(input)) { - const reader = input.getReader(); - const { value } = await reader.read(); - if (!value) return; - buffer = new Uint8Array(value); - } else if (isBlob(input) || isFile(input)) { - buffer = new Uint8Array(await input.slice(0, 12).arrayBuffer()); - } else if (isArrayBuffer(input)) { - buffer = new Uint8Array(input); - } else if (isArrayBufferView(input)) { - buffer = new Uint8Array(input.buffer, input.byteOffset, input.byteLength); - } else if (typeof input === "string") { - buffer = new TextEncoder().encode(input); - } else { - return; - } - - const hex = Array.from(buffer.slice(0, 12)) - .map((b) => b.toString(16).padStart(2, "0").toUpperCase()) - .join(""); - - for (const [signature, mime] of Object.entries(FILE_SIGNATURES)) { - const regex = new RegExp("^" + signature.replace(/\?\?/g, "..")); - if (regex.test(hex)) return mime; - } - - return; -} - -export async function blobToFile( - blob: Blob | File | unknown, - overrides: FilePropertyBag & { name?: string } = {}, -): Promise { - if (isFile(blob)) return blob; - if (!isBlob(blob)) throw new Error("Not a Blob"); - - const type = isMimeType(overrides.type, ["application/octet-stream"]) - ? overrides.type - : await detectMimeType(blob); - const ext = type ? extension(type) : ""; - const name = overrides.name || [randomString(16), ext].filter(Boolean).join("."); - - return new File([blob], name, { - type: type || guess(name), - lastModified: Date.now(), - }); -} - -export async function getFileFromContext(c: Context): Promise { - const contentType = c.req.header("Content-Type") ?? "application/octet-stream"; - - if ( - contentType?.startsWith("multipart/form-data") || - contentType?.startsWith("application/x-www-form-urlencoded") - ) { - try { - const f = await c.req.formData(); - if ([...f.values()].length > 0) { - const v = [...f.values()][0]; - return await blobToFile(v); - } - } catch (e) { - console.warn("Error parsing form data", e); - } - } else { - try { - const blob = await c.req.blob(); - if (isFile(blob)) { - return blob; - } else if (isBlob(blob)) { - return await blobToFile(blob, { name: getContentName(c.req.raw), type: contentType }); - } - } catch (e) { - console.warn("Error parsing blob", e); - } - } - - throw new Error("No file found in request"); -} - -export async function getBodyFromContext(c: Context): Promise { - const contentType = c.req.header("Content-Type") ?? "application/octet-stream"; - - if ( - !contentType?.startsWith("multipart/form-data") && - !contentType?.startsWith("application/x-www-form-urlencoded") - ) { - const body = c.req.raw.body; - if (body) { - return body; - } - } - - return getFileFromContext(c); -} - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status // biome-ignore lint/suspicious/noConstEnum: export const enum HttpStatus { diff --git a/app/src/core/utils/runtime.ts b/app/src/core/utils/runtime.ts index 3e1bfa1..b90f670 100644 --- a/app/src/core/utils/runtime.ts +++ b/app/src/core/utils/runtime.ts @@ -47,3 +47,9 @@ export function isNode() { return false; } } + +export function invariant(condition: boolean | any, message: string) { + if (!condition) { + throw new Error(message); + } +} diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts index f71ef6a..4c73b9d 100644 --- a/app/src/media/AppMedia.ts +++ b/app/src/media/AppMedia.ts @@ -75,13 +75,20 @@ export class AppMedia extends Module { return this._storage!; } - uploadedEventDataToMediaPayload(info: FileUploadedEventData) { + uploadedEventDataToMediaPayload(info: FileUploadedEventData): MediaFieldSchema { + const metadata: any = {}; + if (info.meta.width && info.meta.height) { + metadata.width = info.meta.width; + metadata.height = info.meta.height; + } + return { path: info.name, mime_type: info.meta.type, size: info.meta.size, etag: info.etag, modified_at: new Date(), + metadata, }; } diff --git a/app/src/media/storage/Storage.ts b/app/src/media/storage/Storage.ts index 718d8b1..2df3451 100644 --- a/app/src/media/storage/Storage.ts +++ b/app/src/media/storage/Storage.ts @@ -1,8 +1,9 @@ import { type EmitsEvents, EventManager } from "core/events"; -import { type TSchema, isFile } from "core/utils"; +import { type TSchema, 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"; export type FileListObject = { key: string; @@ -10,7 +11,7 @@ export type FileListObject = { size: number; }; -export type FileMeta = { type: string; size: number }; +export type FileMeta = { type: string; size: number; width?: number; height?: number }; export type FileBody = ReadableStream | File; export type FileUploadPayload = { name: string; @@ -102,7 +103,7 @@ export class Storage implements EmitsEvents { } // try to get better meta info - if (!isMimeType(info?.meta.type, ["application/octet-stream", "application/json"])) { + if (!isMimeType(info.meta.type, ["application/octet-stream", "application/json"])) { const meta = await this.#adapter.getObjectMeta(name); if (!meta) { throw new Error("Failed to get object meta"); @@ -110,6 +111,19 @@ export class Storage implements EmitsEvents { info.meta = meta; } + // try to get width/height for images + if (info.meta.type.startsWith("image") && (!info.meta.width || !info.meta.height)) { + try { + const dim = await detectImageDimensions(file as File); + info.meta = { + ...info.meta, + ...dim, + }; + } catch (e) { + $console.warn("Failed to get image dimensions", e); + } + } + const eventData = { file, ...info, diff --git a/app/src/media/storage/adapters/StorageS3Adapter.ts b/app/src/media/storage/adapters/StorageS3Adapter.ts index 37c651d..960b73d 100644 --- a/app/src/media/storage/adapters/StorageS3Adapter.ts +++ b/app/src/media/storage/adapters/StorageS3Adapter.ts @@ -7,7 +7,7 @@ import type { PutObjectRequest, } from "@aws-sdk/client-s3"; import { AwsClient, isDebug } from "core"; -import { type Static, Type, isFile, parse, pickHeaders, pickHeaders2 } from "core/utils"; +import { type Static, Type, isFile, parse, pickHeaders2 } from "core/utils"; import { transform } from "lodash-es"; import type { FileBody, FileListObject, StorageAdapter } from "../Storage"; @@ -178,7 +178,6 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter { const res = await this.fetch(url, { method: "GET", headers: pickHeaders2(headers, [ - "range", "if-none-match", "accept-encoding", "accept", From f6a511d99812bb30a0aefc4b0c893b30e67dbf47 Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 27 Mar 2025 09:23:14 +0100 Subject: [PATCH 03/27] add media detail dialog and infinite loading --- app/src/core/utils/index.ts | 1 + app/src/core/utils/numbers.ts | 11 ++ app/src/modules/ModuleApi.ts | 11 ++ app/src/ui/client/api/use-api.ts | 48 ++++- app/src/ui/components/code/JsonViewer.tsx | 6 +- app/src/ui/components/overlay/Dropdown.tsx | 22 ++- app/src/ui/components/wouter/Link.tsx | 1 + app/src/ui/elements/media/Dropzone.tsx | 42 ++++- .../ui/elements/media/DropzoneContainer.tsx | 100 +++++++--- app/src/ui/hooks/use-event.ts | 2 +- app/src/ui/lib/mantine/theme.ts | 2 +- app/src/ui/modals/index.tsx | 13 +- app/src/ui/modals/media/MediaInfoModal.tsx | 177 ++++++++++++++++++ app/src/ui/modals/transitions.ts | 7 + .../ui/modules/data/components/EntityForm.tsx | 9 +- app/src/ui/routes/media/media.index.tsx | 13 +- .../test/tests/dropzone-element-test.tsx | 4 +- 17 files changed, 419 insertions(+), 50 deletions(-) create mode 100644 app/src/ui/modals/media/MediaInfoModal.tsx create mode 100644 app/src/ui/modals/transitions.ts diff --git a/app/src/core/utils/index.ts b/app/src/core/utils/index.ts index c2239e4..c94c4bb 100644 --- a/app/src/core/utils/index.ts +++ b/app/src/core/utils/index.ts @@ -2,6 +2,7 @@ export * from "./browser"; export * from "./objects"; export * from "./strings"; export * from "./perf"; +export * from "./file"; export * from "./reqres"; export * from "./xml"; export type { Prettify, PrettifyRec } from "./types"; diff --git a/app/src/core/utils/numbers.ts b/app/src/core/utils/numbers.ts index 33394f6..e9b458b 100644 --- a/app/src/core/utils/numbers.ts +++ b/app/src/core/utils/numbers.ts @@ -11,3 +11,14 @@ export function ensureInt(value?: string | number | null | undefined): number { return typeof value === "number" ? value : Number.parseInt(value, 10); } + +export const formatNumber = { + fileSize: (bytes: number, decimals = 2): string => { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Number.parseFloat((bytes / k ** i).toFixed(dm)) + " " + sizes[i]; + }, +}; diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts index 0ca8162..6c453af 100644 --- a/app/src/modules/ModuleApi.ts +++ b/app/src/modules/ModuleApi.ts @@ -331,4 +331,15 @@ export class FetchPromise> implements Promise { Boolean, ); } + + toString() { + return this.key({ search: true }); + } + + toJSON() { + return { + url: this.request.url, + method: this.request.method, + }; + } } diff --git a/app/src/ui/client/api/use-api.ts b/app/src/ui/client/api/use-api.ts index f832adc..72feeeb 100644 --- a/app/src/ui/client/api/use-api.ts +++ b/app/src/ui/client/api/use-api.ts @@ -1,7 +1,9 @@ import type { Api } from "Api"; -import type { FetchPromise, ModuleApi, ResponseObject } from "modules/ModuleApi"; +import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi"; import useSWR, { type SWRConfiguration, useSWRConfig } from "swr"; +import useSWRInfinite from "swr/infinite"; import { useApi } from "ui/client"; +import { useState } from "react"; export const useApiQuery = < Data, @@ -27,6 +29,50 @@ export const useApiQuery = < }; }; +/** @attention: highly experimental, use with caution! */ +export const useApiInfiniteQuery = < + Data, + RefineFn extends (data: ResponseObject) => unknown = (data: ResponseObject) => Data, +>( + fn: (api: Api, page: number) => FetchPromise, + options?: SWRConfiguration & { refine?: RefineFn }, +) => { + const [endReached, setEndReached] = useState(false); + const api = useApi(); + const promise = (page: number) => fn(api, page); + const refine = options?.refine ?? ((data: any) => data); + + type RefinedData = RefineFn extends (data: ResponseObject) => infer R ? R : Data; + + // @ts-ignore + const swr = useSWRInfinite( + (index, previousPageData: any) => { + if (previousPageData && !previousPageData.length) { + setEndReached(true); + return null; // reached the end + } + return promise(index).request.url; + }, + (url: string) => { + return new FetchPromise(new Request(url), { fetcher: api.fetcher }, refine).execute(); + }, + { + revalidateFirstPage: false, + }, + ); + // @ts-ignore + const data = swr.data ? [].concat(...swr.data) : []; + return { + ...swr, + _data: swr.data, + data, + endReached, + promise: promise(swr.size), + key: promise(swr.size).key(), + api, + }; +}; + export const useInvalidate = (options?: { exact?: boolean }) => { const mutate = useSWRConfig().mutate; const api = useApi(); diff --git a/app/src/ui/components/code/JsonViewer.tsx b/app/src/ui/components/code/JsonViewer.tsx index 2d76d21..923846b 100644 --- a/app/src/ui/components/code/JsonViewer.tsx +++ b/app/src/ui/components/code/JsonViewer.tsx @@ -9,6 +9,7 @@ export const JsonViewer = ({ expand = 0, showSize = false, showCopy = false, + copyIconProps = {}, className, }: { json: object; @@ -16,6 +17,7 @@ export const JsonViewer = ({ expand?: number; showSize?: boolean; showCopy?: boolean; + copyIconProps?: any; className?: string; }) => { const size = showSize ? JSON.stringify(json).length : undefined; @@ -28,7 +30,7 @@ export const JsonViewer = ({ return (
{showContext && ( -
+
{(title || size) && (
{title && {title}} {size && ({size} Bytes)} @@ -36,7 +38,7 @@ export const JsonViewer = ({ )} {showCopy && (
- +
)}
diff --git a/app/src/ui/components/overlay/Dropdown.tsx b/app/src/ui/components/overlay/Dropdown.tsx index 5d2ff4f..78ed2ff 100644 --- a/app/src/ui/components/overlay/Dropdown.tsx +++ b/app/src/ui/components/overlay/Dropdown.tsx @@ -37,7 +37,7 @@ export type DropdownProps = { onClickItem?: (item: DropdownItem) => void; renderItem?: ( item: DropdownItem, - props: { key: number; onClick: () => void }, + props: { key: number; onClick: (e: any) => void }, ) => DropdownClickableChild; }; @@ -65,7 +65,13 @@ export function Dropdown({ setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0), ); - const onClickHandler = openEvent === "onClick" ? toggle : undefined; + const onClickHandler = + openEvent === "onClick" + ? (e) => { + e.stopPropagation(); + toggle(); + } + : undefined; const onContextMenuHandler = useEvent((e) => { if (openEvent !== "onContextMenu") return; e.preventDefault(); @@ -165,10 +171,18 @@ export function Dropdown({ style={dropdownStyle} > {title && ( -
{title}
+
+ {title} +
)} {menuItems.map((item, i) => - itemRenderer(item, { key: i, onClick: () => internalOnClickItem(item) }), + itemRenderer(item, { + key: i, + onClick: (e) => { + e.stopPropagation(); + internalOnClickItem(item); + }, + }), )}
)} diff --git a/app/src/ui/components/wouter/Link.tsx b/app/src/ui/components/wouter/Link.tsx index c1ca181..116555c 100644 --- a/app/src/ui/components/wouter/Link.tsx +++ b/app/src/ui/components/wouter/Link.tsx @@ -88,6 +88,7 @@ export function Link({ } const wouterOnClick = (e: any) => { + onClick?.(e); // prepared for view transition /*if (props.transition !== false) { e.preventDefault(); diff --git a/app/src/ui/elements/media/Dropzone.tsx b/app/src/ui/elements/media/Dropzone.tsx index 952bca6..9301dea 100644 --- a/app/src/ui/elements/media/Dropzone.tsx +++ b/app/src/ui/elements/media/Dropzone.tsx @@ -9,11 +9,12 @@ import { useRef, useState, } from "react"; -import { TbDots } from "react-icons/tb"; +import { TbDots, TbExternalLink, TbTrash, TbUpload } from "react-icons/tb"; import { twMerge } from "tailwind-merge"; import { IconButton } from "ui/components/buttons/IconButton"; -import { Dropdown } from "ui/components/overlay/Dropdown"; +import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown"; import { type FileWithPath, useDropzone } from "./use-dropzone"; +import { formatNumber } from "core/utils"; export type FileState = { body: FileWithPath | string; @@ -41,6 +42,8 @@ export type DropzoneRenderProps = { deleteFile: (file: FileState) => Promise; openFileInput: () => void; }; + onClick?: (file: FileState) => void; + footer?: ReactNode; dropzoneProps: Pick; }; @@ -56,10 +59,12 @@ export type DropzoneProps = { onRejected?: (files: FileWithPath[]) => void; onDeleted?: (file: FileState) => void; onUploaded?: (files: FileStateWithData[]) => void; + onClick?: (file: FileState) => void; placeholder?: { show?: boolean; text?: string; }; + footer?: ReactNode; children?: (props: DropzoneRenderProps) => ReactNode; }; @@ -86,6 +91,8 @@ export function Dropzone({ onDeleted, onUploaded, children, + onClick, + footer, }: DropzoneProps) { const [files, setFiles] = useState(initialItems); const [uploading, setUploading] = useState(false); @@ -393,6 +400,8 @@ export function Dropzone({ autoUpload, flow, }, + onClick, + footer, }; return children ? children(renderProps) : ; @@ -404,6 +413,8 @@ const DropzoneInner = ({ state: { files, isOver, isOverAccepted, showPlaceholder }, actions: { uploadFile, deleteFile, openFileInput }, dropzoneProps: { placeholder, flow }, + onClick, + footer, }: DropzoneRenderProps) => { const Placeholder = showPlaceholder && ( @@ -438,9 +449,11 @@ const DropzoneInner = ({ file={file} handleUpload={uploadHandler} handleDelete={deleteFile} + onClick={onClick} /> ))} {flow === "end" && Placeholder} + {footer}
@@ -486,26 +499,43 @@ type PreviewProps = { file: FileState; handleUpload: (file: FileState) => Promise; handleDelete: (file: FileState) => Promise; + onClick?: (file: FileState) => void; }; -const Preview = ({ file, handleUpload, handleDelete }: PreviewProps) => { +const Preview = ({ file, handleUpload, handleDelete, onClick }: PreviewProps) => { const dropdownItems = [ + file.state === "uploaded" && + typeof file.body === "string" && { + label: "Open", + icon: TbExternalLink, + onClick: () => { + window.open(file.body as string, "_blank"); + }, + }, ["initial", "uploaded"].includes(file.state) && { label: "Delete", + destructive: true, + icon: TbTrash, onClick: () => handleDelete(file), }, ["initial", "pending"].includes(file.state) && { label: "Upload", + icon: TbUpload, onClick: () => handleUpload(file), }, - ]; + ] satisfies (DropdownItem | boolean)[]; return (
{ + if (onClick) { + onClick(file); + } + }} >
@@ -531,7 +561,7 @@ const Preview = ({ file, handleUpload, handleDelete }: PreviewProps) => {

{file.name}

{file.type} - {(file.size / 1024).toFixed(1)} KB + {formatNumber.fileSize(file.size)}
diff --git a/app/src/ui/elements/media/DropzoneContainer.tsx b/app/src/ui/elements/media/DropzoneContainer.tsx index fce4049..d2ce21e 100644 --- a/app/src/ui/elements/media/DropzoneContainer.tsx +++ b/app/src/ui/elements/media/DropzoneContainer.tsx @@ -2,11 +2,20 @@ import type { Api } from "bknd/client"; import type { RepoQueryIn } from "data"; import type { MediaFieldSchema } from "media/AppMedia"; import type { TAppMediaConfig } from "media/media-schema"; -import { type ReactNode, createContext, useContext, useId } from "react"; -import { useApi, useApiQuery, useInvalidate } from "ui/client"; +import { + type ReactNode, + createContext, + useContext, + useId, + useEffect, + useRef, + useState, +} from "react"; +import { useApi, useApiInfiniteQuery, useInvalidate } from "ui/client"; import { useEvent } from "ui/hooks/use-event"; import { Dropzone, type DropzoneProps, type DropzoneRenderProps, type FileState } from "./Dropzone"; import { mediaItemsToFileStates } from "./helper"; +import { useInViewport } from "@mantine/hooks"; export type DropzoneContainerProps = { children?: ReactNode; @@ -36,30 +45,32 @@ export function DropzoneContainer({ const api = useApi(); const invalidate = useInvalidate(); const baseUrl = api.baseUrl; - const defaultQuery = { - limit: query?.limit ? query?.limit : props.maxItems ? props.maxItems : 50, + const pageSize = query?.limit ?? props.maxItems ?? 50; + const defaultQuery = (page: number) => ({ + limit: pageSize, + offset: page * pageSize, sort: "-id", - }; + }); const entity_name = (media?.entity_name ?? "media") as "media"; //console.log("dropzone:baseUrl", baseUrl); - const selectApi = (api: Api) => + const selectApi = (api: Api, page: number) => entity ? api.data.readManyByReference(entity.name, entity.id, entity.field, { - ...defaultQuery, ...query, where: { reference: `${entity.name}.${entity.field}`, entity_id: entity.id, ...query?.where, }, + ...defaultQuery(page), }) : api.data.readMany(entity_name, { - ...defaultQuery, ...query, + ...defaultQuery(page), }); - const $q = useApiQuery(selectApi, { enabled: initialItems !== false && !initialItems }); + const $q = useApiInfiniteQuery(selectApi, {}); const getUploadInfo = useEvent((file) => { const url = entity @@ -88,27 +99,62 @@ export function DropzoneContainer({ const key = id + JSON.stringify(_initialItems); return ( - - {children - ? (props) => ( - - {children} - - ) - : undefined} - +
+ $q.setSize($q.size + 1)} + /> + } + {...props} + > + {children + ? (props) => ( + + {children} + + ) + : undefined} + +
); } +const Footer = ({ items = 0, length = 0, onFirstVisible }) => { + const { ref, inViewport } = useInViewport(); + const [visible, setVisible] = useState(0); + const lastItemsCount = useRef(-1); + + useEffect(() => { + if (inViewport && items > lastItemsCount.current) { + lastItemsCount.current = items; + setVisible((v) => v + 1); + onFirstVisible(); + } + }, [inViewport]); + const _len = length - items; + if (_len <= 0) return null; + + return new Array(Math.max(length - items, 0)).fill(0).map((_, i) => ( +
+ {i === 0 ? (inViewport ? `load ${visible}` : "first") : "other"} +
+ )); +}; + export function useDropzone() { return useContext(DropzoneContainerContext); } diff --git a/app/src/ui/hooks/use-event.ts b/app/src/ui/hooks/use-event.ts index 23f8130..e55baca 100644 --- a/app/src/ui/hooks/use-event.ts +++ b/app/src/ui/hooks/use-event.ts @@ -9,7 +9,7 @@ import { isDebug } from "core"; export const useEvent = (fn: Fn): Fn => { if (isDebug()) { - console.warn("useEvent() is deprecated"); + //console.warn("useEvent() is deprecated"); } return fn; }; diff --git a/app/src/ui/lib/mantine/theme.ts b/app/src/ui/lib/mantine/theme.ts index 0f6bfce..8127b86 100644 --- a/app/src/ui/lib/mantine/theme.ts +++ b/app/src/ui/lib/mantine/theme.ts @@ -31,7 +31,7 @@ export function createMantineTheme(scheme: "light" | "dark"): { }; const input = - "!bg-muted/40 border-transparent disabled:bg-muted/50 disabled:text-primary/50 focus:border-zinc-500"; + "!bg-muted/40 border-transparent disabled:bg-muted/50 disabled:text-primary/50 focus:!border-zinc-500"; return { theme: createTheme({ diff --git a/app/src/ui/modals/index.tsx b/app/src/ui/modals/index.tsx index 03071e3..ee2fa40 100644 --- a/app/src/ui/modals/index.tsx +++ b/app/src/ui/modals/index.tsx @@ -6,6 +6,8 @@ import { CreateModal } from "ui/modules/data/components/schema/create-modal/Crea import { DebugModal } from "./debug/DebugModal"; import { SchemaFormModal } from "./debug/SchemaFormModal"; import { TestModal } from "./debug/TestModal"; +import { scaleFadeIn } from "ui/modals/transitions"; +import { MediaInfoModal } from "ui/modals/media/MediaInfoModal"; const modals = { test: TestModal, @@ -13,6 +15,7 @@ const modals = { form: SchemaFormModal, overlay: OverlayModal, dataCreate: CreateModal, + mediaInfo: MediaInfoModal, }; declare module "@mantine/modals" { @@ -38,8 +41,14 @@ function open( ...cmpModalProps, modal, innerProps, - }; - openContextModal(props); + } as any; + openContextModal({ + transitionProps: { + transition: scaleFadeIn, + duration: 300, + }, + ...props, + }); return { close: () => close(modal), closeAll: $modals.closeAll, diff --git a/app/src/ui/modals/media/MediaInfoModal.tsx b/app/src/ui/modals/media/MediaInfoModal.tsx new file mode 100644 index 0000000..744ea60 --- /dev/null +++ b/app/src/ui/modals/media/MediaInfoModal.tsx @@ -0,0 +1,177 @@ +import type { ContextModalProps } from "@mantine/modals"; +import type { ReactNode } from "react"; +import { useEntityQuery } from "ui/client"; +import { type FileState, Media } from "ui/elements"; +import { autoFormatString, datetimeStringLocal, formatNumber } from "core/utils"; +import { twMerge } from "tailwind-merge"; +import { IconButton } from "ui/components/buttons/IconButton"; +import { TbCheck, TbCopy } from "react-icons/tb"; +import { useClipboard } from "@mantine/hooks"; +import { ButtonLink } from "ui/components/buttons/Button"; +import { routes } from "ui/lib/routes"; +import { useBkndMedia } from "ui/client/schema/media/use-bknd-media"; +import { JsonViewer } from "ui"; + +export type MediaInfoModalProps = { + file: FileState; +}; + +export function MediaInfoModal({ + context, + id, + innerProps: { file }, +}: ContextModalProps) { + const { + config: { entity_name, basepath }, + } = useBkndMedia(); + const $q = useEntityQuery(entity_name as "media", undefined, { + where: { + path: file.path, + }, + }); + const close = () => context.closeModal(id); + const data = $q.data?.[0]; + const origin = window.location.origin; + const entity = data?.reference ? data?.reference.split(".")[0] : undefined; + const entityUrl = entity + ? "/data" + routes.data.entity.edit(entity, data?.entity_id!) + : undefined; + const mediaUrl = data?.path + ? "/data" + routes.data.entity.edit(entity_name, data?.id!) + : undefined; + //const assetUrl = data?.path ? origin + basepath + "/file/" + data?.path : undefined; + + return ( +
+
+ {/* @ts-ignore */} + +
+
+ + {mediaUrl && ( + + #{String(data?.id)} + + )} + + + + + + + {entityUrl && ( + + {data?.reference} #{data?.entity_id} + + )} + + + + {data?.metadata && ( + + )} + +
+
+ ); +} + +const Item = ({ + title, + children, + value, + first, + copyable = true, + copyValue, +}: { + title: string; + children?: ReactNode; + value?: any; + first?: boolean; + copyable?: boolean; + copyValue?: any; +}) => { + const cb = useClipboard(); + + const is_null = !children && (value === null || typeof value === "undefined"); + const can_copy = copyable && !is_null && cb.copy !== undefined; + const _value = value + ? typeof value === "object" && !is_null + ? JSON.stringify(value) + : String(value) + : undefined; + + return ( +
+
{autoFormatString(title)}
+
+ {children ?? ( +
+ {is_null ? "null" : _value} +
+ )} + {can_copy && ( + cb.copy(copyValue ? copyValue : value)} + /> + )} +
+
+ ); +}; + +MediaInfoModal.defaultTitle = undefined; +MediaInfoModal.modalProps = { + withCloseButton: false, + size: "auto", + //size: "90%", + centered: true, + styles: { + content: { + overflowY: "initial !important", + }, + }, + classNames: { + root: "bknd-admin w-full max-w-xl", + content: "overflow-hidden", + title: "font-bold !text-md", + body: "max-h-inherit !p-0", + }, +}; diff --git a/app/src/ui/modals/transitions.ts b/app/src/ui/modals/transitions.ts new file mode 100644 index 0000000..dee6cc7 --- /dev/null +++ b/app/src/ui/modals/transitions.ts @@ -0,0 +1,7 @@ +import type { MantineTransition } from "@mantine/core"; + +export const scaleFadeIn: MantineTransition = { + in: { opacity: 1, transform: "scale(1)" }, + out: { opacity: 0, transform: "scale(0.9)" }, + transitionProperty: "transform, opacity", +}; diff --git a/app/src/ui/modules/data/components/EntityForm.tsx b/app/src/ui/modules/data/components/EntityForm.tsx index 1551551..b373d6b 100644 --- a/app/src/ui/modules/data/components/EntityForm.tsx +++ b/app/src/ui/modules/data/components/EntityForm.tsx @@ -15,12 +15,13 @@ import { type ComponentProps, Suspense } from "react"; import { JsonEditor } from "ui/components/code/JsonEditor"; import * as Formy from "ui/components/form/Formy"; import { FieldLabel } from "ui/components/form/Formy"; -import { Media } from "ui/elements"; +import { type FileState, Media } from "ui/elements"; import { useEvent } from "ui/hooks/use-event"; import { EntityJsonSchemaFormField } from "./fields/EntityJsonSchemaFormField"; import { EntityRelationalFormField } from "./fields/EntityRelationalFormField"; import ErrorBoundary from "ui/components/display/ErrorBoundary"; import { Alert } from "ui/components/display/Alert"; +import { bkndModals } from "ui/modals"; // simplify react form types 🤦 export type FormApi = ReactFormExtendedApi; @@ -237,6 +238,11 @@ function EntityMediaFormField({ }); const key = JSON.stringify([entity, entityId, field.name, value.length]); + const onClick = (file: FileState) => { + bkndModals.open(bkndModals.ids.mediaInfo, { + file, + }); + }; return ( @@ -245,6 +251,7 @@ function EntityMediaFormField({ key={key} maxItems={field.getMaxItems()} initialItems={value} /* @todo: test if better be omitted, so it fetches */ + onClick={onClick} entity={{ name: entity.name, id: entityId, diff --git a/app/src/ui/routes/media/media.index.tsx b/app/src/ui/routes/media/media.index.tsx index 9c32791..16356ad 100644 --- a/app/src/ui/routes/media/media.index.tsx +++ b/app/src/ui/routes/media/media.index.tsx @@ -1,13 +1,14 @@ import { IconPhoto } from "@tabler/icons-react"; import { useBknd } from "ui/client/BkndProvider"; import { Empty } from "ui/components/display/Empty"; -import { Media } from "ui/elements"; +import { type FileState, Media } from "ui/elements"; import { useBrowserTitle } from "ui/hooks/use-browser-title"; import * as AppShell from "ui/layouts/AppShell/AppShell"; import { useLocation } from "wouter"; +import { bkndModals } from "ui/modals"; export function MediaIndex() { - const { app, config } = useBknd(); + const { config } = useBknd(); const [, navigate] = useLocation(); useBrowserTitle(["Media"]); @@ -25,10 +26,16 @@ export function MediaIndex() { ); } + const onClick = (file: FileState) => { + bkndModals.open(bkndModals.ids.mediaInfo, { + file, + }); + }; + return (
- +
); diff --git a/app/src/ui/routes/test/tests/dropzone-element-test.tsx b/app/src/ui/routes/test/tests/dropzone-element-test.tsx index 68ccbc3..cf70a7b 100644 --- a/app/src/ui/routes/test/tests/dropzone-element-test.tsx +++ b/app/src/ui/routes/test/tests/dropzone-element-test.tsx @@ -19,7 +19,7 @@ export default function DropzoneElementTest() { -
+ {/*
Dropzone User Avatar 1 (overwrite) Dropzone Container blank w/ query -
+
*/}
Dropzone Container blank From 7facef47dab3e13fdbde8170eac829affa0fb5fa Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 27 Mar 2025 09:57:31 +0100 Subject: [PATCH 04/27] fix media styling on mobile --- app/src/ui/elements/media/Dropzone.tsx | 6 +-- .../ui/elements/media/DropzoneContainer.tsx | 52 +++++++++---------- app/src/ui/layouts/AppShell/AppShell.tsx | 2 +- app/src/ui/layouts/AppShell/Header.tsx | 2 +- .../ui/routes/data/data.schema.$entity.tsx | 2 +- 5 files changed, 31 insertions(+), 33 deletions(-) diff --git a/app/src/ui/elements/media/Dropzone.tsx b/app/src/ui/elements/media/Dropzone.tsx index 9301dea..57d6c48 100644 --- a/app/src/ui/elements/media/Dropzone.tsx +++ b/app/src/ui/elements/media/Dropzone.tsx @@ -463,7 +463,7 @@ const DropzoneInner = ({ const UploadPlaceholder = ({ onClick, text = "Upload files" }) => { return (
{text} @@ -527,7 +527,7 @@ const Preview = ({ file, handleUpload, handleDelete, onClick }: PreviewProps) => return (
/>
)} -
+
- $q.setSize($q.size + 1)} - /> - } - {...props} - > - {children - ? (props) => ( - - {children} - - ) - : undefined} - -
+ $q.setSize($q.size + 1)} + /> + } + {...props} + > + {children + ? (props) => ( + + {children} + + ) + : undefined} + ); } diff --git a/app/src/ui/layouts/AppShell/AppShell.tsx b/app/src/ui/layouts/AppShell/AppShell.tsx index 16cfc8b..5d61bbf 100644 --- a/app/src/ui/layouts/AppShell/AppShell.tsx +++ b/app/src/ui/layouts/AppShell/AppShell.tsx @@ -99,7 +99,7 @@ export function Main({ children }) { export function Sidebar({ children }) { const open = appShellStore((store) => store.sidebarOpen); const close = appShellStore((store) => store.closeSidebar); - const ref = useClickOutside(close, null, [document.getElementById("header")]); + const ref = useClickOutside(close, ["mouseup", "touchend"]); //, [document.getElementById("header")]); const [location] = useLocation(); const closeHandler = () => { diff --git a/app/src/ui/layouts/AppShell/Header.tsx b/app/src/ui/layouts/AppShell/Header.tsx index a942171..4093094 100644 --- a/app/src/ui/layouts/AppShell/Header.tsx +++ b/app/src/ui/layouts/AppShell/Header.tsx @@ -107,7 +107,7 @@ export function HeaderNavigation() { function SidebarToggler() { const toggle = appShellStore((store) => store.toggleSidebar); const open = appShellStore((store) => store.sidebarOpen); - return ; + return ; } export function Header({ hasSidebar = true }) { diff --git a/app/src/ui/routes/data/data.schema.$entity.tsx b/app/src/ui/routes/data/data.schema.$entity.tsx index 8b493dc..e93d7f2 100644 --- a/app/src/ui/routes/data/data.schema.$entity.tsx +++ b/app/src/ui/routes/data/data.schema.$entity.tsx @@ -103,7 +103,7 @@ export function DataSchemaEntity({ params }) { path={[{ label: "Schema", href: "/" }, { label: entity.label }]} backTo="/" /> - +
From 9e3c081e5063cb6f24f4479eaed18c82b543178b Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 27 Mar 2025 20:41:42 +0100 Subject: [PATCH 05/27] 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 --- .github/workflows/test.yml | 12 +- app/__test__/app/repro.spec.ts | 2 +- .../auth/strategies/OAuthStrategy.spec.ts | 7 +- .../cache/CloudflareKvCache.native-spec.ts | 57 ------ app/__test__/core/cache/MemoryCache.spec.ts | 15 -- app/__test__/core/cache/cache-test-suite.ts | 84 --------- .../data/specs/fields/BooleanField.spec.ts | 4 +- .../data/specs/fields/DateField.spec.ts | 4 +- .../data/specs/fields/EnumField.spec.ts | 9 +- app/__test__/data/specs/fields/Field.spec.ts | 4 +- .../data/specs/fields/FieldIndex.spec.ts | 10 +- .../data/specs/fields/JsonField.spec.ts | 4 +- .../data/specs/fields/JsonSchemaField.spec.ts | 5 +- .../data/specs/fields/NumberField.spec.ts | 4 +- .../data/specs/fields/TextField.spec.ts | 4 +- app/__test__/media/MediaController.spec.ts | 2 +- .../media/StorageR2Adapter.native-spec.ts | 26 ++- .../adapters/StorageCloudinaryAdapter.spec.ts | 63 ------- .../adapters/StorageLocalAdapter.spec.ts | 47 ----- .../media/adapters/StorageS3Adapter.spec.ts | 109 ----------- app/__test__/modules/AppMedia.spec.ts | 2 +- app/build.ts | 10 +- app/package.json | 10 + .../adapter/cloudflare/StorageR2Adapter.ts | 9 +- app/src/adapter/node/index.ts | 6 +- .../StorageLocalAdapter.native-spec.ts | 17 ++ .../node/storage/StorageLocalAdapter.spec.ts | 14 ++ .../node/storage}/StorageLocalAdapter.ts | 15 +- app/src/adapter/node/test.ts | 75 ++++++++ .../core/cache/adapters/CloudflareKvCache.ts | 127 ------------- app/src/core/cache/adapters/MemoryCache.ts | 139 -------------- app/src/core/cache/cache-interface.ts | 178 ------------------ app/src/core/test/index.ts | 48 +++++ .../data/fields/field-test-suite.ts} | 12 +- app/src/data/fields/index.ts | 2 + app/src/media/index.ts | 14 +- app/src/media/storage/Storage.ts | 21 +-- app/src/media/storage/StorageAdapter.ts | 37 ++++ .../adapters/StorageLocalAdapter/index.ts | 5 - .../storage/adapters/adapter-test-suite.ts | 79 ++++++++ .../StorageCloudinaryAdapter.spec.ts | 53 ++++++ .../StorageCloudinaryAdapter.ts | 63 ++++++- .../adapters/s3/StorageS3Adapter.spec.ts | 50 +++++ .../adapters/{ => s3}/StorageS3Adapter.ts | 23 ++- bun.lock | 64 ++++++- 45 files changed, 605 insertions(+), 940 deletions(-) delete mode 100644 app/__test__/core/cache/CloudflareKvCache.native-spec.ts delete mode 100644 app/__test__/core/cache/MemoryCache.spec.ts delete mode 100644 app/__test__/core/cache/cache-test-suite.ts delete mode 100644 app/__test__/media/adapters/StorageCloudinaryAdapter.spec.ts delete mode 100644 app/__test__/media/adapters/StorageLocalAdapter.spec.ts delete mode 100644 app/__test__/media/adapters/StorageS3Adapter.spec.ts create mode 100644 app/src/adapter/node/storage/StorageLocalAdapter.native-spec.ts create mode 100644 app/src/adapter/node/storage/StorageLocalAdapter.spec.ts rename app/src/{media/storage/adapters/StorageLocalAdapter => adapter/node/storage}/StorageLocalAdapter.ts (91%) create mode 100644 app/src/adapter/node/test.ts delete mode 100644 app/src/core/cache/adapters/CloudflareKvCache.ts delete mode 100644 app/src/core/cache/adapters/MemoryCache.ts delete mode 100644 app/src/core/cache/cache-interface.ts create mode 100644 app/src/core/test/index.ts rename app/{__test__/data/specs/fields/inc.ts => src/data/fields/field-test-suite.ts} (95%) create mode 100644 app/src/media/storage/StorageAdapter.ts delete mode 100644 app/src/media/storage/adapters/StorageLocalAdapter/index.ts create mode 100644 app/src/media/storage/adapters/adapter-test-suite.ts create mode 100644 app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.spec.ts rename app/src/media/storage/adapters/{ => cloudinary}/StorageCloudinaryAdapter.ts (72%) create mode 100644 app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts rename app/src/media/storage/adapters/{ => s3}/StorageS3Adapter.ts (88%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9f032d0..1030c9a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,6 +21,14 @@ jobs: working-directory: ./app run: bun install - - name: Run tests + - name: Build working-directory: ./app - run: bun run test \ No newline at end of file + 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 \ No newline at end of file diff --git a/app/__test__/app/repro.spec.ts b/app/__test__/app/repro.spec.ts index 7b69376..b27aa51 100644 --- a/app/__test__/app/repro.spec.ts +++ b/app/__test__/app/repro.spec.ts @@ -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 () => { /** diff --git a/app/__test__/auth/strategies/OAuthStrategy.spec.ts b/app/__test__/auth/strategies/OAuthStrategy.spec.ts index 93ceae0..becd783 100644 --- a/app/__test__/auth/strategies/OAuthStrategy.spec.ts +++ b/app/__test__/auth/strategies/OAuthStrategy.spec.ts @@ -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)); - }); + });*/ }); diff --git a/app/__test__/core/cache/CloudflareKvCache.native-spec.ts b/app/__test__/core/cache/CloudflareKvCache.native-spec.ts deleted file mode 100644 index d5f0812..0000000 --- a/app/__test__/core/cache/CloudflareKvCache.native-spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/app/__test__/core/cache/MemoryCache.spec.ts b/app/__test__/core/cache/MemoryCache.spec.ts deleted file mode 100644 index d78a5d1..0000000 --- a/app/__test__/core/cache/MemoryCache.spec.ts +++ /dev/null @@ -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, - }, - }); -}); diff --git a/app/__test__/core/cache/cache-test-suite.ts b/app/__test__/core/cache/cache-test-suite.ts deleted file mode 100644 index 251dfde..0000000 --- a/app/__test__/core/cache/cache-test-suite.ts +++ /dev/null @@ -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; - createItem: (key: string, value: any) => ICacheItem; - tester: { - test: (name: string, fn: () => Promise) => void; - beforeEach: (fn: () => Promise) => void; - expect: (actual?: any) => { - toBe(expected: any): void; - toEqual(expected: any): void; - toBeUndefined(): void; - }; - }; -}; - -export function runTests({ createCache, createItem, tester }: TestOptions) { - let cache: ICachePool; - 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); - });*/ -} diff --git a/app/__test__/data/specs/fields/BooleanField.spec.ts b/app/__test__/data/specs/fields/BooleanField.spec.ts index 7ed5036..a061e1f 100644 --- a/app/__test__/data/specs/fields/BooleanField.spec.ts +++ b/app/__test__/data/specs/fields/BooleanField.spec.ts @@ -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"); diff --git a/app/__test__/data/specs/fields/DateField.spec.ts b/app/__test__/data/specs/fields/DateField.spec.ts index 3e29bf0..d578843 100644 --- a/app/__test__/data/specs/fields/DateField.spec.ts +++ b/app/__test__/data/specs/fields/DateField.spec.ts @@ -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 () => { diff --git a/app/__test__/data/specs/fields/EnumField.spec.ts b/app/__test__/data/specs/fields/EnumField.spec.ts index 2187bee..066dd88 100644 --- a/app/__test__/data/specs/fields/EnumField.spec.ts +++ b/app/__test__/data/specs/fields/EnumField.spec.ts @@ -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, diff --git a/app/__test__/data/specs/fields/Field.spec.ts b/app/__test__/data/specs/fields/Field.spec.ts index 82ba9de..d5fec44 100644 --- a/app/__test__/data/specs/fields/Field.spec.ts +++ b/app/__test__/data/specs/fields/Field.spec.ts @@ -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, {}); diff --git a/app/__test__/data/specs/fields/FieldIndex.spec.ts b/app/__test__/data/specs/fields/FieldIndex.spec.ts index 8f1590c..0dd656c 100644 --- a/app/__test__/data/specs/fields/FieldIndex.spec.ts +++ b/app/__test__/data/specs/fields/FieldIndex.spec.ts @@ -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): SchemaResponse { + override schema() { return undefined as any; } } diff --git a/app/__test__/data/specs/fields/JsonField.spec.ts b/app/__test__/data/specs/fields/JsonField.spec.ts index dff15a1..0bc0d3b 100644 --- a/app/__test__/data/specs/fields/JsonField.spec.ts +++ b/app/__test__/data/specs/fields/JsonField.spec.ts @@ -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", diff --git a/app/__test__/data/specs/fields/JsonSchemaField.spec.ts b/app/__test__/data/specs/fields/JsonSchemaField.spec.ts index f9f2f54..7770098 100644 --- a/app/__test__/data/specs/fields/JsonSchemaField.spec.ts +++ b/app/__test__/data/specs/fields/JsonSchemaField.spec.ts @@ -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 }); diff --git a/app/__test__/data/specs/fields/NumberField.spec.ts b/app/__test__/data/specs/fields/NumberField.spec.ts index 6708449..e46c075 100644 --- a/app/__test__/data/specs/fields/NumberField.spec.ts +++ b/app/__test__/data/specs/fields/NumberField.spec.ts @@ -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" }); }); diff --git a/app/__test__/data/specs/fields/TextField.spec.ts b/app/__test__/data/specs/fields/TextField.spec.ts index fe83767..47d1bc3 100644 --- a/app/__test__/data/specs/fields/TextField.spec.ts +++ b/app/__test__/data/specs/fields/TextField.spec.ts @@ -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" }); }); diff --git a/app/__test__/media/MediaController.spec.ts b/app/__test__/media/MediaController.spec.ts index 3584317..f55591b 100644 --- a/app/__test__/media/MediaController.spec.ts +++ b/app/__test__/media/MediaController.spec.ts @@ -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(() => { diff --git a/app/__test__/media/StorageR2Adapter.native-spec.ts b/app/__test__/media/StorageR2Adapter.native-spec.ts index 64c7a9f..4e3b95b 100644 --- a/app/__test__/media/StorageR2Adapter.native-spec.ts +++ b/app/__test__/media/StorageR2Adapter.native-spec.ts @@ -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(); }); diff --git a/app/__test__/media/adapters/StorageCloudinaryAdapter.spec.ts b/app/__test__/media/adapters/StorageCloudinaryAdapter.spec.ts deleted file mode 100644 index 9cac2e4..0000000 --- a/app/__test__/media/adapters/StorageCloudinaryAdapter.spec.ts +++ /dev/null @@ -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); - }); -}); diff --git a/app/__test__/media/adapters/StorageLocalAdapter.spec.ts b/app/__test__/media/adapters/StorageLocalAdapter.spec.ts deleted file mode 100644 index b23f84d..0000000 --- a/app/__test__/media/adapters/StorageLocalAdapter.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/app/__test__/media/adapters/StorageS3Adapter.spec.ts b/app/__test__/media/adapters/StorageS3Adapter.spec.ts deleted file mode 100644 index 7b4a0a4..0000000 --- a/app/__test__/media/adapters/StorageS3Adapter.spec.ts +++ /dev/null @@ -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(); - }); - }); -}); diff --git a/app/__test__/modules/AppMedia.spec.ts b/app/__test__/modules/AppMedia.spec.ts index 1423fd6..8e6b5b2 100644 --- a/app/__test__/modules/AppMedia.spec.ts +++ b/app/__test__/modules/AppMedia.spec.ts @@ -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"; diff --git a/app/build.ts b/app/build.ts index 1720b2c..56dc1dc 100644 --- a/app/build.ts +++ b/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, diff --git a/app/package.json b/app/package.json index 3c517a0..2f26ad3 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src/adapter/cloudflare/StorageR2Adapter.ts b/app/src/adapter/cloudflare/StorageR2Adapter.ts index 5432e79..6020d92 100644 --- a/app/src/adapter/cloudflare/StorageR2Adapter.ts +++ b/app/src/adapter/cloudflare/StorageR2Adapter.ts @@ -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) { * 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"; diff --git a/app/src/adapter/node/index.ts b/app/src/adapter/node/index.ts index 5d71d8c..e047040 100644 --- a/app/src/adapter/node/index.ts +++ b/app/src/adapter/node/index.ts @@ -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); diff --git a/app/src/adapter/node/storage/StorageLocalAdapter.native-spec.ts b/app/src/adapter/node/storage/StorageLocalAdapter.native-spec.ts new file mode 100644 index 0000000..2177ce8 --- /dev/null +++ b/app/src/adapter/node/storage/StorageLocalAdapter.native-spec.ts @@ -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); +}); diff --git a/app/src/adapter/node/storage/StorageLocalAdapter.spec.ts b/app/src/adapter/node/storage/StorageLocalAdapter.spec.ts new file mode 100644 index 0000000..ea76d9c --- /dev/null +++ b/app/src/adapter/node/storage/StorageLocalAdapter.spec.ts @@ -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); +}); diff --git a/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts b/app/src/adapter/node/storage/StorageLocalAdapter.ts similarity index 91% rename from app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts rename to app/src/adapter/node/storage/StorageLocalAdapter.ts index 9a1a21c..12b91ce 100644 --- a/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts +++ b/app/src/adapter/node/storage/StorageLocalAdapter.ts @@ -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; -export class StorageLocalAdapter implements StorageAdapter { +export class StorageLocalAdapter extends StorageAdapter { private config: LocalAdapterConfig; constructor(config: any) { + super(); this.config = parse(localAdapterConfig, config); } diff --git a/app/src/adapter/node/test.ts b/app/src/adapter/node/test.ts new file mode 100644 index 0000000..e2c3922 --- /dev/null +++ b/app/src/adapter/node/test.ts @@ -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 = (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 | Iterable, failMsg = parentFailMsg) => { + const e = Array.isArray(expected) ? expected : [expected]; + nodeAssert.ok(e.includes(actual), failMsg); + }, + }) satisfies Matcher; + +const nodeTestResolverProxy = ( + actual: Promise, + handler: { resolve?: any; reject?: any }, +) => { + return new Proxy( + {}, + { + get: (_, prop) => { + if (prop === "then") { + return actual.then(handler.resolve, handler.reject); + } + return actual; + }, + }, + ) as Matcher>; +}; + +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: (actual?: T, failMsg?: string) => ({ + ...nodeTestMatcher(actual, failMsg), + resolves: nodeTestResolverProxy(actual as Promise, { + resolve: (r) => nodeTestMatcher(r, failMsg), + }), + rejects: nodeTestResolverProxy(actual as Promise, { + reject: (r) => nodeTestMatcher(r, failMsg), + }), + }), +}; diff --git a/app/src/core/cache/adapters/CloudflareKvCache.ts b/app/src/core/cache/adapters/CloudflareKvCache.ts deleted file mode 100644 index 61b71f3..0000000 --- a/app/src/core/cache/adapters/CloudflareKvCache.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { ICacheItem, ICachePool } from "../cache-interface"; - -export class CloudflareKVCachePool implements ICachePool { - constructor(private namespace: KVNamespace) {} - - supports = () => ({ - metadata: true, - clear: false, - }); - - async get(key: string): Promise> { - const result = await this.namespace.getWithMetadata(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>> { - const items = new Map>(); - await Promise.all( - keys.map(async (key) => { - const item = await this.get(key); - items.set(key, item); - }), - ); - return items; - } - - async has(key: string): Promise { - const data = await this.namespace.get(key); - return data !== null; - } - - async clear(): Promise { - // Cloudflare KV does not support clearing all keys in one operation - return false; - } - - async delete(key: string): Promise { - await this.namespace.delete(key); - return true; - } - - async deleteMany(keys: string[]): Promise { - const results = await Promise.all(keys.map((key) => this.delete(key))); - return results.every((result) => result); - } - - async save(item: CloudflareKVCacheItem): Promise { - 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 }, - ): Promise { - 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 implements ICacheItem { - _expirationTtl: number | undefined; - - constructor( - private _key: string, - private data: Data | undefined, - private _hit: boolean = false, - private _metadata: Record = {}, - ) {} - - 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 { - return this._metadata; - } - - hit(): boolean { - return this._hit; - } - - set(value: Data, metadata: Record = {}): 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; - } -} diff --git a/app/src/core/cache/adapters/MemoryCache.ts b/app/src/core/cache/adapters/MemoryCache.ts deleted file mode 100644 index 5cf36b2..0000000 --- a/app/src/core/cache/adapters/MemoryCache.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { ICacheItem, ICachePool } from "../cache-interface"; - -export class MemoryCache implements ICachePool { - private cache: Map> = new Map(); - private maxSize?: number; - - constructor(options?: { maxSize?: number }) { - this.maxSize = options?.maxSize; - } - - supports = () => ({ - metadata: true, - clear: true, - }); - - async get(key: string): Promise> { - if (!this.cache.has(key)) { - // use undefined to denote a miss initially - return new MemoryCacheItem(key, undefined!); - } - return this.cache.get(key)!; - } - - async getMany(keys: string[] = []): Promise>> { - const items = new Map>(); - for (const key of keys) { - items.set(key, await this.get(key)); - } - return items; - } - - async has(key: string): Promise { - return this.cache.has(key) && this.cache.get(key)!.hit(); - } - - async clear(): Promise { - this.cache.clear(); - return true; - } - - async delete(key: string): Promise { - return this.cache.delete(key); - } - - async deleteMany(keys: string[]): Promise { - let success = true; - for (const key of keys) { - if (!this.delete(key)) { - success = false; - } - } - return success; - } - - async save(item: MemoryCacheItem): Promise { - this.checkSizeAndPurge(); - this.cache.set(item.key(), item); - return true; - } - - async put( - key: string, - value: Data, - options: { expiresAt?: Date; ttl?: number; metadata?: Record } = {}, - ): Promise { - 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 implements ICacheItem { - private _key: string; - private _value: Data | undefined; - private expiration: Date | null = null; - private _metadata: Record = {}; - - constructor(key: string, value: Data, metadata: Record = {}) { - this._key = key; - this.set(value, metadata); - } - - key(): string { - return this._key; - } - - metadata(): Record { - 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 = {}): 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; - } -} diff --git a/app/src/core/cache/cache-interface.ts b/app/src/core/cache/cache-interface.ts deleted file mode 100644 index c6e099e..0000000 --- a/app/src/core/cache/cache-interface.ts +++ /dev/null @@ -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 { - /** - * 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; - - /** - * 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): 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 { - 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>; - - /** - * 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>>; - - /** - * 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; - - /** - * Deletes all items in the pool. - * @returns True if the pool was successfully cleared. False if there was an error. - */ - clear(): Promise; - - /** - * 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; - - /** - * 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; - - /** - * 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): Promise; - - /** - * 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 }, - ): Promise; - put( - key: string, - value: any, - options?: { ttl?: number; metadata?: Record }, - ): Promise; - put( - key: string, - value: any, - options?: ({ ttl?: number } | { expiresAt?: Date }) & { metadata?: Record }, - ): Promise; -} diff --git a/app/src/core/test/index.ts b/app/src/core/test/index.ts new file mode 100644 index 0000000..22485ef --- /dev/null +++ b/app/src/core/test/index.ts @@ -0,0 +1,48 @@ +export type Matcher = { + toEqual: (expected: T, failMsg?: string) => void; + toBe: (expected: T, failMsg?: string) => void; + toBeUndefined: (failMsg?: string) => void; + toBeString: (failMsg?: string) => void; + toBeOneOf: (expected: T | Array | Iterable, failMsg?: string) => void; + toBeDefined: (failMsg?: string) => void; +}; +export type TestFn = (() => void | Promise) | ((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: ( + actual?: T, + failMsg?: string, + ) => Matcher & { + resolves: Matcher>; + rejects: Matcher>; + }; +}; + +export async function retry( + fn: () => Promise, + condition: (result: T) => boolean, + retries: number, + delay: number, +): Promise { + 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; +} diff --git a/app/__test__/data/specs/fields/inc.ts b/app/src/data/fields/field-test-suite.ts similarity index 95% rename from app/__test__/data/specs/fields/inc.ts rename to app/src/data/fields/field-test-suite.ts index ff2d00e..4a2394d 100644 --- a/app/__test__/data/specs/fields/inc.ts +++ b/app/src/data/fields/field-test-suite.ts @@ -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) => 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" } diff --git a/app/src/data/fields/index.ts b/app/src/data/fields/index.ts index 58a34f8..92f7c02 100644 --- a/app/src/data/fields/index.ts +++ b/app/src/data/fields/index.ts @@ -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"; diff --git a/app/src/media/index.ts b/app/src/media/index.ts index d131fa2..a49a4f9 100644 --- a/app/src/media/index.ts +++ b/app/src/media/index.ts @@ -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"; diff --git a/app/src/media/storage/Storage.ts b/app/src/media/storage/Storage.ts index 2df3451..ae66070 100644 --- a/app/src/media/storage/Storage.ts +++ b/app/src/media/storage/Storage.ts @@ -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; - putObject(key: string, body: FileBody): Promise; - deleteObject(key: string): Promise; - objectExists(key: string): Promise; - getObject(key: string, headers: Headers): Promise; - getObjectUrl(key: string): string; - getObjectMeta(key: string): Promise; - getSchema(): TSchema | undefined; - toJSON(secrets?: boolean): any; -} - export type StorageConfig = { body_max_size?: number; }; diff --git a/app/src/media/storage/StorageAdapter.ts b/app/src/media/storage/StorageAdapter.ts new file mode 100644 index 0000000..09aa957 --- /dev/null +++ b/app/src/media/storage/StorageAdapter.ts @@ -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; + abstract putObject(key: string, body: FileBody): Promise; + abstract deleteObject(key: string): Promise; + abstract objectExists(key: string): Promise; + abstract getObject(key: string, headers: Headers): Promise; + abstract getObjectUrl(key: string): string; + abstract getObjectMeta(key: string): Promise; + abstract getSchema(): TSchema | undefined; + abstract toJSON(secrets?: boolean): any; +} diff --git a/app/src/media/storage/adapters/StorageLocalAdapter/index.ts b/app/src/media/storage/adapters/StorageLocalAdapter/index.ts deleted file mode 100644 index a3f1804..0000000 --- a/app/src/media/storage/adapters/StorageLocalAdapter/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - StorageLocalAdapter, - type LocalAdapterConfig, - localAdapterConfig, -} from "./StorageLocalAdapter"; diff --git a/app/src/media/storage/adapters/adapter-test-suite.ts b/app/src/media/storage/adapters/adapter-test-suite.ts new file mode 100644 index 0000000..88aa7f7 --- /dev/null +++ b/app/src/media/storage/adapters/adapter-test-suite.ts @@ -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); + } + }); +} diff --git a/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.spec.ts b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.spec.ts new file mode 100644 index 0000000..207e030 --- /dev/null +++ b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.spec.ts @@ -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, + }); +}); diff --git a/app/src/media/storage/adapters/StorageCloudinaryAdapter.ts b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts similarity index 72% rename from app/src/media/storage/adapters/StorageCloudinaryAdapter.ts rename to app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts index 43509f7..71ee9e6 100644 --- a/app/src/media/storage/adapters/StorageCloudinaryAdapter.ts +++ b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts @@ -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 { 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, 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 { const res = await fetch(this.getObjectUrl(key), { method: "GET", @@ -211,13 +239,30 @@ export class StorageCloudinaryAdapter implements StorageAdapter { async deleteObject(key: string): Promise { 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) { diff --git a/app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts b/app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts new file mode 100644 index 0000000..a23744b --- /dev/null +++ b/app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts @@ -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); + }); +}); diff --git a/app/src/media/storage/adapters/StorageS3Adapter.ts b/app/src/media/storage/adapters/s3/StorageS3Adapter.ts similarity index 88% rename from app/src/media/storage/adapters/StorageS3Adapter.ts rename to app/src/media/storage/adapters/s3/StorageS3Adapter.ts index 960b73d..4154126 100644 --- a/app/src/media/storage/adapters/StorageS3Adapter.ts +++ b/app/src/media/storage/adapters/s3/StorageS3Adapter.ts @@ -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; -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 { + getUrl(path: string = "", searchParamsObj: Record = {}): 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 = {}, ) { 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 = {}, ) { 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 { 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 = {}, ): Promise { const url = this.getUrl(key, params); - const res = await this.fetch(url, { + const res = await this.client.fetch(url, { method: "DELETE", }); } diff --git a/bun.lock b/bun.lock index f0fda3c..e4334ae 100644 --- a/bun.lock +++ b/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=="], From 11a28eba887195eb333ef7c7ee4023f76789526d Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 28 Mar 2025 18:03:09 +0100 Subject: [PATCH 06/27] improve cli creds extraction --- app/.gitignore | 3 ++ app/src/cli/commands/run/platform.ts | 5 +-- app/src/cli/commands/run/run.ts | 48 ++++++++++++++++------------ app/src/cli/types.d.ts | 7 ++-- 4 files changed, 35 insertions(+), 28 deletions(-) create mode 100644 app/.gitignore diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..74f7dc3 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,3 @@ +test-results +playwright-report +bknd.config.* \ No newline at end of file diff --git a/app/src/cli/commands/run/platform.ts b/app/src/cli/commands/run/platform.ts index 62707bd..90b8358 100644 --- a/app/src/cli/commands/run/platform.ts +++ b/app/src/cli/commands/run/platform.ts @@ -72,9 +72,10 @@ export async function getConfigPath(filePath?: string) { } } - const paths = ["./bknd.config", "./bknd.config.ts", "./bknd.config.js"]; + const exts = ["", ".js", ".ts", ".mjs", ".cjs", ".json"]; + const paths = exts.map((e) => `bknd.config${e}`); for (const p of paths) { - const _p = path.resolve(process.cwd(), p); + const _p = path.relative(process.cwd(), p); if (await fileExists(_p)) { return _p; } diff --git a/app/src/cli/commands/run/run.ts b/app/src/cli/commands/run/run.ts index ff15f10..2d156e1 100644 --- a/app/src/cli/commands/run/run.ts +++ b/app/src/cli/commands/run/run.ts @@ -7,6 +7,7 @@ import { colorizeConsole, config } from "core"; import dotenv from "dotenv"; import { registries } from "modules/registries"; import c from "picocolors"; +import path from "node:path"; import { PLATFORMS, type Platform, @@ -15,8 +16,12 @@ import { getConnectionCredentialsFromEnv, startServer, } from "./platform"; +import { makeConfig } from "adapter"; -dotenv.config(); +const env_files = [".env", ".dev.vars"]; +dotenv.config({ + path: env_files.map((file) => path.resolve(process.cwd(), file)), +}); const isBun = typeof Bun !== "undefined"; export const run: CliCommand = (program) => { @@ -85,24 +90,12 @@ async function makeApp(config: MakeAppConfig) { return app; } -export async function makeConfigApp(config: CliBkndConfig, platform?: Platform) { - const appConfig = typeof config.app === "function" ? config.app(process.env) : config.app; - const app = App.create(appConfig); - - app.emgr.onEvent( - App.Events.AppBuiltEvent, - async () => { - await attachServeStatic(app, platform ?? "node"); - app.registerAdminController(); - - await config.onBuilt?.(app); - }, - "sync", - ); - - await config.beforeBuild?.(app); - await app.build(config.buildConfig); - return app; +export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform) { + const config = makeConfig(_config, process.env); + return makeApp({ + ...config, + server: { platform }, + }); } async function action(options: { @@ -118,19 +111,31 @@ async function action(options: { const configFilePath = await getConfigPath(options.config); let app: App | undefined = undefined; + // first start from arguments if given if (options.dbUrl) { console.info("Using connection from", c.cyan("--db-url")); const connection = options.dbUrl ? { url: options.dbUrl, authToken: options.dbToken } : undefined; app = await makeApp({ connection, server: { platform: options.server } }); + + // check configuration file to be present } else if (configFilePath) { console.info("Using config from", c.cyan(configFilePath)); - const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig; - app = await makeConfigApp(config, options.server); + try { + const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig; + app = await makeConfigApp(config, options.server); + } catch (e) { + console.error("Failed to load config:", e); + process.exit(1); + } + + // try to use an in-memory connection } else if (options.memory) { console.info("Using", c.cyan("in-memory"), "connection"); app = await makeApp({ server: { platform: options.server } }); + + // finally try to use env variables } else { const credentials = getConnectionCredentialsFromEnv(); if (credentials) { @@ -139,6 +144,7 @@ async function action(options: { } } + // if nothing helps, create a file based app if (!app) { const connection = { url: "file:data.db" } as Config; console.info("Using connection", c.cyan(connection.url)); diff --git a/app/src/cli/types.d.ts b/app/src/cli/types.d.ts index 30bde3a..b1c20ea 100644 --- a/app/src/cli/types.d.ts +++ b/app/src/cli/types.d.ts @@ -1,12 +1,9 @@ -import type { CreateAppConfig } from "App"; -import type { FrameworkBkndConfig } from "adapter"; +import type { BkndConfig } from "adapter"; import type { Command } from "commander"; export type CliCommand = (program: Command) => void; -export type CliBkndConfig = FrameworkBkndConfig & { - app: CreateAppConfig | ((env: Env) => CreateAppConfig); - setAdminHtml?: boolean; +export type CliBkndConfig = BkndConfig & { server?: { port?: number; platform?: "node" | "bun"; From b29c04e8c9c868bbadaac7773cb1c035bfbc550d Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 28 Mar 2025 20:52:00 +0100 Subject: [PATCH 07/27] added more cli instructions --- app/src/cli/commands/run/platform.ts | 2 +- docs/usage/cli.mdx | 92 ++++++++++++++++++++++------ 2 files changed, 74 insertions(+), 20 deletions(-) diff --git a/app/src/cli/commands/run/platform.ts b/app/src/cli/commands/run/platform.ts index 90b8358..480a979 100644 --- a/app/src/cli/commands/run/platform.ts +++ b/app/src/cli/commands/run/platform.ts @@ -75,7 +75,7 @@ export async function getConfigPath(filePath?: string) { const exts = ["", ".js", ".ts", ".mjs", ".cjs", ".json"]; const paths = exts.map((e) => `bknd.config${e}`); for (const p of paths) { - const _p = path.relative(process.cwd(), p); + const _p = path.resolve(process.cwd(), p); if (await fileExists(_p)) { return _p; } diff --git a/docs/usage/cli.mdx b/docs/usage/cli.mdx index 7106774..b203be8 100644 --- a/docs/usage/cli.mdx +++ b/docs/usage/cli.mdx @@ -3,8 +3,7 @@ title: 'Using the CLI' description: 'How to start a bknd instance using the CLI.' --- -Instead of running **bknd** using a framework, you can also use the CLI to quickly spin up a -full functional instance. To see all available options, run: +The bknd package includes a command-line interface (CLI) that allows you to run a bknd instance and perform various tasks. ``` npx bknd @@ -15,18 +14,21 @@ Here is the output: $ npx bknd Usage: bknd [options] [command] -bknd cli +⚡ bknd cli v0.10.3-rc.1 Options: - -V, --version output the version number - -h, --help display help for command + -V, --version output the version number + -h, --help display help for command Commands: - user create and update user (auth) - schema [options] get schema - run [options] - config [options] get default config - help [command] display help for command + config [options] get default config + copy-assets [options] copy static assets + create [options] create a new project + debug debug bknd + run [options] run an instance + schema [options] get schema + user create and update user (auth) + help [command] display help for command ``` ## Starting an instance (`run`) @@ -38,29 +40,81 @@ Usage: bknd run [options] Options: -p, --port port to run on (default: 1337, env: PORT) + -m, --memory use in-memory database -c, --config config file --db-url database url, can be any valid libsql url --db-token database token - --server server type (choices: "node", "bun", default: "node") + --server server type (choices: "node", "bun", default: "bun") + --no-open don't open browser window on start -h, --help display help for command ``` -### In-memory database -To start an instance with an ephemeral in-memory database, run the following: -``` -npx bknd run -``` -Keep in mind that the database is not persisted and will be lost when the process is terminated. +To order in which the connection is determined is as follows: +1. `--db-url` +2. `--config` or reading the filesystem looking for `bknd.config.[js|ts|mjs|cjs|json]` +3. `--memory` +4. Environment variables `DB_URL` and `DB_TOKEN` in `.env` or `.dev.vars` +5. Fallback to file-based database `data.db` ### File-based database -To start an instance with a file-based database, run the following: +By default, a file-based database `data.db` is used when running without any arguments. You can specify a different file name or path using the `--db-url` option. The database file will be created in the current working directory if it does not exist. + ``` npx bknd run --db-url file:data.db ``` +### Using configuration file (`bknd.config.*`) +You can create a configuration file on the working directory that automatically gets picked up: `bknd.config.[js|ts|mjs|cjs|json]` + +Here is an example of a `bknd.config.ts` file: + +```ts +import type { BkndConfig } from "bknd/adapter"; + +export default { + // you can either specify the connection directly + connection: { + url: "file:data.db", + }, + // or use the `app` function which passes the environment variables + app: ({ env }) => ({ + connection: { + url: env.DB_URL, + } + }) +} satisfies BkndConfig; +``` +The `app` function is useful if you need a cross-platform way to access the environment variables. For example, on Cloudflare Workers, you can only access environment variables inside a request handler. If you're exclusively using a node-like environment, it's safe to access the environment variables directly from `process.env`. + + +If you're using `npx bknd run`, make sure to create a file in a file format that `node` can load, otherwise you may run into an error that the file couldn't be found: + +```sh +[INF] 2025-03-28 18:02:21 Using config from bknd.config.ts +[ERR] 2025-03-28 18:02:21 Failed to load config: Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'bknd.config.ts' imported from [...] + at packageResolve (node:internal/modules/esm/resolve:857:9) + at [...] { + code: 'ERR_MODULE_NOT_FOUND' +} +``` + +If you still want to use a `.ts` extension, you can start the CLI e.g. using `tsx`: + +```sh +npx tsx node_modules/.bin/bknd run +``` + ### Turso/LibSQL database To start an instance with a Turso/LibSQL database, run the following: ``` npx bknd run --db-url libsql://your-db.turso.io --db-token ``` -The `--db-token` option is optional and only required if the database is protected. \ No newline at end of file +The `--db-token` option is optional and only required if the database is protected. + + +### In-memory database +To start an instance with an ephemeral in-memory database, run the following: +``` +npx bknd run --memory +``` +Keep in mind that the database is not persisted and will be lost when the process is terminated. \ No newline at end of file From b2fd907e8ca39a60279ee54d4eb6bfd8ee76f34b Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 28 Mar 2025 21:12:50 +0100 Subject: [PATCH 08/27] updated docs, fixed run with node/tsx --- app/__test__/helper.ts | 1 + app/package.json | 34 ++++++++++++++++----------------- app/src/cli/commands/run/run.ts | 2 +- app/tsconfig.build.json | 3 ++- docs/usage/cli.mdx | 8 +++++++- 5 files changed, 28 insertions(+), 20 deletions(-) diff --git a/app/__test__/helper.ts b/app/__test__/helper.ts index 405e46f..16b8b8e 100644 --- a/app/__test__/helper.ts +++ b/app/__test__/helper.ts @@ -78,6 +78,7 @@ export const assetsTmpPath = `${import.meta.dir}/_assets/tmp`; export async function enableFetchLogging() { const originalFetch = global.fetch; + // @ts-ignore global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const response = await originalFetch(input, init); const url = input instanceof URL || typeof input === "string" ? input : input.url; diff --git a/app/package.json b/app/package.json index 2f26ad3..9faf7ca 100644 --- a/app/package.json +++ b/app/package.json @@ -124,52 +124,52 @@ ".": { "types": "./dist/types/index.d.ts", "import": "./dist/index.js", - "require": "./dist/index.cjs" + "require": "./dist/index.js" }, "./ui": { "types": "./dist/types/ui/index.d.ts", "import": "./dist/ui/index.js", - "require": "./dist/ui/index.cjs" + "require": "./dist/ui/index.js" }, "./elements": { "types": "./dist/types/ui/elements/index.d.ts", "import": "./dist/ui/elements/index.js", - "require": "./dist/ui/elements/index.cjs" + "require": "./dist/ui/elements/index.js" }, "./client": { "types": "./dist/types/ui/client/index.d.ts", "import": "./dist/ui/client/index.js", - "require": "./dist/ui/client/index.cjs" + "require": "./dist/ui/client/index.js" }, "./data": { "types": "./dist/types/data/index.d.ts", "import": "./dist/data/index.js", - "require": "./dist/data/index.cjs" + "require": "./dist/data/index.js" }, "./core": { "types": "./dist/types/core/index.d.ts", "import": "./dist/core/index.js", - "require": "./dist/core/index.cjs" + "require": "./dist/core/index.js" }, "./utils": { "types": "./dist/types/core/utils/index.d.ts", "import": "./dist/core/utils/index.js", - "require": "./dist/core/utils/index.cjs" + "require": "./dist/core/utils/index.js" }, "./cli": { "types": "./dist/types/cli/index.d.ts", "import": "./dist/cli/index.js", - "require": "./dist/cli/index.cjs" + "require": "./dist/cli/index.js" }, "./media": { "types": "./dist/types/media/index.d.ts", "import": "./dist/media/index.js", - "require": "./dist/media/index.cjs" + "require": "./dist/media/index.js" }, "./adapter/cloudflare": { "types": "./dist/types/adapter/cloudflare/index.d.ts", "import": "./dist/adapter/cloudflare/index.js", - "require": "./dist/adapter/cloudflare/index.cjs" + "require": "./dist/adapter/cloudflare/index.js" }, "./adapter": { "types": "./dist/types/adapter/index.d.ts", @@ -178,37 +178,37 @@ "./adapter/vite": { "types": "./dist/types/adapter/vite/index.d.ts", "import": "./dist/adapter/vite/index.js", - "require": "./dist/adapter/vite/index.cjs" + "require": "./dist/adapter/vite/index.js" }, "./adapter/nextjs": { "types": "./dist/types/adapter/nextjs/index.d.ts", "import": "./dist/adapter/nextjs/index.js", - "require": "./dist/adapter/nextjs/index.cjs" + "require": "./dist/adapter/nextjs/index.js" }, "./adapter/react-router": { "types": "./dist/types/adapter/react-router/index.d.ts", "import": "./dist/adapter/react-router/index.js", - "require": "./dist/adapter/react-router/index.cjs" + "require": "./dist/adapter/react-router/index.js" }, "./adapter/bun": { "types": "./dist/types/adapter/bun/index.d.ts", "import": "./dist/adapter/bun/index.js", - "require": "./dist/adapter/bun/index.cjs" + "require": "./dist/adapter/bun/index.js" }, "./adapter/node": { "types": "./dist/types/adapter/node/index.d.ts", "import": "./dist/adapter/node/index.js", - "require": "./dist/adapter/node/index.cjs" + "require": "./dist/adapter/node/index.js" }, "./adapter/astro": { "types": "./dist/types/adapter/astro/index.d.ts", "import": "./dist/adapter/astro/index.js", - "require": "./dist/adapter/astro/index.cjs" + "require": "./dist/adapter/astro/index.js" }, "./adapter/aws": { "types": "./dist/types/adapter/aws/index.d.ts", "import": "./dist/adapter/aws/index.js", - "require": "./dist/adapter/aws/index.cjs" + "require": "./dist/adapter/aws/index.js" }, "./dist/main.css": "./dist/ui/main.css", "./dist/styles.css": "./dist/ui/styles.css", diff --git a/app/src/cli/commands/run/run.ts b/app/src/cli/commands/run/run.ts index 2d156e1..8962c3b 100644 --- a/app/src/cli/commands/run/run.ts +++ b/app/src/cli/commands/run/run.ts @@ -91,7 +91,7 @@ async function makeApp(config: MakeAppConfig) { } export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform) { - const config = makeConfig(_config, process.env); + const config = makeConfig(_config, { env: process.env }); return makeApp({ ...config, server: { platform }, diff --git a/app/tsconfig.build.json b/app/tsconfig.build.json index 5f1c8af..f3a19cb 100644 --- a/app/tsconfig.build.json +++ b/app/tsconfig.build.json @@ -1,4 +1,5 @@ { "extends": "./tsconfig.json", - "include": ["./src/**/*.ts", "./src/**/*.tsx"] + "include": ["./src/**/*.ts", "./src/**/*.tsx"], + "exclude": ["./node_modules", "./__test__"] } diff --git a/docs/usage/cli.mdx b/docs/usage/cli.mdx index b203be8..0c19a5f 100644 --- a/docs/usage/cli.mdx +++ b/docs/usage/cli.mdx @@ -98,7 +98,13 @@ If you're using `npx bknd run`, make sure to create a file in a file format that } ``` -If you still want to use a `.ts` extension, you can start the CLI e.g. using `tsx`: +If you still want to use a `.ts` extension, you can start the CLI e.g. using `node` (>=v22.6.0): + +```sh +node --experimental-strip-types node_modules/.bin/bknd run +``` + +Or with `tsx`: ```sh npx tsx node_modules/.bin/bknd run From 36e4224b3367b52d53496515c0bf79476089cb18 Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 1 Apr 2025 11:19:55 +0200 Subject: [PATCH 09/27] refactored EventManager to run asyncs on call only, app defaults to run before response (#129) * refactored EventManager to run asyncs on call only, app defaults to run before response * fix tests --- app/__test__/app/App.spec.ts | 85 +++++++++++++++- app/__test__/core/EventManager.spec.ts | 14 +-- app/__test__/data/specs/Mutator.spec.ts | 3 + app/__test__/data/specs/Repository.spec.ts | 13 ++- app/__test__/media/Storage.spec.ts | 8 +- app/src/App.ts | 98 +++++++++++++------ .../cloudflare/cloudflare-workers.adapter.ts | 18 +++- app/src/adapter/cloudflare/modes/cached.ts | 14 ++- app/src/adapter/cloudflare/modes/durable.ts | 20 ++-- app/src/adapter/cloudflare/modes/fresh.ts | 17 +++- app/src/core/events/EventManager.ts | 18 ++-- 11 files changed, 244 insertions(+), 64 deletions(-) diff --git a/app/__test__/app/App.spec.ts b/app/__test__/app/App.spec.ts index c5e9794..860258a 100644 --- a/app/__test__/app/App.spec.ts +++ b/app/__test__/app/App.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, mock, test } from "bun:test"; import type { ModuleBuildContext } from "../../src"; -import { type App, createApp } from "../../src/App"; +import { App, createApp } from "../../src/App"; import * as proto from "../../src/data/prototype"; describe("App", () => { @@ -51,4 +51,87 @@ describe("App", () => { expect(todos[0]?.title).toBe("ctx"); expect(todos[1]?.title).toBe("api"); }); + + test("lifecycle events are triggered", async () => { + const firstBoot = mock(() => null); + const configUpdate = mock(() => null); + const appBuilt = mock(() => null); + const appRequest = mock(() => null); + const beforeResponse = mock(() => null); + + const app = createApp(); + + app.emgr.onEvent( + App.Events.AppFirstBoot, + (event) => { + expect(event).toBeInstanceOf(App.Events.AppFirstBoot); + expect(event.params.app.version()).toBe(app.version()); + firstBoot(); + }, + "sync", + ); + app.emgr.onEvent( + App.Events.AppBuiltEvent, + (event) => { + expect(event).toBeInstanceOf(App.Events.AppBuiltEvent); + expect(event.params.app.version()).toBe(app.version()); + appBuilt(); + }, + "sync", + ); + app.emgr.onEvent( + App.Events.AppConfigUpdatedEvent, + () => { + configUpdate(); + }, + "sync", + ); + app.emgr.onEvent( + App.Events.AppRequest, + (event) => { + expect(event).toBeInstanceOf(App.Events.AppRequest); + expect(event.params.app.version()).toBe(app.version()); + expect(event.params.request).toBeInstanceOf(Request); + appRequest(); + }, + "sync", + ); + app.emgr.onEvent( + App.Events.AppBeforeResponse, + (event) => { + expect(event).toBeInstanceOf(App.Events.AppBeforeResponse); + expect(event.params.app.version()).toBe(app.version()); + expect(event.params.response).toBeInstanceOf(Response); + beforeResponse(); + }, + "sync", + ); + + await app.build(); + expect(firstBoot).toHaveBeenCalled(); + expect(appBuilt).toHaveBeenCalled(); + //expect(configUpdate).toHaveBeenCalled(); + expect(appRequest).not.toHaveBeenCalled(); + expect(beforeResponse).not.toHaveBeenCalled(); + }); + + test("emgr exec modes", async () => { + const called = mock(() => null); + const app = createApp({ + options: { + asyncEventsMode: "sync", + }, + }); + + // register async listener + app.emgr.onEvent(App.Events.AppFirstBoot, async () => { + called(); + }); + + await app.build(); + await app.server.request(new Request("http://localhost")); + + // expect async listeners to be executed sync after request + expect(called).toHaveBeenCalled(); + }); }); diff --git a/app/__test__/core/EventManager.spec.ts b/app/__test__/core/EventManager.spec.ts index 995ebfa..3d8b981 100644 --- a/app/__test__/core/EventManager.spec.ts +++ b/app/__test__/core/EventManager.spec.ts @@ -70,6 +70,9 @@ describe("EventManager", async () => { new SpecialEvent({ foo: "bar" }); new InformationalEvent(); + // execute asyncs + await emgr.executeAsyncs(); + expect(call).toHaveBeenCalledTimes(2); expect(delayed).toHaveBeenCalled(); }); @@ -80,15 +83,11 @@ describe("EventManager", async () => { call(); return Promise.all(p); }; - const emgr = new EventManager( - { InformationalEvent }, - { - asyncExecutor, - }, - ); + const emgr = new EventManager({ InformationalEvent }); emgr.onEvent(InformationalEvent, async () => {}); await emgr.emit(new InformationalEvent()); + await emgr.executeAsyncs(asyncExecutor); expect(call).toHaveBeenCalled(); }); @@ -125,6 +124,9 @@ describe("EventManager", async () => { const e2 = await emgr.emit(new ReturnEvent({ foo: "bar" })); expect(e2.returned).toBe(true); expect(e2.params.foo).toBe("bar-1-0"); + + await emgr.executeAsyncs(); + expect(onInvalidReturn).toHaveBeenCalled(); expect(asyncEventCallback).toHaveBeenCalled(); }); diff --git a/app/__test__/data/specs/Mutator.spec.ts b/app/__test__/data/specs/Mutator.spec.ts index 4b3bee7..7110956 100644 --- a/app/__test__/data/specs/Mutator.spec.ts +++ b/app/__test__/data/specs/Mutator.spec.ts @@ -288,14 +288,17 @@ describe("[data] Mutator (Events)", async () => { test("events were fired", async () => { const { data } = await mutator.insertOne({ label: "test" }); + await mutator.emgr.executeAsyncs(); expect(events.has(MutatorEvents.MutatorInsertBefore.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorInsertAfter.slug)).toBeTrue(); await mutator.updateOne(data.id, { label: "test2" }); + await mutator.emgr.executeAsyncs(); expect(events.has(MutatorEvents.MutatorUpdateBefore.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorUpdateAfter.slug)).toBeTrue(); await mutator.deleteOne(data.id); + await mutator.emgr.executeAsyncs(); expect(events.has(MutatorEvents.MutatorDeleteBefore.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorDeleteAfter.slug)).toBeTrue(); }); diff --git a/app/__test__/data/specs/Repository.spec.ts b/app/__test__/data/specs/Repository.spec.ts index 2a42b9e..982187c 100644 --- a/app/__test__/data/specs/Repository.spec.ts +++ b/app/__test__/data/specs/Repository.spec.ts @@ -198,22 +198,27 @@ describe("[data] Repository (Events)", async () => { }); test("events were fired", async () => { - await em.repository(items).findId(1); + const repo = em.repository(items); + await repo.findId(1); + await repo.emgr.executeAsyncs(); expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue(); events.clear(); - await em.repository(items).findOne({ id: 1 }); + await repo.findOne({ id: 1 }); + await repo.emgr.executeAsyncs(); expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue(); events.clear(); - await em.repository(items).findMany({ where: { id: 1 } }); + await repo.findMany({ where: { id: 1 } }); + await repo.emgr.executeAsyncs(); expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue(); events.clear(); - await em.repository(items).findManyByReference(1, "categories"); + await repo.findManyByReference(1, "categories"); + await repo.emgr.executeAsyncs(); expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue(); events.clear(); diff --git a/app/__test__/media/Storage.spec.ts b/app/__test__/media/Storage.spec.ts index f493606..1234123 100644 --- a/app/__test__/media/Storage.spec.ts +++ b/app/__test__/media/Storage.spec.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { type FileBody, Storage, type StorageAdapter } from "../../src/media/storage/Storage"; +import { type FileBody, Storage } from "../../src/media/storage/Storage"; import * as StorageEvents from "../../src/media/storage/events"; +import { StorageAdapter } from "media"; -class TestAdapter implements StorageAdapter { +class TestAdapter extends StorageAdapter { files: Record = {}; getName() { @@ -61,7 +62,7 @@ describe("Storage", async () => { test("uploads a file", async () => { const { meta: { type, size }, - } = await storage.uploadFile("hello", "world.txt"); + } = await storage.uploadFile("hello" as any, "world.txt"); expect({ type, size }).toEqual({ type: "text/plain", size: 0 }); }); @@ -71,6 +72,7 @@ describe("Storage", async () => { }); test("events were fired", async () => { + await storage.emgr.executeAsyncs(); expect(events.has(StorageEvents.FileUploadedEvent.slug)).toBeTrue(); expect(events.has(StorageEvents.FileDeletedEvent.slug)).toBeTrue(); // @todo: file access must be tested in controllers diff --git a/app/src/App.ts b/app/src/App.ts index ac0ea1d..b4e0ea5 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -4,9 +4,10 @@ import { Event } from "core/events"; import { Connection, type LibSqlCredentials, LibsqlConnection } from "data"; import type { Hono } from "hono"; import { + ModuleManager, type InitialModuleConfigs, type ModuleBuildContext, - ModuleManager, + type ModuleConfigs, type ModuleManagerOptions, type Modules, } from "modules/ModuleManager"; @@ -16,6 +17,7 @@ import { SystemController } from "modules/server/SystemController"; // biome-ignore format: must be there import { Api, type ApiOptions } from "Api"; +import type { ServerEnv } from "modules/Controller"; export type AppPlugin = (app: App) => Promise | void; @@ -29,12 +31,25 @@ export class AppBuiltEvent extends AppEvent { export class AppFirstBoot extends AppEvent { static override slug = "app-first-boot"; } -export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const; +export class AppRequest extends AppEvent<{ request: Request }> { + static override slug = "app-request"; +} +export class AppBeforeResponse extends AppEvent<{ request: Request; response: Response }> { + static override slug = "app-before-response"; +} +export const AppEvents = { + AppConfigUpdatedEvent, + AppBuiltEvent, + AppFirstBoot, + AppRequest, + AppBeforeResponse, +} as const; export type AppOptions = { plugins?: AppPlugin[]; seed?: (ctx: ModuleBuildContext & { app: App }) => Promise; manager?: Omit; + asyncEventsMode?: "sync" | "async" | "none"; }; export type CreateAppConfig = { connection?: @@ -70,35 +85,9 @@ export class App { this.modules = new ModuleManager(connection, { ...(options?.manager ?? {}), initial: _initialConfig, - onUpdated: async (key, config) => { - // if the EventManager was disabled, we assume we shouldn't - // respond to events, such as "onUpdated". - // this is important if multiple changes are done, and then build() is called manually - if (!this.emgr.enabled) { - $console.warn("App config updated, but event manager is disabled, skip."); - return; - } - - $console.log("App config updated", key); - // @todo: potentially double syncing - await this.build({ sync: true }); - await this.emgr.emit(new AppConfigUpdatedEvent({ app: this })); - }, - onFirstBoot: async () => { - $console.log("App first boot"); - this.trigger_first_boot = true; - }, - onServerInit: async (server) => { - server.use(async (c, next) => { - c.set("app", this); - await next(); - - try { - // gracefully add the app id - c.res.headers.set("X-bknd-id", this._id); - } catch (e) {} - }); - }, + onUpdated: this.onUpdated.bind(this), + onFirstBoot: this.onFirstBoot.bind(this), + onServerInit: this.onServerInit.bind(this), }); this.modules.ctx().emgr.registerEvents(AppEvents); } @@ -213,6 +202,53 @@ export class App { return new Api({ host: "http://localhost", ...(options ?? {}), fetcher }); } + + async onUpdated(module: Module, config: ModuleConfigs[Module]) { + // if the EventManager was disabled, we assume we shouldn't + // respond to events, such as "onUpdated". + // this is important if multiple changes are done, and then build() is called manually + if (!this.emgr.enabled) { + $console.warn("App config updated, but event manager is disabled, skip."); + return; + } + + $console.log("App config updated", module); + // @todo: potentially double syncing + await this.build({ sync: true }); + await this.emgr.emit(new AppConfigUpdatedEvent({ app: this })); + } + + async onFirstBoot() { + $console.log("App first boot"); + this.trigger_first_boot = true; + } + + async onServerInit(server: Hono) { + server.use(async (c, next) => { + c.set("app", this); + await this.emgr.emit(new AppRequest({ app: this, request: c.req.raw })); + await next(); + + try { + // gracefully add the app id + c.res.headers.set("X-bknd-id", this._id); + } catch (e) {} + + await this.emgr.emit( + new AppBeforeResponse({ app: this, request: c.req.raw, response: c.res }), + ); + + // execute collected async events (async by default) + switch (this.options?.asyncEventsMode ?? "async") { + case "sync": + await this.emgr.executeAsyncs(); + break; + case "async": + this.emgr.executeAsyncs(); + break; + } + }); + } } export function createApp(config: CreateAppConfig = {}) { diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index 7483d52..dec7d09 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -9,6 +9,7 @@ import { getBinding } from "./bindings"; import { getCached } from "./modes/cached"; import { getDurable } from "./modes/durable"; import { getFresh, getWarm } from "./modes/fresh"; +import type { CreateAppConfig } from "App"; export type CloudflareBkndConfig = FrameworkBkndConfig> & { mode?: "warm" | "fresh" | "cache" | "durable"; @@ -32,8 +33,14 @@ export type Context = { ctx: ExecutionContext; }; +export const constants = { + exec_async_event_id: "cf_register_waituntil", + cache_endpoint: "/__bknd/cache", + do_endpoint: "/__bknd/do", +}; + let media_registered: boolean = false; -export function makeCfConfig(config: CloudflareBkndConfig, context: Context) { +export function makeCfConfig(config: CloudflareBkndConfig, context: Context): CreateAppConfig { if (!media_registered) { registerMedia(context.env as any); media_registered = true; @@ -61,7 +68,14 @@ export function makeCfConfig(config: CloudflareBkndConfig, context: Context) { } } - return appConfig; + return { + ...appConfig, + options: { + ...appConfig.options, + // if not specified explicitly, disable it to use ExecutionContext's waitUntil + asyncEventsMode: config.options?.asyncEventsMode ?? "none", + }, + }; } export function serve(config: CloudflareBkndConfig = {}) { diff --git a/app/src/adapter/cloudflare/modes/cached.ts b/app/src/adapter/cloudflare/modes/cached.ts index c126ff7..78d8eb4 100644 --- a/app/src/adapter/cloudflare/modes/cached.ts +++ b/app/src/adapter/cloudflare/modes/cached.ts @@ -1,6 +1,6 @@ import { App } from "bknd"; import { createRuntimeApp } from "bknd/adapter"; -import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index"; +import { type CloudflareBkndConfig, constants, type Context, makeCfConfig } from "../index"; export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...args }: Context) { const { kv } = config.bindings?.(env)!; @@ -19,13 +19,23 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...arg ...makeCfConfig(config, { env, ctx, ...args }), initialConfig, onBuilt: async (app) => { - app.module.server.client.get("/__bknd/cache", async (c) => { + app.module.server.client.get(constants.cache_endpoint, async (c) => { await kv.delete(key); return c.json({ message: "Cache cleared" }); }); await config.onBuilt?.(app); }, beforeBuild: async (app) => { + app.emgr.onEvent( + App.Events.AppBeforeResponse, + async (event) => { + ctx.waitUntil(event.params.app.emgr.executeAsyncs()); + }, + { + mode: "sync", + id: constants.exec_async_event_id, + }, + ); app.emgr.onEvent( App.Events.AppConfigUpdatedEvent, async ({ params: { app } }) => { diff --git a/app/src/adapter/cloudflare/modes/durable.ts b/app/src/adapter/cloudflare/modes/durable.ts index 63fce34..369d451 100644 --- a/app/src/adapter/cloudflare/modes/durable.ts +++ b/app/src/adapter/cloudflare/modes/durable.ts @@ -1,7 +1,7 @@ import { DurableObject } from "cloudflare:workers"; -import type { App, CreateAppConfig } from "bknd"; +import { App, type CreateAppConfig } from "bknd"; import { createRuntimeApp, makeConfig } from "bknd/adapter"; -import type { CloudflareBkndConfig, Context } from "../index"; +import { type CloudflareBkndConfig, type Context, constants } from "../index"; export async function getDurable(config: CloudflareBkndConfig, ctx: Context) { const { dobj } = config.bindings?.(ctx.env)!; @@ -67,7 +67,17 @@ export class DurableBkndApp extends DurableObject { this.app = await createRuntimeApp({ ...config, onBuilt: async (app) => { - app.modules.server.get("/__do", async (c) => { + app.emgr.onEvent( + App.Events.AppBeforeResponse, + async (event) => { + this.ctx.waitUntil(event.params.app.emgr.executeAsyncs()); + }, + { + mode: "sync", + id: constants.exec_async_event_id, + }, + ); + app.modules.server.get(constants.do_endpoint, async (c) => { // @ts-ignore const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf; return c.json({ @@ -92,7 +102,6 @@ export class DurableBkndApp extends DurableObject { this.keepAlive(options.keepAliveSeconds); } - console.log("id", this.id); const res = await this.app!.fetch(request); const headers = new Headers(res.headers); headers.set("X-BuildTime", buildtime.toString()); @@ -109,16 +118,13 @@ export class DurableBkndApp extends DurableObject { async beforeBuild(app: App) {} protected keepAlive(seconds: number) { - console.log("keep alive for", seconds); if (this.interval) { - console.log("clearing, there is a new"); clearInterval(this.interval); } let i = 0; this.interval = setInterval(() => { i += 1; - //console.log("keep-alive", i); if (i === seconds) { console.log("cleared"); clearInterval(this.interval); diff --git a/app/src/adapter/cloudflare/modes/fresh.ts b/app/src/adapter/cloudflare/modes/fresh.ts index b13c537..2d34d88 100644 --- a/app/src/adapter/cloudflare/modes/fresh.ts +++ b/app/src/adapter/cloudflare/modes/fresh.ts @@ -1,12 +1,25 @@ -import type { App } from "bknd"; +import { App } from "bknd"; import { createRuntimeApp } from "bknd/adapter"; -import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index"; +import { type CloudflareBkndConfig, type Context, makeCfConfig, constants } from "../index"; export async function makeApp(config: CloudflareBkndConfig, ctx: Context) { return await createRuntimeApp( { ...makeCfConfig(config, ctx), adminOptions: config.html ? { html: config.html } : undefined, + onBuilt: async (app) => { + app.emgr.onEvent( + App.Events.AppBeforeResponse, + async (event) => { + ctx.ctx.waitUntil(event.params.app.emgr.executeAsyncs()); + }, + { + mode: "sync", + id: constants.exec_async_event_id, + }, + ); + await config.onBuilt?.(app); + }, }, ctx, ); diff --git a/app/src/core/events/EventManager.ts b/app/src/core/events/EventManager.ts index c2de8c9..1c20e58 100644 --- a/app/src/core/events/EventManager.ts +++ b/app/src/core/events/EventManager.ts @@ -22,6 +22,7 @@ export class EventManager< protected events: EventClass[] = []; protected listeners: EventListener[] = []; enabled: boolean = true; + protected asyncs: (() => Promise)[] = []; constructor( events?: RegisteredEvents, @@ -29,7 +30,6 @@ export class EventManager< listeners?: EventListener[]; onError?: (event: Event, e: unknown) => void; onInvalidReturn?: (event: Event, e: InvalidEventReturn) => void; - asyncExecutor?: typeof Promise.all; }, ) { if (events) { @@ -176,9 +176,15 @@ export class EventManager< this.events.forEach((event) => this.onEvent(event, handler, config)); } - protected executeAsyncs(promises: (() => Promise)[]) { - const executor = this.options?.asyncExecutor ?? ((e) => Promise.all(e)); - executor(promises.map((p) => p())).then(() => void 0); + protected collectAsyncs(promises: (() => Promise)[]) { + this.asyncs.push(...promises); + } + + async executeAsyncs(executor: typeof Promise.all = (e) => Promise.all(e)): Promise { + if (this.asyncs.length === 0) return; + const asyncs = [...this.asyncs]; + this.asyncs = []; + await executor(asyncs.map((p) => p())); } async emit>(event: Actual): Promise { @@ -209,8 +215,8 @@ export class EventManager< return !listener.once; }); - // execute asyncs - this.executeAsyncs(asyncs); + // collect asyncs + this.collectAsyncs(asyncs); // execute syncs let _event: Actual = event; From 3f26c45dd928f8710394b516c543a7c297ccd1f5 Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 1 Apr 2025 11:43:11 +0200 Subject: [PATCH 10/27] refactored adapters to run test suites (#126) * refactored adapters to run test suites * fix bun version for tests * added missing adapter tests and refactored examples to use `bknd.config.ts` where applicable --- .github/workflows/test.yml | 2 +- app/__test__/adapter/adapter.test.ts | 62 +++++++++ app/package.json | 1 + app/src/App.ts | 6 +- app/src/adapter/adapter-test-suite.ts | 90 +++++++++++++ app/src/adapter/astro/astro.adapter.spec.ts | 15 +++ app/src/adapter/astro/astro.adapter.ts | 41 +++--- app/src/adapter/aws/aws-lambda.adapter.ts | 116 +++++++++-------- app/src/adapter/aws/aws.adapter.spec.ts | 19 +++ app/src/adapter/bun/bun.adapter.spec.ts | 15 +++ app/src/adapter/bun/bun.adapter.ts | 76 ++++++----- app/src/adapter/bun/test.ts | 7 + .../cloudflare-workers.adapter.spec.ts | 60 +++++++++ .../cloudflare/cloudflare-workers.adapter.ts | 64 ++------- app/src/adapter/cloudflare/config.ts | 64 +++++++++ app/src/adapter/cloudflare/modes/cached.ts | 21 ++- app/src/adapter/cloudflare/modes/durable.ts | 23 ++-- app/src/adapter/cloudflare/modes/fresh.ts | 66 +++++----- .../storage}/StorageR2Adapter.native-spec.ts | 4 +- .../{ => storage}/StorageR2Adapter.ts | 6 +- app/src/adapter/index.ts | 123 ++++++++++++------ app/src/adapter/nextjs/nextjs.adapter.spec.ts | 16 +++ app/src/adapter/nextjs/nextjs.adapter.ts | 49 +++---- app/src/adapter/node/index.ts | 11 +- .../adapter/node/node.adapter.native-spec.ts | 15 +++ app/src/adapter/node/node.adapter.spec.ts | 15 +++ app/src/adapter/node/node.adapter.ts | 59 +++++---- .../node/storage/StorageLocalAdapter.spec.ts | 3 +- .../node/storage/StorageLocalAdapter.ts | 4 +- app/src/adapter/node/test.ts | 24 ++++ .../react-router/react-router.adapter.spec.ts | 15 +++ .../react-router/react-router.adapter.ts | 43 +++--- app/src/core/console.ts | 56 +++++--- app/src/core/test/index.ts | 3 + app/src/core/utils/test.ts | 5 +- .../StorageCloudinaryAdapter.spec.ts | 3 +- .../adapters/s3/StorageS3Adapter.spec.ts | 3 +- examples/astro/bknd.config.ts | 65 +++++++++ examples/astro/src/bknd.ts | 23 ++++ .../astro/src/pages/admin/[...admin].astro | 4 +- examples/astro/src/pages/api/[...api].ts | 75 +---------- examples/astro/src/pages/index.astro | 2 +- examples/astro/src/pages/ssr.astro | 4 +- examples/aws-lambda/index.mjs | 4 +- examples/aws-lambda/package.json | 38 +++--- examples/aws-lambda/test.js | 6 +- examples/bun/bun.lock | 32 +++++ examples/bun/index.ts | 5 +- examples/bun/minimal.ts | 6 - examples/cloudflare-worker/src/index.ts | 2 +- examples/nextjs/bknd.config.ts | 74 +++++++++++ examples/nextjs/src/bknd.ts | 87 +------------ examples/react-router/app/bknd.ts | 79 +---------- examples/react-router/app/routes/api.$.ts | 2 +- examples/react-router/bknd.config.ts | 64 +++++++++ 55 files changed, 1130 insertions(+), 647 deletions(-) create mode 100644 app/__test__/adapter/adapter.test.ts create mode 100644 app/src/adapter/adapter-test-suite.ts create mode 100644 app/src/adapter/astro/astro.adapter.spec.ts create mode 100644 app/src/adapter/aws/aws.adapter.spec.ts create mode 100644 app/src/adapter/bun/bun.adapter.spec.ts create mode 100644 app/src/adapter/bun/test.ts create mode 100644 app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts create mode 100644 app/src/adapter/cloudflare/config.ts rename app/{__test__/media => src/adapter/cloudflare/storage}/StorageR2Adapter.native-spec.ts (88%) rename app/src/adapter/cloudflare/{ => storage}/StorageR2Adapter.ts (96%) create mode 100644 app/src/adapter/nextjs/nextjs.adapter.spec.ts create mode 100644 app/src/adapter/node/node.adapter.native-spec.ts create mode 100644 app/src/adapter/node/node.adapter.spec.ts create mode 100644 app/src/adapter/react-router/react-router.adapter.spec.ts create mode 100644 examples/astro/bknd.config.ts create mode 100644 examples/astro/src/bknd.ts create mode 100644 examples/bun/bun.lock delete mode 100644 examples/bun/minimal.ts create mode 100644 examples/nextjs/bknd.config.ts create mode 100644 examples/react-router/bknd.config.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1030c9a..6fbec3c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v1 with: - bun-version: latest + bun-version: "1.2.5" - name: Install dependencies working-directory: ./app diff --git a/app/__test__/adapter/adapter.test.ts b/app/__test__/adapter/adapter.test.ts new file mode 100644 index 0000000..734fb08 --- /dev/null +++ b/app/__test__/adapter/adapter.test.ts @@ -0,0 +1,62 @@ +import { expect, describe, it, beforeAll, afterAll } from "bun:test"; +import * as adapter from "adapter"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; +import { adapterTestSuite } from "adapter/adapter-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("adapter", () => { + it("makes config", () => { + expect(adapter.makeConfig({})).toEqual({}); + expect(adapter.makeConfig({}, { env: { TEST: "test" } })).toEqual({}); + + // merges everything returned from `app` with the config + expect(adapter.makeConfig({ app: (a) => a as any }, { env: { TEST: "test" } })).toEqual({ + env: { TEST: "test" }, + } as any); + }); + + it("reuses apps correctly", async () => { + const id = crypto.randomUUID(); + + const first = await adapter.createAdapterApp( + { + initialConfig: { server: { cors: { origin: "random" } } }, + }, + undefined, + { id }, + ); + const second = await adapter.createAdapterApp(); + const third = await adapter.createAdapterApp(undefined, undefined, { id }); + + await first.build(); + await second.build(); + await third.build(); + + expect(first.toJSON().server.cors.origin).toEqual("random"); + expect(first).toBe(third); + expect(first).not.toBe(second); + expect(second).not.toBe(third); + expect(second.toJSON().server.cors.origin).toEqual("*"); + + // recreate the first one + const first2 = await adapter.createAdapterApp(undefined, undefined, { id, force: true }); + await first2.build(); + expect(first2).not.toBe(first); + expect(first2).not.toBe(third); + expect(first2).not.toBe(second); + expect(first2.toJSON().server.cors.origin).toEqual("*"); + }); + + adapterTestSuite(bunTestRunner, { + makeApp: adapter.createFrameworkApp, + label: "framework app", + }); + + adapterTestSuite(bunTestRunner, { + makeApp: adapter.createRuntimeApp, + label: "runtime app", + }); +}); diff --git a/app/package.json b/app/package.json index 9faf7ca..44c8dbd 100644 --- a/app/package.json +++ b/app/package.json @@ -19,6 +19,7 @@ "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:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail", "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", diff --git a/app/src/App.ts b/app/src/App.ts index b4e0ea5..d8d147a 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -68,12 +68,14 @@ export type AppConfig = InitialModuleConfigs; export type LocalApiOptions = Request | ApiOptions; export class App { - modules: ModuleManager; static readonly Events = AppEvents; + + modules: ModuleManager; adminController?: AdminController; + _id: string = crypto.randomUUID(); + private trigger_first_boot = false; private plugins: AppPlugin[]; - private _id: string = crypto.randomUUID(); private _building: boolean = false; constructor( diff --git a/app/src/adapter/adapter-test-suite.ts b/app/src/adapter/adapter-test-suite.ts new file mode 100644 index 0000000..0ddb2b7 --- /dev/null +++ b/app/src/adapter/adapter-test-suite.ts @@ -0,0 +1,90 @@ +import type { TestRunner } from "core/test"; +import type { BkndConfig, DefaultArgs, FrameworkOptions, RuntimeOptions } from "./index"; +import type { App } from "App"; + +export function adapterTestSuite< + Config extends BkndConfig = BkndConfig, + Args extends DefaultArgs = DefaultArgs, +>( + testRunner: TestRunner, + { + makeApp, + makeHandler, + label = "app", + overrides = {}, + }: { + makeApp: ( + config: Config, + args?: Args, + opts?: RuntimeOptions | FrameworkOptions, + ) => Promise; + makeHandler?: ( + config?: Config, + args?: Args, + opts?: RuntimeOptions | FrameworkOptions, + ) => (request: Request) => Promise; + label?: string; + overrides?: { + dbUrl?: string; + }; + }, +) { + const { test, expect, mock } = testRunner; + const id = crypto.randomUUID(); + + test(`creates ${label}`, async () => { + const beforeBuild = mock(async () => null) as any; + const onBuilt = mock(async () => null) as any; + + const config = { + app: (env) => ({ + connection: { url: env.url }, + initialConfig: { + server: { cors: { origin: env.origin } }, + }, + }), + beforeBuild, + onBuilt, + } as const satisfies BkndConfig; + + const app = await makeApp( + config as any, + { + url: overrides.dbUrl ?? ":memory:", + origin: "localhost", + } as any, + { id }, + ); + expect(app).toBeDefined(); + expect(app.toJSON().server.cors.origin).toEqual("localhost"); + expect(beforeBuild).toHaveBeenCalledTimes(1); + expect(onBuilt).toHaveBeenCalledTimes(1); + }); + + if (makeHandler) { + const getConfig = async (fetcher: (r: Request) => Promise) => { + const res = await fetcher(new Request("http://localhost:3000/api/system/config")); + const data = (await res.json()) as any; + return { res, data }; + }; + + test("responds with the same app id", async () => { + const fetcher = makeHandler(undefined, undefined, { id }); + + const { res, data } = await getConfig(fetcher); + expect(res.ok).toBe(true); + expect(res.status).toBe(200); + expect(data.server.cors.origin).toEqual("localhost"); + }); + + test("creates fresh & responds to api config", async () => { + // set the same id, but force recreate + const fetcher = makeHandler(undefined, undefined, { id, force: true }); + + const { res, data } = await getConfig(fetcher); + expect(res.ok).toBe(true); + expect(res.status).toBe(200); + expect(data.server.cors.origin).toEqual("*"); + }); + } +} diff --git a/app/src/adapter/astro/astro.adapter.spec.ts b/app/src/adapter/astro/astro.adapter.spec.ts new file mode 100644 index 0000000..3f3d1e8 --- /dev/null +++ b/app/src/adapter/astro/astro.adapter.spec.ts @@ -0,0 +1,15 @@ +import { afterAll, beforeAll, describe } from "bun:test"; +import * as astro from "./astro.adapter"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; +import { adapterTestSuite } from "adapter/adapter-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("astro adapter", () => { + adapterTestSuite(bunTestRunner, { + makeApp: astro.getApp, + makeHandler: (c, a, o) => (request: Request) => astro.serve(c, a, o)({ request }), + }); +}); diff --git a/app/src/adapter/astro/astro.adapter.ts b/app/src/adapter/astro/astro.adapter.ts index 61971e3..a684f73 100644 --- a/app/src/adapter/astro/astro.adapter.ts +++ b/app/src/adapter/astro/astro.adapter.ts @@ -1,34 +1,25 @@ -import type { App } from "bknd"; -import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; -import { Api, type ApiOptions } from "bknd/client"; - -export type AstroBkndConfig = FrameworkBkndConfig; +import { type FrameworkBkndConfig, createFrameworkApp, type FrameworkOptions } from "bknd/adapter"; +type AstroEnv = NodeJS.ProcessEnv; type TAstro = { request: Request; }; +export type AstroBkndConfig = FrameworkBkndConfig; -export type Options = { - mode?: "static" | "dynamic"; -} & Omit & { - host?: string; - }; - -export async function getApi(Astro: TAstro, options: Options = { mode: "static" }) { - const api = new Api({ - host: new URL(Astro.request.url).origin, - headers: options.mode === "dynamic" ? Astro.request.headers : undefined, - }); - await api.verifyAuth(); - return api; +export async function getApp( + config: AstroBkndConfig = {}, + args: Env = {} as Env, + opts: FrameworkOptions = {}, +) { + return await createFrameworkApp(config, args ?? import.meta.env, opts); } -let app: App; -export function serve(config: AstroBkndConfig = {}) { - return async (args: Context) => { - if (!app) { - app = await createFrameworkApp(config, args); - } - return app.fetch(args.request); +export function serve( + config: AstroBkndConfig = {}, + args: Env = {} as Env, + opts?: FrameworkOptions, +) { + return async (fnArgs: TAstro) => { + return (await getApp(config, args, opts)).fetch(fnArgs.request); }; } diff --git a/app/src/adapter/aws/aws-lambda.adapter.ts b/app/src/adapter/aws/aws-lambda.adapter.ts index 9488065..f73b76e 100644 --- a/app/src/adapter/aws/aws-lambda.adapter.ts +++ b/app/src/adapter/aws/aws-lambda.adapter.ts @@ -1,68 +1,76 @@ import type { App } from "bknd"; import { handle } from "hono/aws-lambda"; -import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter"; +import { serveStatic } from "@hono/node-server/serve-static"; +import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; -export type AwsLambdaBkndConfig = RuntimeBkndConfig & { - assets?: - | { - mode: "local"; - root: string; - } - | { - mode: "url"; - url: string; - }; -}; +type AwsLambdaEnv = object; +export type AwsLambdaBkndConfig = + RuntimeBkndConfig & { + assets?: + | { + mode: "local"; + root: string; + } + | { + mode: "url"; + url: string; + }; + }; -let app: App; -export async function createApp({ - adminOptions = false, - assets, - ...config -}: AwsLambdaBkndConfig = {}) { - if (!app) { - let additional: Partial = { - adminOptions, - }; +export async function createApp( + { adminOptions = false, assets, ...config }: AwsLambdaBkndConfig = {}, + args: Env = {} as Env, + opts?: RuntimeOptions, +): Promise { + let additional: Partial = { + adminOptions, + }; - if (assets?.mode) { - switch (assets.mode) { - case "local": - // @todo: serve static outside app context - additional = { - adminOptions: adminOptions === false ? undefined : adminOptions, - serveStatic: (await import("@hono/node-server/serve-static")).serveStatic({ - root: assets.root, - onFound: (path, c) => { - c.res.headers.set("Cache-Control", "public, max-age=31536000"); - }, - }), - }; - break; - case "url": - additional.adminOptions = { - ...(typeof adminOptions === "object" ? adminOptions : {}), - assets_path: assets.url, - }; - break; - default: - throw new Error("Invalid assets mode"); - } + if (assets?.mode) { + switch (assets.mode) { + case "local": + // @todo: serve static outside app context + additional = { + adminOptions: adminOptions === false ? undefined : adminOptions, + serveStatic: serveStatic({ + root: assets.root, + onFound: (path, c) => { + c.res.headers.set("Cache-Control", "public, max-age=31536000"); + }, + }), + }; + break; + case "url": + additional.adminOptions = { + ...(typeof adminOptions === "object" ? adminOptions : {}), + assets_path: assets.url, + }; + break; + default: + throw new Error("Invalid assets mode"); } - - app = await createRuntimeApp({ - ...config, - ...additional, - }); } - return app; + return await createRuntimeApp( + { + ...config, + ...additional, + }, + args ?? process.env, + opts, + ); } -export function serveLambda(config: AwsLambdaBkndConfig = {}) { - console.log("serving lambda"); +export function serve( + config: AwsLambdaBkndConfig = {}, + args: Env = {} as Env, + opts?: RuntimeOptions, +) { return async (event) => { - const app = await createApp(config); + const app = await createApp(config, args, opts); return await handle(app.server)(event); }; } + +// compatibility with old code +export const serveLambda = serve; diff --git a/app/src/adapter/aws/aws.adapter.spec.ts b/app/src/adapter/aws/aws.adapter.spec.ts new file mode 100644 index 0000000..e6873d8 --- /dev/null +++ b/app/src/adapter/aws/aws.adapter.spec.ts @@ -0,0 +1,19 @@ +import { afterAll, beforeAll, describe } from "bun:test"; +import * as awsLambda from "./aws-lambda.adapter"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; +import { adapterTestSuite } from "adapter/adapter-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("aws adapter", () => { + adapterTestSuite(bunTestRunner, { + makeApp: awsLambda.createApp, + // @todo: add a request to lambda event translator? + makeHandler: (c, a, o) => async (request: Request) => { + const app = await awsLambda.createApp(c, a, o); + return app.fetch(request); + }, + }); +}); diff --git a/app/src/adapter/bun/bun.adapter.spec.ts b/app/src/adapter/bun/bun.adapter.spec.ts new file mode 100644 index 0000000..7423190 --- /dev/null +++ b/app/src/adapter/bun/bun.adapter.spec.ts @@ -0,0 +1,15 @@ +import { afterAll, beforeAll, describe } from "bun:test"; +import * as bun from "./bun.adapter"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; +import { adapterTestSuite } from "adapter/adapter-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("bun adapter", () => { + adapterTestSuite(bunTestRunner, { + makeApp: bun.createApp, + makeHandler: bun.createHandler, + }); +}); diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index 2087c5e..44fb795 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -1,47 +1,64 @@ /// import path from "node:path"; -import type { App } from "bknd"; -import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter"; +import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; import { registerLocalMediaAdapter } from "bknd/adapter/node"; import { config } from "bknd/core"; import type { ServeOptions } from "bun"; import { serveStatic } from "hono/bun"; -let app: App; +type BunEnv = Bun.Env; +export type BunBkndConfig = RuntimeBkndConfig & Omit; -export type BunBkndConfig = RuntimeBkndConfig & Omit; - -export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {}) { +export async function createApp( + { distPath, ...config }: BunBkndConfig = {}, + args: Env = {} as Env, + opts?: RuntimeOptions, +) { const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); + registerLocalMediaAdapter(); - if (!app) { - registerLocalMediaAdapter(); - app = await createRuntimeApp({ + return await createRuntimeApp( + { ...config, serveStatic: serveStatic({ root }), - }); - } - - return app; + }, + args ?? (process.env as Env), + opts, + ); } -export function serve({ - distPath, - connection, - initialConfig, - options, - port = config.server.default_port, - onBuilt, - buildConfig, - adminOptions, - ...serveOptions -}: BunBkndConfig = {}) { +export function createHandler( + config: BunBkndConfig = {}, + args: Env = {} as Env, + opts?: RuntimeOptions, +) { + return async (req: Request) => { + const app = await createApp(config, args ?? (process.env as Env), opts); + return app.fetch(req); + }; +} + +export function serve( + { + distPath, + connection, + initialConfig, + options, + port = config.server.default_port, + onBuilt, + buildConfig, + adminOptions, + ...serveOptions + }: BunBkndConfig = {}, + args: Env = {} as Env, + opts?: RuntimeOptions, +) { Bun.serve({ ...serveOptions, port, - fetch: async (request: Request) => { - const app = await createApp({ + fetch: createHandler( + { connection, initialConfig, options, @@ -49,9 +66,10 @@ export function serve({ buildConfig, adminOptions, distPath, - }); - return app.fetch(request); - }, + }, + args, + opts, + ), }); console.log(`Server is running on http://localhost:${port}`); diff --git a/app/src/adapter/bun/test.ts b/app/src/adapter/bun/test.ts new file mode 100644 index 0000000..7bd314a --- /dev/null +++ b/app/src/adapter/bun/test.ts @@ -0,0 +1,7 @@ +import { expect, test, mock } from "bun:test"; + +export const bunTestRunner = { + expect, + test, + mock, +}; diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts new file mode 100644 index 0000000..22449a4 --- /dev/null +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts @@ -0,0 +1,60 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { makeApp } from "./modes/fresh"; +import { makeConfig } from "./config"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; +import { adapterTestSuite } from "adapter/adapter-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; +import type { CloudflareBkndConfig } from "./cloudflare-workers.adapter"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("cf adapter", () => { + const DB_URL = ":memory:"; + const $ctx = (env?: any, request?: Request, ctx?: ExecutionContext) => ({ + request: request ?? (null as any), + env: env ?? { DB_URL }, + ctx: ctx ?? (null as any), + }); + + it("makes config", async () => { + expect( + makeConfig( + { + connection: { url: DB_URL }, + }, + {}, + ), + ).toEqual({ connection: { url: DB_URL } }); + + expect( + makeConfig( + { + app: (env) => ({ + connection: { url: env.DB_URL }, + }), + }, + { + DB_URL, + }, + ), + ).toEqual({ connection: { url: DB_URL } }); + }); + + adapterTestSuite(bunTestRunner, { + makeApp, + makeHandler: (c, a, o) => { + return async (request: any) => { + const app = await makeApp( + // needs a fallback, otherwise tries to launch D1 + c ?? { + connection: { url: DB_URL }, + }, + a, + o, + ); + return app.fetch(request); + }; + }, + }); +}); diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index dec7d09..f1ac9e6 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -1,19 +1,16 @@ /// -import { type FrameworkBkndConfig, makeConfig } from "bknd/adapter"; +import type { FrameworkBkndConfig } from "bknd/adapter"; import { Hono } from "hono"; import { serveStatic } from "hono/cloudflare-workers"; -import { D1Connection } from "./D1Connection"; -import { registerMedia } from "./StorageR2Adapter"; -import { getBinding } from "./bindings"; import { getCached } from "./modes/cached"; import { getDurable } from "./modes/durable"; import { getFresh, getWarm } from "./modes/fresh"; -import type { CreateAppConfig } from "App"; -export type CloudflareBkndConfig = FrameworkBkndConfig> & { +export type CloudflareEnv = object; +export type CloudflareBkndConfig = FrameworkBkndConfig & { mode?: "warm" | "fresh" | "cache" | "durable"; - bindings?: (args: Context) => { + bindings?: (args: Env) => { kv?: KVNamespace; dobj?: DurableObjectNamespace; db?: D1Database; @@ -27,58 +24,15 @@ export type CloudflareBkndConfig = FrameworkBkndConfig> html?: string; }; -export type Context = { +export type Context = { request: Request; env: Env; ctx: ExecutionContext; }; -export const constants = { - exec_async_event_id: "cf_register_waituntil", - cache_endpoint: "/__bknd/cache", - do_endpoint: "/__bknd/do", -}; - -let media_registered: boolean = false; -export function makeCfConfig(config: CloudflareBkndConfig, context: Context): CreateAppConfig { - if (!media_registered) { - registerMedia(context.env as any); - media_registered = true; - } - - const appConfig = makeConfig(config, context); - const bindings = config.bindings?.(context); - if (!appConfig.connection) { - let db: D1Database | undefined; - if (bindings?.db) { - console.log("Using database from bindings"); - db = bindings.db; - } else if (Object.keys(context.env ?? {}).length > 0) { - const binding = getBinding(context.env, "D1Database"); - if (binding) { - console.log(`Using database from env "${binding.key}"`); - db = binding.value; - } - } - - if (db) { - appConfig.connection = new D1Connection({ binding: db }); - } else { - throw new Error("No database connection given"); - } - } - - return { - ...appConfig, - options: { - ...appConfig.options, - // if not specified explicitly, disable it to use ExecutionContext's waitUntil - asyncEventsMode: config.options?.asyncEventsMode ?? "none", - }, - }; -} - -export function serve(config: CloudflareBkndConfig = {}) { +export function serve( + config: CloudflareBkndConfig = {}, +) { return { async fetch(request: Request, env: Env, ctx: ExecutionContext) { const url = new URL(request.url); @@ -113,7 +67,7 @@ export function serve(config: CloudflareBkndConfig = {}) { } } - const context = { request, env, ctx } as Context; + const context = { request, env, ctx } as Context; const mode = config.mode ?? "warm"; switch (mode) { diff --git a/app/src/adapter/cloudflare/config.ts b/app/src/adapter/cloudflare/config.ts new file mode 100644 index 0000000..4b9f3d7 --- /dev/null +++ b/app/src/adapter/cloudflare/config.ts @@ -0,0 +1,64 @@ +import { registerMedia } from "./storage/StorageR2Adapter"; +import { getBinding } from "./bindings"; +import { D1Connection } from "./D1Connection"; +import type { CloudflareBkndConfig, CloudflareEnv } from "."; +import { App } from "bknd"; +import { makeConfig as makeAdapterConfig } from "bknd/adapter"; +import type { ExecutionContext } from "hono"; + +export const constants = { + exec_async_event_id: "cf_register_waituntil", + cache_endpoint: "/__bknd/cache", + do_endpoint: "/__bknd/do", +}; + +let media_registered: boolean = false; +export function makeConfig( + config: CloudflareBkndConfig, + args: Env = {} as Env, +) { + if (!media_registered) { + registerMedia(args as any); + media_registered = true; + } + + const appConfig = makeAdapterConfig(config, args); + const bindings = config.bindings?.(args); + if (!appConfig.connection) { + let db: D1Database | undefined; + if (bindings?.db) { + console.log("Using database from bindings"); + db = bindings.db; + } else if (Object.keys(args).length > 0) { + const binding = getBinding(args, "D1Database"); + if (binding) { + console.log(`Using database from env "${binding.key}"`); + db = binding.value; + } + } + + if (db) { + appConfig.connection = new D1Connection({ binding: db }); + } else { + throw new Error("No database connection given"); + } + } + + return appConfig; +} + +export function registerAsyncsExecutionContext( + app: App, + ctx: { waitUntil: ExecutionContext["waitUntil"] }, +) { + app.emgr.onEvent( + App.Events.AppBeforeResponse, + async (event) => { + ctx.waitUntil(event.params.app.emgr.executeAsyncs()); + }, + { + mode: "sync", + id: constants.exec_async_event_id, + }, + ); +} diff --git a/app/src/adapter/cloudflare/modes/cached.ts b/app/src/adapter/cloudflare/modes/cached.ts index 78d8eb4..4791124 100644 --- a/app/src/adapter/cloudflare/modes/cached.ts +++ b/app/src/adapter/cloudflare/modes/cached.ts @@ -1,8 +1,12 @@ import { App } from "bknd"; import { createRuntimeApp } from "bknd/adapter"; -import { type CloudflareBkndConfig, constants, type Context, makeCfConfig } from "../index"; +import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index"; +import { makeConfig, registerAsyncsExecutionContext, constants } from "../config"; -export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...args }: Context) { +export async function getCached( + config: CloudflareBkndConfig, + { env, ctx, ...args }: Context, +) { const { kv } = config.bindings?.(env)!; if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings"); const key = config.key ?? "app"; @@ -16,9 +20,10 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...arg const app = await createRuntimeApp( { - ...makeCfConfig(config, { env, ctx, ...args }), + ...makeConfig(config, env), initialConfig, onBuilt: async (app) => { + registerAsyncsExecutionContext(app, ctx); app.module.server.client.get(constants.cache_endpoint, async (c) => { await kv.delete(key); return c.json({ message: "Cache cleared" }); @@ -26,16 +31,6 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...arg await config.onBuilt?.(app); }, beforeBuild: async (app) => { - app.emgr.onEvent( - App.Events.AppBeforeResponse, - async (event) => { - ctx.waitUntil(event.params.app.emgr.executeAsyncs()); - }, - { - mode: "sync", - id: constants.exec_async_event_id, - }, - ); app.emgr.onEvent( App.Events.AppConfigUpdatedEvent, async ({ params: { app } }) => { diff --git a/app/src/adapter/cloudflare/modes/durable.ts b/app/src/adapter/cloudflare/modes/durable.ts index 369d451..d09a006 100644 --- a/app/src/adapter/cloudflare/modes/durable.ts +++ b/app/src/adapter/cloudflare/modes/durable.ts @@ -1,9 +1,13 @@ import { DurableObject } from "cloudflare:workers"; -import { App, type CreateAppConfig } from "bknd"; +import type { App, CreateAppConfig } from "bknd"; import { createRuntimeApp, makeConfig } from "bknd/adapter"; -import { type CloudflareBkndConfig, type Context, constants } from "../index"; +import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index"; +import { constants, registerAsyncsExecutionContext } from "../config"; -export async function getDurable(config: CloudflareBkndConfig, ctx: Context) { +export async function getDurable( + config: CloudflareBkndConfig, + ctx: Context, +) { const { dobj } = config.bindings?.(ctx.env)!; if (!dobj) throw new Error("durable object is not defined in cloudflare.bindings"); const key = config.key ?? "app"; @@ -17,7 +21,7 @@ export async function getDurable(config: CloudflareBkndConfig, ctx: Context) { const id = dobj.idFromName(key); const stub = dobj.get(id) as unknown as DurableBkndApp; - const create_config = makeConfig(config, ctx); + const create_config = makeConfig(config, ctx.env); const res = await stub.fire(ctx.request, { config: create_config, @@ -67,16 +71,7 @@ export class DurableBkndApp extends DurableObject { this.app = await createRuntimeApp({ ...config, onBuilt: async (app) => { - app.emgr.onEvent( - App.Events.AppBeforeResponse, - async (event) => { - this.ctx.waitUntil(event.params.app.emgr.executeAsyncs()); - }, - { - mode: "sync", - id: constants.exec_async_event_id, - }, - ); + registerAsyncsExecutionContext(app, this.ctx); app.modules.server.get(constants.do_endpoint, async (c) => { // @ts-ignore const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf; diff --git a/app/src/adapter/cloudflare/modes/fresh.ts b/app/src/adapter/cloudflare/modes/fresh.ts index 2d34d88..b5730b1 100644 --- a/app/src/adapter/cloudflare/modes/fresh.ts +++ b/app/src/adapter/cloudflare/modes/fresh.ts @@ -1,40 +1,48 @@ -import { App } from "bknd"; -import { createRuntimeApp } from "bknd/adapter"; -import { type CloudflareBkndConfig, type Context, makeCfConfig, constants } from "../index"; +import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; +import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index"; +import { makeConfig, registerAsyncsExecutionContext } from "../config"; -export async function makeApp(config: CloudflareBkndConfig, ctx: Context) { - return await createRuntimeApp( +export async function makeApp( + config: CloudflareBkndConfig, + args: Env = {} as Env, + opts?: RuntimeOptions, +) { + return await createRuntimeApp( { - ...makeCfConfig(config, ctx), + ...makeConfig(config, args), adminOptions: config.html ? { html: config.html } : undefined, - onBuilt: async (app) => { - app.emgr.onEvent( - App.Events.AppBeforeResponse, - async (event) => { - ctx.ctx.waitUntil(event.params.app.emgr.executeAsyncs()); - }, - { - mode: "sync", - id: constants.exec_async_event_id, - }, - ); - await config.onBuilt?.(app); - }, }, - ctx, + args, + opts, ); } -export async function getFresh(config: CloudflareBkndConfig, ctx: Context) { - const app = await makeApp(config, ctx); +export async function getWarm( + config: CloudflareBkndConfig, + ctx: Context, + opts: RuntimeOptions = {}, +) { + const app = await makeApp( + { + ...config, + onBuilt: async (app) => { + registerAsyncsExecutionContext(app, ctx.ctx); + config.onBuilt?.(app); + }, + }, + ctx.env, + opts, + ); return app.fetch(ctx.request); } -let warm_app: App; -export async function getWarm(config: CloudflareBkndConfig, ctx: Context) { - if (!warm_app) { - warm_app = await makeApp(config, ctx); - } - - return warm_app.fetch(ctx.request); +export async function getFresh( + config: CloudflareBkndConfig, + ctx: Context, + opts: RuntimeOptions = {}, +) { + return await getWarm(config, ctx, { + ...opts, + force: true, + }); } diff --git a/app/__test__/media/StorageR2Adapter.native-spec.ts b/app/src/adapter/cloudflare/storage/StorageR2Adapter.native-spec.ts similarity index 88% rename from app/__test__/media/StorageR2Adapter.native-spec.ts rename to app/src/adapter/cloudflare/storage/StorageR2Adapter.native-spec.ts index 4e3b95b..16c4fa5 100644 --- a/app/__test__/media/StorageR2Adapter.native-spec.ts +++ b/app/src/adapter/cloudflare/storage/StorageR2Adapter.native-spec.ts @@ -1,7 +1,7 @@ import { createWriteStream, readFileSync } from "node:fs"; import { test } from "node:test"; import { Miniflare } from "miniflare"; -import { StorageR2Adapter } from "adapter/cloudflare/StorageR2Adapter"; +import { StorageR2Adapter } from "./StorageR2Adapter"; import { adapterTestSuite } from "media"; import { nodeTestRunner } from "adapter/node"; import path from "node:path"; @@ -23,7 +23,7 @@ test("StorageR2Adapter", async () => { const bucket = (await mf.getR2Bucket("BUCKET")) as unknown as R2Bucket; const adapter = new StorageR2Adapter(bucket); - const basePath = path.resolve(import.meta.dirname, "../_assets"); + 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" }); diff --git a/app/src/adapter/cloudflare/StorageR2Adapter.ts b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts similarity index 96% rename from app/src/adapter/cloudflare/StorageR2Adapter.ts rename to app/src/adapter/cloudflare/storage/StorageR2Adapter.ts index 6020d92..62c41f6 100644 --- a/app/src/adapter/cloudflare/StorageR2Adapter.ts +++ b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts @@ -1,10 +1,8 @@ import { registries } from "bknd"; import { isDebug } from "bknd/core"; import { StringEnum, Type } from "bknd/utils"; -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"; +import { guessMimeType as guess, StorageAdapter, type FileBody } from "bknd/media"; +import { getBindings } from "../bindings"; export function makeSchema(bindings: string[] = []) { return Type.Object( diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index 6d416e9..84fb8c5 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -12,76 +12,113 @@ export type BkndConfig = CreateAppConfig & { export type FrameworkBkndConfig = BkndConfig; +export type CreateAdapterAppOptions = { + force?: boolean; + id?: string; +}; +export type FrameworkOptions = CreateAdapterAppOptions; +export type RuntimeOptions = CreateAdapterAppOptions; + export type RuntimeBkndConfig = BkndConfig & { distPath?: string; serveStatic?: MiddlewareHandler | [string, MiddlewareHandler]; adminOptions?: AdminControllerOptions | false; }; -export function makeConfig(config: BkndConfig, args?: Args): CreateAppConfig { +export type DefaultArgs = { + [key: string]: any; +}; + +export function makeConfig( + config: BkndConfig, + args?: Args, +): CreateAppConfig { let additionalConfig: CreateAppConfig = {}; - if ("app" in config && config.app) { - if (typeof config.app === "function") { + const { app, ...rest } = config; + if (app) { + if (typeof app === "function") { if (!args) { throw new Error("args is required when config.app is a function"); } - additionalConfig = config.app(args); + additionalConfig = app(args); } else { - additionalConfig = config.app; + additionalConfig = app; } } - return { ...config, ...additionalConfig }; + return { ...rest, ...additionalConfig }; } -export async function createFrameworkApp( - config: FrameworkBkndConfig, +// a map that contains all apps by id +const apps = new Map(); +export async function createAdapterApp( + config: Config = {} as Config, args?: Args, + opts?: CreateAdapterAppOptions, ): Promise { - const app = App.create(makeConfig(config, args)); + const id = opts?.id ?? "app"; + let app = apps.get(id); + if (!app || opts?.force) { + app = App.create(makeConfig(config, args)); + apps.set(id, app); + } + return app; +} - if (config.onBuilt) { +export async function createFrameworkApp( + config: FrameworkBkndConfig = {}, + args?: Args, + opts?: FrameworkOptions, +): Promise { + const app = await createAdapterApp(config, args, opts); + + if (!app.isBuilt()) { + if (config.onBuilt) { + app.emgr.onEvent( + App.Events.AppBuiltEvent, + async () => { + await config.onBuilt?.(app); + }, + "sync", + ); + } + + await config.beforeBuild?.(app); + await app.build(config.buildConfig); + } + + return app; +} + +export async function createRuntimeApp( + { serveStatic, adminOptions, ...config }: RuntimeBkndConfig = {}, + args?: Args, + opts?: RuntimeOptions, +): Promise { + const app = await createAdapterApp(config, args, opts); + + if (!app.isBuilt()) { app.emgr.onEvent( App.Events.AppBuiltEvent, async () => { + if (serveStatic) { + const [path, handler] = Array.isArray(serveStatic) + ? serveStatic + : [$config.server.assets_path + "*", serveStatic]; + app.modules.server.get(path, handler); + } + await config.onBuilt?.(app); + if (adminOptions !== false) { + app.registerAdminController(adminOptions); + } }, "sync", ); + + await config.beforeBuild?.(app); + await app.build(config.buildConfig); } - await config.beforeBuild?.(app); - await app.build(config.buildConfig); - - return app; -} - -export async function createRuntimeApp( - { serveStatic, adminOptions, ...config }: RuntimeBkndConfig, - env?: Env, -): Promise { - const app = App.create(makeConfig(config, env)); - - app.emgr.onEvent( - App.Events.AppBuiltEvent, - async () => { - if (serveStatic) { - const [path, handler] = Array.isArray(serveStatic) - ? serveStatic - : [$config.server.assets_path + "*", serveStatic]; - app.modules.server.get(path, handler); - } - - await config.onBuilt?.(app); - if (adminOptions !== false) { - app.registerAdminController(adminOptions); - } - }, - "sync", - ); - - await config.beforeBuild?.(app); - await app.build(config.buildConfig); - return app; } diff --git a/app/src/adapter/nextjs/nextjs.adapter.spec.ts b/app/src/adapter/nextjs/nextjs.adapter.spec.ts new file mode 100644 index 0000000..8e3f2e4 --- /dev/null +++ b/app/src/adapter/nextjs/nextjs.adapter.spec.ts @@ -0,0 +1,16 @@ +import { afterAll, beforeAll, describe } from "bun:test"; +import * as nextjs from "./nextjs.adapter"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; +import { adapterTestSuite } from "adapter/adapter-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; +import type { NextjsBkndConfig } from "./nextjs.adapter"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("nextjs adapter", () => { + adapterTestSuite(bunTestRunner, { + makeApp: nextjs.getApp, + makeHandler: nextjs.serve, + }); +}); diff --git a/app/src/adapter/nextjs/nextjs.adapter.ts b/app/src/adapter/nextjs/nextjs.adapter.ts index 32ff102..2b3b829 100644 --- a/app/src/adapter/nextjs/nextjs.adapter.ts +++ b/app/src/adapter/nextjs/nextjs.adapter.ts @@ -1,36 +1,19 @@ -import type { App } from "bknd"; -import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; -import { isNode } from "core/utils"; +import { createFrameworkApp, type FrameworkBkndConfig, type FrameworkOptions } from "bknd/adapter"; +import { isNode } from "bknd/utils"; +import type { NextApiRequest } from "next"; -export type NextjsBkndConfig = FrameworkBkndConfig & { +type NextjsEnv = NextApiRequest["env"]; + +export type NextjsBkndConfig = FrameworkBkndConfig & { cleanRequest?: { searchParams?: string[] }; }; -type NextjsContext = { - env: Record; -}; - -let app: App; -let building: boolean = false; - -export async function getApp( - config: NextjsBkndConfig, - args?: Args, +export async function getApp( + config: NextjsBkndConfig, + args: Env = {} as Env, + opts?: FrameworkOptions, ) { - if (building) { - while (building) { - await new Promise((resolve) => setTimeout(resolve, 5)); - } - if (app) return app; - } - - building = true; - if (!app) { - app = await createFrameworkApp(config, args); - await app.build(); - } - building = false; - return app; + return await createFrameworkApp(config, args ?? (process.env as Env), opts); } function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) { @@ -56,11 +39,13 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ }); } -export function serve({ cleanRequest, ...config }: NextjsBkndConfig = {}) { +export function serve( + { cleanRequest, ...config }: NextjsBkndConfig = {}, + args: Env = {} as Env, + opts?: FrameworkOptions, +) { return async (req: Request) => { - if (!app) { - app = await getApp(config, { env: process.env ?? {} }); - } + const app = await getApp(config, args, opts); const request = getCleanRequest(req, cleanRequest); return app.fetch(request); }; diff --git a/app/src/adapter/node/index.ts b/app/src/adapter/node/index.ts index e047040..c009c07 100644 --- a/app/src/adapter/node/index.ts +++ b/app/src/adapter/node/index.ts @@ -5,6 +5,15 @@ export * from "./node.adapter"; export { StorageLocalAdapter, type LocalAdapterConfig }; export { nodeTestRunner } from "./test"; +let registered = false; export function registerLocalMediaAdapter() { - registries.media.register("local", StorageLocalAdapter); + if (!registered) { + registries.media.register("local", StorageLocalAdapter); + registered = true; + } + + return (config: Partial = {}) => { + const adapter = new StorageLocalAdapter(config); + return adapter.toJSON(true); + }; } diff --git a/app/src/adapter/node/node.adapter.native-spec.ts b/app/src/adapter/node/node.adapter.native-spec.ts new file mode 100644 index 0000000..c4ece3b --- /dev/null +++ b/app/src/adapter/node/node.adapter.native-spec.ts @@ -0,0 +1,15 @@ +import { describe, before, after } from "node:test"; +import * as node from "./node.adapter"; +import { adapterTestSuite } from "adapter/adapter-test-suite"; +import { nodeTestRunner } from "adapter/node"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; + +before(() => disableConsoleLog()); +after(enableConsoleLog); + +describe("node adapter", () => { + adapterTestSuite(nodeTestRunner, { + makeApp: node.createApp, + makeHandler: node.createHandler, + }); +}); diff --git a/app/src/adapter/node/node.adapter.spec.ts b/app/src/adapter/node/node.adapter.spec.ts new file mode 100644 index 0000000..4050c68 --- /dev/null +++ b/app/src/adapter/node/node.adapter.spec.ts @@ -0,0 +1,15 @@ +import { afterAll, beforeAll, describe } from "bun:test"; +import * as node from "./node.adapter"; +import { adapterTestSuite } from "adapter/adapter-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("node adapter (bun)", () => { + adapterTestSuite(bunTestRunner, { + makeApp: node.createApp, + makeHandler: node.createHandler, + }); +}); diff --git a/app/src/adapter/node/node.adapter.ts b/app/src/adapter/node/node.adapter.ts index 97a8b82..816eb92 100644 --- a/app/src/adapter/node/node.adapter.ts +++ b/app/src/adapter/node/node.adapter.ts @@ -2,11 +2,11 @@ import path from "node:path"; import { serve as honoServe } from "@hono/node-server"; import { serveStatic } from "@hono/node-server/serve-static"; import { registerLocalMediaAdapter } from "adapter/node/index"; -import type { App } from "bknd"; -import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter"; +import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; import { config as $config } from "bknd/core"; -export type NodeBkndConfig = RuntimeBkndConfig & { +type NodeEnv = NodeJS.ProcessEnv; +export type NodeBkndConfig = RuntimeBkndConfig & { port?: number; hostname?: string; listener?: Parameters[1]; @@ -14,14 +14,11 @@ export type NodeBkndConfig = RuntimeBkndConfig & { relativeDistPath?: string; }; -export function serve({ - distPath, - relativeDistPath, - port = $config.server.default_port, - hostname, - listener, - ...config -}: NodeBkndConfig = {}) { +export async function createApp( + { distPath, relativeDistPath, ...config }: NodeBkndConfig = {}, + args: Env = {} as Env, + opts?: RuntimeOptions, +) { const root = path.relative( process.cwd(), path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"), @@ -30,23 +27,39 @@ export function serve({ console.warn("relativeDistPath is deprecated, please use distPath instead"); } - let app: App; + registerLocalMediaAdapter(); + return await createRuntimeApp( + { + ...config, + serveStatic: serveStatic({ root }), + }, + // @ts-ignore + args ?? { env: process.env }, + opts, + ); +} +export function createHandler( + config: NodeBkndConfig = {}, + args: Env = {} as Env, + opts?: RuntimeOptions, +) { + return async (req: Request) => { + const app = await createApp(config, args ?? (process.env as Env), opts); + return app.fetch(req); + }; +} + +export function serve( + { port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig = {}, + args: Env = {} as Env, + opts?: RuntimeOptions, +) { honoServe( { port, hostname, - fetch: async (req: Request) => { - if (!app) { - registerLocalMediaAdapter(); - app = await createRuntimeApp({ - ...config, - serveStatic: serveStatic({ root }), - }); - } - - return app.fetch(req); - }, + fetch: createHandler(config, args, opts), }, (connInfo) => { console.log(`Server is running on http://localhost:${connInfo.port}`); diff --git a/app/src/adapter/node/storage/StorageLocalAdapter.spec.ts b/app/src/adapter/node/storage/StorageLocalAdapter.spec.ts index ea76d9c..b231c15 100644 --- a/app/src/adapter/node/storage/StorageLocalAdapter.spec.ts +++ b/app/src/adapter/node/storage/StorageLocalAdapter.spec.ts @@ -3,6 +3,7 @@ import { StorageLocalAdapter } from "./StorageLocalAdapter"; // @ts-ignore import { assetsPath, assetsTmpPath } from "../../../../__test__/helper"; import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; describe("StorageLocalAdapter (bun)", async () => { const adapter = new StorageLocalAdapter({ @@ -10,5 +11,5 @@ describe("StorageLocalAdapter (bun)", async () => { }); const file = Bun.file(`${assetsPath}/image.png`); - await adapterTestSuite({ test, expect }, adapter, file); + await adapterTestSuite(bunTestRunner, adapter, file); }); diff --git a/app/src/adapter/node/storage/StorageLocalAdapter.ts b/app/src/adapter/node/storage/StorageLocalAdapter.ts index 12b91ce..8a44f40 100644 --- a/app/src/adapter/node/storage/StorageLocalAdapter.ts +++ b/app/src/adapter/node/storage/StorageLocalAdapter.ts @@ -7,14 +7,14 @@ export const localAdapterConfig = Type.Object( { path: Type.String({ default: "./" }), }, - { title: "Local", description: "Local file system storage" }, + { title: "Local", description: "Local file system storage", additionalProperties: false }, ); export type LocalAdapterConfig = Static; export class StorageLocalAdapter extends StorageAdapter { private config: LocalAdapterConfig; - constructor(config: any) { + constructor(config: Partial = {}) { super(); this.config = parse(localAdapterConfig, config); } diff --git a/app/src/adapter/node/test.ts b/app/src/adapter/node/test.ts index e2c3922..992cbee 100644 --- a/app/src/adapter/node/test.ts +++ b/app/src/adapter/node/test.ts @@ -2,6 +2,17 @@ import nodeAssert from "node:assert/strict"; import { test } from "node:test"; import type { Matcher, Test, TestFn, TestRunner } from "core/test"; +// Track mock function calls +const mockCalls = new WeakMap(); +function createMockFunction any>(fn: T): T { + const mockFn = (...args: Parameters) => { + const currentCalls = mockCalls.get(mockFn) || 0; + mockCalls.set(mockFn, currentCalls + 1); + return fn(...args); + }; + return mockFn as T; +} + const nodeTestMatcher = (actual: T, parentFailMsg?: string) => ({ toEqual: (expected: T, failMsg = parentFailMsg) => { @@ -23,6 +34,18 @@ const nodeTestMatcher = (actual: T, parentFailMsg?: string) => const e = Array.isArray(expected) ? expected : [expected]; nodeAssert.ok(e.includes(actual), failMsg); }, + toHaveBeenCalled: (failMsg = parentFailMsg) => { + const calls = mockCalls.get(actual as Function) || 0; + nodeAssert.ok(calls > 0, failMsg || "Expected function to have been called at least once"); + }, + toHaveBeenCalledTimes: (expected: number, failMsg = parentFailMsg) => { + const calls = mockCalls.get(actual as Function) || 0; + nodeAssert.strictEqual( + calls, + expected, + failMsg || `Expected function to have been called ${expected} times`, + ); + }, }) satisfies Matcher; const nodeTestResolverProxy = ( @@ -63,6 +86,7 @@ nodeTest.skipIf = (condition: boolean): Test => { export const nodeTestRunner: TestRunner = { test: nodeTest, + mock: createMockFunction, expect: (actual?: T, failMsg?: string) => ({ ...nodeTestMatcher(actual, failMsg), resolves: nodeTestResolverProxy(actual as Promise, { diff --git a/app/src/adapter/react-router/react-router.adapter.spec.ts b/app/src/adapter/react-router/react-router.adapter.spec.ts new file mode 100644 index 0000000..25ef895 --- /dev/null +++ b/app/src/adapter/react-router/react-router.adapter.spec.ts @@ -0,0 +1,15 @@ +import { afterAll, beforeAll, describe } from "bun:test"; +import * as rr from "./react-router.adapter"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; +import { adapterTestSuite } from "adapter/adapter-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("react-router adapter", () => { + adapterTestSuite(bunTestRunner, { + makeApp: rr.getApp, + makeHandler: (c, a, o) => (request: Request) => rr.serve(c, a?.env, o)({ request }), + }); +}); diff --git a/app/src/adapter/react-router/react-router.adapter.ts b/app/src/adapter/react-router/react-router.adapter.ts index 7e796c6..4474509 100644 --- a/app/src/adapter/react-router/react-router.adapter.ts +++ b/app/src/adapter/react-router/react-router.adapter.ts @@ -1,39 +1,26 @@ -import type { App } from "bknd"; import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; +import type { FrameworkOptions } from "adapter"; -type ReactRouterContext = { +type ReactRouterEnv = NodeJS.ProcessEnv; +type ReactRouterFunctionArgs = { request: Request; }; -export type ReactRouterBkndConfig = FrameworkBkndConfig; +export type ReactRouterBkndConfig = FrameworkBkndConfig; -let app: App; -let building: boolean = false; - -export async function getApp( - config: ReactRouterBkndConfig, - args?: Args, +export async function getApp( + config: ReactRouterBkndConfig, + args: Env = {} as Env, + opts?: FrameworkOptions, ) { - if (building) { - while (building) { - await new Promise((resolve) => setTimeout(resolve, 5)); - } - if (app) return app; - } - - building = true; - if (!app) { - app = await createFrameworkApp(config, args); - await app.build(); - } - building = false; - return app; + return await createFrameworkApp(config, args ?? process.env, opts); } -export function serve( - config: ReactRouterBkndConfig = {}, +export function serve( + config: ReactRouterBkndConfig = {}, + args: Env = {} as Env, + opts?: FrameworkOptions, ) { - return async (args: Args) => { - app = await getApp(config, args); - return app.fetch(args.request); + return async (fnArgs: ReactRouterFunctionArgs) => { + return (await getApp(config, args, opts)).fetch(fnArgs.request); }; } diff --git a/app/src/core/console.ts b/app/src/core/console.ts index 2d8e11b..daa7587 100644 --- a/app/src/core/console.ts +++ b/app/src/core/console.ts @@ -65,27 +65,53 @@ function __tty(_type: any, args: any[]) { } export type TConsoleSeverity = keyof typeof __consoles; -const level = env("cli_log_level", "log"); +declare global { + var __consoleConfig: + | { + level: TConsoleSeverity; + id?: string; + } + | undefined; +} + +// Ensure the config exists only once globally +const defaultLevel = env("cli_log_level", "log") as TConsoleSeverity; + +// biome-ignore lint/suspicious/noAssignInExpressions: +const config = (globalThis.__consoleConfig ??= { + level: defaultLevel, + //id: crypto.randomUUID(), // for debugging +}); const keys = Object.keys(__consoles); -export const $console = new Proxy( - {}, - { - get: (_, prop) => { - if (prop === "original") { +export const $console = new Proxy(config as any, { + get: (_, prop) => { + switch (prop) { + case "original": return console; - } + case "setLevel": + return (l: TConsoleSeverity) => { + config.level = l; + }; + case "resetLevel": + return () => { + config.level = defaultLevel; + }; + } - const current = keys.indexOf(level as string); - const requested = keys.indexOf(prop as string); - if (prop in __consoles && requested <= current) { - return (...args: any[]) => __tty(prop, args); - } - return () => null; - }, + const current = keys.indexOf(config.level); + const requested = keys.indexOf(prop as string); + + if (prop in __consoles && requested <= current) { + return (...args: any[]) => __tty(prop, args); + } + return () => null; }, -) as typeof console & { +}) as typeof console & { original: typeof console; +} & { + setLevel: (l: TConsoleSeverity) => void; + resetLevel: () => void; }; export function colorizeConsole(con: typeof console) { diff --git a/app/src/core/test/index.ts b/app/src/core/test/index.ts index 22485ef..ca1ffba 100644 --- a/app/src/core/test/index.ts +++ b/app/src/core/test/index.ts @@ -5,6 +5,8 @@ export type Matcher = { toBeString: (failMsg?: string) => void; toBeOneOf: (expected: T | Array | Iterable, failMsg?: string) => void; toBeDefined: (failMsg?: string) => void; + toHaveBeenCalled: (failMsg?: string) => void; + toHaveBeenCalledTimes: (expected: number, failMsg?: string) => void; }; export type TestFn = (() => void | Promise) | ((done: (err?: unknown) => void) => void); export interface Test { @@ -15,6 +17,7 @@ export interface Test { } export type TestRunner = { test: Test; + mock: any>(fn: T) => T | any; expect: ( actual?: T, failMsg?: string, diff --git a/app/src/core/utils/test.ts b/app/src/core/utils/test.ts index f5cb0e8..cc3b5c3 100644 --- a/app/src/core/utils/test.ts +++ b/app/src/core/utils/test.ts @@ -1,3 +1,5 @@ +import { $console } from "core"; + type ConsoleSeverity = "log" | "warn" | "error"; const _oldConsoles = { log: console.log, @@ -34,13 +36,14 @@ export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn" severities.forEach((severity) => { console[severity] = () => null; }); - return enableConsoleLog; + $console.setLevel("error"); } export function enableConsoleLog() { Object.entries(_oldConsoles).forEach(([severity, fn]) => { console[severity as ConsoleSeverity] = fn; }); + $console.resetLevel(); } export function tryit(fn: () => void, fallback?: any) { diff --git a/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.spec.ts b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.spec.ts index 207e030..455e6d4 100644 --- a/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.spec.ts +++ b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.spec.ts @@ -4,6 +4,7 @@ import { config } from "dotenv"; // @ts-ignore import { assetsPath, assetsTmpPath } from "../../../../../__test__/helper"; import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite"; +import { bunTestRunner } from "adapter/bun/test"; const dotenvOutput = config({ path: `${import.meta.dir}/.env` }); const { @@ -43,7 +44,7 @@ describe.skipIf(ALL_TESTS)("StorageCloudinaryAdapter", async () => { }); }); - await adapterTestSuite({ test, expect }, adapter, file, { + await adapterTestSuite(bunTestRunner, adapter, file, { // eventual consistency retries: 20, retryTimeout: 1000, diff --git a/app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts b/app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts index a23744b..f0b1b52 100644 --- a/app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts +++ b/app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts @@ -4,6 +4,7 @@ import { StorageS3Adapter } from "./StorageS3Adapter"; import { config } from "dotenv"; import { adapterTestSuite } from "media"; import { assetsPath } from "../../../../../__test__/helper"; +import { bunTestRunner } from "adapter/bun/test"; //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 } = @@ -45,6 +46,6 @@ describe.skipIf(ALL_TESTS)("StorageS3Adapter", async () => { const file = Bun.file(`${assetsPath}/image.png`) as unknown as File; describe.each(versions)("%s", async (_name, adapter) => { - await adapterTestSuite({ test, expect }, adapter, file); + await adapterTestSuite(bunTestRunner, adapter, file); }); }); diff --git a/examples/astro/bknd.config.ts b/examples/astro/bknd.config.ts new file mode 100644 index 0000000..ebe3f2a --- /dev/null +++ b/examples/astro/bknd.config.ts @@ -0,0 +1,65 @@ +import type { AstroBkndConfig } from "bknd/adapter/astro"; +import { registerLocalMediaAdapter } from "bknd/adapter/node"; +import { boolean, em, entity, text } from "bknd/data"; +import { secureRandomString } from "bknd/utils"; + +// since we're running in node, we can register the local media adapter +const local = registerLocalMediaAdapter(); + +// the em() function makes it easy to create an initial schema +const schema = em({ + todos: entity("todos", { + title: text(), + done: boolean(), + }), +}); + +// register your schema to get automatic type completion +type Database = (typeof schema)["DB"]; +declare module "bknd/core" { + interface DB extends Database {} +} + +export default { + // we can use any libsql config, and if omitted, uses in-memory + app: (env) => ({ + connection: { + url: env.DB_URL ?? "file:data.db", + }, + }), + // an initial config is only applied if the database is empty + initialConfig: { + data: schema.toJSON(), + // we're enabling auth ... + auth: { + enabled: true, + jwt: { + issuer: "bknd-astro-example", + secret: secureRandomString(64), + }, + }, + // ... and media + media: { + enabled: true, + adapter: local({ + path: "./public", + }), + }, + }, + options: { + // the seed option is only executed if the database was empty + seed: async (ctx) => { + // create some entries + await ctx.em.mutator("todos").insertMany([ + { title: "Learn bknd", done: true }, + { title: "Build something cool", done: false }, + ]); + + // and create a user + await ctx.app.module.auth.createUser({ + email: "test@bknd.io", + password: "12345678", + }); + }, + }, +} as const satisfies AstroBkndConfig; diff --git a/examples/astro/src/bknd.ts b/examples/astro/src/bknd.ts new file mode 100644 index 0000000..2229784 --- /dev/null +++ b/examples/astro/src/bknd.ts @@ -0,0 +1,23 @@ +import type { AstroGlobal } from "astro"; +import { getApp as getBkndApp } from "bknd/adapter/astro"; +import config from "../bknd.config"; + +export { config }; + +export async function getApp() { + return await getBkndApp(config); +} + +export async function getApi( + astro: AstroGlobal, + opts?: { mode: "static" } | { mode?: "dynamic"; verify?: boolean }, +) { + const app = await getApp(); + if (opts?.mode !== "static" && opts?.verify) { + const api = app.getApi({ headers: astro.request.headers }); + await api.verifyAuth(); + return api; + } + + return app.getApi(); +} diff --git a/examples/astro/src/pages/admin/[...admin].astro b/examples/astro/src/pages/admin/[...admin].astro index de6b15d..d672c47 100644 --- a/examples/astro/src/pages/admin/[...admin].astro +++ b/examples/astro/src/pages/admin/[...admin].astro @@ -2,9 +2,9 @@ import { Admin } from "bknd/ui"; import "bknd/dist/styles.css"; -import { getApi } from "bknd/adapter/astro"; +import { getApi } from "../../bknd"; -const api = await getApi(Astro, { mode: "dynamic" }); +const api = await getApi(Astro, { verify: true }); const user = api.getUser(); export const prerender = false; diff --git a/examples/astro/src/pages/api/[...api].ts b/examples/astro/src/pages/api/[...api].ts index 16472ca..477270d 100644 --- a/examples/astro/src/pages/api/[...api].ts +++ b/examples/astro/src/pages/api/[...api].ts @@ -1,77 +1,6 @@ import type { APIContext } from "astro"; -import { App } from "bknd"; import { serve } from "bknd/adapter/astro"; -import { registerLocalMediaAdapter } from "bknd/adapter/node"; -import { boolean, em, entity, text } from "bknd/data"; -import { secureRandomString } from "bknd/utils"; +import { config } from "../../bknd"; export const prerender = false; - -// since we're running in node, we can register the local media adapter -registerLocalMediaAdapter(); - -// the em() function makes it easy to create an initial schema -const schema = em({ - todos: entity("todos", { - title: text(), - done: boolean(), - }), -}); - -// register your schema to get automatic type completion -type Database = (typeof schema)["DB"]; -declare module "bknd/core" { - interface DB extends Database {} -} - -export const ALL = serve({ - // we can use any libsql config, and if omitted, uses in-memory - connection: { - url: "file:data.db", - }, - // an initial config is only applied if the database is empty - initialConfig: { - data: schema.toJSON(), - // we're enabling auth ... - auth: { - enabled: true, - jwt: { - issuer: "bknd-astro-example", - secret: secureRandomString(64), - }, - }, - // ... and media - media: { - enabled: true, - adapter: { - type: "local", - config: { - path: "./public", - }, - }, - }, - }, - options: { - // the seed option is only executed if the database was empty - seed: async (ctx) => { - await ctx.em.mutator("todos").insertMany([ - { title: "Learn bknd", done: true }, - { title: "Build something cool", done: false }, - ]); - }, - }, - // here we can hook into the app lifecycle events ... - beforeBuild: async (app) => { - app.emgr.onEvent( - App.Events.AppFirstBoot, - async () => { - // ... to create an initial user - await app.module.auth.createUser({ - email: "test@bknd.io", - password: "12345678", - }); - }, - "sync", - ); - }, -}); +export const ALL = serve(config); diff --git a/examples/astro/src/pages/index.astro b/examples/astro/src/pages/index.astro index 854df7f..56ae737 100644 --- a/examples/astro/src/pages/index.astro +++ b/examples/astro/src/pages/index.astro @@ -1,5 +1,5 @@ --- -import { getApi } from "bknd/adapter/astro"; +import { getApi } from "../bknd"; import Card from "../components/Card.astro"; import Layout from "../layouts/Layout.astro"; diff --git a/examples/astro/src/pages/ssr.astro b/examples/astro/src/pages/ssr.astro index ca8f380..027f48f 100644 --- a/examples/astro/src/pages/ssr.astro +++ b/examples/astro/src/pages/ssr.astro @@ -1,8 +1,8 @@ --- -import { getApi } from "bknd/adapter/astro"; +import { getApi } from "../bknd"; import Card from "../components/Card.astro"; import Layout from "../layouts/Layout.astro"; -const api = await getApi(Astro, { mode: "dynamic" }); +const api = await getApi(Astro, { verify: true }); const { data } = await api.data.readMany("todos"); const user = api.getUser(); diff --git a/examples/aws-lambda/index.mjs b/examples/aws-lambda/index.mjs index 9f66545..0f76fd8 100644 --- a/examples/aws-lambda/index.mjs +++ b/examples/aws-lambda/index.mjs @@ -1,6 +1,6 @@ -import { serveLambda } from "bknd/adapter/aws"; +import { serve } from "bknd/adapter/aws"; -export const handler = serveLambda({ +export const handler = serve({ // to get local assets, run `npx bknd copy-assets` // this is automatically done in `deploy.sh` assets: { diff --git a/examples/aws-lambda/package.json b/examples/aws-lambda/package.json index 1f7bc02..0d7fa09 100644 --- a/examples/aws-lambda/package.json +++ b/examples/aws-lambda/package.json @@ -1,21 +1,21 @@ { - "name": "aws-lambda", - "version": "1.0.0", - "main": "index.mjs", - "scripts": { - "test": "esbuild index.mjs --bundle --format=cjs --platform=node --external:fs --outfile=dist/index.js && node test.js", - "deploy": "./deploy.sh", - "clean": "./clean.sh" - }, - "keywords": [], - "author": "", - "license": "ISC", - "description": "", - "dependencies": { - "bknd": "file:../../app/bknd-0.9.0-rc.1-11.tgz" - }, - "devDependencies": { - "esbuild": "^0.25.0", - "dotenv": "^16.4.7" - } + "name": "aws-lambda", + "version": "1.0.0", + "main": "index.mjs", + "scripts": { + "test": "esbuild index.mjs --bundle --format=cjs --platform=node --external:fs --outfile=dist/index.js && node test.js", + "deploy": "./deploy.sh", + "clean": "./clean.sh" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "bknd": "file:../../app" + }, + "devDependencies": { + "esbuild": "^0.25.0", + "dotenv": "^16.4.7" + } } diff --git a/examples/aws-lambda/test.js b/examples/aws-lambda/test.js index a7d586b..4820804 100644 --- a/examples/aws-lambda/test.js +++ b/examples/aws-lambda/test.js @@ -3,11 +3,11 @@ const handler = require("./dist/index.js").handler; const event = { httpMethod: "GET", - path: "/", - //path: "/api/system/config", + //path: "/", + path: "/api/system/config", //path: "/assets/main-B6sEDlfs.js", headers: { - //"Content-Type": "application/json", + "Content-Type": "application/json", "User-Agent": "curl/7.64.1", Accept: "*/*", }, diff --git a/examples/bun/bun.lock b/examples/bun/bun.lock new file mode 100644 index 0000000..a8cad87 --- /dev/null +++ b/examples/bun/bun.lock @@ -0,0 +1,32 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "bun", + "dependencies": { + "bknd": "file:../../app", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5.0.0", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.2.2", "", { "dependencies": { "bun-types": "1.2.2" } }, "sha512-tr74gdku+AEDN5ergNiBnplr7hpDp3V1h7fqI2GcR/rsUaM39jpSeKH0TFibRvU0KwniRx5POgaYnaXbk0hU+w=="], + + "@types/node": ["@types/node@22.13.4", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-ywP2X0DYtX3y08eFVx5fNIw7/uIv8hYUKgXoK8oayJlLnKcRfEYCxWMVE1XagUdVtCJlZT1AU4LXEABW+L1Peg=="], + + "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], + + "bknd": ["/app@file:../../app", { "devDependencies": { "@types/node": "^22.10.0" }, "bin": { "bknd": "dist/cli/index.js" } }], + + "bun-types": ["bun-types@1.2.2", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-RCbMH5elr9gjgDGDhkTTugA21XtJAy/9jkKe/G3WR2q17VPGhcquf9Sir6uay9iW+7P/BV0CAHA1XlHXMAVKHg=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], + } +} diff --git a/examples/bun/index.ts b/examples/bun/index.ts index 3375019..44f9e8f 100644 --- a/examples/bun/index.ts +++ b/examples/bun/index.ts @@ -1,4 +1,3 @@ -// @ts-ignore somehow causes types:build issues on app import { type BunBkndConfig, serve } from "bknd/adapter/bun"; // Actually, all it takes is the following line: @@ -7,8 +6,8 @@ import { type BunBkndConfig, serve } from "bknd/adapter/bun"; // this is optional, if omitted, it uses an in-memory database const config: BunBkndConfig = { connection: { - url: "file:data.db" - } + url: "file:data.db", + }, }; serve(config); diff --git a/examples/bun/minimal.ts b/examples/bun/minimal.ts deleted file mode 100644 index 65175f9..0000000 --- a/examples/bun/minimal.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createApp } from "bknd"; - -const app = createApp(); -await app.build(); - -export default app; diff --git a/examples/cloudflare-worker/src/index.ts b/examples/cloudflare-worker/src/index.ts index 5e489d1..b9b9e76 100644 --- a/examples/cloudflare-worker/src/index.ts +++ b/examples/cloudflare-worker/src/index.ts @@ -6,5 +6,5 @@ export default serve({ mode: "warm", onBuilt: async (app) => { app.modules.server.get("/custom", (c) => c.json({ hello: "world" })); - } + }, }); diff --git a/examples/nextjs/bknd.config.ts b/examples/nextjs/bknd.config.ts new file mode 100644 index 0000000..68d15b5 --- /dev/null +++ b/examples/nextjs/bknd.config.ts @@ -0,0 +1,74 @@ +import type { NextjsBkndConfig } from "bknd/adapter/nextjs"; +import { boolean, em, entity, text } from "bknd/data"; +import { registerLocalMediaAdapter } from "bknd/adapter/node"; +import { secureRandomString } from "bknd/utils"; + +// The local media adapter works well in development, and server based +// deployments. However, on vercel or any other serverless deployments, +// you shouldn't use a filesystem based media adapter. +// +// Additionally, if you run the bknd api on the "edge" runtime, +// this would not work as well. +// +// For production, it is recommended to uncomment the line below. +const local = registerLocalMediaAdapter(); + +const schema = em({ + todos: entity("todos", { + title: text(), + done: boolean(), + }), +}); + +// register your schema to get automatic type completion +type Database = (typeof schema)["DB"]; +declare module "bknd/core" { + interface DB extends Database {} +} + +export default { + app: (env) => ({ + connection: { + url: env.DB_URL ?? "file:data.db", + }, + }), + // an initial config is only applied if the database is empty + initialConfig: { + data: schema.toJSON(), + // we're enabling auth ... + auth: { + enabled: true, + jwt: { + issuer: "bknd-nextjs-example", + secret: secureRandomString(64), + }, + cookie: { + pathSuccess: "/ssr", + pathLoggedOut: "/ssr", + }, + }, + // ... and media + media: { + enabled: true, + adapter: local({ + path: "./public", + }), + }, + }, + options: { + // the seed option is only executed if the database was empty + seed: async (ctx) => { + // create some entries + await ctx.em.mutator("todos").insertMany([ + { title: "Learn bknd", done: true }, + { title: "Build something cool", done: false }, + ]); + + // and create a user + await ctx.app.module.auth.createUser({ + email: "test@bknd.io", + password: "12345678", + }); + }, + }, +} as const satisfies NextjsBkndConfig; diff --git a/examples/nextjs/src/bknd.ts b/examples/nextjs/src/bknd.ts index 24061f7..d38baa4 100644 --- a/examples/nextjs/src/bknd.ts +++ b/examples/nextjs/src/bknd.ts @@ -1,90 +1,11 @@ -import { type NextjsBkndConfig, getApp as getBkndApp } from "bknd/adapter/nextjs"; -import { App } from "bknd"; -import { boolean, em, entity, text } from "bknd/data"; -import { registerLocalMediaAdapter } from "bknd/adapter/node"; -import { secureRandomString } from "bknd/utils"; +import { getApp as getBkndApp } from "bknd/adapter/nextjs"; import { headers } from "next/headers"; +import config from "../bknd.config"; -// The local media adapter works well in development, and server based -// deployments. However, on vercel or any other serverless deployments, -// you shouldn't use a filesystem based media adapter. -// -// Additionally, if you run the bknd api on the "edge" runtime, -// this would not work as well. -// -// For production, it is recommended to uncomment the line below. -registerLocalMediaAdapter(); - -const schema = em({ - todos: entity("todos", { - title: text(), - done: boolean(), - }), -}); - -// register your schema to get automatic type completion -type Database = (typeof schema)["DB"]; -declare module "bknd/core" { - interface DB extends Database {} -} - -export const config = { - connection: { - url: "file:data.db", - }, - // an initial config is only applied if the database is empty - initialConfig: { - data: schema.toJSON(), - // we're enabling auth ... - auth: { - enabled: true, - jwt: { - issuer: "bknd-nextjs-example", - secret: secureRandomString(64), - }, - cookie: { - pathSuccess: "/ssr", - pathLoggedOut: "/ssr", - }, - }, - // ... and media - media: { - enabled: true, - adapter: { - type: "local", - config: { - path: "./public", - }, - }, - }, - }, - options: { - // the seed option is only executed if the database was empty - seed: async (ctx) => { - await ctx.em.mutator("todos").insertMany([ - { title: "Learn bknd", done: true }, - { title: "Build something cool", done: false }, - ]); - }, - }, - // here we can hook into the app lifecycle events ... - beforeBuild: async (app) => { - app.emgr.onEvent( - App.Events.AppFirstBoot, - async () => { - // ... to create an initial user - await app.module.auth.createUser({ - email: "test@bknd.io", - password: "12345678", - }); - }, - "sync", - ); - }, -} as const satisfies NextjsBkndConfig; +export { config }; export async function getApp() { - return await getBkndApp(config); + return await getBkndApp(config, process.env); } export async function getApi(opts?: { verify?: boolean }) { diff --git a/examples/react-router/app/bknd.ts b/examples/react-router/app/bknd.ts index 6819cad..0676bb1 100644 --- a/examples/react-router/app/bknd.ts +++ b/examples/react-router/app/bknd.ts @@ -1,79 +1,8 @@ -import { App } from "bknd"; -import { registerLocalMediaAdapter } from "bknd/adapter/node"; -import { type ReactRouterBkndConfig, getApp as getBkndApp } from "bknd/adapter/react-router"; -import { boolean, em, entity, text } from "bknd/data"; -import { secureRandomString } from "bknd/utils"; +import { getApp as getBkndApp } from "bknd/adapter/react-router"; +import config from "../bknd.config"; -// since we're running in node, we can register the local media adapter -registerLocalMediaAdapter(); - -const schema = em({ - todos: entity("todos", { - title: text(), - done: boolean(), - }), -}); - -// register your schema to get automatic type completion -type Database = (typeof schema)["DB"]; -declare module "bknd/core" { - interface DB extends Database {} -} - -const config = { - // we can use any libsql config, and if omitted, uses in-memory - connection: { - url: "file:test.db", - }, - // an initial config is only applied if the database is empty - initialConfig: { - data: schema.toJSON(), - // we're enabling auth ... - auth: { - enabled: true, - jwt: { - issuer: "bknd-remix-example", - secret: secureRandomString(64), - }, - }, - // ... and media - media: { - enabled: true, - adapter: { - type: "local", - config: { - path: "./public", - }, - }, - }, - }, - options: { - // the seed option is only executed if the database was empty - seed: async (ctx) => { - await ctx.em.mutator("todos").insertMany([ - { title: "Learn bknd", done: true }, - { title: "Build something cool", done: false }, - ]); - }, - }, - // here we can hook into the app lifecycle events ... - beforeBuild: async (app) => { - app.emgr.onEvent( - App.Events.AppFirstBoot, - async () => { - // ... to create an initial user - await app.module.auth.createUser({ - email: "test@bknd.io", - password: "12345678", - }); - }, - "sync", - ); - }, -} as const satisfies ReactRouterBkndConfig; - -export async function getApp(args?: { request: Request }) { - return await getBkndApp(config, args); +export async function getApp() { + return await getBkndApp(config, process.env as any); } export async function getApi(args?: { request: Request }, opts?: { verify?: boolean }) { diff --git a/examples/react-router/app/routes/api.$.ts b/examples/react-router/app/routes/api.$.ts index d01a6de..ee80848 100644 --- a/examples/react-router/app/routes/api.$.ts +++ b/examples/react-router/app/routes/api.$.ts @@ -1,7 +1,7 @@ import { getApp } from "~/bknd"; const handler = async (args: { request: Request }) => { - const app = await getApp(args); + const app = await getApp(); return app.fetch(args.request); }; diff --git a/examples/react-router/bknd.config.ts b/examples/react-router/bknd.config.ts new file mode 100644 index 0000000..4cfa714 --- /dev/null +++ b/examples/react-router/bknd.config.ts @@ -0,0 +1,64 @@ +import { registerLocalMediaAdapter } from "bknd/adapter/node"; +import type { ReactRouterBkndConfig } from "bknd/adapter/react-router"; +import { boolean, em, entity, text } from "bknd/data"; +import { secureRandomString } from "bknd/utils"; + +// since we're running in node, we can register the local media adapter +const local = registerLocalMediaAdapter(); + +const schema = em({ + todos: entity("todos", { + title: text(), + done: boolean(), + }), +}); + +// register your schema to get automatic type completion +type Database = (typeof schema)["DB"]; +declare module "bknd/core" { + interface DB extends Database {} +} + +export default { + // we can use any libsql config, and if omitted, uses in-memory + app: (env) => ({ + connection: { + url: env?.DB_URL ?? "file:data.db", + }, + }), + // an initial config is only applied if the database is empty + initialConfig: { + data: schema.toJSON(), + // we're enabling auth ... + auth: { + enabled: true, + jwt: { + issuer: "bknd-remix-example", + secret: secureRandomString(64), + }, + }, + // ... and media + media: { + enabled: true, + adapter: local({ + path: "./public", + }), + }, + }, + options: { + // the seed option is only executed if the database was empty + seed: async (ctx) => { + // create some entries + await ctx.em.mutator("todos").insertMany([ + { title: "Learn bknd", done: true }, + { title: "Build something cool", done: false }, + ]); + + // and create a user + await ctx.app.module.auth.createUser({ + email: "test@bknd.io", + password: "12345678", + }); + }, + }, +} as const satisfies ReactRouterBkndConfig<{ DB_URL?: string }>; From 2f067451b45a1d536ad7a631093a893e296ba2b0 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 1 Apr 2025 05:58:13 -0500 Subject: [PATCH 11/27] chore: update dependencies and enhance TypeScript configuration for Astro example (#128) --- examples/astro/package.json | 10 +++++----- examples/astro/tsconfig.json | 8 ++++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/examples/astro/package.json b/examples/astro/package.json index 7437959..f205b50 100644 --- a/examples/astro/package.json +++ b/examples/astro/package.json @@ -11,13 +11,13 @@ }, "dependencies": { "@astrojs/check": "^0.9.4", - "@astrojs/react": "^3.6.3", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", + "@astrojs/react": "^4.2.2", + "@types/react": "^19.0.12", + "@types/react-dom": "^19.0.4", "astro": "^4.16.16", "bknd": "file:../../app", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.1.0", + "react-dom": "^19.1.0", "typescript": "^5.7.2" } } diff --git a/examples/astro/tsconfig.json b/examples/astro/tsconfig.json index bcbf8b5..032ad64 100644 --- a/examples/astro/tsconfig.json +++ b/examples/astro/tsconfig.json @@ -1,3 +1,7 @@ { - "extends": "astro/tsconfigs/strict" -} + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react" + } +} \ No newline at end of file From 9134d121cdd6da8eeb6e6904df3f2e6917557cfc Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 1 Apr 2025 13:24:32 +0200 Subject: [PATCH 12/27] keep extension from file when generating random name (#127) * keep extension from file when generating random name * added test for random name generation --- app/__test__/media/mime-types.spec.ts | 5 +++++ app/src/media/utils/index.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/__test__/media/mime-types.spec.ts b/app/__test__/media/mime-types.spec.ts index 37f29a8..dd13f7c 100644 --- a/app/__test__/media/mime-types.spec.ts +++ b/app/__test__/media/mime-types.spec.ts @@ -96,5 +96,10 @@ describe("media/mime-types", () => { `getRandomizedFilename(): ${filename} should end with ${ext}`, ).toBe(ext); } + + // make sure it keeps the extension, even if the file has a different type + const file = new File([""], "image.jpg", { type: "text/plain" }); + const [, ext] = getRandomizedFilename(file).split("."); + expect(ext).toBe("jpg"); }); }); diff --git a/app/src/media/utils/index.ts b/app/src/media/utils/index.ts index 575fd1a..d9867db 100644 --- a/app/src/media/utils/index.ts +++ b/app/src/media/utils/index.ts @@ -19,7 +19,7 @@ export function getRandomizedFilename(file: File | string, length = 16): string } let ext = getExtensionFromName(filename); - if (isFile(file) && file.type) { + if (!ext && isFile(file) && file.type) { const _ext = extension(file.type); if (_ext.length > 0) ext = _ext; } From 44b3f720051f49bc2e08717abeb5116b69d2629d Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 1 Apr 2025 13:37:11 +0200 Subject: [PATCH 13/27] added media overlay preview fallback --- app/package.json | 2 +- app/src/ui/modals/media/MediaInfoModal.tsx | 16 ++++++++++++++-- app/vite.dev.ts | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/app/package.json b/app/package.json index 44c8dbd..0ddd590 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.10.3-rc.1", + "version": "0.11.0-rc.1", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "homepage": "https://bknd.io", "repository": { diff --git a/app/src/ui/modals/media/MediaInfoModal.tsx b/app/src/ui/modals/media/MediaInfoModal.tsx index 744ea60..43f0f1c 100644 --- a/app/src/ui/modals/media/MediaInfoModal.tsx +++ b/app/src/ui/modals/media/MediaInfoModal.tsx @@ -44,8 +44,7 @@ export function MediaInfoModal({ return (
- {/* @ts-ignore */} - +
@@ -157,6 +156,19 @@ const Item = ({ ); }; +const FilePreview = ({ file }: { file: FileState }) => { + if (file.type.startsWith("image/") || file.type.startsWith("video/")) { + // @ts-ignore + return ; + } + + return ( +
+ No Preview Available +
+ ); +}; + MediaInfoModal.defaultTitle = undefined; MediaInfoModal.modalProps = { withCloseButton: false, diff --git a/app/vite.dev.ts b/app/vite.dev.ts index 6705b2b..3b8a7aa 100644 --- a/app/vite.dev.ts +++ b/app/vite.dev.ts @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises"; import { serveStatic } from "@hono/node-server/serve-static"; import { showRoutes } from "hono/dev"; import { App, registries } from "./src"; -import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAdapter"; +import { StorageLocalAdapter } from "./src/adapter/node"; import { EntityManager, LibsqlConnection } from "data"; import { __bknd } from "modules/ModuleManager"; From e4608b7df74fb12d1e58092ef84c538ed4a2185d Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 1 Apr 2025 13:49:58 +0200 Subject: [PATCH 14/27] cosmetics: fixed admin skeleton, use password field on auth, use $console in auth middleware --- app/__test__/api/DataApi.spec.ts | 12 +++++++++++- app/package.json | 4 ++-- app/src/auth/middlewares.ts | 6 +++--- app/src/ui/Admin.tsx | 8 ++++---- app/src/ui/elements/auth/AuthForm.tsx | 4 ++-- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/app/__test__/api/DataApi.spec.ts b/app/__test__/api/DataApi.spec.ts index c6bb3df..51786ca 100644 --- a/app/__test__/api/DataApi.spec.ts +++ b/app/__test__/api/DataApi.spec.ts @@ -5,7 +5,8 @@ import { DataApi } from "../../src/data/api/DataApi"; import { DataController } from "../../src/data/api/DataController"; import { dataConfigSchema } from "../../src/data/data-schema"; import * as proto from "../../src/data/prototype"; -import { disableConsoleLog, enableConsoleLog, schemaToEm } from "../helper"; +import { schemaToEm } from "../helper"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; beforeAll(disableConsoleLog); afterAll(enableConsoleLog); @@ -64,6 +65,15 @@ describe("DataApi", () => { const res = await req; expect(res.data).toEqual(payload as any); } + + { + // make sure sort is working + const req = await api.readMany("posts", { + select: ["title"], + sort: "-id", + }); + expect(req.data).toEqual(payload.reverse() as any); + } }); it("updates many", async () => { diff --git a/app/package.json b/app/package.json index 0ddd590..c3d2f55 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.11.0-rc.1", + "version": "0.11.0-rc.2", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "homepage": "https://bknd.io", "repository": { @@ -32,7 +32,7 @@ "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias", "updater": "bun x npm-check-updates -ui", "cli": "LOCAL=1 bun src/cli/index.ts", - "prepublishOnly": "bun run types && bun run test && bun run build:all && cp ../README.md ./", + "prepublishOnly": "bun run types && bun run test && bun run test:node && bun run build:all && cp ../README.md ./", "postpublish": "rm -f README.md" }, "license": "FSL-1.1-MIT", diff --git a/app/src/auth/middlewares.ts b/app/src/auth/middlewares.ts index 83e64ce..dcd0994 100644 --- a/app/src/auth/middlewares.ts +++ b/app/src/auth/middlewares.ts @@ -1,4 +1,4 @@ -import type { Permission } from "core"; +import { $console, type Permission } from "core"; import { patternMatch } from "core/utils"; import type { Context } from "hono"; import { createMiddleware } from "hono/factory"; @@ -49,7 +49,7 @@ export const auth = (options?: { // make sure to only register once if (authCtx.registered) { skipped = true; - console.warn(`auth middleware already registered for ${getPath(c)}`); + $console.warn(`auth middleware already registered for ${getPath(c)}`); } else { authCtx.registered = true; @@ -93,7 +93,7 @@ export const permission = ( if (app?.module.auth.enabled) { throw new Error(msg); } else { - console.warn(msg); + $console.warn(msg); } } else if (!authCtx.skip) { const guard = app.modules.ctx().guard; diff --git a/app/src/ui/Admin.tsx b/app/src/ui/Admin.tsx index 0312177..59bf859 100644 --- a/app/src/ui/Admin.tsx +++ b/app/src/ui/Admin.tsx @@ -66,15 +66,15 @@ const Skeleton = ({ theme }: { theme?: any }) => {
-