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..6e00e61 --- /dev/null +++ b/app/build.ts @@ -0,0 +1,175 @@ +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") +}); diff --git a/app/index.html b/app/index.html deleted file mode 100644 index 47f4b01..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 1c6b57a..a12d36f 100644 --- a/app/package.json +++ b/app/package.json @@ -5,18 +5,16 @@ "bin": "./dist/cli/index.js", "version": "0.0.13", "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" @@ -77,6 +75,7 @@ "@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,20 +87,6 @@ "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", "bknd/dist/manifest.json"], - "sourcemap": true, - "metafile": true, - "platform": "browser", - "format": ["esm", "cjs"], - "splitting": true, - "loader": { - ".svg": "dataurl" - } - }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" @@ -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", @@ -170,10 +160,8 @@ "import": "./dist/adapter/node/index.js", "require": "./dist/adapter/node/index.cjs" }, - "./dist/static/manifest.json": "./dist/static/.vite/manifest.json", - "./dist/styles.css": "./dist/styles.css", - "./dist/index.html": "./dist/static/index.html", - "./dist/manifest.json": "./dist/static/.vite/manifest.json" + "./dist/styles.css": "./dist/ui/main.css", + "./dist/manifest.json": "./dist/static/manifest.json" }, "files": [ "dist", diff --git a/app/src/adapter/vite/vite.adapter.ts b/app/src/adapter/vite/vite.adapter.ts index 6d7b3cf..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.registerAdminController(); - 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/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 dbb5cad..0663f2f 100644 --- a/app/src/cli/commands/run/run.ts +++ b/app/src/cli/commands/run/run.ts @@ -54,7 +54,7 @@ async function makeApp(config: MakeAppConfig) { "app-built", async () => { await attachServeStatic(app, config.server?.platform ?? "node"); - app.registerAdminController({ html: await getHtml() }); + app.registerAdminController(); if (config.onBuilt) { await config.onBuilt(app); @@ -75,7 +75,7 @@ export async function makeConfigApp(config: BkndConfig, platform?: Platform) { "app-built", async () => { await attachServeStatic(app, platform ?? "node"); - app.registerAdminController({ html: await getHtml() }); + app.registerAdminController(); if (config.onBuilt) { await config.onBuilt(app); diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index cd252a5..522c55a 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -7,20 +7,12 @@ import { Hono } from "hono"; import { html } from "hono/html"; import { Fragment } from "hono/jsx"; import * as SystemPermissions from "modules/permissions"; -import type { Manifest } from "vite"; -const viteInject = ` -import RefreshRuntime from "/@react-refresh" -RefreshRuntime.injectIntoGlobalHook(window) -window.$RefreshReg$ = () => {} -window.$RefreshSig$ = () => (type) => type -window.__vite_plugin_react_preamble_installed__ = true -`; const htmlBkndContextReplace = ""; export type AdminControllerOptions = { html?: string; - viteManifest?: Manifest; + forceDev?: boolean; }; export class AdminController implements ClassController { @@ -114,44 +106,36 @@ export class AdminController implements ClassController { } console.warn( - "Custom HTML needs to include '' to inject BKND context" + `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; - // @todo: implement guard redirect once cookie sessions arrive + const assets = { + js: "main.js", + css: "styles.css" + }; - const isProd = !isDebug(); - let script: string | undefined; - let css: string[] = []; - - // @todo: check why nextjs imports manifest, it's not required if (isProd) { - const manifest: Manifest = this.options.viteManifest - ? this.options.viteManifest - : isProd - ? // @ts-ignore cases issues when building types - await import("bknd/dist/manifest.json", { assert: { type: "json" } }).then( - (m) => m.default - ) - : {}; - //console.log("manifest", manifest, manifest["index.html"]); - const entry = Object.values(manifest).find((f: any) => f.isEntry === true); - if (!entry) { - // do something smart - return; + 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); } - - script = "/" + entry.file; - css = entry.css?.map((c: string) => "/" + c) ?? []; } return ( {/* dnd complains otherwise */} - {html``} + {html``} @@ -162,14 +146,21 @@ export class AdminController implements ClassController { BKND {isProd ? ( -