From 342f56f3f5a47d8dc0f4884d214d1acb52032a69 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Tue, 24 Mar 2026 07:17:03 +0100 Subject: [PATCH] Server Refactor --- CLAUDE.md | 18 +++- README.md | 102 ++++++++++++------ comparisons/NEXTJS.md | 40 ++++--- src/__tests__/e2e/e2e.test.ts | 52 ++++++--- .../server/grab-page-server-path.test.ts | 69 ++++++++++++ src/commands/build/index.ts | 3 - src/commands/dev/index.ts | 4 +- src/commands/start/index.ts | 5 +- .../bundler/all-pages-bun-bundler.ts | 14 +-- src/functions/bundler/record-artifacts.ts | 8 +- src/functions/bunext-init.ts | 15 +-- src/functions/server/watcher.ts | 9 +- .../server/web-pages/generate-web-html.tsx | 49 ++++++--- src/utils/log.ts | 1 + 14 files changed, 278 insertions(+), 111 deletions(-) create mode 100644 src/__tests__/functions/server/grab-page-server-path.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 83e5b0a..03dee9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,11 +40,21 @@ bunext start # Start production server from pre-built artifacts - `src/presets/` — Default 404/500 components and sample `bunext.config.ts` ### Page module contract -Pages live in `src/pages/`. A page file may export: -- Default export: React component receiving `ServerProps | StaticProps` -- `server`: `BunextPageServerFn` — runs server-side before rendering, return value becomes props +Pages live in `src/pages/`. Server logic is **separated from page files** into companion `.server.ts` / `.server.tsx` files to avoid bundling server-only code into the client. + +**Page file** (`page.tsx`) — client-safe exports only (bundled to the browser): +- Default export: React component receiving props from the server file - `meta`: `BunextPageModuleMeta` — SEO/OG metadata -- `head`: ReactNode — extra `` content +- `Head`: FC — extra `` content +- `config`: `BunextRouteConfig` — cache settings +- `html_props`: `BunextHTMLProps` — attributes on the `` element + +**Server file** (`page.server.ts` or `page.server.tsx`) — server-only, never sent to the browser: +- Default export or `export const server`: `BunextPageServerFn` — runs server-side before rendering, return value becomes props + +The framework resolves the companion by replacing the page extension with `.server.ts` or `.server.tsx`. If neither exists, no server function runs and only the default `url` prop is injected. + +`__root.tsx` follows the same contract; its server companion is `__root.server.ts`. API routes live in `src/pages/api/` and follow standard Bun `Request → Response` handler conventions. diff --git a/README.md b/README.md index 64df12c..d336eeb 100644 --- a/README.md +++ b/README.md @@ -199,16 +199,19 @@ A typical Bunext project has the following layout: my-app/ ├── src/ │ └── pages/ # File-system routes (pages and API handlers) -│ ├── __root.tsx # Optional: root layout wrapper for all pages -│ ├── index.tsx # Route: / -│ ├── about.tsx # Route: /about -│ ├── 404.tsx # Optional: custom 404 page -│ ├── 500.tsx # Optional: custom 500 page +│ ├── __root.tsx # Optional: root layout wrapper for all pages +│ ├── __root.server.ts # Optional: root-level server logic +│ ├── index.tsx # Route: / +│ ├── index.server.ts # Optional: server logic for index route +│ ├── about.tsx # Route: /about +│ ├── 404.tsx # Optional: custom 404 page +│ ├── 500.tsx # Optional: custom 500 page │ ├── blog/ -│ │ ├── index.tsx # Route: /blog -│ │ └── [slug].tsx # Route: /blog/:slug (dynamic) +│ │ ├── index.tsx # Route: /blog +│ │ ├── index.server.ts # Server logic for /blog +│ │ └── [slug].tsx # Route: /blog/:slug (dynamic) │ └── api/ -│ └── users.ts # API route: /api/users +│ └── users.ts # API route: /api/users ├── public/ # Static files served at /public/* ├── .bunext/ # Internal build artifacts (do not edit manually) │ └── public/ @@ -275,19 +278,15 @@ export default function HomePage() { ### Server Function -Export a `server` function to run server-side logic before rendering. The return value's `props` field is spread into the page component as props, and `query` carries route query parameters. +Server logic lives in a companion **`.server.ts`** (or `.server.tsx`) file alongside the page. The framework looks for `.server.ts` or `.server.tsx` next to the page file and loads it separately on the server — it is never bundled into the client JS. -```tsx -// src/pages/profile.tsx +The server file exports the server function as either `export default` or `export const server`. The return value's `props` field is spread into the page component as props, and `query` carries route query parameters. + +```ts +// src/pages/profile.server.ts import type { BunextPageServerFn } from "@moduletrace/bunext/types"; -type Props = { - props?: { username: string; bio: string }; - query?: Record; - url?: URL; -}; - -export const server: BunextPageServerFn<{ +const server: BunextPageServerFn<{ username: string; bio: string; }> = async (ctx) => { @@ -304,6 +303,17 @@ export const server: BunextPageServerFn<{ }; }; +export default server; +``` + +```tsx +// src/pages/profile.tsx (client-only exports — bundled to the browser) +type Props = { + props?: { username: string; bio: string }; + query?: Record; + url?: URL; +}; + export default function ProfilePage({ props, url }: Props) { return (
@@ -315,6 +325,8 @@ export default function ProfilePage({ props, url }: Props) { } ``` +> **Why separate files?** Bundling server code (Bun APIs, database clients, `fs`, secrets) into the same file as a React component causes TypeScript compilation errors because the bundler processes the page file for the browser. The `.server.ts` companion file is loaded only by the server at request time and is never included in the client bundle. + The server function receives a `ctx` object (type `BunxRouteParams`) with: | Field | Type | Description | @@ -366,10 +378,13 @@ The `url` prop exposes the following fields from the standard Web `URL` interfac ### Redirects from Server -Return a `redirect` object from the `server` function to redirect the client: +Return a `redirect` object from the server function to redirect the client: -```tsx -export const server: BunextPageServerFn = async (ctx) => { +```ts +// src/pages/dashboard.server.ts +import type { BunextPageServerFn } from "@moduletrace/bunext/types"; + +const server: BunextPageServerFn = async (ctx) => { const isLoggedIn = false; // check auth if (!isLoggedIn) { @@ -384,6 +399,8 @@ export const server: BunextPageServerFn = async (ctx) => { return { props: {} }; }; + +export default server; ``` `permanent: true` sends a `301` redirect. Otherwise it sends `302`, or the value of `status_code` if provided. @@ -392,8 +409,11 @@ export const server: BunextPageServerFn = async (ctx) => { Control status codes, headers, and other response options from the server function: -```tsx -export const server: BunextPageServerFn = async (ctx) => { +```ts +// src/pages/submit.server.ts +import type { BunextPageServerFn } from "@moduletrace/bunext/types"; + +const server: BunextPageServerFn = async (ctx) => { return { props: { message: "Created" }, responseOptions: { @@ -404,11 +424,13 @@ export const server: BunextPageServerFn = async (ctx) => { }, }; }; + +export default server; ``` ### SEO Metadata -Export a `meta` object to inject SEO and Open Graph tags into the ``: +Export a `meta` object from the **page file** (not the server file) to inject SEO and Open Graph tags into the ``: ```tsx import type { BunextPageModuleMeta } from "@moduletrace/bunext/types"; @@ -447,7 +469,7 @@ export default function AboutPage() { ### Dynamic Metadata -`meta` can also be an async function that receives the request context and server response: +`meta` can also be an async function that receives the request context and server response. Like `meta`, it is exported from the **page file**: ```tsx import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types"; @@ -483,7 +505,21 @@ export default function Page() { ### Root Layout -Create `src/pages/__root.tsx` to wrap every page in a shared layout. The root component receives `children` (the current page component) along with all server props: +Create `src/pages/__root.tsx` to wrap every page in a shared layout. The root component receives `children` (the current page component) along with all server props. + +If the root layout also needs server-side logic, place it in `src/pages/__root.server.ts` (or `.server.tsx`) — the same `.server.*` convention used by regular pages: + +```ts +// src/pages/__root.server.ts +import type { BunextPageServerFn } from "@moduletrace/bunext/types"; + +const server: BunextPageServerFn = async (ctx) => { + // e.g. fetch navigation links, check auth + return { props: { navLinks: ["/", "/about"] } }; +}; + +export default server; +``` ```tsx // src/pages/__root.tsx @@ -636,12 +672,13 @@ export default function ProductsPage() { ### Dynamic Cache Control from Server Function -Cache settings can also be returned from the `server` function, which lets you conditionally enable caching based on request data: +Cache settings can also be returned from the server function, which lets you conditionally enable caching based on request data: -```tsx +```ts +// src/pages/products.server.ts import type { BunextPageServerFn } from "@moduletrace/bunext/types"; -export const server: BunextPageServerFn = async (ctx) => { +const server: BunextPageServerFn = async (ctx) => { const data = await fetchProducts(); return { @@ -651,6 +688,11 @@ export const server: BunextPageServerFn = async (ctx) => { }; }; +export default server; +``` + +```tsx +// src/pages/products.tsx export default function ProductsPage({ props }: any) { return (
    @@ -957,7 +999,7 @@ Request 1. Match route via FileSystemRouter 2. Find bundled artifact in BUNDLER_CTX_MAP 3. Import page module (with cache-busting timestamp in dev) - 4. Run module.server(ctx) for server-side data + 4. Import companion server module (.server.ts/tsx) if it exists; run its exported function for server-side data 5. Resolve meta (static object or async function) 6. renderToString(component) → inject into HTML template 7. Inject window.__PAGE_PROPS__, hydration