diff --git a/app/package.json b/app/package.json index 58210db..1c6b57a 100644 --- a/app/package.json +++ b/app/package.json @@ -72,6 +72,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", @@ -96,7 +97,7 @@ "metafile": true, "platform": "browser", "format": ["esm", "cjs"], - "splitting": false, + "splitting": true, "loader": { ".svg": "dataurl" } diff --git a/app/src/Api.ts b/app/src/Api.ts index f54ccdf..d94aff9 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -5,17 +5,19 @@ 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?: any; + user?: TApiUser; }; } } export type ApiOptions = { host: string; - user?: object; + user?: TApiUser; token?: string; headers?: Headers; key?: string; @@ -24,7 +26,7 @@ export type ApiOptions = { export class Api { private token?: string; - private user?: object; + private user?: TApiUser; private verified = false; private token_transport: "header" | "cookie" | "none" = "header"; @@ -111,6 +113,10 @@ export class Api { }; } + getUser(): TApiUser | null { + return this.user || null; + } + private buildApis() { const baseParams = { host: this.options.host, diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index a7cb1a4..9dc071c 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -5,8 +5,6 @@ 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 = { diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index 820c3c2..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) => { @@ -35,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..b8565d9 100644 --- a/app/src/adapter/nextjs/nextjs.adapter.ts +++ b/app/src/adapter/nextjs/nextjs.adapter.ts @@ -1,5 +1,36 @@ -import { App, type CreateAppConfig } from "bknd"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { Api, App, type CreateAppConfig } from "bknd"; import { isDebug } from "bknd/core"; +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 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/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index ff98c41..32c7b42 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -257,11 +257,15 @@ export class Authenticator = Record< return c.json(data); } - const referer = new URL(redirect ?? c.req.header("Referer") ?? "/"); + const successPath = "/"; + const successUrl = new URL(c.req.url).origin + successPath.replace(/\/+$/, "/"); + const referer = new URL(redirect ?? c.req.header("Referer") ?? successUrl); if ("token" in data) { + // @todo: add config await this.setAuthCookie(c, data.token); - return c.redirect("/"); + // can't navigate to "/" – doesn't work on nextjs + return c.redirect(successUrl); } let message = "An error occured"; diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index 1cffa11..96af5c1 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -117,6 +117,7 @@ export class AdminController implements ClassController { 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 diff --git a/app/src/ui/client/ClientProvider.tsx b/app/src/ui/client/ClientProvider.tsx index 4cca06b..078d23a 100644 --- a/app/src/ui/client/ClientProvider.tsx +++ b/app/src/ui/client/ClientProvider.tsx @@ -1,4 +1,5 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { TApiUser } from "Api"; import { createContext, useContext, useEffect, useState } from "react"; import { useBkndWindowContext } from "ui/client/BkndProvider"; import { AppQueryClient } from "./utils/AppQueryClient"; @@ -20,7 +21,7 @@ export const ClientProvider = ({ children, baseUrl, user -}: { children?: any; baseUrl?: string; user?: object }) => { +}: { children?: any; baseUrl?: string; user?: TApiUser | null }) => { const [actualBaseUrl, setActualBaseUrl] = useState(null); const winCtx = useBkndWindowContext(); diff --git a/app/tsup.adapters.ts b/app/tsup.adapters.ts index a97712c..d5a3948 100644 --- a/app/tsup.adapters.ts +++ b/app/tsup.adapters.ts @@ -10,13 +10,20 @@ function baseConfig(adapter: string): Options { entry: [`src/adapter/${adapter}`], format: ["esm"], platform: "neutral", - minify, + minify: false, outDir: `dist/adapter/${adapter}`, watch, define: { __isDev: "0" }, - external: [new RegExp(`^(?!\\.\\/src\\/adapter\\/${adapter}\\/).+$`)], + external: [ + "cloudflare:workers", + /^@?hono.*?/, + /^bknd.*?/, + /.*\.html$/, + /^node.*/, + /^react.*?/ + ], metafile: true, splitting: false, treeshake: true @@ -35,7 +42,8 @@ await build({ await build({ ...baseConfig("nextjs"), format: ["esm", "cjs"], - platform: "node" + platform: "node", + external: [...baseConfig("nextjs").external!, /^next.*/] }); await build({ @@ -44,7 +52,8 @@ await build({ }); await build({ - ...baseConfig("bun") + ...baseConfig("bun"), + external: [/^hono.*?/, /^bknd.*?/, "node:path"] }); await build({ diff --git a/bun.lockb b/bun.lockb index ec74acc..2264076 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..408fb8e 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: @@ -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/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/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 index 1c856e0..e2b5b06 100644 Binary files a/examples/nextjs/test.db and b/examples/nextjs/test.db differ diff --git a/examples/remix/app/root.tsx b/examples/remix/app/root.tsx index e65961c..d2e5a75 100644 --- a/examples/remix/app/root.tsx +++ b/examples/remix/app/root.tsx @@ -3,6 +3,12 @@ import { Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData } from " import { Api } from "bknd"; import { ClientProvider } from "bknd/ui"; +declare module "@remix-run/server-runtime" { + export interface AppLoadContext { + api: Api; + } +} + export function Layout({ children }: { children: React.ReactNode }) { return ( diff --git a/examples/remix/app/routes/_index.tsx b/examples/remix/app/routes/_index.tsx index 8bdc1f3..78f547f 100644 --- a/examples/remix/app/routes/_index.tsx +++ b/examples/remix/app/routes/_index.tsx @@ -1,31 +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, 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 811d13b..0207428 100644 --- a/examples/remix/app/routes/admin.$.tsx +++ b/examples/remix/app/routes/admin.$.tsx @@ -1,19 +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(() => { - if (typeof window === "undefined") return; - setLoaded(true); - }, []); - if (!loaded) return null; - - return ( - - - - ); -} +export default adminPage();