diff --git a/bun.lock b/bun.lock index a002cc6e..ec7c7bda 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ }, "app": { "name": "bknd", - "version": "0.15.0", + "version": "0.16.0-rc.0", "bin": "./dist/cli/index.js", "dependencies": { "@cfworker/json-schema": "^4.1.1", @@ -32,7 +32,7 @@ "bcryptjs": "^3.0.2", "dayjs": "^1.11.13", "fast-xml-parser": "^5.0.8", - "hono": "^4.7.11", + "hono": "4.8.3", "json-schema-form-react": "^0.0.2", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", @@ -72,7 +72,7 @@ "dotenv": "^16.4.7", "jotai": "^2.12.2", "jsdom": "^26.0.0", - "jsonv-ts": "^0.2.2", + "jsonv-ts": "link:jsonv-ts", "kysely-d1": "^0.3.0", "kysely-generic-sqlite": "^1.2.1", "libsql-stateless-easy": "^1.8.0", @@ -124,7 +124,6 @@ "version": "0.5.1", "devDependencies": { "@types/bun": "latest", - "bknd": "workspace:*", "tsdx": "^0.14.1", "typescript": "^5.0.0", }, @@ -2208,7 +2207,7 @@ "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], - "hono": ["hono@4.7.11", "", {}, "sha512-rv0JMwC0KALbbmwJDEnxvQCeJh+xbS3KEWW5PC9cMJ08Ur9xgatI0HmtgYZfOdOSOeYsp5LO2cOhdI8cLEbDEQ=="], + "hono": ["hono@4.8.3", "", {}, "sha512-jYZ6ZtfWjzBdh8H/0CIFfCBHaFL75k+KMzaM177hrWWm2TWL39YMYaJgB74uK/niRc866NMlH9B8uCvIo284WQ=="], "hosted-git-info": ["hosted-git-info@2.8.9", "", {}, "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw=="], @@ -2500,7 +2499,7 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsonv-ts": ["jsonv-ts@0.2.2", "", { "optionalDependencies": { "hono": "4.7.11" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-g4kUDYYPBb32Nfbv3R55Se6G4CAv4UOXOPJIEPqw8XX+0SSy44T/AREkwFfGz9GwYuX9UxcvOILbgac2nshL4A=="], + "jsonv-ts": ["jsonv-ts@link:jsonv-ts", {}], "jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="], diff --git a/examples/waku/.gitignore b/examples/waku/.gitignore new file mode 100644 index 00000000..ad583432 --- /dev/null +++ b/examples/waku/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.env* +*.tsbuildinfo +.cache +.DS_Store +*.pem diff --git a/examples/waku/bknd-types.d.ts b/examples/waku/bknd-types.d.ts new file mode 100644 index 00000000..91125535 --- /dev/null +++ b/examples/waku/bknd-types.d.ts @@ -0,0 +1,15 @@ +import type { DB } from "bknd/core"; +import type { Insertable, Selectable, Updateable, Generated } from "kysely"; + +declare global { + type BkndEntity = Selectable; + type BkndEntityCreate = Insertable; + type BkndEntityUpdate = Updateable; +} + +interface Database { +} + +declare module "bknd/core" { + interface DB extends Database {} +} \ No newline at end of file diff --git a/examples/waku/bknd.config.ts b/examples/waku/bknd.config.ts new file mode 100644 index 00000000..4cf18aab --- /dev/null +++ b/examples/waku/bknd.config.ts @@ -0,0 +1,64 @@ +import { registerLocalMediaAdapter } from "bknd/adapter/node"; +import type { BkndConfig } from "bknd/adapter"; +import { boolean, em, entity, text } from "bknd/data"; +import { secureRandomString } from "bknd/utils"; + +// since we're running in node, we can register the local media adapter +const local = registerLocalMediaAdapter(); + +const schema = em({ + todos: entity("todos", { + title: text(), + done: boolean(), + }), +}); + +// register your schema to get automatic type completion +type Database = (typeof schema)["DB"]; +declare module "bknd/core" { + interface DB extends Database {} +} + +export default { + // we can use any libsql config, and if omitted, uses in-memory + app: (env) => ({ + connection: { + url: env?.DB_URL ?? "file:data.db", + }, + }), + // an initial config is only applied if the database is empty + initialConfig: { + data: schema.toJSON(), + // we're enabling auth ... + auth: { + enabled: true, + jwt: { + issuer: "bknd-remix-example", + secret: secureRandomString(64), + }, + }, + // ... and media + media: { + enabled: true, + adapter: local({ + path: "./public/uploads", + }), + }, + }, + options: { + // the seed option is only executed if the database was empty + seed: async (ctx) => { + // create some entries + await ctx.em.mutator("todos").insertMany([ + { title: "Learn bknd", done: true }, + { title: "Build something cool", done: false }, + ]); + + // and create a user + await ctx.app.module.auth.createUser({ + email: "test@bknd.io", + password: "12345678", + }); + }, + }, +} as const satisfies BkndConfig<{ DB_URL?: string }>; diff --git a/examples/waku/package.json b/examples/waku/package.json new file mode 100644 index 00000000..1ce31e49 --- /dev/null +++ b/examples/waku/package.json @@ -0,0 +1,26 @@ +{ + "name": "waku", + "version": "0.0.0", + "type": "module", + "private": true, + "scripts": { + "dev": "waku dev", + "build": "waku build", + "start": "waku start" + }, + "dependencies": { + "react": "19.0.0", + "react-dom": "19.0.0", + "react-server-dom-webpack": "19.0.0", + "waku": "0.23.3", + "bknd": "file:../../app" + }, + "devDependencies": { + "@tailwindcss/postcss": "4.1.10", + "@types/react": "19.1.8", + "@types/react-dom": "19.1.6", + "postcss": "8.5.6", + "tailwindcss": "4.1.10", + "typescript": "5.8.3" + } +} diff --git a/examples/waku/postcss.config.js b/examples/waku/postcss.config.js new file mode 100644 index 00000000..a34a3d56 --- /dev/null +++ b/examples/waku/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; diff --git a/examples/waku/public/images/favicon.png b/examples/waku/public/images/favicon.png new file mode 100644 index 00000000..cd90d790 Binary files /dev/null and b/examples/waku/public/images/favicon.png differ diff --git a/examples/waku/public/robots.txt b/examples/waku/public/robots.txt new file mode 100644 index 00000000..b4d27bb5 --- /dev/null +++ b/examples/waku/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: /RSC/ \ No newline at end of file diff --git a/examples/waku/src/bknd/admin/impl.tsx b/examples/waku/src/bknd/admin/impl.tsx new file mode 100644 index 00000000..e1c9afd0 --- /dev/null +++ b/examples/waku/src/bknd/admin/impl.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { Admin, type BkndAdminProps } from "bknd/ui"; + +export const AdminImpl = (props: BkndAdminProps) => { + if (typeof window === "undefined") { + return null; + } + + return ( + + ); +}; + +export default AdminImpl; diff --git a/examples/waku/src/bknd/admin/index.tsx b/examples/waku/src/bknd/admin/index.tsx new file mode 100644 index 00000000..fe7643d9 --- /dev/null +++ b/examples/waku/src/bknd/admin/index.tsx @@ -0,0 +1,18 @@ +"use client"; + +import type { BkndAdminProps } from "bknd/ui"; +import { lazy } from "react"; +import { BrowserOnly } from "../../lib/waku/client"; + +const AdminImpl = import.meta.env.SSR ? undefined : lazy(() => import("./impl")); + +export const AdminLoader = (props: BkndAdminProps) => { + return ( + + {/* @ts-expect-error */} + + + ); +}; + +export default AdminLoader; diff --git a/examples/waku/src/bknd/index.ts b/examples/waku/src/bknd/index.ts new file mode 100644 index 00000000..5f9d4ae6 --- /dev/null +++ b/examples/waku/src/bknd/index.ts @@ -0,0 +1,22 @@ +import { createFrameworkApp } from "bknd/adapter"; +import config from "../../bknd.config"; + +export async function getApp() { + return await createFrameworkApp(config, process.env, { + force: import.meta.env && !import.meta.env.PROD, + }); +} + +export async function getApi(opts?: { + headers?: Headers | any; + verify?: boolean; +}) { + const app = await getApp(); + if (opts?.verify && opts.headers) { + const api = app.getApi({ headers: opts.headers }); + await api.verifyAuth(); + return api; + } + + return app.getApi(); +} diff --git a/examples/waku/src/components/counter.tsx b/examples/waku/src/components/counter.tsx new file mode 100644 index 00000000..0e540b8b --- /dev/null +++ b/examples/waku/src/components/counter.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { useState } from 'react'; + +export const Counter = () => { + const [count, setCount] = useState(0); + + const handleIncrement = () => setCount((c) => c + 1); + + return ( +
+
Count: {count}
+ +
+ ); +}; diff --git a/examples/waku/src/components/footer.tsx b/examples/waku/src/components/footer.tsx new file mode 100644 index 00000000..8cfd9c89 --- /dev/null +++ b/examples/waku/src/components/footer.tsx @@ -0,0 +1,18 @@ +export const Footer = () => { + return ( + + ); +}; diff --git a/examples/waku/src/components/header.tsx b/examples/waku/src/components/header.tsx new file mode 100644 index 00000000..1b03ba54 --- /dev/null +++ b/examples/waku/src/components/header.tsx @@ -0,0 +1,11 @@ +import { Link } from 'waku'; + +export const Header = () => { + return ( +
+

+ Waku starter +

+
+ ); +}; diff --git a/examples/waku/src/lib/waku/client.tsx b/examples/waku/src/lib/waku/client.tsx new file mode 100644 index 00000000..2d889c97 --- /dev/null +++ b/examples/waku/src/lib/waku/client.tsx @@ -0,0 +1,20 @@ +"use client"; + +import * as React from "react"; + +export function BrowserOnly(props: React.SuspenseProps) { + const hydrated = useHydrated(); + if (!hydrated) { + return props.fallback; + } + return ; +} + +const noopStore = () => () => {}; + +const useHydrated = () => + React.useSyncExternalStore( + noopStore, + () => true, + () => false + ); diff --git a/examples/waku/src/lib/waku/server.ts b/examples/waku/src/lib/waku/server.ts new file mode 100644 index 00000000..608aa26e --- /dev/null +++ b/examples/waku/src/lib/waku/server.ts @@ -0,0 +1,26 @@ +"use server"; + +import { getContext } from "waku/middleware/context"; +import { getApi } from "../../bknd"; + +export { unstable_rerenderRoute as rerender } from "waku/router/server"; + +export function context() { + return getContext(); +} + +export function handlerReq() { + return getContext().req; +} + +export function headers() { + const context = getContext(); + return new Headers(context.req.headers); +} + +export async function getUserApi(opts?: { verify?: boolean }) { + return await getApi({ + headers: headers(), + verify: !!opts?.verify, + }); +} diff --git a/examples/waku/src/pages.gen.ts b/examples/waku/src/pages.gen.ts new file mode 100644 index 00000000..a3c9fb7f --- /dev/null +++ b/examples/waku/src/pages.gen.ts @@ -0,0 +1,30 @@ +// deno-fmt-ignore-file +// biome-ignore format: generated types do not need formatting +// prettier-ignore +import type { PathsForPages, GetConfigResponse } from 'waku/router'; + +// prettier-ignore +import type { getConfig as File_About_getConfig } from './pages/about'; +// prettier-ignore +import type { getConfig as File_AdminAdmin_getConfig } from './pages/admin/[...admin]'; +// prettier-ignore +import type { getConfig as File_Index_getConfig } from './pages/index'; + +// prettier-ignore +type Page = +| ({ path: '/about' } & GetConfigResponse) +| ({ path: '/admin/[...admin]' } & GetConfigResponse) +| { path: '/admin'; render: 'dynamic' } +| ({ path: '/' } & GetConfigResponse) +| { path: '/login'; render: 'dynamic' } +| { path: '/test'; render: 'dynamic' }; + +// prettier-ignore +declare module 'waku/router' { + interface RouteConfig { + paths: PathsForPages; + } + interface CreatePagesConfig { + pages: Page; + } +} diff --git a/examples/waku/src/pages/_layout.tsx b/examples/waku/src/pages/_layout.tsx new file mode 100644 index 00000000..2e3c7e93 --- /dev/null +++ b/examples/waku/src/pages/_layout.tsx @@ -0,0 +1,28 @@ +import "../styles.css"; + +import type { ReactNode } from "react"; + +import { ClientProvider } from "bknd/client"; + +type RootLayoutProps = { children: ReactNode }; + +export default async function RootLayout({ children }: RootLayoutProps) { + const data = await getData(); + + return children; +} + +const getData = async () => { + const data = { + description: "An internet website!", + icon: "/images/favicon.png", + }; + + return data; +}; + +export const getConfig = async () => { + return { + render: "static", + } as const; +}; diff --git a/examples/waku/src/pages/about.tsx b/examples/waku/src/pages/about.tsx new file mode 100644 index 00000000..15d4c90e --- /dev/null +++ b/examples/waku/src/pages/about.tsx @@ -0,0 +1,32 @@ +import { Link } from 'waku'; + +export default async function AboutPage() { + const data = await getData(); + + return ( +
+ {data.title} +

{data.headline}

+

{data.body}

+ + Return home + +
+ ); +} + +const getData = async () => { + const data = { + title: 'About', + headline: 'About Waku', + body: 'The minimal React framework', + }; + + return data; +}; + +export const getConfig = async () => { + return { + render: 'static', + } as const; +}; diff --git a/examples/waku/src/pages/admin/[...admin].tsx b/examples/waku/src/pages/admin/[...admin].tsx new file mode 100644 index 00000000..72db1a23 --- /dev/null +++ b/examples/waku/src/pages/admin/[...admin].tsx @@ -0,0 +1,32 @@ +/** + * See https://github.com/wakujs/waku/issues/1499 + */ + +import { Suspense, lazy } from "react"; +import { getUserApi } from "../../lib/waku/server"; + +const AdminComponent = lazy(() => import("../../bknd/admin")); + +export default async function HomePage() { + const api = await getUserApi({ verify: true }); + + // @ts-ignore + const styles = await import("bknd/dist/styles.css?inline").then((m) => m.default); + return ( + <> + + + + + + ); +} + +// Enable dynamic server rendering. +// Static rendering is possible if you want to render at build time. +// The Hono context will not be available. +export const getConfig = async () => { + return { + render: "dynamic", + } as const; +}; diff --git a/examples/waku/src/pages/admin/index.tsx b/examples/waku/src/pages/admin/index.tsx new file mode 100644 index 00000000..7d32f6fe --- /dev/null +++ b/examples/waku/src/pages/admin/index.tsx @@ -0,0 +1,6 @@ +import { unstable_redirect as redirect } from "waku/router/server"; + +export default async function AdminIndex() { + await new Promise((resolve) => setTimeout(resolve, 100)); + redirect("/admin/data"); +} diff --git a/examples/waku/src/pages/api/[...api].ts b/examples/waku/src/pages/api/[...api].ts new file mode 100644 index 00000000..eaf8bb35 --- /dev/null +++ b/examples/waku/src/pages/api/[...api].ts @@ -0,0 +1,5 @@ +import { getApp } from "../../bknd"; + +export default async function handler(request: Request) { + return (await getApp()).fetch(request); +} diff --git a/examples/waku/src/pages/index.tsx b/examples/waku/src/pages/index.tsx new file mode 100644 index 00000000..d4c0c2cc --- /dev/null +++ b/examples/waku/src/pages/index.tsx @@ -0,0 +1,72 @@ +import { Link } from "waku"; + +import { Counter } from "../components/counter"; +import { getUserApi, rerender } from "../lib/waku/server"; + +async function toggleTodo(todo: any, path: string) { + "use server"; + console.log("toggleTodo", todo, path); + const api = await getUserApi(); + await api.data.updateOne("todos", todo.id, { + done: !todo.done, + }); + console.log("rerender"); + rerender(path); +} + +export default async function HomePage({ path }: any) { + const api = await getUserApi({ verify: true }); + const todos = await api.data.readMany("todos"); + const user = api.getUser(); + const data = await getData(); + + return ( +
+ {data.title} +

{data.headline}

+

{data.body}

+
    + {todos?.map((todo) => ( +
  • + {todo.title} {todo.done ? "✅" : "❌"} +
    + +
    +
  • + ))} +
+ + + About page + + {user ? ( + + Logout ({user.email}) + + ) : ( + + Login + + )} + + Admin + +
+ ); +} + +const getData = async () => { + const data = { + title: "Waku", + headline: "Waku", + body: "Hello world!", + }; + + return data; +}; + +export const getConfig = async () => { + return { + render: "dynamic", + } as const; +}; diff --git a/examples/waku/src/pages/login.tsx b/examples/waku/src/pages/login.tsx new file mode 100644 index 00000000..db41ac0a --- /dev/null +++ b/examples/waku/src/pages/login.tsx @@ -0,0 +1,9 @@ +export default function LoginPage() { + return ( +
+ + + +
+ ); +} diff --git a/examples/waku/src/pages/test.tsx b/examples/waku/src/pages/test.tsx new file mode 100644 index 00000000..d74ce923 --- /dev/null +++ b/examples/waku/src/pages/test.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useEffect } from "react"; + +export default function Test() { + useEffect(() => { + bridge(); + }, []); + return null; +} + +async function bridge() { + const aud = new URLSearchParams(location.search).get("aud") || ""; + // 1. Verify the user still has an auth cookie + const me = await fetch("/api/auth/me", { credentials: "include" }); + console.log("sso-bridge:me", me); + if (!me.ok) { + console.log("sso-bridge:no session"); + parent.postMessage({ type: "NOSESSION" }, aud); + } else { + console.log("sso-bridge:session"); + + // 2. Get short-lived JWT (internal endpoint, same origin) + const res = await fetch("/api/issue-jwt", { + credentials: "include", + headers: { "Content-Type": "application/json" }, + method: "POST", + body: JSON.stringify({ + aud, + }), + }); + console.log("sso-bridge:res", res); + const { jwt, exp } = (await res.json()) as any; // exp = unix timestamp seconds + console.log("sso-bridge:jwt", { jwt, exp }); + + // 3. Send token up + parent.postMessage({ type: "JWT", jwt, exp }, aud); + + // 4. Listen for refresh requests + window.addEventListener("message", async (ev) => { + console.log("sso-bridge:message", ev); + if (ev.origin !== aud) return; + if (ev.data !== "REFRESH") return; + console.log("sso-bridge:message:refresh"); + + const r = await fetch("/api/issue-jwt", { + credentials: "include", + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ aud: ev.origin }), + }); + console.log("sso-bridge:message:r", r); + const { jwt, exp } = (await r.json()) as any; + console.log("sso-bridge:message:jwt", { jwt, exp }); + parent.postMessage({ type: "JWT", jwt, exp }, ev.origin); + }); + } +} diff --git a/examples/waku/src/styles.css b/examples/waku/src/styles.css new file mode 100644 index 00000000..f1d3c37c --- /dev/null +++ b/examples/waku/src/styles.css @@ -0,0 +1,3 @@ +@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,400;0,700;1,400;1,700&display=swap') +layer(base); +@import 'tailwindcss'; diff --git a/examples/waku/tsconfig.json b/examples/waku/tsconfig.json new file mode 100644 index 00000000..c85d769b --- /dev/null +++ b/examples/waku/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "strict": true, + "target": "esnext", + "noEmit": true, + "isolatedModules": true, + "moduleDetection": "force", + "downlevelIteration": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "jsx": "react-jsx" + } +}