diff --git a/app/__test__/auth/Authenticator.spec.ts b/app/__test__/auth/Authenticator.spec.ts index 93af826..620ab03 100644 --- a/app/__test__/auth/Authenticator.spec.ts +++ b/app/__test__/auth/Authenticator.spec.ts @@ -1,8 +1,9 @@ -/*import { describe, expect, test } from "bun:test"; -import { decodeJwt, jwtVerify } from "jose"; -import { Authenticator, type User, type UserPool } from "../authenticate/Authenticator"; -import { PasswordStrategy } from "../authenticate/strategies/PasswordStrategy"; -import * as hash from "../utils/hash";*/ +import { describe, expect, test } from "bun:test"; +import { Authenticator, type User, type UserPool } from "../../src/auth"; +import { cookieConfig } from "../../src/auth/authenticate/Authenticator"; +import { PasswordStrategy } from "../../src/auth/authenticate/strategies/PasswordStrategy"; +import * as hash from "../../src/auth/utils/hash"; +import { Default, parse } from "../../src/core/utils"; /*class MemoryUserPool implements UserPool { constructor(private users: User[] = []) {} @@ -17,10 +18,14 @@ import * as hash from "../utils/hash";*/ this.users.push(newUser); return newUser; } -} +}*/ describe("Authenticator", async () => { - const userpool = new MemoryUserPool([ + test("cookie options", async () => { + console.log("parsed", parse(cookieConfig, undefined)); + console.log(Default(cookieConfig, {})); + }); + /*const userpool = new MemoryUserPool([ { id: 1, email: "d", username: "test", password: await hash.sha256("test") }, ]); @@ -37,5 +42,5 @@ describe("Authenticator", async () => { const { iat, ...decoded } = decodeJwt(token); expect(decoded).toEqual({ id: 1, email: "d", username: "test" }); expect(await auth.verify(token)).toBe(true); - }); -});*/ + });*/ +}); diff --git a/app/__test__/core/object/SchemaObject.spec.ts b/app/__test__/core/object/SchemaObject.spec.ts index f0d8434..01db1c7 100644 --- a/app/__test__/core/object/SchemaObject.spec.ts +++ b/app/__test__/core/object/SchemaObject.spec.ts @@ -65,11 +65,11 @@ describe("SchemaObject", async () => { expect(m.get()).toEqual({ methods: ["GET", "PATCH"] }); // array values are fully overwritten, whether accessed by index ... - m.patch("methods[0]", "POST"); - expect(m.get()).toEqual({ methods: ["POST"] }); + await m.patch("methods[0]", "POST"); + expect(m.get().methods[0]).toEqual("POST"); // or by path! - m.patch("methods", ["GET", "DELETE"]); + await m.patch("methods", ["GET", "DELETE"]); expect(m.get()).toEqual({ methods: ["GET", "DELETE"] }); }); @@ -93,15 +93,15 @@ describe("SchemaObject", async () => { expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } }); // expect no change, because the default then applies - m.remove("s.a"); + await m.remove("s.a"); expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } }); // adding another path, and then deleting it - m.patch("s.c", "d"); + await m.patch("s.c", "d"); expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" }, c: "d" } } as any); // now it should be removed without applying again - m.remove("s.c"); + await m.remove("s.c"); expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } }); }); @@ -113,14 +113,14 @@ describe("SchemaObject", async () => { ); expect(m.get()).toEqual({ methods: ["GET", "PATCH"] }); - m.set({ methods: ["GET", "POST"] }); + await m.set({ methods: ["GET", "POST"] }); expect(m.get()).toEqual({ methods: ["GET", "POST"] }); // wrong type expect(() => m.set({ methods: [1] as any })).toThrow(); }); - test("listener", async () => { + test("listener: onUpdate", async () => { let called = false; let result: any; const m = new SchemaObject( @@ -142,6 +142,30 @@ describe("SchemaObject", async () => { expect(result).toEqual({ methods: ["GET", "POST"] }); }); + test("listener: onBeforeUpdate", async () => { + let called = false; + const m = new SchemaObject( + Type.Object({ + methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] }) + }), + undefined, + { + onBeforeUpdate: async (from, to) => { + await new Promise((r) => setTimeout(r, 10)); + called = true; + to.methods.push("OPTIONS"); + return to; + } + } + ); + + const result = await m.set({ methods: ["GET", "POST"] }); + expect(called).toBe(true); + expect(result).toEqual({ methods: ["GET", "POST", "OPTIONS"] }); + const [, result2] = await m.patch("methods", ["GET", "POST"]); + expect(result2).toEqual({ methods: ["GET", "POST", "OPTIONS"] }); + }); + test("throwIfRestricted", async () => { const m = new SchemaObject(Type.Object({}), undefined, { restrictPaths: ["a.b"] @@ -175,9 +199,9 @@ describe("SchemaObject", async () => { } ); - expect(() => m.patch("s.b.c", "e")).toThrow(); - expect(m.bypass().patch("s.b.c", "e")).toBeDefined(); - expect(() => m.patch("s.b.c", "f")).toThrow(); + expect(m.patch("s.b.c", "e")).rejects.toThrow(); + expect(m.bypass().patch("s.b.c", "e")).resolves.toBeDefined(); + expect(m.patch("s.b.c", "f")).rejects.toThrow(); expect(m.get()).toEqual({ s: { a: "b", b: { c: "e" } } }); }); @@ -222,7 +246,7 @@ describe("SchemaObject", async () => { overwritePaths: [/^entities\..*\.fields\..*\.config/] }); - m.patch("entities.some.fields.a", { type: "string", config: { another: "one" } }); + await m.patch("entities.some.fields.a", { type: "string", config: { another: "one" } }); expect(m.get()).toEqual({ entities: { @@ -251,7 +275,7 @@ describe("SchemaObject", async () => { overwritePaths: [/^entities\..*\.fields\..*\.config\.html_config$/] }); - m.patch("entities.test", { + await m.patch("entities.test", { fields: { content: { type: "text" @@ -296,7 +320,7 @@ describe("SchemaObject", async () => { expect(m.patch("desc", "entities.users.config.sort_dir")).rejects.toThrow(); - m.patch("entities.test", { + await m.patch("entities.test", { fields: { content: { type: "text" @@ -304,7 +328,7 @@ describe("SchemaObject", async () => { } }); - m.patch("entities.users.config", { + await m.patch("entities.users.config", { sort_dir: "desc" }); diff --git a/app/__test__/modules/AppAuth.spec.ts b/app/__test__/modules/AppAuth.spec.ts index 7861fc6..be1c7e1 100644 --- a/app/__test__/modules/AppAuth.spec.ts +++ b/app/__test__/modules/AppAuth.spec.ts @@ -19,10 +19,23 @@ describe("AppAuth", () => { await auth.build(); const config = auth.toJSON(); - expect(config.jwt.secret).toBeUndefined(); + expect(config.jwt).toBeUndefined(); expect(config.strategies.password.config).toBeUndefined(); }); + test("enabling auth: generate secret", async () => { + const auth = new AppAuth(undefined, ctx); + await auth.build(); + + const oldConfig = auth.toJSON(true); + //console.log(oldConfig); + await auth.schema().patch("enabled", true); + await auth.build(); + const newConfig = auth.toJSON(true); + //console.log(newConfig); + expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret); + }); + test("creates user on register", async () => { const auth = new AppAuth( { diff --git a/app/bknd.config.js b/app/bknd.config.js.ignore similarity index 69% rename from app/bknd.config.js rename to app/bknd.config.js.ignore index 6b4b0e1..5f279d3 100644 --- a/app/bknd.config.js +++ b/app/bknd.config.js.ignore @@ -5,7 +5,8 @@ export default { connection: { type: "libsql", config: { - url: "http://localhost:8080" + //url: "http://localhost:8080" + url: ":memory:" } } } diff --git a/app/build-cf.ts b/app/build-cf.ts deleted file mode 100644 index 3015fbe..0000000 --- a/app/build-cf.ts +++ /dev/null @@ -1,42 +0,0 @@ -import process from "node:process"; -import { $ } from "bun"; -import * as esbuild from "esbuild"; -import type { BuildOptions } from "esbuild"; - -const isDev = process.env.NODE_ENV !== "production"; - -const metafile = true; -const sourcemap = false; - -const config: BuildOptions = { - entryPoints: ["worker.ts"], - bundle: true, - format: "esm", - external: ["__STATIC_CONTENT_MANIFEST", "@xyflow/react"], - platform: "browser", - conditions: ["worker", "browser"], - target: "es2022", - sourcemap, - metafile, - minify: !isDev, - loader: { - ".html": "copy" - }, - outfile: "dist/worker.js" -}; - -const dist = config.outfile!.split("/")[0]; -if (!isDev) { - await $`rm -rf ${dist}`; -} - -const result = await esbuild.build(config); - -if (result.metafile) { - console.log("writing metafile to", `${dist}/meta.json`); - await Bun.write(`${dist}/meta.json`, JSON.stringify(result.metafile!)); -} - -if (!isDev) { - await $`gzip ${dist}/worker.js -c > ${dist}/worker.js.gz`; -} diff --git a/app/build.esbuild.ts b/app/build.esbuild.ts new file mode 100644 index 0000000..6cb1e13 --- /dev/null +++ b/app/build.esbuild.ts @@ -0,0 +1,257 @@ +import { $, type Subprocess } from "bun"; +import * as esbuild from "esbuild"; +import postcss from "esbuild-postcss"; +import { entryOutputMeta } from "./internal/esbuild.entry-output-meta.plugin"; +import { guessMimeType } from "./src/media/storage/mime-types"; + +const args = process.argv.slice(2); +const watch = args.includes("--watch"); +const minify = args.includes("--minify"); +const types = args.includes("--types"); +const sourcemap = args.includes("--sourcemap"); + +type BuildOptions = esbuild.BuildOptions & { name: string }; + +const baseOptions: Partial> & { plugins?: any[] } = { + minify, + sourcemap, + metafile: true, + format: "esm", + drop: ["console", "debugger"], + loader: { + ".svg": "dataurl" + }, + define: { + __isDev: "0" + } +}; + +// @ts-ignore +type BuildFn = (format?: "esm" | "cjs") => BuildOptions; + +// build BE +const builds: Record = { + backend: (format = "esm") => ({ + ...baseOptions, + name: `backend ${format}`, + entryPoints: [ + "src/index.ts", + "src/data/index.ts", + "src/core/index.ts", + "src/core/utils/index.ts", + "src/ui/index.ts", + "src/ui/main.css" + ], + outdir: "dist", + outExtension: { ".js": format === "esm" ? ".js" : ".cjs" }, + platform: "browser", + splitting: false, + bundle: true, + plugins: [postcss()], + //target: "es2022", + format + }), + /*components: (format = "esm") => ({ + ...baseOptions, + name: `components ${format}`, + entryPoints: ["src/ui/index.ts", "src/ui/main.css"], + outdir: "dist/ui", + outExtension: { ".js": format === "esm" ? ".js" : ".cjs" }, + format, + platform: "browser", + splitting: false, + //target: "es2022", + bundle: true, + //external: ["react", "react-dom", "@tanstack/react-query-devtools"], + plugins: [postcss()], + loader: { + ".svg": "dataurl", + ".js": "jsx" + } + }),*/ + static: (format = "esm") => ({ + ...baseOptions, + name: `static ${format}`, + entryPoints: ["src/ui/main.tsx", "src/ui/main.css"], + entryNames: "[dir]/[name]-[hash]", + outdir: "dist/static", + outExtension: { ".js": format === "esm" ? ".js" : ".cjs" }, + platform: "browser", + bundle: true, + splitting: true, + inject: ["src/ui/inject.js"], + target: "es2022", + format, + loader: { + ".svg": "dataurl", + ".js": "jsx" + }, + define: { + __isDev: "0", + "process.env.NODE_ENV": '"production"' + }, + chunkNames: "chunks/[name]-[hash]", + plugins: [ + postcss(), + entryOutputMeta(async (info) => { + const manifest: Record = {}; + const toAsset = (output: string) => { + const name = output.split("/").pop()!; + return { + name, + path: output, + mime: guessMimeType(name) + }; + }; + for (const { output, meta } of info) { + manifest[meta.entryPoint as string] = toAsset(output); + if (meta.cssBundle) { + manifest["src/ui/main.css"] = toAsset(meta.cssBundle); + } + } + + const manifest_file = "dist/static/manifest.json"; + await Bun.write(manifest_file, JSON.stringify(manifest, null, 2)); + console.log(`Manifest written to ${manifest_file}`, manifest); + }) + ] + }) +}; + +function adapter(adapter: string, overrides: Partial = {}): BuildOptions { + return { + ...baseOptions, + name: `adapter ${adapter} ${overrides?.format === "cjs" ? "cjs" : "esm"}`, + entryPoints: [`src/adapter/${adapter}`], + platform: "neutral", + outfile: `dist/adapter/${adapter}/index.${overrides?.format === "cjs" ? "cjs" : "js"}`, + external: [ + "cloudflare:workers", + "@hono*", + "hono*", + "bknd*", + "*.html", + "node*", + "react*", + "next*", + "libsql", + "@libsql*" + ], + splitting: false, + treeShaking: true, + bundle: true, + ...overrides + }; +} +const adapters = [ + adapter("vite", { platform: "node" }), + adapter("cloudflare"), + adapter("nextjs", { platform: "node", format: "esm" }), + adapter("nextjs", { platform: "node", format: "cjs" }), + adapter("remix", { format: "esm" }), + adapter("remix", { format: "cjs" }), + adapter("bun"), + adapter("node", { platform: "node", format: "esm" }), + adapter("node", { platform: "node", format: "cjs" }) +]; + +const collect = [ + builds.static(), + builds.backend(), + //builds.components(), + builds.backend("cjs"), + //builds.components("cjs"), + ...adapters +]; + +if (watch) { + const _state: { + timeout: Timer | undefined; + cleanup: Subprocess | undefined; + building: Subprocess | undefined; + } = { + timeout: undefined, + cleanup: undefined, + building: undefined + }; + + async function rebuildTypes() { + if (!types) return; + if (_state.timeout) { + clearTimeout(_state.timeout); + if (_state.cleanup) _state.cleanup.kill(); + if (_state.building) _state.building.kill(); + } + _state.timeout = setTimeout(async () => { + _state.cleanup = Bun.spawn(["bun", "clean:types"], { + onExit: () => { + _state.cleanup = undefined; + _state.building = Bun.spawn(["bun", "build:types"], { + onExit: () => { + _state.building = undefined; + console.log("Types rebuilt"); + } + }); + } + }); + }, 1000); + } + + for (const { name, ...build } of collect) { + const ctx = await esbuild.context({ + ...build, + plugins: [ + ...(build.plugins ?? []), + { + name: "rebuild-notify", + setup(build) { + build.onEnd((result) => { + console.log(`rebuilt ${name} with ${result.errors.length} errors`); + rebuildTypes(); + }); + } + } + ] + }); + ctx.watch(); + } +} else { + await $`rm -rf dist`; + + async function _build() { + let i = 0; + const count = collect.length; + for await (const { name, ...build } of collect) { + await esbuild.build({ + ...build, + plugins: [ + ...(build.plugins || []), + { + name: "progress", + setup(build) { + i++; + build.onEnd((result) => { + const errors = result.errors.length; + const from = String(i).padStart(String(count).length); + console.log(`[${from}/${count}] built ${name} with ${errors} errors`); + }); + } + } + ] + }); + } + + console.log("All builds complete"); + } + + async function _buildtypes() { + if (!types) return; + Bun.spawn(["bun", "build:types"], { + onExit: () => { + console.log("Types rebuilt"); + } + }); + } + + await Promise.all([_build(), _buildtypes()]); +} diff --git a/app/build.ts b/app/build.ts new file mode 100644 index 0000000..ab66688 --- /dev/null +++ b/app/build.ts @@ -0,0 +1,181 @@ +import { $ } from "bun"; +import * as esbuild from "esbuild"; +import postcss from "esbuild-postcss"; +import * as tsup from "tsup"; +import { guessMimeType } from "./src/media/storage/mime-types"; + +const args = process.argv.slice(2); +const watch = args.includes("--watch"); +const minify = args.includes("--minify"); +const types = args.includes("--types"); +const sourcemap = args.includes("--sourcemap"); + +await $`rm -rf dist`; +if (types) { + Bun.spawn(["bun", "build:types"], { + onExit: () => { + console.log("Types built"); + } + }); +} + +/** + * Build static assets + * Using esbuild because tsup doesn't include "react" + */ +const result = await esbuild.build({ + minify, + sourcemap, + entryPoints: ["src/ui/main.tsx"], + entryNames: "[dir]/[name]-[hash]", + outdir: "dist/static", + platform: "browser", + bundle: true, + splitting: true, + metafile: true, + drop: ["console", "debugger"], + inject: ["src/ui/inject.js"], + target: "es2022", + format: "esm", + plugins: [postcss()], + loader: { + ".svg": "dataurl", + ".js": "jsx" + }, + define: { + __isDev: "0", + "process.env.NODE_ENV": '"production"' + }, + chunkNames: "chunks/[name]-[hash]" +}); + +// Write manifest +{ + const manifest: Record = {}; + const toAsset = (output: string) => { + const name = output.split("/").pop()!; + return { + name, + path: output, + mime: guessMimeType(name) + }; + }; + + const info = Object.entries(result.metafile.outputs) + .filter(([, meta]) => { + return meta.entryPoint && meta.entryPoint === "src/ui/main.tsx"; + }) + .map(([output, meta]) => ({ output, meta })); + + for (const { output, meta } of info) { + manifest[meta.entryPoint as string] = toAsset(output); + if (meta.cssBundle) { + manifest["src/ui/main.css"] = toAsset(meta.cssBundle); + } + } + + const manifest_file = "dist/static/manifest.json"; + await Bun.write(manifest_file, JSON.stringify(manifest, null, 2)); + console.log(`Manifest written to ${manifest_file}`, manifest); +} + +/** + * Building backend and general API + */ +await tsup.build({ + minify, + sourcemap, + watch, + entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"], + outDir: "dist", + external: ["bun:test"], + metafile: true, + platform: "browser", + format: ["esm", "cjs"], + splitting: false, + loader: { + ".svg": "dataurl" + } +}); + +/** + * Building UI for direct imports + */ +await tsup.build({ + minify, + sourcemap, + watch, + entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css"], + outDir: "dist/ui", + external: ["bun:test"], + metafile: true, + platform: "browser", + format: ["esm", "cjs"], + splitting: true, + loader: { + ".svg": "dataurl" + }, + onSuccess: async () => { + console.log("--- ui built"); + }, + esbuildOptions: (options) => { + options.chunkNames = "chunks/[name]-[hash]"; + } +}); + +/** + * Building adapters + */ +function baseConfig(adapter: string): tsup.Options { + return { + minify, + sourcemap, + watch, + entry: [`src/adapter/${adapter}`], + format: ["esm"], + platform: "neutral", + outDir: `dist/adapter/${adapter}`, + define: { + __isDev: "0" + }, + external: [ + /^cloudflare*/, + /^@?(hono|libsql).*?/, + /^(bknd|react|next|node).*?/, + /.*\.(html)$/ + ], + metafile: true, + splitting: false, + treeshake: true + }; +} + +await tsup.build({ + ...baseConfig("vite"), + platform: "node" +}); + +await tsup.build({ + ...baseConfig("cloudflare") +}); + +await tsup.build({ + ...baseConfig("nextjs"), + format: ["esm", "cjs"], + platform: "node" +}); + +await tsup.build({ + ...baseConfig("remix"), + format: ["esm", "cjs"] +}); + +await tsup.build({ + ...baseConfig("bun") +}); + +await tsup.build({ + ...baseConfig("node"), + platform: "node", + format: ["esm", "cjs"] +}); diff --git a/app/index.html b/app/index.html deleted file mode 100644 index 4807db2..0000000 --- a/app/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - BKND - - -
-
- - - diff --git a/app/internal/esbuild.entry-output-meta.plugin.ts b/app/internal/esbuild.entry-output-meta.plugin.ts new file mode 100644 index 0000000..6bd3ab4 --- /dev/null +++ b/app/internal/esbuild.entry-output-meta.plugin.ts @@ -0,0 +1,33 @@ +import type { Metafile, Plugin } from "esbuild"; + +export const entryOutputMeta = ( + onComplete?: ( + outputs: { + output: string; + meta: Metafile["outputs"][string]; + }[] + ) => void | Promise +): Plugin => ({ + name: "report-entry-output-plugin", + setup(build) { + build.initialOptions.metafile = true; // Ensure metafile is enabled + + build.onEnd(async (result) => { + console.log("result", result); + if (result?.metafile?.outputs) { + const entries = build.initialOptions.entryPoints! as string[]; + + const outputs = Object.entries(result.metafile.outputs) + .filter(([, meta]) => { + return meta.entryPoint && entries.includes(meta.entryPoint); + }) + .map(([output, meta]) => ({ output, meta })); + if (outputs.length === 0) { + return; + } + + await onComplete?.(outputs); + } + }); + } +}); diff --git a/app/package.json b/app/package.json index 6adcdb6..25200b0 100644 --- a/app/package.json +++ b/app/package.json @@ -5,18 +5,16 @@ "bin": "./dist/cli/index.js", "version": "0.0.14", "scripts": { - "build:all": "rm -rf dist && bun build:css && bun run build && bun build:vite && bun build:adapters && bun build:cli", + "build:all": "bun run build && bun run build:cli", "dev": "vite", "test": "ALL_TESTS=1 bun test --bail", - "build": "bun tsup && bun build:types", - "watch": "bun tsup --watch --onSuccess 'bun run build:types'", + "build": "bun run build.ts --minify --types", + "watch": "bun run build.ts --types --watch", "types": "bun tsc --noEmit", + "clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo", "build:types": "tsc --emitDeclarationOnly", - "build:css": "bun tailwindcss -i ./src/ui/styles.css -o ./dist/styles.css", - "watch:css": "bun tailwindcss --watch -i ./src/ui/styles.css -o ./dist/styles.css", - "build:vite": "NODE_ENV=production vite build", - "build:adapters": "bun tsup.adapters.ts --minify", - "watch:adapters": "bun tsup.adapters.ts --watch", + "build:css": "bun tailwindcss -i src/ui/main.css -o ./dist/static/styles.css", + "watch:css": "bun tailwindcss --watch -i src/ui/main.css -o ./dist/styles.css", "updater": "bun x npm-check-updates -ui", "build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify", "cli": "LOCAL=1 bun src/cli/index.ts" @@ -29,8 +27,8 @@ "@codemirror/lang-liquid": "^6.2.1", "@dagrejs/dagre": "^1.1.4", "@hello-pangea/dnd": "^17.0.0", - "@hono/typebox-validator": "^0.2.4", - "@hono/zod-validator": "^0.2.2", + "@hono/typebox-validator": "^0.2.6", + "@hono/zod-validator": "^0.4.1", "@hookform/resolvers": "^3.9.1", "@libsql/client": "^0.14.0", "@libsql/kysely-libsql": "^0.4.1", @@ -50,8 +48,7 @@ "codemirror-lang-liquid": "^1.0.0", "dayjs": "^1.11.13", "fast-xml-parser": "^4.4.0", - "hono": "^4.4.12", - "jose": "^5.6.3", + "hono": "^4.6.12", "jotai": "^2.10.1", "kysely": "^0.27.4", "liquidjs": "^10.15.0", @@ -69,14 +66,16 @@ }, "devDependencies": { "@aws-sdk/client-s3": "^3.613.0", - "@hono/node-server": "^1.13.3", - "@hono/vite-dev-server": "^0.16.0", + "@hono/node-server": "^1.13.7", + "@hono/vite-dev-server": "^0.17.0", "@tanstack/react-query-devtools": "^5.59.16", "@types/diff": "^5.2.3", + "@types/node": "^22.10.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", "autoprefixer": "^10.4.20", + "esbuild-postcss": "^0.0.4", "node-fetch": "^3.3.2", "openapi-types": "^12.1.3", "postcss": "^8.4.47", @@ -88,27 +87,13 @@ "vite-plugin-static-copy": "^2.0.0", "vite-tsconfig-paths": "^5.0.1" }, - "tsup": { - "entry": ["src/index.ts", "src/ui/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"], - "minify": true, - "outDir": "dist", - "external": ["bun:test"], - "sourcemap": true, - "metafile": true, - "platform": "browser", - "format": ["esm", "cjs"], - "splitting": false, - "loader": { - ".svg": "dataurl" - }, - "esbuild": { - "drop": ["console", "debugger"] - } - }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", @@ -120,6 +105,11 @@ "import": "./dist/ui/index.js", "require": "./dist/ui/index.cjs" }, + "./client": { + "types": "./dist/ui/client/index.d.ts", + "import": "./dist/ui/client/index.js", + "require": "./dist/ui/client/index.cjs" + }, "./data": { "types": "./dist/data/index.d.ts", "import": "./dist/data/index.js", @@ -165,9 +155,13 @@ "import": "./dist/adapter/bun/index.js", "require": "./dist/adapter/bun/index.cjs" }, - "./dist/static/manifest.json": "./dist/static/.vite/manifest.json", - "./dist/styles.css": "./dist/styles.css", - "./dist/index.html": "./dist/static/index.html" + "./adapter/node": { + "types": "./dist/adapter/node/index.d.ts", + "import": "./dist/adapter/node/index.js", + "require": "./dist/adapter/node/index.cjs" + }, + "./dist/styles.css": "./dist/ui/main.css", + "./dist/manifest.json": "./dist/static/manifest.json" }, "files": [ "dist", diff --git a/app/src/Api.ts b/app/src/Api.ts index b413819..d94aff9 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -1,22 +1,34 @@ import { AuthApi } from "auth/api/AuthApi"; import { DataApi } from "data/api/DataApi"; -import { decodeJwt } from "jose"; +import { decode } from "hono/jwt"; +import { omit } from "lodash-es"; import { MediaApi } from "media/api/MediaApi"; import { SystemApi } from "modules/SystemApi"; +export type TApiUser = object; + +declare global { + interface Window { + __BKND__: { + user?: TApiUser; + }; + } +} + export type ApiOptions = { host: string; + user?: TApiUser; token?: string; - tokenStorage?: "localStorage"; - localStorage?: { - key?: string; - }; + headers?: Headers; + key?: string; + localStorage?: boolean; }; export class Api { private token?: string; - private user?: object; + private user?: TApiUser; private verified = false; + private token_transport: "header" | "cookie" | "none" = "header"; public system!: SystemApi; public data!: DataApi; @@ -24,7 +36,12 @@ export class Api { public media!: MediaApi; constructor(private readonly options: ApiOptions) { - if (options.token) { + if (options.user) { + this.user = options.user; + this.token_transport = "none"; + this.verified = true; + } else if (options.token) { + this.token_transport = "header"; this.updateToken(options.token); } else { this.extractToken(); @@ -33,28 +50,48 @@ export class Api { this.buildApis(); } - private extractToken() { - if (this.options.tokenStorage === "localStorage") { - const key = this.options.localStorage?.key ?? "auth"; - const raw = localStorage.getItem(key); + get tokenKey() { + return this.options.key ?? "auth"; + } - if (raw) { - const { token } = JSON.parse(raw); - this.token = token; - this.user = decodeJwt(token) as any; + private extractToken() { + if (this.options.headers) { + // try cookies + const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth"); + if (cookieToken) { + this.updateToken(cookieToken); + this.token_transport = "cookie"; + this.verified = true; + return; + } + + // try authorization header + const headerToken = this.options.headers.get("authorization")?.replace("Bearer ", ""); + if (headerToken) { + this.token_transport = "header"; + this.updateToken(headerToken); + return; + } + } else if (this.options.localStorage) { + const token = localStorage.getItem(this.tokenKey); + if (token) { + this.token_transport = "header"; + this.updateToken(token); } } + + //console.warn("Couldn't extract token"); } updateToken(token?: string, rebuild?: boolean) { this.token = token; - this.user = token ? (decodeJwt(token) as any) : undefined; + this.user = token ? omit(decode(token).payload as any, ["iat", "iss", "exp"]) : undefined; - if (this.options.tokenStorage === "localStorage") { - const key = this.options.localStorage?.key ?? "auth"; + if (this.options.localStorage) { + const key = this.tokenKey; if (token) { - localStorage.setItem(key, JSON.stringify({ token })); + localStorage.setItem(key, token); } else { localStorage.removeItem(key); } @@ -69,8 +106,6 @@ export class Api { } getAuthState() { - if (!this.token) return; - return { token: this.token, user: this.user, @@ -78,10 +113,16 @@ export class Api { }; } + getUser(): TApiUser | null { + return this.user || null; + } + private buildApis() { const baseParams = { host: this.options.host, - token: this.token + token: this.token, + headers: this.options.headers, + token_transport: this.token_transport }; this.system = new SystemApi(baseParams); @@ -93,3 +134,15 @@ export class Api { this.media = new MediaApi(baseParams); } } + +function getCookieValue(cookies: string | null, name: string) { + if (!cookies) return null; + + for (const cookie of cookies.split("; ")) { + const [key, value] = cookie.split("="); + if (key === name && value) { + return decodeURIComponent(value); + } + } + return null; +} diff --git a/app/src/App.ts b/app/src/App.ts index 4d7a432..a617a0a 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -7,6 +7,7 @@ import { type Modules } from "modules/ModuleManager"; import * as SystemPermissions from "modules/permissions"; +import { AdminController, type AdminControllerOptions } from "modules/server/AdminController"; import { SystemController } from "modules/server/SystemController"; export type AppPlugin = (app: App) => void; @@ -58,7 +59,7 @@ export class App { static create(config: CreateAppConfig) { let connection: Connection | undefined = undefined; - if (config.connection instanceof Connection) { + if (Connection.isConnection(config.connection)) { connection = config.connection; } else if (typeof config.connection === "object") { switch (config.connection.type) { @@ -66,6 +67,8 @@ export class App { connection = new LibsqlConnection(config.connection.config); break; } + } else { + throw new Error(`Unknown connection of type ${typeof config.connection} given.`); } if (!connection) { throw new Error("Invalid connection"); @@ -79,7 +82,6 @@ export class App { } async build(options?: { sync?: boolean; drop?: boolean; save?: boolean }) { - //console.log("building"); await this.modules.build(); if (options?.sync) { @@ -136,6 +138,12 @@ export class App { return this.modules.version(); } + registerAdminController(config?: AdminControllerOptions) { + // register admin + this.modules.server.route("/", new AdminController(this, config).getController()); + return this; + } + toJSON(secrets?: boolean) { return this.modules.toJSON(secrets); } diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index 9c276ce..9c62047 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -1,26 +1,48 @@ -import { readFile } from "node:fs/promises"; import path from "node:path"; import { App, type CreateAppConfig } from "bknd"; +import { LibsqlConnection } from "bknd/data"; import { serveStatic } from "hono/bun"; -let app: App; -export function serve(config: CreateAppConfig, distPath?: string) { +async function getConnection(conn?: CreateAppConfig["connection"]) { + if (conn) { + if (LibsqlConnection.isConnection(conn)) { + return conn; + } + + return new LibsqlConnection(conn.config); + } + + const createClient = await import("@libsql/client/node").then((m) => m.createClient); + if (!createClient) { + throw new Error('libsql client not found, you need to install "@libsql/client/node"'); + } + + console.log("Using in-memory database"); + return new LibsqlConnection(createClient({ url: ":memory:" })); +} + +export function serve(_config: Partial = {}, distPath?: string) { const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); + let app: App; return async (req: Request) => { if (!app) { - app = App.create(config); + const connection = await getConnection(_config.connection); + app = App.create({ + ..._config, + connection + }); app.emgr.on( "app-built", async () => { app.modules.server.get( - "/assets/*", + "/*", serveStatic({ root }) ); - app.module?.server?.setAdminHtml(await readFile(root + "/index.html", "utf-8")); + app.registerAdminController(); }, "sync" ); @@ -28,6 +50,6 @@ export function serve(config: CreateAppConfig, distPath?: string) { await app.build(); } - return app.modules.server.fetch(req); + return app.fetch(req); }; } diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index 8798bbe..5224c10 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -4,21 +4,15 @@ import { Hono } from "hono"; import { serveStatic } from "hono/cloudflare-workers"; import type { BkndConfig, CfBkndModeCache } from "../index"; -// @ts-ignore -//import manifest from "__STATIC_CONTENT_MANIFEST"; - -import _html from "../../static/index.html"; - type Context = { request: Request; env: any; ctx: ExecutionContext; manifest: any; - html: string; + html?: string; }; -export function serve(_config: BkndConfig, manifest?: string, overrideHtml?: string) { - const html = overrideHtml ?? _html; +export function serve(_config: BkndConfig, manifest?: string, html?: string) { return { async fetch(request: Request, env: any, ctx: ExecutionContext) { const url = new URL(request.url); @@ -113,11 +107,10 @@ async function getFresh(config: BkndConfig, { env, html }: Context) { "sync" ); } - await app.build(); - if (config?.setAdminHtml !== false) { - app.module.server.setAdminHtml(html); + if (config.setAdminHtml) { + app.registerAdminController({ html }); } return app; @@ -147,6 +140,7 @@ async function getCached( await cache.delete(key); return c.json({ message: "Cache cleared" }); }); + app.registerAdminController({ html }); config.onBuilt!(app); }, @@ -163,13 +157,13 @@ async function getCached( ); await app.build(); - if (!cachedConfig) { - saveConfig(app.toJSON(true)); + + if (config.setAdminHtml) { + app.registerAdminController({ html }); } - //addAssetsRoute(app, manifest); - if (config?.setAdminHtml !== false) { - app.module.server.setAdminHtml(html); + if (!cachedConfig) { + saveConfig(app.toJSON(true)); } return app; @@ -184,7 +178,7 @@ export class DurableBkndApp extends DurableObject { request: Request, options: { config: CreateAppConfig; - html: string; + html?: string; keepAliveSeconds?: number; setAdminHtml?: boolean; } @@ -212,10 +206,6 @@ export class DurableBkndApp extends DurableObject { colo: context.colo }); }); - - if (options?.setAdminHtml !== false) { - app.module.server.setAdminHtml(options.html); - } }, "sync" ); diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index 04a9bb8..b4b3682 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -1,3 +1,4 @@ +import type { IncomingMessage } from "node:http"; import type { App, CreateAppConfig } from "bknd"; export type CfBkndModeCache = (env: Env) => { @@ -16,6 +17,7 @@ export type CloudflareBkndConfig = { forceHttps?: boolean; }; +// @todo: move to App export type BkndConfig = { app: CreateAppConfig | ((env: Env) => CreateAppConfig); setAdminHtml?: boolean; @@ -34,3 +36,27 @@ export type BkndConfigJson = { port?: number; }; }; + +export function nodeRequestToRequest(req: IncomingMessage): Request { + let protocol = "http"; + try { + protocol = req.headers["x-forwarded-proto"] as string; + } catch (e) {} + const host = req.headers.host; + const url = `${protocol}://${host}${req.url}`; + const headers = new Headers(); + + for (const [key, value] of Object.entries(req.headers)) { + if (Array.isArray(value)) { + headers.append(key, value.join(", ")); + } else if (value) { + headers.append(key, value); + } + } + + const method = req.method || "GET"; + return new Request(url, { + method, + headers + }); +} diff --git a/app/src/adapter/nextjs/AdminPage.tsx b/app/src/adapter/nextjs/AdminPage.tsx new file mode 100644 index 0000000..222e40f --- /dev/null +++ b/app/src/adapter/nextjs/AdminPage.tsx @@ -0,0 +1,24 @@ +import { withApi } from "bknd/adapter/nextjs"; +import type { InferGetServerSidePropsType } from "next"; +import dynamic from "next/dynamic"; + +export const getServerSideProps = withApi(async (context) => { + return { + props: { + user: context.api.getUser() + } + }; +}); + +export function adminPage() { + const Admin = dynamic(() => import("bknd/ui").then((mod) => mod.Admin), { ssr: false }); + const ClientProvider = dynamic(() => import("bknd/ui").then((mod) => mod.ClientProvider)); + return (props: InferGetServerSidePropsType) => { + if (typeof document === "undefined") return null; + return ( + + + + ); + }; +} diff --git a/app/src/adapter/nextjs/index.ts b/app/src/adapter/nextjs/index.ts index 957fa9e..ef03af0 100644 --- a/app/src/adapter/nextjs/index.ts +++ b/app/src/adapter/nextjs/index.ts @@ -1 +1,2 @@ export * from "./nextjs.adapter"; +export * from "./AdminPage"; diff --git a/app/src/adapter/nextjs/nextjs.adapter.ts b/app/src/adapter/nextjs/nextjs.adapter.ts index d533d94..a888210 100644 --- a/app/src/adapter/nextjs/nextjs.adapter.ts +++ b/app/src/adapter/nextjs/nextjs.adapter.ts @@ -1,5 +1,35 @@ -import { App, type CreateAppConfig } from "bknd"; -import { isDebug } from "bknd/core"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { Api, App, type CreateAppConfig } from "bknd"; +import { nodeRequestToRequest } from "../index"; + +type GetServerSidePropsContext = { + req: IncomingMessage; + res: ServerResponse; + params?: Params; + query: any; + preview?: boolean; + previewData?: any; + draftMode?: boolean; + resolvedUrl: string; + locale?: string; + locales?: string[]; + defaultLocale?: string; +}; + +export function createApi({ req }: GetServerSidePropsContext) { + const request = nodeRequestToRequest(req); + //console.log("createApi:request.headers", request.headers); + return new Api({ + host: new URL(request.url).origin, + headers: request.headers + }); +} + +export function withApi(handler: (ctx: GetServerSidePropsContext & { api: Api }) => T) { + return (ctx: GetServerSidePropsContext & { api: Api }) => { + return handler({ ...ctx, api: createApi(ctx) }); + }; +} function getCleanRequest(req: Request) { // clean search params from "route" attribute @@ -15,7 +45,7 @@ function getCleanRequest(req: Request) { let app: App; export function serve(config: CreateAppConfig) { return async (req: Request) => { - if (!app || isDebug()) { + if (!app) { app = App.create(config); await app.build(); } diff --git a/app/src/adapter/node/index.ts b/app/src/adapter/node/index.ts new file mode 100644 index 0000000..5360ddb --- /dev/null +++ b/app/src/adapter/node/index.ts @@ -0,0 +1,73 @@ +import path from "node:path"; +import { serve as honoServe } from "@hono/node-server"; +import { serveStatic } from "@hono/node-server/serve-static"; +import { App, type CreateAppConfig } from "bknd"; +import { LibsqlConnection } from "bknd/data"; + +async function getConnection(conn?: CreateAppConfig["connection"]) { + if (conn) { + if (LibsqlConnection.isConnection(conn)) { + return conn; + } + + return new LibsqlConnection(conn.config); + } + + const createClient = await import("@libsql/client/node").then((m) => m.createClient); + if (!createClient) { + throw new Error('libsql client not found, you need to install "@libsql/client/node"'); + } + + console.log("Using in-memory database"); + return new LibsqlConnection(createClient({ url: ":memory:" })); +} + +export type NodeAdapterOptions = { + relativeDistPath?: string; + port?: number; + hostname?: string; + listener?: Parameters[1]; +}; + +export function serve(_config: Partial = {}, options: NodeAdapterOptions = {}) { + const root = path.relative( + process.cwd(), + path.resolve(options.relativeDistPath ?? "./node_modules/bknd/dist", "static") + ); + let app: App; + + honoServe( + { + port: options.port ?? 1337, + hostname: options.hostname, + fetch: async (req: Request) => { + if (!app) { + const connection = await getConnection(_config.connection); + app = App.create({ + ..._config, + connection + }); + + app.emgr.on( + "app-built", + async () => { + app.modules.server.get( + "/*", + serveStatic({ + root + }) + ); + app.registerAdminController(); + }, + "sync" + ); + + await app.build(); + } + + return app.fetch(req); + } + }, + options.listener + ); +} diff --git a/app/src/adapter/remix/AdminPage.tsx b/app/src/adapter/remix/AdminPage.tsx new file mode 100644 index 0000000..6e40032 --- /dev/null +++ b/app/src/adapter/remix/AdminPage.tsx @@ -0,0 +1,19 @@ +import { Suspense, lazy, useEffect, useState } from "react"; + +export function adminPage() { + const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin }))); + return () => { + const [loaded, setLoaded] = useState(false); + useEffect(() => { + if (typeof window === "undefined") return; + setLoaded(true); + }, []); + if (!loaded) return null; + + return ( + + + + ); + }; +} diff --git a/app/src/adapter/remix/index.ts b/app/src/adapter/remix/index.ts index 77b0812..e02c2c0 100644 --- a/app/src/adapter/remix/index.ts +++ b/app/src/adapter/remix/index.ts @@ -1 +1,2 @@ export * from "./remix.adapter"; +export * from "./AdminPage"; diff --git a/app/src/adapter/vite/vite.adapter.ts b/app/src/adapter/vite/vite.adapter.ts index 16b5393..6faaefe 100644 --- a/app/src/adapter/vite/vite.adapter.ts +++ b/app/src/adapter/vite/vite.adapter.ts @@ -1,59 +1,34 @@ -import { readFile } from "node:fs/promises"; import { serveStatic } from "@hono/node-server/serve-static"; import type { BkndConfig } from "bknd"; import { App } from "bknd"; -async function getHtml() { - return readFile("index.html", "utf8"); -} -function addViteScripts(html: string) { - return html.replace( - "", - ` - -` - ); -} - function createApp(config: BkndConfig, env: any) { const create_config = typeof config.app === "function" ? config.app(env) : config.app; return App.create(create_config); } -function setAppBuildListener(app: App, config: BkndConfig, html: string) { +function setAppBuildListener(app: App, config: BkndConfig, html?: string) { app.emgr.on( "app-built", async () => { await config.onBuilt?.(app); - app.module.server.setAdminHtml(html); - app.module.server.client.get("/assets/!*", serveStatic({ root: "./" })); + if (config.setAdminHtml) { + app.registerAdminController({ html, forceDev: true }); + app.module.server.client.get("/assets/*", serveStatic({ root: "./" })); + } }, "sync" ); } export async function serveFresh(config: BkndConfig, _html?: string) { - let html = _html; - if (!html) { - html = await getHtml(); - } - - html = addViteScripts(html); - return { async fetch(request: Request, env: any, ctx: ExecutionContext) { const app = createApp(config, env); - setAppBuildListener(app, config, html); + setAppBuildListener(app, config, _html); await app.build(); - //console.log("routes", app.module.server.client.routes); return app.fetch(request, env, ctx); } }; @@ -61,18 +36,11 @@ export async function serveFresh(config: BkndConfig, _html?: string) { let app: App; export async function serveCached(config: BkndConfig, _html?: string) { - let html = _html; - if (!html) { - html = await getHtml(); - } - - html = addViteScripts(html); - return { async fetch(request: Request, env: any, ctx: ExecutionContext) { if (!app) { app = createApp(config, env); - setAppBuildListener(app, config, html); + setAppBuildListener(app, config, _html); await app.build(); } diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index a829c8d..ba7b00d 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -1,16 +1,9 @@ import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth"; import { Exception } from "core"; -import { transformObject } from "core/utils"; -import { - type Entity, - EntityIndex, - type EntityManager, - EnumField, - type Field, - type Mutator -} from "data"; +import { type Static, secureRandomString, transformObject } from "core/utils"; +import { type Entity, EntityIndex, type EntityManager } from "data"; import { type FieldSchema, entity, enumm, make, text } from "data/prototype"; -import { cloneDeep, mergeWith, omit, pick } from "lodash-es"; +import { pick } from "lodash-es"; import { Module } from "modules/Module"; import { AuthController } from "./api/AuthController"; import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema"; @@ -22,9 +15,25 @@ declare global { } } +type AuthSchema = Static; + export class AppAuth extends Module { private _authenticator?: Authenticator; cache: Record = {}; + _controller!: AuthController; + + override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) { + const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default; + + if (!from.enabled && to.enabled) { + if (to.jwt.secret === defaultSecret) { + console.warn("No JWT secret provided, generating a random one"); + to.jwt.secret = secureRandomString(64); + } + } + + return to; + } override async build() { if (!this.config.enabled) { @@ -46,22 +55,32 @@ export class AppAuth extends Module { return new STRATEGIES[strategy.type].cls(strategy.config as any); } catch (e) { throw new Error( - `Could not build strategy ${String(name)} with config ${JSON.stringify(strategy.config)}` + `Could not build strategy ${String( + name + )} with config ${JSON.stringify(strategy.config)}` ); } }); - const { fields, ...jwt } = this.config.jwt; this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), { - jwt + jwt: this.config.jwt, + cookie: this.config.cookie }); this.registerEntities(); super.setBuilt(); - const controller = new AuthController(this); + this._controller = new AuthController(this); //this.ctx.server.use(controller.getMiddleware); - this.ctx.server.route(this.config.basepath, controller.getController()); + this.ctx.server.route(this.config.basepath, this._controller.getController()); + } + + get controller(): AuthController { + if (!this.isBuilt()) { + throw new Error("Can't access controller, AppAuth not built yet"); + } + + return this._controller; } getMiddleware() { @@ -97,6 +116,9 @@ export class AppAuth extends Module { identifier, profile }); + if (!this.config.allow_register && action === "register") { + throw new Exception("Registration is not allowed", 403); + } const fields = this.getUsersEntity() .getFillableFields("create") @@ -124,7 +146,11 @@ export class AppAuth extends Module { } private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) { - console.log("--- trying to login", { strategy: strategy.getName(), identifier, profile }); + /*console.log("--- trying to login", { + strategy: strategy.getName(), + identifier, + profile + });*/ if (!("email" in profile)) { throw new Exception("Profile must have email"); } @@ -263,17 +289,9 @@ export class AppAuth extends Module { return this.configDefault; } - const obj = { + return { ...this.config, ...this.authenticator.toJSON(secrets) }; - - return { - ...obj, - jwt: { - ...obj.jwt, - fields: this.config.jwt.fields - } - }; } } diff --git a/app/src/auth/api/AuthApi.ts b/app/src/auth/api/AuthApi.ts index 6b6ef0d..df7ffb0 100644 --- a/app/src/auth/api/AuthApi.ts +++ b/app/src/auth/api/AuthApi.ts @@ -34,7 +34,7 @@ export class AuthApi extends ModuleApi { } async strategies() { - return this.get<{ strategies: AppAuthSchema["strategies"] }>(["strategies"]); + return this.get>(["strategies"]); } async logout() {} diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index b9eb02f..bd4ef63 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -5,27 +5,13 @@ import { Hono, type MiddlewareHandler } from "hono"; export class AuthController implements ClassController { constructor(private auth: AppAuth) {} + get guard() { + return this.auth.ctx.guard; + } + getMiddleware: MiddlewareHandler = async (c, next) => { - // @todo: consider adding app name to the payload, because user is not refetched - - //try { - if (c.req.raw.headers.has("Authorization")) { - const bearerHeader = String(c.req.header("Authorization")); - const token = bearerHeader.replace("Bearer ", ""); - const verified = await this.auth.authenticator.verify(token); - - // @todo: don't extract user from token, but from the database or cache - this.auth.ctx.guard.setUserContext(this.auth.authenticator.getUser()); - /*console.log("jwt verified?", { - verified, - auth: this.auth.authenticator.isUserLoggedIn() - });*/ - } else { - this.auth.authenticator.__setUserNull(); - } - /* } catch (e) { - this.auth.authenticator.__setUserNull(); - }*/ + const user = await this.auth.authenticator.resolveAuthFromRequest(c); + this.auth.ctx.guard.setUserContext(user); await next(); }; @@ -33,7 +19,6 @@ export class AuthController implements ClassController { getController(): Hono { const hono = new Hono(); const strategies = this.auth.authenticator.getStrategies(); - //console.log("strategies", strategies); for (const [name, strategy] of Object.entries(strategies)) { //console.log("registering", name, "at", `/${name}`); @@ -48,8 +33,23 @@ export class AuthController implements ClassController { return c.json({ user: null }, 403); }); + hono.get("/logout", async (c) => { + await this.auth.authenticator.logout(c); + if (this.auth.authenticator.isJsonRequest(c)) { + return c.json({ ok: true }); + } + + const referer = c.req.header("referer"); + if (referer) { + return c.redirect(referer); + } + + return c.redirect("/"); + }); + hono.get("/strategies", async (c) => { - return c.json({ strategies: this.auth.toJSON(false).strategies }); + const { strategies, basepath } = this.auth.toJSON(false); + return c.json({ strategies, basepath }); }); return hono; diff --git a/app/src/auth/auth-schema.ts b/app/src/auth/auth-schema.ts index 19e5581..202e0b4 100644 --- a/app/src/auth/auth-schema.ts +++ b/app/src/auth/auth-schema.ts @@ -1,4 +1,4 @@ -import { jwtConfig } from "auth/authenticate/Authenticator"; +import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator"; import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies"; import { type Static, StringRecord, Type, objectTransform } from "core/utils"; @@ -51,15 +51,9 @@ export const authConfigSchema = Type.Object( enabled: Type.Boolean({ default: false }), basepath: Type.String({ default: "/api/auth" }), entity_name: Type.String({ default: "users" }), - jwt: Type.Composite( - [ - jwtConfig, - Type.Object({ - fields: Type.Array(Type.String(), { default: ["id", "email", "role"] }) - }) - ], - { default: {}, additionalProperties: false } - ), + allow_register: Type.Optional(Type.Boolean({ default: true })), + jwt: jwtConfig, + cookie: cookieConfig, strategies: Type.Optional( StringRecord(strategiesSchema, { title: "Strategies", diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index b335623..082cd1c 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -1,8 +1,22 @@ -import { type Static, type TSchema, Type, parse, randomString, transformObject } from "core/utils"; -import type { Hono } from "hono"; -import { type JWTVerifyOptions, SignJWT, jwtVerify } from "jose"; +import { Exception } from "core"; +import { addFlashMessage } from "core/server/flash"; +import { + type Static, + StringEnum, + type TSchema, + Type, + parse, + randomString, + transformObject +} from "core/utils"; +import type { Context, Hono } from "hono"; +import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; +import { decode, sign, verify } from "hono/jwt"; +import type { CookieOptions } from "hono/utils/cookie"; +import { omit } from "lodash-es"; type Input = any; // workaround +export type JWTPayload = Parameters[0]; // @todo: add schema to interface to ensure proper inference export interface Strategy { @@ -38,13 +52,29 @@ export interface UserPool { create: (user: CreateUser) => Promise; } +const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds +export const cookieConfig = Type.Partial( + Type.Object({ + path: Type.String({ default: "/" }), + sameSite: StringEnum(["strict", "lax", "none"], { default: "lax" }), + secure: Type.Boolean({ default: true }), + httpOnly: Type.Boolean({ default: true }), + expires: Type.Number({ default: defaultCookieExpires }), // seconds + renew: Type.Boolean({ default: true }), + pathSuccess: Type.String({ default: "/" }), + pathLoggedOut: Type.String({ default: "/" }) + }), + { default: {}, additionalProperties: false } +); + export const jwtConfig = Type.Object( { // @todo: autogenerate a secret if not present. But it must be persisted from AppAuth - secret: Type.String({ default: "secret" }), - alg: Type.Optional(Type.String({ enum: ["HS256"], default: "HS256" })), - expiresIn: Type.Optional(Type.String()), - issuer: Type.Optional(Type.String()) + secret: Type.String({ default: "" }), + alg: Type.Optional(StringEnum(["HS256", "HS384", "HS512"], { default: "HS256" })), + expires: Type.Optional(Type.Number()), // seconds + issuer: Type.Optional(Type.String()), + fields: Type.Array(Type.String(), { default: ["id", "email", "role"] }) }, { default: {}, @@ -52,7 +82,8 @@ export const jwtConfig = Type.Object( } ); export const authenticatorConfig = Type.Object({ - jwt: jwtConfig + jwt: jwtConfig, + cookie: cookieConfig }); type AuthConfig = Static; @@ -74,11 +105,6 @@ export class Authenticator = Record< this.userResolver = userResolver ?? (async (a, s, i, p) => p as any); this.strategies = strategies as Strategies; this.config = parse(authenticatorConfig, config ?? {}); - - /*const secret = String(this.config.jwt.secret); - if (secret === "secret" || secret.length === 0) { - this.config.jwt.secret = randomString(64, true); - }*/ } async resolve( @@ -86,7 +112,7 @@ export class Authenticator = Record< strategy: Strategy, identifier: string, profile: ProfileExchange - ) { + ): Promise { //console.log("resolve", { action, strategy: strategy.getName(), profile }); const user = await this.userResolver(action, strategy, identifier, profile); @@ -136,50 +162,140 @@ export class Authenticator = Record< } } - const jwt = new SignJWT(user) - .setProtectedHeader({ alg: this.config.jwt?.alg ?? "HS256" }) - .setIssuedAt(); + const payload: JWTPayload = { + ...user, + iat: Math.floor(Date.now() / 1000) + }; + // issuer if (this.config.jwt?.issuer) { - jwt.setIssuer(this.config.jwt.issuer); + payload.iss = this.config.jwt.issuer; } - if (this.config.jwt?.expiresIn) { - jwt.setExpirationTime(this.config.jwt.expiresIn); + // expires in seconds + if (this.config.jwt?.expires) { + payload.exp = Math.floor(Date.now() / 1000) + this.config.jwt.expires; } - return jwt.sign(new TextEncoder().encode(this.config.jwt?.secret ?? "")); + return sign(payload, this.config.jwt?.secret ?? "", this.config.jwt?.alg ?? "HS256"); } async verify(jwt: string): Promise { - const options: JWTVerifyOptions = { - algorithms: [this.config.jwt?.alg ?? "HS256"] - }; - - if (this.config.jwt?.issuer) { - options.issuer = this.config.jwt.issuer; - } - - if (this.config.jwt?.expiresIn) { - options.maxTokenAge = this.config.jwt.expiresIn; - } - try { - const { payload } = await jwtVerify( + const payload = await verify( jwt, - new TextEncoder().encode(this.config.jwt?.secret ?? ""), - options + this.config.jwt?.secret ?? "", + this.config.jwt?.alg ?? "HS256" ); - this._user = payload; + + // manually verify issuer (hono doesn't support it) + if (this.config.jwt?.issuer) { + if (payload.iss !== this.config.jwt.issuer) { + throw new Exception("Invalid issuer", 403); + } + } + + this._user = omit(payload, ["iat", "exp", "iss"]) as SafeUser; return true; } catch (e) { this._user = undefined; - //console.error(e); + console.error(e); } return false; } + private get cookieOptions(): CookieOptions { + const { expires = defaultCookieExpires, renew, ...cookieConfig } = this.config.cookie; + + return { + ...cookieConfig, + expires: new Date(Date.now() + expires * 1000) + }; + } + + private async getAuthCookie(c: Context): Promise { + const secret = this.config.jwt.secret; + + const token = await getSignedCookie(c, secret, "auth"); + if (typeof token !== "string") { + await deleteCookie(c, "auth", this.cookieOptions); + return undefined; + } + + return token; + } + + async requestCookieRefresh(c: Context) { + if (this.config.cookie.renew) { + console.log("renewing cookie", c.req.url); + const token = await this.getAuthCookie(c); + if (token) { + await this.setAuthCookie(c, token); + } + } + } + + private async setAuthCookie(c: Context, token: string) { + const secret = this.config.jwt.secret; + await setSignedCookie(c, "auth", token, secret, this.cookieOptions); + } + + async logout(c: Context) { + const cookie = await this.getAuthCookie(c); + if (cookie) { + await deleteCookie(c, "auth", this.cookieOptions); + await addFlashMessage(c, "Signed out", "info"); + } + } + + isJsonRequest(c: Context): boolean { + //return c.req.header("Content-Type") === "application/x-www-form-urlencoded"; + return c.req.header("Content-Type") === "application/json"; + } + + async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) { + if (this.isJsonRequest(c)) { + return c.json(data); + } + + const successPath = this.config.cookie.pathSuccess ?? "/"; + const successUrl = new URL(c.req.url).origin + successPath.replace(/\/+$/, "/"); + const referer = new URL(redirect ?? c.req.header("Referer") ?? successUrl); + + if ("token" in data) { + await this.setAuthCookie(c, data.token); + // can't navigate to "/" – doesn't work on nextjs + return c.redirect(successUrl); + } + + let message = "An error occured"; + if (data instanceof Exception) { + message = data.message; + } + + await addFlashMessage(c, message, "error"); + return c.redirect(referer); + } + + // @todo: don't extract user from token, but from the database or cache + async resolveAuthFromRequest(c: Context): Promise { + let token: string | undefined; + if (c.req.raw.headers.has("Authorization")) { + const bearerHeader = String(c.req.header("Authorization")); + token = bearerHeader.replace("Bearer ", ""); + } else { + token = await this.getAuthCookie(c); + } + + if (token) { + await this.verify(token); + return this._user; + } + + return undefined; + } + toJSON(secrets?: boolean) { return { ...this.config, diff --git a/app/src/auth/authenticate/strategies/PasswordStrategy.ts b/app/src/auth/authenticate/strategies/PasswordStrategy.ts index 36af6ec..ef940d7 100644 --- a/app/src/auth/authenticate/strategies/PasswordStrategy.ts +++ b/app/src/auth/authenticate/strategies/PasswordStrategy.ts @@ -1,7 +1,7 @@ import type { Authenticator, Strategy } from "auth"; import { type Static, StringEnum, Type, parse } from "core/utils"; import { hash } from "core/utils"; -import { Hono } from "hono"; +import { type Context, Hono } from "hono"; type LoginSchema = { username: string; password: string } | { email: string; password: string }; type RegisterSchema = { email: string; password: string; [key: string]: any }; @@ -54,22 +54,34 @@ export class PasswordStrategy implements Strategy { getController(authenticator: Authenticator): Hono { const hono = new Hono(); + async function getBody(c: Context) { + if (authenticator.isJsonRequest(c)) { + return await c.req.json(); + } else { + return Object.fromEntries((await c.req.formData()).entries()); + } + } + return hono .post("/login", async (c) => { - const body = (await c.req.json()) ?? {}; + const body = await getBody(c); - const payload = await this.login(body); - const data = await authenticator.resolve("login", this, payload.password, payload); + try { + const payload = await this.login(body); + const data = await authenticator.resolve("login", this, payload.password, payload); - return c.json(data); + return await authenticator.respond(c, data); + } catch (e) { + return await authenticator.respond(c, e); + } }) .post("/register", async (c) => { - const body = (await c.req.json()) ?? {}; + const body = await getBody(c); const payload = await this.register(body); const data = await authenticator.resolve("register", this, payload.password, payload); - return c.json(data); + return await authenticator.respond(c, data); }); } diff --git a/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts b/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts index 0214059..6015ebd 100644 --- a/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts +++ b/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts @@ -1,5 +1,5 @@ import type { AuthAction, Authenticator, Strategy } from "auth"; -import { Exception } from "core"; +import { Exception, isDebug } from "core"; import { type Static, StringEnum, type TSchema, Type, filterKeys, parse } from "core/utils"; import { type Context, Hono } from "hono"; import { getSignedCookie, setSignedCookie } from "hono/cookie"; @@ -173,7 +173,7 @@ export class OAuthStrategy implements Strategy { const config = await this.getConfig(); const { client, as, type } = config; //console.log("config", config); - //console.log("callbackParams", callbackParams, options); + console.log("callbackParams", callbackParams, options); const parameters = oauth.validateAuthResponse( as, client, // no client_secret required @@ -216,7 +216,7 @@ export class OAuthStrategy implements Strategy { expectedNonce ); if (oauth.isOAuth2Error(result)) { - //console.log("callback.error", result); + console.log("callback.error", result); // @todo: Handle OAuth 2.0 response body error throw new OAuthCallbackException(result, "processAuthorizationCodeOpenIDResponse"); } @@ -317,10 +317,15 @@ export class OAuthStrategy implements Strategy { const secret = "secret"; const cookie_name = "_challenge"; - const setState = async ( - c: Context, - config: { state: string; action: AuthAction; redirect?: string } - ): Promise => { + type TState = { + state: string; + action: AuthAction; + redirect?: string; + mode: "token" | "cookie"; + }; + + const setState = async (c: Context, config: TState): Promise => { + console.log("--- setting state", config); await setSignedCookie(c, cookie_name, JSON.stringify(config), secret, { secure: true, httpOnly: true, @@ -329,12 +334,18 @@ export class OAuthStrategy implements Strategy { }); }; - const getState = async ( - c: Context - ): Promise<{ state: string; action: AuthAction; redirect?: string }> => { - const state = await getSignedCookie(c, secret, cookie_name); + const getState = async (c: Context): Promise => { + if (c.req.header("X-State-Challenge")) { + return { + state: c.req.header("X-State-Challenge"), + action: c.req.header("X-State-Action"), + mode: "token" + } as any; + } + + const value = await getSignedCookie(c, secret, cookie_name); try { - return JSON.parse(state as string); + return JSON.parse(value as string); } catch (e) { throw new Error("Invalid state"); } @@ -345,22 +356,68 @@ export class OAuthStrategy implements Strategy { const params = new URLSearchParams(url.search); const state = await getState(c); - console.log("url", url); + console.log("state", state); + + // @todo: add config option to determine if state.action is allowed + const redirect_uri = + state.mode === "cookie" + ? url.origin + url.pathname + : url.origin + url.pathname.replace("/callback", "/token"); const profile = await this.callback(params, { - redirect_uri: url.origin + url.pathname, + redirect_uri, state: state.state }); - const { user, token } = await auth.resolve(state.action, this, profile.sub, profile); - console.log("******** RESOLVED ********", { user, token }); + try { + const data = await auth.resolve(state.action, this, profile.sub, profile); + console.log("******** RESOLVED ********", data); - if (state.redirect) { - console.log("redirect to", state.redirect + "?token=" + token); - return c.redirect(state.redirect + "?token=" + token); + if (state.mode === "cookie") { + return await auth.respond(c, data, state.redirect); + } + + return c.json(data); + } catch (e) { + if (state.mode === "cookie") { + return await auth.respond(c, e, state.redirect); + } + + throw e; + } + }); + + hono.get("/token", async (c) => { + const url = new URL(c.req.url); + const params = new URLSearchParams(url.search); + + return c.json({ + code: params.get("code") ?? null + }); + }); + + hono.post("/:action", async (c) => { + const action = c.req.param("action") as AuthAction; + if (!["login", "register"].includes(action)) { + return c.notFound(); } - return c.json({ user, token }); + const url = new URL(c.req.url); + const path = url.pathname.replace(`/${action}`, ""); + const redirect_uri = url.origin + path + "/callback"; + const referer = new URL(c.req.header("Referer") ?? "/"); + + const state = oauth.generateRandomCodeVerifier(); + const response = await this.request({ + redirect_uri, + state + }); + //console.log("_state", state); + + await setState(c, { state, action, redirect: referer.toString(), mode: "cookie" }); + console.log("--redirecting to", response.url); + + return c.redirect(response.url); }); hono.get("/:action", async (c) => { @@ -371,31 +428,29 @@ export class OAuthStrategy implements Strategy { const url = new URL(c.req.url); const path = url.pathname.replace(`/${action}`, ""); - const redirect_uri = url.origin + path + "/callback"; - const q_redirect = (c.req.query("redirect") as string) ?? undefined; + const redirect_uri = url.origin + path + "/token"; - const state = await oauth.generateRandomCodeVerifier(); + const state = oauth.generateRandomCodeVerifier(); const response = await this.request({ redirect_uri, state }); - //console.log("_state", state); - await setState(c, { state, action, redirect: q_redirect }); - - if (c.req.header("Accept") === "application/json") { + if (isDebug()) { return c.json({ url: response.url, redirect_uri, challenge: state, + action, params: response.params }); } - //return c.text(response.url); - console.log("--redirecting to", response.url); - - return c.redirect(response.url); + return c.json({ + url: response.url, + challenge: state, + action + }); }); return hono; diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index caae555..880d134 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -1,5 +1,5 @@ import { Exception, Permission } from "core"; -import { type Static, Type, objectTransform } from "core/utils"; +import { objectTransform } from "core/utils"; import { Role } from "./Role"; export type GuardUserContext = { @@ -11,6 +11,8 @@ export type GuardConfig = { enabled?: boolean; }; +const debug = false; + export class Guard { permissions: Permission[]; user?: GuardUserContext; @@ -96,12 +98,12 @@ export class Guard { if (this.user && typeof this.user.role === "string") { const role = this.roles?.find((role) => role.name === this.user?.role); if (role) { - console.log("guard: role found", this.user.role); + debug && console.log("guard: role found", this.user.role); return role; } } - console.log("guard: role not found", this.user, this.user?.role); + debug && console.log("guard: role not found", this.user, this.user?.role); return this.getDefaultRole(); } @@ -109,10 +111,14 @@ export class Guard { return this.roles?.find((role) => role.is_default); } + isEnabled() { + return this.config?.enabled === true; + } + hasPermission(permission: Permission): boolean; hasPermission(name: string): boolean; hasPermission(permissionOrName: Permission | string): boolean { - if (this.config?.enabled !== true) { + if (!this.isEnabled()) { //console.log("guard not enabled, allowing"); return true; } @@ -126,10 +132,10 @@ export class Guard { const role = this.getUserRole(); if (!role) { - console.log("guard: role not found, denying"); + debug && console.log("guard: role not found, denying"); return false; } else if (role.implicit_allow === true) { - console.log("guard: role implicit allow, allowing"); + debug && console.log("guard: role implicit allow, allowing"); return true; } @@ -137,11 +143,12 @@ export class Guard { (rolePermission) => rolePermission.permission.name === name ); - console.log("guard: rolePermission, allowing?", { - permission: name, - role: role.name, - allowing: !!rolePermission - }); + debug && + console.log("guard: rolePermission, allowing?", { + permission: name, + role: role.name, + allowing: !!rolePermission + }); return !!rolePermission; } diff --git a/app/src/cli/commands/config.ts b/app/src/cli/commands/config.ts index 461ee78..3d853ab 100644 --- a/app/src/cli/commands/config.ts +++ b/app/src/cli/commands/config.ts @@ -7,6 +7,7 @@ export const config: CliCommand = (program) => { .description("get default config") .option("--pretty", "pretty print") .action((options) => { - console.log(getDefaultConfig(options.pretty)); + const config = getDefaultConfig(); + console.log(options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config)); }); }; diff --git a/app/src/cli/commands/run/platform.ts b/app/src/cli/commands/run/platform.ts index 76c033e..46a725b 100644 --- a/app/src/cli/commands/run/platform.ts +++ b/app/src/cli/commands/run/platform.ts @@ -28,7 +28,7 @@ export async function serveStatic(server: Platform): Promise } export async function attachServeStatic(app: any, platform: Platform) { - app.module.server.client.get("/assets/*", await serveStatic(platform)); + app.module.server.client.get("/*", await serveStatic(platform)); } export async function startServer(server: Platform, app: any, options: { port: number }) { diff --git a/app/src/cli/commands/run/run.ts b/app/src/cli/commands/run/run.ts index 14730f0..0663f2f 100644 --- a/app/src/cli/commands/run/run.ts +++ b/app/src/cli/commands/run/run.ts @@ -1,9 +1,9 @@ import type { Config } from "@libsql/client/node"; import { App } from "App"; import type { BkndConfig } from "adapter"; +import type { CliCommand } from "cli/types"; import { Option } from "commander"; import type { Connection } from "data"; -import type { CliCommand } from "../../types"; import { PLATFORMS, type Platform, @@ -48,14 +48,13 @@ type MakeAppConfig = { }; async function makeApp(config: MakeAppConfig) { - const html = await getHtml(); const app = new App(config.connection); app.emgr.on( "app-built", async () => { await attachServeStatic(app, config.server?.platform ?? "node"); - app.module.server.setAdminHtml(html); + app.registerAdminController(); if (config.onBuilt) { await config.onBuilt(app); @@ -70,14 +69,13 @@ async function makeApp(config: MakeAppConfig) { export async function makeConfigApp(config: BkndConfig, platform?: Platform) { const appConfig = typeof config.app === "function" ? config.app(process.env) : config.app; - const html = await getHtml(); const app = App.create(appConfig); app.emgr.on( "app-built", async () => { await attachServeStatic(app, platform ?? "node"); - app.module.server.setAdminHtml(html); + app.registerAdminController(); if (config.onBuilt) { await config.onBuilt(app); diff --git a/app/src/cli/commands/schema.ts b/app/src/cli/commands/schema.ts index 8c59d7e..13c1c1e 100644 --- a/app/src/cli/commands/schema.ts +++ b/app/src/cli/commands/schema.ts @@ -7,6 +7,7 @@ export const schema: CliCommand = (program) => { .description("get schema") .option("--pretty", "pretty print") .action((options) => { - console.log(getDefaultSchema(options.pretty)); + const schema = getDefaultSchema(); + console.log(options.pretty ? JSON.stringify(schema, null, 2) : JSON.stringify(schema)); }); }; diff --git a/app/src/core/object/SchemaObject.ts b/app/src/core/object/SchemaObject.ts index 8865c50..c70ef28 100644 --- a/app/src/core/object/SchemaObject.ts +++ b/app/src/core/object/SchemaObject.ts @@ -11,6 +11,10 @@ import { export type SchemaObjectOptions = { onUpdate?: (config: Static) => void | Promise; + onBeforeUpdate?: ( + from: Static, + to: Static + ) => Static | Promise>; restrictPaths?: string[]; overwritePaths?: (RegExp | string)[]; forceParse?: boolean; @@ -45,6 +49,13 @@ export class SchemaObject { return this._default; } + private async onBeforeUpdate(from: Static, to: Static): Promise> { + if (this.options?.onBeforeUpdate) { + return this.options.onBeforeUpdate(from, to); + } + return to; + } + get(options?: { stripMark?: boolean }): Static { if (options?.stripMark) { return stripMark(this._config); @@ -58,8 +69,10 @@ export class SchemaObject { forceParse: true, skipMark: this.isForceParse() }); - this._value = valid; - this._config = Object.freeze(valid); + const updatedConfig = noEmit ? valid : await this.onBeforeUpdate(this._config, valid); + + this._value = updatedConfig; + this._config = Object.freeze(updatedConfig); if (noEmit !== true) { await this.options?.onUpdate?.(this._config); @@ -134,7 +147,7 @@ export class SchemaObject { overwritePaths.length > 1 ? overwritePaths.filter((k) => overwritePaths.some((k2) => { - console.log("keep?", { k, k2 }, k2 !== k && k2.startsWith(k)); + //console.log("keep?", { k, k2 }, k2 !== k && k2.startsWith(k)); return k2 !== k && k2.startsWith(k); }) ) diff --git a/app/src/core/server/flash.ts b/app/src/core/server/flash.ts new file mode 100644 index 0000000..c64753b --- /dev/null +++ b/app/src/core/server/flash.ts @@ -0,0 +1,40 @@ +import type { Context } from "hono"; +import { setCookie } from "hono/cookie"; + +const flash_key = "__bknd_flash"; +export type FlashMessageType = "error" | "warning" | "success" | "info"; + +export async function addFlashMessage( + c: Context, + message: string, + type: FlashMessageType = "info" +) { + setCookie(c, flash_key, JSON.stringify({ type, message }), { + path: "/" + }); +} + +function getCookieValue(name) { + const cookies = document.cookie.split("; "); + for (const cookie of cookies) { + const [key, value] = cookie.split("="); + if (key === name) { + try { + return decodeURIComponent(value as any); + } catch (e) { + return null; + } + } + } + return null; // Return null if the cookie is not found +} + +export function getFlashMessage( + clear = true +): { type: FlashMessageType; message: string } | undefined { + const flash = getCookieValue(flash_key); + if (flash && clear) { + document.cookie = `${flash_key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; + } + return flash ? JSON.parse(flash) : undefined; +} diff --git a/app/src/core/utils/crypto.ts b/app/src/core/utils/crypto.ts index 6996d1c..21a188a 100644 --- a/app/src/core/utils/crypto.ts +++ b/app/src/core/utils/crypto.ts @@ -27,3 +27,9 @@ export async function checksum(s: any) { const o = typeof s === "string" ? s : JSON.stringify(s); return await digest("SHA-1", o); } + +export function secureRandomString(length: number): string { + const array = new Uint8Array(length); + crypto.getRandomValues(array); + return Array.from(array, (byte) => String.fromCharCode(33 + (byte % 94))).join(""); +} diff --git a/app/src/core/utils/strings.ts b/app/src/core/utils/strings.ts index adc68b7..a5a7c72 100644 --- a/app/src/core/utils/strings.ts +++ b/app/src/core/utils/strings.ts @@ -15,10 +15,6 @@ export function ucFirstAll(str: string, split: string = " "): string { .join(split); } -export function ucFirstAllSnakeToPascalWithSpaces(str: string, split: string = " "): string { - return ucFirstAll(snakeToPascalWithSpaces(str), split); -} - export function randomString(length: number, includeSpecial = false): string { const base = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; const special = "!@#$%^&*()_+{}:\"<>?|[];',./`~"; @@ -49,6 +45,54 @@ export function pascalToKebab(pascalStr: string): string { return pascalStr.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); } +type StringCaseType = + | "snake_case" + | "PascalCase" + | "camelCase" + | "kebab-case" + | "SCREAMING_SNAKE_CASE" + | "unknown"; +export function detectCase(input: string): StringCaseType { + if (/^[a-z]+(_[a-z]+)*$/.test(input)) { + return "snake_case"; + } else if (/^[A-Z][a-zA-Z]*$/.test(input)) { + return "PascalCase"; + } else if (/^[a-z][a-zA-Z]*$/.test(input)) { + return "camelCase"; + } else if (/^[a-z]+(-[a-z]+)*$/.test(input)) { + return "kebab-case"; + } else if (/^[A-Z]+(_[A-Z]+)*$/.test(input)) { + return "SCREAMING_SNAKE_CASE"; + } else { + return "unknown"; + } +} +export function identifierToHumanReadable(str: string) { + const _case = detectCase(str); + switch (_case) { + case "snake_case": + return snakeToPascalWithSpaces(str); + case "PascalCase": + return kebabToPascalWithSpaces(pascalToKebab(str)); + case "camelCase": + return ucFirst(kebabToPascalWithSpaces(pascalToKebab(str))); + case "kebab-case": + return kebabToPascalWithSpaces(str); + case "SCREAMING_SNAKE_CASE": + return snakeToPascalWithSpaces(str.toLowerCase()); + case "unknown": + return str; + } +} + +export function kebabToPascalWithSpaces(str: string): string { + return str.split("-").map(ucFirst).join(" "); +} + +export function ucFirstAllSnakeToPascalWithSpaces(str: string, split: string = " "): string { + return ucFirstAll(snakeToPascalWithSpaces(str), split); +} + /** * Replace simple mustache like {placeholders} in a string * diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 418e8ae..4036b44 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -15,7 +15,7 @@ import { import { Hono } from "hono"; import type { Handler } from "hono/types"; import type { ModuleBuildContext } from "modules"; -import { AppData } from "../AppData"; +import * as SystemPermissions from "modules/permissions"; import { type AppDataConfig, FIELDS } from "../data-schema"; export class DataController implements ClassController { @@ -89,12 +89,10 @@ export class DataController implements ClassController { return func; } - // add timing - /*hono.use("*", async (c, next) => { - startTime(c, "data"); + hono.use("*", async (c, next) => { + this.ctx.guard.throwUnlessGranted(SystemPermissions.accessApi); await next(); - endTime(c, "data"); - });*/ + }); // info hono.get( diff --git a/app/src/data/connection/Connection.ts b/app/src/data/connection/Connection.ts index b3f7e10..bc97ff0 100644 --- a/app/src/data/connection/Connection.ts +++ b/app/src/data/connection/Connection.ts @@ -42,6 +42,7 @@ export type DbFunctions = { }; export abstract class Connection { + cls = "bknd:connection"; kysely: Kysely; constructor( @@ -52,6 +53,15 @@ export abstract class Connection { this.kysely = kysely; } + /** + * This is a helper function to manage Connection classes + * coming from different places + * @param conn + */ + static isConnection(conn: any): conn is Connection { + return conn?.cls === "bknd:connection"; + } + getIntrospector(): ConnectionIntrospector { return this.kysely.introspection as ConnectionIntrospector; } diff --git a/app/src/data/entities/EntityManager.ts b/app/src/data/entities/EntityManager.ts index 353d3a9..674d7e2 100644 --- a/app/src/data/entities/EntityManager.ts +++ b/app/src/data/entities/EntityManager.ts @@ -36,7 +36,7 @@ export class EntityManager { relations.forEach((relation) => this.addRelation(relation)); indices.forEach((index) => this.addIndex(index)); - if (!(connection instanceof Connection)) { + if (!Connection.isConnection(connection)) { throw new UnableToConnectException(""); } diff --git a/app/src/modules/Module.ts b/app/src/modules/Module.ts index c3364c3..ecdf4ce 100644 --- a/app/src/modules/Module.ts +++ b/app/src/modules/Module.ts @@ -13,7 +13,7 @@ export type ModuleBuildContext = { guard: Guard; }; -export abstract class Module { +export abstract class Module> { private _built = false; private _schema: SchemaObject>; private _listener: any = () => null; @@ -28,10 +28,15 @@ export abstract class Module { await this._listener(c); }, restrictPaths: this.getRestrictedPaths(), - overwritePaths: this.getOverwritePaths() + overwritePaths: this.getOverwritePaths(), + onBeforeUpdate: this.onBeforeUpdate.bind(this) }); } + onBeforeUpdate(from: ConfigSchema, to: ConfigSchema): ConfigSchema | Promise { + return to; + } + setListener(listener: (c: ReturnType<(typeof this)["getSchema"]>) => void | Promise) { this._listener = listener; return this; @@ -92,7 +97,8 @@ export abstract class Module { }, forceParse: this.useForceParse(), restrictPaths: this.getRestrictedPaths(), - overwritePaths: this.getOverwritePaths() + overwritePaths: this.getOverwritePaths(), + onBeforeUpdate: this.onBeforeUpdate.bind(this) }); } diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts index 4c6aa8d..541505b 100644 --- a/app/src/modules/ModuleApi.ts +++ b/app/src/modules/ModuleApi.ts @@ -6,6 +6,8 @@ export type BaseModuleApiOptions = { host: string; basepath?: string; token?: string; + headers?: Headers; + token_transport?: "header" | "cookie" | "none"; }; export type ApiResponse = { @@ -53,18 +55,22 @@ export abstract class ModuleApi { } } - const headers = new Headers(_init?.headers ?? {}); + const headers = new Headers(this.options.headers ?? {}); + // add init headers + for (const [key, value] of Object.entries(_init?.headers ?? {})) { + headers.set(key, value as string); + } + headers.set("Accept", "application/json"); - if (this.options.token) { + // only add token if initial headers not provided + if (this.options.token && this.options.token_transport === "header") { //console.log("setting token", this.options.token); headers.set("Authorization", `Bearer ${this.options.token}`); - } else { - //console.log("no token"); } let body: any = _init?.body; - if (_init && "body" in _init && ["POST", "PATCH"].includes(method)) { + if (_init && "body" in _init && ["POST", "PATCH", "PUT"].includes(method)) { const requestContentType = (headers.get("Content-Type") as string) ?? undefined; if (!requestContentType || requestContentType.startsWith("application/json")) { body = JSON.stringify(_init.body); @@ -137,6 +143,18 @@ export abstract class ModuleApi { }); } + protected async put( + _input: string | (string | number | PrimaryFieldType)[], + body?: any, + _init?: RequestInit + ) { + return this.request(_input, undefined, { + ..._init, + body, + method: "PUT" + }); + } + protected async delete( _input: string | (string | number | PrimaryFieldType)[], _init?: RequestInit diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index 85c6ea5..51a1768 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -425,19 +425,19 @@ export class ModuleManager { } } -export function getDefaultSchema(pretty = false) { +export function getDefaultSchema() { const schema = { type: "object", ...transformObject(MODULES, (module) => module.prototype.getSchema()) }; - return JSON.stringify(schema, null, pretty ? 2 : undefined); + return schema as any; } -export function getDefaultConfig(pretty = false): ModuleConfigs { +export function getDefaultConfig(): ModuleConfigs { const config = transformObject(MODULES, (module) => { return Default(module.prototype.getSchema(), {}); }); - return JSON.stringify(config, null, pretty ? 2 : undefined) as any; + return config as any; } diff --git a/app/src/modules/SystemApi.ts b/app/src/modules/SystemApi.ts index 7dd056c..1d226c6 100644 --- a/app/src/modules/SystemApi.ts +++ b/app/src/modules/SystemApi.ts @@ -1,5 +1,5 @@ import { ModuleApi } from "./ModuleApi"; -import type { ModuleConfigs, ModuleSchemas } from "./ModuleManager"; +import type { ModuleConfigs, ModuleKey, ModuleSchemas } from "./ModuleManager"; export type ApiSchemaResponse = { version: number; @@ -21,4 +21,31 @@ export class SystemApi extends ModuleApi { secrets: options?.secrets ? 1 : 0 }); } + + async setConfig( + module: Module, + value: ModuleConfigs[Module], + force?: boolean + ) { + return await this.post( + ["config", "set", module].join("/") + `?force=${force ? 1 : 0}`, + value + ); + } + + async addConfig(module: Module, path: string, value: any) { + return await this.post(["config", "add", module, path], value); + } + + async patchConfig(module: Module, path: string, value: any) { + return await this.patch(["config", "patch", module, path], value); + } + + async overwriteConfig(module: Module, path: string, value: any) { + return await this.put(["config", "overwrite", module, path], value); + } + + async removeConfig(module: Module, path: string) { + return await this.delete(["config", "remove", module, path]); + } } diff --git a/app/src/modules/migrations.ts b/app/src/modules/migrations.ts index 8a28557..9cf0beb 100644 --- a/app/src/modules/migrations.ts +++ b/app/src/modules/migrations.ts @@ -78,6 +78,21 @@ export const migrations: Migration[] = [ up: async (config, { db }) => { return config; } + }, + { + version: 7, + up: async (config, { db }) => { + // automatically adds auth.cookie options + // remove "expiresIn" (string), it's now "expires" (number) + const { expiresIn, ...jwt } = config.auth.jwt; + return { + ...config, + auth: { + ...config.auth, + jwt + } + }; + } } ]; diff --git a/app/src/modules/permissions/index.ts b/app/src/modules/permissions/index.ts index 8b9cb9b..a2d891d 100644 --- a/app/src/modules/permissions/index.ts +++ b/app/src/modules/permissions/index.ts @@ -1,5 +1,7 @@ import { Permission } from "core"; +export const accessAdmin = new Permission("system.access.admin"); +export const accessApi = new Permission("system.access.api"); export const configRead = new Permission("system.config.read"); export const configReadSecrets = new Permission("system.config.read.secrets"); export const configWrite = new Permission("system.config.write"); diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx new file mode 100644 index 0000000..522c55a --- /dev/null +++ b/app/src/modules/server/AdminController.tsx @@ -0,0 +1,181 @@ +/** @jsxImportSource hono/jsx */ + +import type { App } from "App"; +import { type ClassController, isDebug } from "core"; +import { addFlashMessage } from "core/server/flash"; +import { Hono } from "hono"; +import { html } from "hono/html"; +import { Fragment } from "hono/jsx"; +import * as SystemPermissions from "modules/permissions"; + +const htmlBkndContextReplace = ""; + +export type AdminControllerOptions = { + html?: string; + forceDev?: boolean; +}; + +export class AdminController implements ClassController { + constructor( + private readonly app: App, + private options: AdminControllerOptions = {} + ) {} + + get ctx() { + return this.app.modules.ctx(); + } + + private withBasePath(route: string = "") { + return (this.app.modules.configs().server.admin.basepath + route).replace(/\/+$/, "/"); + } + + getController(): Hono { + const auth = this.app.module.auth; + const configs = this.app.modules.configs(); + // if auth is not enabled, authenticator is undefined + const auth_enabled = configs.auth.enabled; + const hono = new Hono<{ + Variables: { + html: string; + }; + }>().basePath(this.withBasePath()); + const authRoutes = { + root: "/", + success: configs.auth.cookie.pathSuccess ?? "/", + loggedOut: configs.auth.cookie.pathLoggedOut ?? "/", + login: "/auth/login", + logout: "/auth/logout" + }; + + hono.use("*", async (c, next) => { + const obj = { + user: auth.authenticator?.getUser(), + logout_route: this.withBasePath(authRoutes.logout) + }; + const html = await this.getHtml(obj); + if (!html) { + console.warn("Couldn't generate HTML for admin UI"); + // re-casting to void as a return is not required + return c.notFound() as unknown as void; + } + c.set("html", html); + + // refresh cookie if needed + await auth.authenticator?.requestCookieRefresh(c); + await next(); + }); + + if (auth_enabled) { + hono.get(authRoutes.login, async (c) => { + if ( + this.app.module.auth.authenticator?.isUserLoggedIn() && + this.ctx.guard.granted(SystemPermissions.accessAdmin) + ) { + return c.redirect(authRoutes.success); + } + + const html = c.get("html"); + return c.html(html); + }); + + hono.get(authRoutes.logout, async (c) => { + await auth.authenticator?.logout(c); + return c.redirect(authRoutes.loggedOut); + }); + } + + hono.get("*", async (c) => { + if (!this.ctx.guard.granted(SystemPermissions.accessAdmin)) { + await addFlashMessage(c, "You are not authorized to access the Admin UI", "error"); + return c.redirect(authRoutes.login); + } + + const html = c.get("html"); + return c.html(html); + }); + + return hono; + } + + private async getHtml(obj: any = {}) { + const bknd_context = `window.__BKND__ = JSON.parse('${JSON.stringify(obj)}');`; + + if (this.options.html) { + if (this.options.html.includes(htmlBkndContextReplace)) { + return this.options.html.replace(htmlBkndContextReplace, bknd_context); + } + + console.warn( + `Custom HTML needs to include '${htmlBkndContextReplace}' to inject BKND context` + ); + return this.options.html as string; + } + + const configs = this.app.modules.configs(); + const isProd = !isDebug() && !this.options.forceDev; + + const assets = { + js: "main.js", + css: "styles.css" + }; + + if (isProd) { + try { + // @ts-ignore + const manifest = await import("bknd/dist/manifest.json", { + assert: { type: "json" } + }).then((m) => m.default); + assets.js = manifest["src/ui/main.tsx"].name; + assets.css = manifest["src/ui/main.css"].name; + } catch (e) { + console.error("Error loading manifest", e); + } + } + + return ( + + {/* dnd complains otherwise */} + {html``} + + + + + BKND + {isProd ? ( + + - -` - ); -} - -function createApp(config: BkndConfig, env: any) { - const create_config = typeof config.app === "function" ? config.app(env) : config.app; - return App.create(create_config); -} - -function setAppBuildListener(app: App, config: BkndConfig, html: string) { - app.emgr.on( - "app-built", - async () => { - await config.onBuilt?.(app); - app.module.server.setAdminHtml(html); - app.module.server.client.get("/assets/!*", serveStatic({ root: "./" })); - }, - "sync" - ); -} - -export async function serveFresh(config: BkndConfig, _html?: string) { - let html = _html; - if (!html) { - html = await getHtml(); - } - - html = addViteScripts(html); - - return { - async fetch(request: Request, env: any) { - const app = createApp(config, env); - - setAppBuildListener(app, config, html); - await app.build(); - - return app.fetch(request, env); - } - }; -} - registries.media.add("local", { cls: StorageLocalAdapter, schema: StorageLocalAdapter.prototype.getSchema() }); -const connection = new LibsqlConnection( - createClient({ - url: "file:.db/new.db" - }) -); +const credentials = { + url: import.meta.env.VITE_DB_URL!, + authToken: import.meta.env.VITE_DB_TOKEN! +}; +if (!credentials.url) { + throw new Error("Missing VITE_DB_URL env variable. Add it to .env file"); +} -const app = await serveFresh({ - app: { - connection - }, - setAdminHtml: true -}); +const connection = new LibsqlConnection(createClient(credentials)); -export default app; +export default { + async fetch(request: Request) { + const app = App.create({ connection }); + + app.emgr.on( + "app-built", + async () => { + app.registerAdminController({ forceDev: true }); + app.module.server.client.get("/assets/*", serveStatic({ root: "./" })); + }, + "sync" + ); + await app.build(); + + return app.fetch(request); + } +}; diff --git a/biome.json b/biome.json index 17ed925..f297b92 100644 --- a/biome.json +++ b/biome.json @@ -59,6 +59,9 @@ "noImplicitAnyLet": "warn", "noConfusingVoidType": "off" }, + "security": { + "noDangerouslySetInnerHtml": "off" + }, "style": { "noNonNullAssertion": "off", "noInferrableTypes": "off", diff --git a/bun.lockb b/bun.lockb index f97dc61..0d9a834 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/integration/nextjs.mdx b/docs/integration/nextjs.mdx index 007f94f..898568b 100644 --- a/docs/integration/nextjs.mdx +++ b/docs/integration/nextjs.mdx @@ -4,11 +4,6 @@ description: 'Run bknd inside Next.js' --- import InstallBknd from '/snippets/install-bknd.mdx'; - - Next.js support is currently experimental, this guide only covers adding bknd using `pages` - folder. - - ## Installation Install bknd as a dependency: @@ -17,10 +12,10 @@ Install bknd as a dependency: ``` tsx // pages/api/[...route].ts import { serve } from "bknd/adapter/nextjs"; -import type { PageConfig } from "next"; -export const config: PageConfig = { - runtime: "edge" +export const config = { + runtime: "experimental-edge", + unstable_allowDynamic: ["**/*.js"] }; export default serve({ @@ -39,20 +34,37 @@ For more information about the connection object, refer to the [Setup](/setup) g Create a file `[[...admin]].tsx` inside the `pages/admin` folder: ```tsx // pages/admin/[[...admin]].tsx -import type { PageConfig } from "next"; -import dynamic from "next/dynamic"; +import { adminPage, getServerSideProps } from "bknd/adapter/nextjs"; import "bknd/dist/styles.css"; -export const config: PageConfig = { - runtime: "experimental-edge", -}; +export { getServerSideProps }; +export default adminPage(); +``` -const Admin = dynamic( - () => import("bknd/ui").then((mod) => mod.Admin), - { ssr: false }, -); +## Example usage of the API in pages dir +Using pages dir, you need to wrap the `getServerSideProps` function with `withApi` to get access +to the API. With the API, you can query the database or retrieve the authentication status: +```tsx +import { withApi } from "bknd/adapter/nextjs"; +import type { InferGetServerSidePropsType as InferProps } from "next"; -export default function AdminPage() { - return ; +export const getServerSideProps = withApi(async (context) => { + const { data = [] } = await context.api.data.readMany("todos"); + const user = context.api.getUser(); + + return { props: { data, user } }; +}); + +export default function Home(props: InferProps) { + const { data, user } = props; + return ( +
+

Data

+
{JSON.stringify(data, null, 2)}
+ +

User

+
{JSON.stringify(user, null, 2)}
+
+ ); } ``` \ No newline at end of file diff --git a/docs/integration/remix.mdx b/docs/integration/remix.mdx index 900f533..5e5761f 100644 --- a/docs/integration/remix.mdx +++ b/docs/integration/remix.mdx @@ -4,10 +4,6 @@ description: 'Run bknd inside Remix' --- import InstallBknd from '/snippets/install-bknd.mdx'; - - Remix SSR support is currently limited. - - ## Installation Install bknd as a dependency: @@ -32,28 +28,90 @@ export const action = handler; ``` For more information about the connection object, refer to the [Setup](/setup) guide. +Now make sure that you wrap your root layout with the `ClientProvider` so that all components +share the same context: +```tsx +// app/root.tsx +export function Layout(props) { + // nothing to change here, just for orientation + return ( + {/* ... */} + ); +} + +// add the api to the `AppLoadContext` +// so you don't have to manually type it again +declare module "@remix-run/server-runtime" { + export interface AppLoadContext { + api: Api; + } +} + +// export a loader that initiates the API +// and pass it through the context +export const loader = async (args: LoaderFunctionArgs) => { + const api = new Api({ + host: new URL(args.request.url).origin, + headers: args.request.headers + }); + + // get the user from the API + const user = api.getUser(); + + // add api to the context + args.context.api = api; + + return { user }; +}; + +export default function App() { + const { user } = useLoaderData(); + return ( + + + + ); +} +``` + ## Enabling the Admin UI Create a new splat route file at `app/routes/admin.$.tsx`: ```tsx // app/routes/admin.$.tsx -import { Suspense, lazy, useEffect, useState } from "react"; +import { adminPage } from "bknd/adapter/remix"; import "bknd/dist/styles.css"; -const Admin = lazy(() => import("bknd/ui") - .then((mod) => ({ default: mod.Admin }))); +export default adminPage(); +``` -export default function AdminPage() { - const [loaded, setLoaded] = useState(false); - useEffect(() => { - setLoaded(true); - }, []); - if (!loaded) return null; +## Example usage of the API +Since the API has already been constructed in the root layout, you can now use it in any page: +```tsx +// app/routes/_index.tsx +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { useLoaderData } from "@remix-run/react"; + +export const loader = async (args: LoaderFunctionArgs) => { + const { api } = args.context; + + // get the authenticated user + const user = api.getAuthState().user; + + // get the data from the API + const { data } = await api.data.readMany("todos"); + return { data, user }; +}; + +export default function Index() { + const { data, user } = useLoaderData(); return ( - - - +
+

Data

+
{JSON.stringify(data, null, 2)}
+

User

+
{JSON.stringify(user, null, 2)}
+
); } - ``` \ No newline at end of file diff --git a/examples/bun/index.ts b/examples/bun/index.ts index 7432622..2d2b9cd 100644 --- a/examples/bun/index.ts +++ b/examples/bun/index.ts @@ -1,10 +1,9 @@ -/* -// somehow causes types:build issues on app - +// @ts-ignore somehow causes types:build issues on app import type { CreateAppConfig } from "bknd"; +// @ts-ignore somehow causes types:build issues on app import { serve } from "bknd/adapter/bun"; -const root = "../../node_modules/bknd/dist"; +// this is optional, if omitted, it uses an in-memory database const config = { connection: { type: "libsql", @@ -16,8 +15,12 @@ const config = { Bun.serve({ port: 1337, - fetch: serve(config, root) + fetch: serve( + config, + // this is only required to run inside the same workspace + // leave blank if you're running this from a different project + "../../app/dist" + ) }); console.log("Server running at http://localhost:1337"); -s*/ diff --git a/examples/bun/tsconfig.json b/examples/bun/tsconfig.json index 9e6a2e9..6f14404 100644 --- a/examples/bun/tsconfig.json +++ b/examples/bun/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "lib": ["ESNext", "DOM"], - "target": "ESNext", + "target": "ES2022", "module": "ESNext", "jsx": "react-jsx", "allowJs": true, diff --git a/examples/cloudflare-worker/build.ts b/examples/cloudflare-worker/build.ts index d643c41..746b59e 100644 --- a/examples/cloudflare-worker/build.ts +++ b/examples/cloudflare-worker/build.ts @@ -8,17 +8,20 @@ const result = await esbuild.build({ conditions: ["worker", "browser"], entryPoints: ["./src/index.ts"], outdir: "dist", - external: [], + external: ["__STATIC_CONTENT_MANIFEST", "cloudflare:workers"], format: "esm", target: "es2022", keepNames: true, bundle: true, metafile: true, minify: true, + loader: { + ".html": "copy" + }, define: { IS_CLOUDFLARE_WORKER: "true" } }); await Bun.write("dist/meta.json", JSON.stringify(result.metafile)); -//console.log("result", result.metafile); +await $`gzip dist/index.js -c > dist/index.js.gz`; diff --git a/examples/cloudflare-worker/src/index.ts b/examples/cloudflare-worker/src/index.ts index e6a9a9c..6a79894 100644 --- a/examples/cloudflare-worker/src/index.ts +++ b/examples/cloudflare-worker/src/index.ts @@ -8,15 +8,13 @@ export default serve( connection: { type: "libsql", config: { - url: env.DB_URL, - authToken: env.DB_TOKEN + url: "http://localhost:8080" } } }), onBuilt: async (app) => { - app.modules.server.get("/", (c) => c.json({ hello: "world" })); - }, - setAdminHtml: true + app.modules.server.get("/hello", (c) => c.json({ hello: "world" })); + } }, manifest ); diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index 40b4da3..fc734a4 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -4,6 +4,8 @@ "private": true, "scripts": { "dev": "next dev", + "db": "turso dev --db-file test.db", + "db:check": "sqlite3 test.db \"PRAGMA wal_checkpoint(FULL);\"", "build": "next build", "start": "next start", "lint": "next lint" diff --git a/examples/nextjs/src/components/BkndAdmin.tsx b/examples/nextjs/src/components/BkndAdmin.tsx deleted file mode 100644 index 4d885db..0000000 --- a/examples/nextjs/src/components/BkndAdmin.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import dynamic from "next/dynamic"; - -const Admin = dynamic(() => import("bknd/ui").then((mod) => mod.Admin), { ssr: false }); -if (typeof window !== "undefined") { - // @ts-ignore - import("bknd/dist/styles.css"); -} - -export function BkndAdmin() { - return ; -} diff --git a/examples/nextjs/src/pages/admin/[[...admin]].tsx b/examples/nextjs/src/pages/admin/[[...admin]].tsx index d1e98b5..58b4991 100644 --- a/examples/nextjs/src/pages/admin/[[...admin]].tsx +++ b/examples/nextjs/src/pages/admin/[[...admin]].tsx @@ -1,14 +1,5 @@ -import type { PageConfig } from "next"; -import dynamic from "next/dynamic"; - -export const config: PageConfig = { - runtime: "experimental-edge" -}; - -const Admin = dynamic(() => import("bknd/ui").then((mod) => mod.Admin), { ssr: false }); +import { adminPage, getServerSideProps } from "bknd/adapter/nextjs"; import "bknd/dist/styles.css"; -export default function AdminPage() { - if (typeof document === "undefined") return null; - return ; -} +export { getServerSideProps }; +export default adminPage(); diff --git a/examples/nextjs/src/pages/api/[...route].ts b/examples/nextjs/src/pages/api/[...route].ts index a9d64c5..5455c9e 100644 --- a/examples/nextjs/src/pages/api/[...route].ts +++ b/examples/nextjs/src/pages/api/[...route].ts @@ -1,59 +1,19 @@ import { serve } from "bknd/adapter/nextjs"; -import type { PageConfig } from "next"; -export const config: PageConfig = { - runtime: "edge" +export const config = { + runtime: "experimental-edge", + // add a matcher for bknd dist to allow dynamic otherwise build may fail. + // inside this repo it's '../../app/dist/index.js', outside probably inside node_modules + // see https://github.com/vercel/next.js/issues/51401 + // and https://github.com/vercel/next.js/pull/69402 + unstable_allowDynamic: ["**/*.js"] }; export default serve({ connection: { type: "libsql", config: { - url: process.env.DB_URL!, - authToken: process.env.DB_AUTH_TOKEN! + url: "http://localhost:8080" } } -}); /* -let app: App; - -async function getApp() { - if (!app) { - app = App.create({ - connection: { - type: "libsql", - config: { - url: process.env.DB_URL!, - authToken: process.env.DB_AUTH_TOKEN! - } - } - }); - await app.build(); - } - - return app; -} - -function getCleanRequest(req: Request) { - // clean search params from "route" attribute - const url = new URL(req.url); - url.searchParams.delete("route"); - return new Request(url.toString(), { - method: req.method, - headers: req.headers, - body: req.body - }); -} - -export default async (req: Request, requestContext: any) => { - //console.log("here"); - if (!app) { - app = await getApp(); - } - //const app = await getApp(); - const request = getCleanRequest(req); - //console.log("url", req.url); - //console.log("req", req); - return app.fetch(request, process.env); -}; -//export default handle(app.server.getInstance()) -*/ +}); diff --git a/examples/nextjs/src/pages/index.tsx b/examples/nextjs/src/pages/index.tsx index 5948fd2..53e81f0 100644 --- a/examples/nextjs/src/pages/index.tsx +++ b/examples/nextjs/src/pages/index.tsx @@ -1,115 +1,24 @@ -import Image from "next/image"; -import localFont from "next/font/local"; +import { withApi } from "bknd/adapter/nextjs"; +import type { InferGetServerSidePropsType } from "next"; -const geistSans = localFont({ - src: "./fonts/GeistVF.woff", - variable: "--font-geist-sans", - weight: "100 900", -}); -const geistMono = localFont({ - src: "./fonts/GeistMonoVF.woff", - variable: "--font-geist-mono", - weight: "100 900", +export const getServerSideProps = withApi(async (context) => { + const { data = [] } = await context.api.data.readMany("todos"); + const user = context.api.getUser(); + + return { props: { data, user } }; }); -export default function Home() { - return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/pages/index.tsx - - . -
  2. -
  3. Save and see your changes instantly.
  4. -
+export default function Home({ + data, + user +}: InferGetServerSidePropsType) { + return ( +
+

Data

+
{JSON.stringify(data, null, 2)}
- -
- -
- ); +

User

+
{JSON.stringify(user, null, 2)}
+ + ); } diff --git a/examples/nextjs/test.db b/examples/nextjs/test.db new file mode 100644 index 0000000..e2b5b06 Binary files /dev/null and b/examples/nextjs/test.db differ diff --git a/examples/node/index.js b/examples/node/index.js new file mode 100644 index 0000000..92ae1d2 --- /dev/null +++ b/examples/node/index.js @@ -0,0 +1,20 @@ +import { serve } from "bknd/adapter/node"; + +// this is optional, if omitted, it uses an in-memory database +/** @type {import("bknd").CreateAppConfig} */ +const config = { + connection: { + type: "libsql", + config: { + url: "http://localhost:8080" + } + } +}; + +serve(config, { + relativeDistPath: "../../node_modules/bknd/dist", + port: 1337, + listener: ({ port }) => { + console.log(`Server is running on http://localhost:${port}`); + } +}); diff --git a/examples/node/package.json b/examples/node/package.json new file mode 100644 index 0000000..630b647 --- /dev/null +++ b/examples/node/package.json @@ -0,0 +1,20 @@ +{ + "name": "node", + "module": "index.js", + "type": "module", + "private": true, + "scripts": { + "start": "node index.js", + "dev": "tsx --watch index.js" + }, + "dependencies": { + "bknd": "workspace:*", + "@hono/node-server": "^1.13.3" + }, + "devDependencies": { + "tsx": "^4.19.2" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } +} \ No newline at end of file diff --git a/examples/remix/app/root.tsx b/examples/remix/app/root.tsx index 39665ff..6c36638 100644 --- a/examples/remix/app/root.tsx +++ b/examples/remix/app/root.tsx @@ -1,7 +1,12 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; -import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react"; -import { Api } from "bknd"; -import { ClientProvider } from "bknd/ui"; +import { Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData } from "@remix-run/react"; +import { Api, ClientProvider } from "bknd/client"; + +declare module "@remix-run/server-runtime" { + export interface AppLoadContext { + api: Api; + } +} export function Layout({ children }: { children: React.ReactNode }) { return ( @@ -22,15 +27,26 @@ export function Layout({ children }: { children: React.ReactNode }) { } export const loader = async (args: LoaderFunctionArgs) => { - args.context.api = new Api({ - host: new URL(args.request.url).origin + const api = new Api({ + host: new URL(args.request.url).origin, + headers: args.request.headers }); - return null; + + // add api to the context + args.context.api = api; + + return { + user: api.getAuthState().user + }; }; export default function App() { + const data = useLoaderData(); + + // add user to the client provider to indicate + // that you're authed using cookie return ( - + ); diff --git a/examples/remix/app/routes/_index.tsx b/examples/remix/app/routes/_index.tsx index 37c3c55..78f547f 100644 --- a/examples/remix/app/routes/_index.tsx +++ b/examples/remix/app/routes/_index.tsx @@ -1,30 +1,26 @@ -import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; -import { useLoaderData } from "@remix-run/react"; -import type { Api } from "bknd"; -import { useClient } from "bknd/ui"; +import { type MetaFunction, useLoaderData } from "@remix-run/react"; +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; export const meta: MetaFunction = () => { return [{ title: "Remix & bknd" }, { name: "description", content: "Welcome to Remix & bknd!" }]; }; export const loader = async (args: LoaderFunctionArgs) => { - const api = args.context.api as Api; + const api = args.context.api; + const user = api.getAuthState().user; const { data } = await api.data.readMany("todos"); - return { data }; + return { data, user }; }; export default function Index() { - const data = useLoaderData(); - const client = useClient(); - - const query = client.query().data.entity("todos").readMany(); + const { data, user } = useLoaderData(); return (
- hello -
{client.baseUrl}
+

Data

{JSON.stringify(data, null, 2)}
-
{JSON.stringify(query.data, null, 2)}
+

User

+
{JSON.stringify(user, null, 2)}
); } diff --git a/examples/remix/app/routes/admin.$.tsx b/examples/remix/app/routes/admin.$.tsx index 76d0c75..0207428 100644 --- a/examples/remix/app/routes/admin.$.tsx +++ b/examples/remix/app/routes/admin.$.tsx @@ -1,18 +1,4 @@ -import { Suspense, lazy, useEffect, useState } from "react"; - -const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin }))); +import { adminPage } from "bknd/adapter/remix"; import "bknd/dist/styles.css"; -export default function AdminPage() { - const [loaded, setLoaded] = useState(false); - useEffect(() => { - setLoaded(true); - }, []); - if (!loaded) return null; - - return ( - - - - ); -} +export default adminPage(); diff --git a/examples/remix/package.json b/examples/remix/package.json index 40b6617..5de343f 100644 --- a/examples/remix/package.json +++ b/examples/remix/package.json @@ -6,7 +6,8 @@ "scripts": { "build": "remix vite:build", "dev": "remix vite:dev", - "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", + "db": "turso dev --db-file test.db", + "db:check": "sqlite3 test.db \"PRAGMA wal_checkpoint(FULL);\"", "start": "remix-serve ./build/server/index.js", "typecheck": "tsc" }, @@ -17,7 +18,8 @@ "bknd": "workspace:*", "isbot": "^4.1.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "remix-utils": "^7.7.0" }, "devDependencies": { "@remix-run/dev": "^2.14.0", diff --git a/examples/remix/test.db b/examples/remix/test.db new file mode 100644 index 0000000..a614373 Binary files /dev/null and b/examples/remix/test.db differ diff --git a/examples/sw/index.html b/examples/sw/index.html index 886bb69..03a320d 100644 --- a/examples/sw/index.html +++ b/examples/sw/index.html @@ -7,10 +7,7 @@ Params
- +
@@ -29,6 +26,19 @@