Server Refactor
This commit is contained in:
parent
7dd8d87be8
commit
342f56f3f5
18
CLAUDE.md
18
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`
|
- `src/presets/` — Default 404/500 components and sample `bunext.config.ts`
|
||||||
|
|
||||||
### Page module contract
|
### Page module contract
|
||||||
Pages live in `src/pages/`. A page file may export:
|
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.
|
||||||
- Default export: React component receiving `ServerProps | StaticProps`
|
|
||||||
- `server`: `BunextPageServerFn` — runs server-side before rendering, return value becomes props
|
**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
|
- `meta`: `BunextPageModuleMeta` — SEO/OG metadata
|
||||||
- `head`: ReactNode — extra `<head>` content
|
- `Head`: FC — extra `<head>` content
|
||||||
|
- `config`: `BunextRouteConfig` — cache settings
|
||||||
|
- `html_props`: `BunextHTMLProps` — attributes on the `<html>` 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.
|
API routes live in `src/pages/api/` and follow standard Bun `Request → Response` handler conventions.
|
||||||
|
|
||||||
|
|||||||
86
README.md
86
README.md
@ -200,12 +200,15 @@ my-app/
|
|||||||
├── src/
|
├── src/
|
||||||
│ └── pages/ # File-system routes (pages and API handlers)
|
│ └── pages/ # File-system routes (pages and API handlers)
|
||||||
│ ├── __root.tsx # Optional: root layout wrapper for all pages
|
│ ├── __root.tsx # Optional: root layout wrapper for all pages
|
||||||
|
│ ├── __root.server.ts # Optional: root-level server logic
|
||||||
│ ├── index.tsx # Route: /
|
│ ├── index.tsx # Route: /
|
||||||
|
│ ├── index.server.ts # Optional: server logic for index route
|
||||||
│ ├── about.tsx # Route: /about
|
│ ├── about.tsx # Route: /about
|
||||||
│ ├── 404.tsx # Optional: custom 404 page
|
│ ├── 404.tsx # Optional: custom 404 page
|
||||||
│ ├── 500.tsx # Optional: custom 500 page
|
│ ├── 500.tsx # Optional: custom 500 page
|
||||||
│ ├── blog/
|
│ ├── blog/
|
||||||
│ │ ├── index.tsx # Route: /blog
|
│ │ ├── index.tsx # Route: /blog
|
||||||
|
│ │ ├── index.server.ts # Server logic for /blog
|
||||||
│ │ └── [slug].tsx # Route: /blog/:slug (dynamic)
|
│ │ └── [slug].tsx # Route: /blog/:slug (dynamic)
|
||||||
│ └── api/
|
│ └── api/
|
||||||
│ └── users.ts # API route: /api/users
|
│ └── users.ts # API route: /api/users
|
||||||
@ -275,19 +278,15 @@ export default function HomePage() {
|
|||||||
|
|
||||||
### Server Function
|
### 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 `<page>.server.ts` or `<page>.server.tsx` next to the page file and loads it separately on the server — it is never bundled into the client JS.
|
||||||
|
|
||||||
```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.
|
||||||
// src/pages/profile.tsx
|
|
||||||
|
```ts
|
||||||
|
// src/pages/profile.server.ts
|
||||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
type Props = {
|
const server: BunextPageServerFn<{
|
||||||
props?: { username: string; bio: string };
|
|
||||||
query?: Record<string, string>;
|
|
||||||
url?: URL;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const server: BunextPageServerFn<{
|
|
||||||
username: string;
|
username: string;
|
||||||
bio: string;
|
bio: string;
|
||||||
}> = async (ctx) => {
|
}> = 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<string, string>;
|
||||||
|
url?: URL;
|
||||||
|
};
|
||||||
|
|
||||||
export default function ProfilePage({ props, url }: Props) {
|
export default function ProfilePage({ props, url }: Props) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -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:
|
The server function receives a `ctx` object (type `BunxRouteParams`) with:
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
@ -366,10 +378,13 @@ The `url` prop exposes the following fields from the standard Web `URL` interfac
|
|||||||
|
|
||||||
### Redirects from Server
|
### 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
|
```ts
|
||||||
export const server: BunextPageServerFn = async (ctx) => {
|
// src/pages/dashboard.server.ts
|
||||||
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
const server: BunextPageServerFn = async (ctx) => {
|
||||||
const isLoggedIn = false; // check auth
|
const isLoggedIn = false; // check auth
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
@ -384,6 +399,8 @@ export const server: BunextPageServerFn = async (ctx) => {
|
|||||||
|
|
||||||
return { props: {} };
|
return { props: {} };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default server;
|
||||||
```
|
```
|
||||||
|
|
||||||
`permanent: true` sends a `301` redirect. Otherwise it sends `302`, or the value of `status_code` if provided.
|
`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:
|
Control status codes, headers, and other response options from the server function:
|
||||||
|
|
||||||
```tsx
|
```ts
|
||||||
export const server: BunextPageServerFn = async (ctx) => {
|
// src/pages/submit.server.ts
|
||||||
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
const server: BunextPageServerFn = async (ctx) => {
|
||||||
return {
|
return {
|
||||||
props: { message: "Created" },
|
props: { message: "Created" },
|
||||||
responseOptions: {
|
responseOptions: {
|
||||||
@ -404,11 +424,13 @@ export const server: BunextPageServerFn = async (ctx) => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default server;
|
||||||
```
|
```
|
||||||
|
|
||||||
### SEO Metadata
|
### SEO Metadata
|
||||||
|
|
||||||
Export a `meta` object to inject SEO and Open Graph tags into the `<head>`:
|
Export a `meta` object from the **page file** (not the server file) to inject SEO and Open Graph tags into the `<head>`:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
|
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
|
||||||
@ -447,7 +469,7 @@ export default function AboutPage() {
|
|||||||
|
|
||||||
### Dynamic Metadata
|
### 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
|
```tsx
|
||||||
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
||||||
@ -483,7 +505,21 @@ export default function Page() {
|
|||||||
|
|
||||||
### Root Layout
|
### 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
|
```tsx
|
||||||
// src/pages/__root.tsx
|
// src/pages/__root.tsx
|
||||||
@ -636,12 +672,13 @@ export default function ProductsPage() {
|
|||||||
|
|
||||||
### Dynamic Cache Control from Server Function
|
### 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";
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
export const server: BunextPageServerFn = async (ctx) => {
|
const server: BunextPageServerFn = async (ctx) => {
|
||||||
const data = await fetchProducts();
|
const data = await fetchProducts();
|
||||||
|
|
||||||
return {
|
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) {
|
export default function ProductsPage({ props }: any) {
|
||||||
return (
|
return (
|
||||||
<ul>
|
<ul>
|
||||||
@ -957,7 +999,7 @@ Request
|
|||||||
1. Match route via FileSystemRouter
|
1. Match route via FileSystemRouter
|
||||||
2. Find bundled artifact in BUNDLER_CTX_MAP
|
2. Find bundled artifact in BUNDLER_CTX_MAP
|
||||||
3. Import page module (with cache-busting timestamp in dev)
|
3. Import page module (with cache-busting timestamp in dev)
|
||||||
4. Run module.server(ctx) for server-side data
|
4. Import companion server module (<page>.server.ts/tsx) if it exists; run its exported function for server-side data
|
||||||
5. Resolve meta (static object or async function)
|
5. Resolve meta (static object or async function)
|
||||||
6. renderToString(component) → inject into HTML template
|
6. renderToString(component) → inject into HTML template
|
||||||
7. Inject window.__PAGE_PROPS__, hydration <script>, CSS <link>
|
7. Inject window.__PAGE_PROPS__, hydration <script>, CSS <link>
|
||||||
|
|||||||
@ -58,7 +58,7 @@ This report compares the two on their overlapping surface — server-side render
|
|||||||
| Router | `Bun.FileSystemRouter` | Custom (Pages Router) / React Router (App Router) |
|
| Router | `Bun.FileSystemRouter` | Custom (Pages Router) / React Router (App Router) |
|
||||||
| SSR method | `renderToString` (complete response, by design) | `renderToReadableStream` (streaming) |
|
| SSR method | `renderToString` (complete response, by design) | `renderToReadableStream` (streaming) |
|
||||||
| Component model | Classic SSR + hydration | React Server Components + Client Components |
|
| Component model | Classic SSR + hydration | React Server Components + Client Components |
|
||||||
| Data fetching | Per-page `server` export | `getServerSideProps`, `getStaticProps`, `fetch` in RSC |
|
| Data fetching | Per-page `.server.ts` companion file | `getServerSideProps`, `getStaticProps`, `fetch` in RSC |
|
||||||
| State persistence | `window.__PAGE_PROPS__` | RSC payload, router cache |
|
| State persistence | `window.__PAGE_PROPS__` | RSC payload, router cache |
|
||||||
| Dev HMR transport | Server-Sent Events (SSE) | WebSocket |
|
| Dev HMR transport | Server-Sent Events (SSE) | WebSocket |
|
||||||
| Config format | `bunext.config.ts` | `next.config.js` / `next.config.ts` |
|
| Config format | `bunext.config.ts` | `next.config.js` / `next.config.ts` |
|
||||||
@ -210,19 +210,25 @@ Streaming SSR's benefits — progressive flushing, Suspense-based partial render
|
|||||||
|
|
||||||
### Data Fetching
|
### Data Fetching
|
||||||
|
|
||||||
**Bunext** exposes a single data-fetching primitive: the `server` export on each page module.
|
**Bunext** exposes a single data-fetching primitive: a companion **`.server.ts`** file alongside each page.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export const server: BunextPageServerFn = async (ctx) => {
|
// src/pages/products.server.ts
|
||||||
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
const server: BunextPageServerFn = async (ctx) => {
|
||||||
const data = await db.query(...);
|
const data = await db.query(...);
|
||||||
return { props: { data } };
|
return { props: { data } };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default server;
|
||||||
```
|
```
|
||||||
|
|
||||||
The return value is serialized to `window.__PAGE_PROPS__` and passed as component props. A `url` object (copy of the request `URL`) is **always** injected into server props as a default, so every page can read URL metadata without writing a server function.
|
The server file is never bundled into client JS — it runs exclusively on the server at request time. The return value is serialized to `window.__PAGE_PROPS__` and passed as component props. A `url` object (copy of the request `URL`) is **always** injected into server props as a default, so every page can read URL metadata without writing a server file at all.
|
||||||
|
|
||||||
**Design notes:**
|
**Design notes:**
|
||||||
- One server function per page. Data fetching is centralised at the page level, not scattered across components.
|
- One server file per page. Data fetching is centralised at the page level, not scattered across components.
|
||||||
|
- The page file (`.tsx`) exports only client-safe code — the React component, `meta`, `Head`, `config`, and `html_props`. Server-only code (Bun APIs, database clients, `fs`) lives in the `.server.ts` companion and is never sent to the browser.
|
||||||
- All rendering is on-demand. SSG is intentionally out of scope — see [Caching](#caching) for how Bunext addresses this differently.
|
- All rendering is on-demand. SSG is intentionally out of scope — see [Caching](#caching) for how Bunext addresses this differently.
|
||||||
- Server function result is passed via `window.__PAGE_PROPS__`, serialized to JSON and embedded in the HTML — large payloads increase page size.
|
- Server function result is passed via `window.__PAGE_PROPS__`, serialized to JSON and embedded in the HTML — large payloads increase page size.
|
||||||
|
|
||||||
@ -289,7 +295,7 @@ Bunext's caching model is its answer to SSG. Rather than pre-building pages at d
|
|||||||
|
|
||||||
**Bunext** implements a **file-based HTML cache**:
|
**Bunext** implements a **file-based HTML cache**:
|
||||||
|
|
||||||
- Enabled per-page via `config.cachePage` or returned dynamically from the `server` function at runtime.
|
- Enabled per-page via `config.cachePage` (exported from the page file) or returned dynamically from the server function in the `.server.ts` companion at runtime.
|
||||||
- On a cache miss, the rendered HTML is written to `public/__bunext/cache/<key>.res.html` alongside a metadata file `<key>.meta.json` (creation timestamp, expiry, paradigm).
|
- On a cache miss, the rendered HTML is written to `public/__bunext/cache/<key>.res.html` alongside a metadata file `<key>.meta.json` (creation timestamp, expiry, paradigm).
|
||||||
- On a cache hit, the HTML file is read and returned with `X-Bunext-Cache: HIT`.
|
- On a cache hit, the HTML file is read and returned with `X-Bunext-Cache: HIT`.
|
||||||
- A cron job runs every 30 seconds to delete expired entries.
|
- A cron job runs every 30 seconds to delete expired entries.
|
||||||
@ -316,7 +322,7 @@ The key distinction from SSG: Bunext's cache is **demand-driven**. A site with 1
|
|||||||
|
|
||||||
### Metadata and SEO
|
### Metadata and SEO
|
||||||
|
|
||||||
**Bunext** supports both static and dynamic metadata:
|
**Bunext** supports both static and dynamic metadata. These exports live in the **page file** (not the `.server.ts` companion), since they are processed at the server HTML-generation step and may reference types from the client module:
|
||||||
|
|
||||||
- `export const meta: BunextPageModuleMeta` — static object with `title`, `description`, `keywords`, `author`, `robots`, `canonical`, `themeColor`, `og.*`, and `twitter.*` fields.
|
- `export const meta: BunextPageModuleMeta` — static object with `title`, `description`, `keywords`, `author`, `robots`, `canonical`, `themeColor`, `og.*`, and `twitter.*` fields.
|
||||||
- `export const meta: BunextPageModuleMetaFn` — async function receiving `ctx` and `serverRes` for dynamic metadata based on fetched data.
|
- `export const meta: BunextPageModuleMetaFn` — async function receiving `ctx` and `serverRes` for dynamic metadata based on fetched data.
|
||||||
@ -540,10 +546,13 @@ Next.js wraps these in `NextRequest` and `NextResponse`, which add convenience m
|
|||||||
|
|
||||||
### Conditional Runtime Caching
|
### Conditional Runtime Caching
|
||||||
|
|
||||||
Bunext's `server` function can return `cachePage: true` and `cacheExpiry: N` based on runtime data — the authenticated state of the user, A/B test bucket, content freshness, or any other request-time condition:
|
Bunext's server function can return `cachePage: true` and `cacheExpiry: N` based on runtime data — the authenticated state of the user, A/B test bucket, content freshness, or any other request-time condition:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export const server: BunextPageServerFn = async (ctx) => {
|
// src/pages/products.server.ts
|
||||||
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
const server: BunextPageServerFn = async (ctx) => {
|
||||||
const user = await getUser(ctx.req);
|
const user = await getUser(ctx.req);
|
||||||
return {
|
return {
|
||||||
props: { data },
|
props: { data },
|
||||||
@ -551,16 +560,21 @@ export const server: BunextPageServerFn = async (ctx) => {
|
|||||||
cacheExpiry: 300,
|
cacheExpiry: 300,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default server;
|
||||||
```
|
```
|
||||||
|
|
||||||
Next.js's ISR and full-route cache operate on fixed revalidation intervals set at build time. There is no mechanism to decide at runtime whether a specific request should be cached.
|
Next.js's ISR and full-route cache operate on fixed revalidation intervals set at build time. There is no mechanism to decide at runtime whether a specific request should be cached.
|
||||||
|
|
||||||
### Page Response Transform
|
### Page Response Transform
|
||||||
|
|
||||||
The `resTransform` field in the page `server` function's `ctx` parameter lets the developer post-process the final HTML response generated by the framework — add headers, set cookies, modify status codes — without touching middleware:
|
The `resTransform` field in the server function's `ctx` parameter lets the developer post-process the final HTML response generated by the framework — add headers, set cookies, modify status codes — without touching middleware:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export const server: BunextPageServerFn = async (ctx) => {
|
// src/pages/page.server.ts
|
||||||
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
const server: BunextPageServerFn = async (ctx) => {
|
||||||
ctx.resTransform = (res) => {
|
ctx.resTransform = (res) => {
|
||||||
res.headers.set("X-Custom-Header", "value");
|
res.headers.set("X-Custom-Header", "value");
|
||||||
return res;
|
return res;
|
||||||
@ -568,6 +582,8 @@ export const server: BunextPageServerFn = async (ctx) => {
|
|||||||
|
|
||||||
return { props: {} };
|
return { props: {} };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default server;
|
||||||
```
|
```
|
||||||
|
|
||||||
This exists because page responses are generated entirely by the framework (`renderToString` → HTML template). Unlike API routes — where the developer returns a `Response` directly and already has full control — there is no other hook to modify the final page response at the route level without going through global middleware.
|
This exists because page responses are generated entirely by the framework (`renderToString` → HTML template). Unlike API routes — where the developer returns a `Response` directly and already has full control — there is no other hook to modify the final page response at the route level without going through global middleware.
|
||||||
@ -588,7 +604,7 @@ Next.js's codebase spans Turbopack (Rust), SWC (Rust), the App Router internals,
|
|||||||
|
|
||||||
The RSC model requires developers to constantly reason about the `"use client"` / `"use server"` boundary: what can be async, what has access to browser APIs, what gets serialized into the RSC payload. Mistakes at this boundary produce runtime errors that are difficult to diagnose.
|
The RSC model requires developers to constantly reason about the `"use client"` / `"use server"` boundary: what can be async, what has access to browser APIs, what gets serialized into the RSC payload. Mistakes at this boundary produce runtime errors that are difficult to diagnose.
|
||||||
|
|
||||||
Bunext has one rule: the `server` function runs on the server, the component runs on both (SSR then hydration). There is no boundary to reason about.
|
Bunext has one rule: the `.server.ts` companion file runs on the server, the page file runs on both (SSR then hydration). The separation is enforced by the file system, not by decorators or directives. There is no boundary to reason about inside a file.
|
||||||
|
|
||||||
### No Vendor Lock-In
|
### No Vendor Lock-In
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
||||||
import startServer from "../../../src/functions/server/start-server";
|
import startServer from "../../../src/functions/server/start-server";
|
||||||
import rewritePagesModule from "../../../src/utils/rewrite-pages-module";
|
|
||||||
import pagePathTransform from "../../../src/utils/page-path-transform";
|
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
|
||||||
@ -10,9 +8,6 @@ const fixtureDir = path.resolve(__dirname, "../../../test/e2e-fixture");
|
|||||||
const fixturePagesDir = path.join(fixtureDir, "src", "pages");
|
const fixturePagesDir = path.join(fixtureDir, "src", "pages");
|
||||||
const fixtureIndexPage = path.join(fixturePagesDir, "index.tsx");
|
const fixtureIndexPage = path.join(fixturePagesDir, "index.tsx");
|
||||||
|
|
||||||
// The rewritten page path (inside .bunext/pages, stripped of server logic)
|
|
||||||
const rewrittenIndexPage = pagePathTransform({ page_path: fixtureIndexPage });
|
|
||||||
|
|
||||||
let originalCwd = process.cwd();
|
let originalCwd = process.cwd();
|
||||||
let originalPort: string | undefined;
|
let originalPort: string | undefined;
|
||||||
|
|
||||||
@ -37,10 +32,6 @@ describe("E2E Integration", () => {
|
|||||||
dir: fixturePagesDir,
|
dir: fixturePagesDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rewrite the fixture page (strips server logic) into .bunext/pages
|
|
||||||
// so that grab-page-react-component-string can resolve the import
|
|
||||||
await rewritePagesModule({ page_file_path: fixtureIndexPage });
|
|
||||||
|
|
||||||
// Pre-populate the bundler context map so grab-page-component can
|
// Pre-populate the bundler context map so grab-page-component can
|
||||||
// look up the compiled path. The `path` value only needs to be
|
// look up the compiled path. The `path` value only needs to be
|
||||||
// present for the guard check; SSR does not require the file to exist.
|
// present for the guard check; SSR does not require the file to exist.
|
||||||
@ -70,12 +61,6 @@ describe("E2E Integration", () => {
|
|||||||
delete process.env.PORT;
|
delete process.env.PORT;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the rewritten page created during setup
|
|
||||||
const rewrittenDir = path.dirname(rewrittenIndexPage);
|
|
||||||
if (fs.existsSync(rewrittenDir)) {
|
|
||||||
fs.rmSync(rewrittenDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any generated .bunext artifacts from the fixture
|
// Remove any generated .bunext artifacts from the fixture
|
||||||
const dotBunext = path.join(fixtureDir, ".bunext");
|
const dotBunext = path.join(fixtureDir, ".bunext");
|
||||||
if (fs.existsSync(dotBunext)) {
|
if (fs.existsSync(dotBunext)) {
|
||||||
@ -102,4 +87,41 @@ describe("E2E Integration", () => {
|
|||||||
// Default 404 component is rendered
|
// Default 404 component is rendered
|
||||||
expect(text).toContain("404");
|
expect(text).toContain("404");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("server props injected from .server.ts companion file", async () => {
|
||||||
|
const serverFilePath = path.join(fixturePagesDir, "index.server.ts");
|
||||||
|
const pageFilePath = fixtureIndexPage;
|
||||||
|
|
||||||
|
// Write a temporary .server.ts companion that injects a prop
|
||||||
|
await Bun.write(serverFilePath, `
|
||||||
|
import type { BunextPageServerFn } from "../../../../../src/types";
|
||||||
|
|
||||||
|
const server: BunextPageServerFn<{ greeting: string }> = async () => {
|
||||||
|
return { props: { greeting: "Hello from server" } };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default server;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add the fixture page to the BUNDLER_CTX_MAP
|
||||||
|
global.BUNDLER_CTX_MAP[pageFilePath] = {
|
||||||
|
path: ".bunext/public/pages/index.js",
|
||||||
|
hash: "index",
|
||||||
|
type: "text/javascript",
|
||||||
|
entrypoint: pageFilePath,
|
||||||
|
local_path: pageFilePath,
|
||||||
|
url_path: "/",
|
||||||
|
file_name: "index",
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`http://localhost:${server.port}/`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
// __PAGE_PROPS__ should include the prop from the server companion
|
||||||
|
expect(html).toContain("Hello from server");
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
fs.unlinkSync(serverFilePath);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
69
src/__tests__/functions/server/grab-page-server-path.test.ts
Normal file
69
src/__tests__/functions/server/grab-page-server-path.test.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import grabPageServerPath from "../../../../src/functions/server/web-pages/grab-page-server-path";
|
||||||
|
|
||||||
|
const tmpDir = path.join(import.meta.dir, "__tmp_server_path__");
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("grabPageServerPath", () => {
|
||||||
|
it("returns undefined when no companion file exists", () => {
|
||||||
|
const { server_file_path } = grabPageServerPath({
|
||||||
|
file_path: path.join(tmpDir, "index.tsx"),
|
||||||
|
});
|
||||||
|
expect(server_file_path).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves .server.ts companion for a .tsx page", () => {
|
||||||
|
const serverFile = path.join(tmpDir, "profile.server.ts");
|
||||||
|
fs.writeFileSync(serverFile, "export default async () => ({})");
|
||||||
|
|
||||||
|
const { server_file_path } = grabPageServerPath({
|
||||||
|
file_path: path.join(tmpDir, "profile.tsx"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(server_file_path).toBe(serverFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves .server.tsx companion when only .server.tsx exists", () => {
|
||||||
|
const serverFile = path.join(tmpDir, "about.server.tsx");
|
||||||
|
fs.writeFileSync(serverFile, "export default async () => ({})");
|
||||||
|
|
||||||
|
const { server_file_path } = grabPageServerPath({
|
||||||
|
file_path: path.join(tmpDir, "about.tsx"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(server_file_path).toBe(serverFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers .server.ts over .server.tsx when both exist", () => {
|
||||||
|
const tsFile = path.join(tmpDir, "blog.server.ts");
|
||||||
|
const tsxFile = path.join(tmpDir, "blog.server.tsx");
|
||||||
|
fs.writeFileSync(tsFile, "export default async () => ({})");
|
||||||
|
fs.writeFileSync(tsxFile, "export default async () => ({})");
|
||||||
|
|
||||||
|
const { server_file_path } = grabPageServerPath({
|
||||||
|
file_path: path.join(tmpDir, "blog.tsx"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(server_file_path).toBe(tsFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves companion for a .ts page file", () => {
|
||||||
|
const serverFile = path.join(tmpDir, "api-page.server.ts");
|
||||||
|
fs.writeFileSync(serverFile, "export default async () => ({})");
|
||||||
|
|
||||||
|
const { server_file_path } = grabPageServerPath({
|
||||||
|
file_path: path.join(tmpDir, "api-page.ts"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(server_file_path).toBe(serverFile);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -13,9 +13,6 @@ export default function () {
|
|||||||
return new Command("build")
|
return new Command("build")
|
||||||
.description("Build Project")
|
.description("Build Project")
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
process.env.NODE_ENV = "production";
|
|
||||||
process.env.BUILD = "true";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
rmSync(HYDRATION_DST_DIR, { recursive: true });
|
rmSync(HYDRATION_DST_DIR, { recursive: true });
|
||||||
rmSync(BUNX_CWD_PAGES_REWRITE_DIR, { recursive: true });
|
rmSync(BUNX_CWD_PAGES_REWRITE_DIR, { recursive: true });
|
||||||
|
|||||||
@ -2,9 +2,9 @@ import { Command } from "commander";
|
|||||||
import startServer from "../../functions/server/start-server";
|
import startServer from "../../functions/server/start-server";
|
||||||
import { log } from "../../utils/log";
|
import { log } from "../../utils/log";
|
||||||
import bunextInit from "../../functions/bunext-init";
|
import bunextInit from "../../functions/bunext-init";
|
||||||
// import rewritePagesModule from "../../utils/rewrite-pages-module";
|
|
||||||
import grabDirNames from "../../utils/grab-dir-names";
|
import grabDirNames from "../../utils/grab-dir-names";
|
||||||
import { rmSync } from "fs";
|
import { rmSync } from "fs";
|
||||||
|
import allPagesBunBundler from "../../functions/bundler/all-pages-bun-bundler";
|
||||||
|
|
||||||
const { HYDRATION_DST_DIR, BUNX_CWD_PAGES_REWRITE_DIR } = grabDirNames();
|
const { HYDRATION_DST_DIR, BUNX_CWD_PAGES_REWRITE_DIR } = grabDirNames();
|
||||||
|
|
||||||
@ -21,8 +21,8 @@ export default function () {
|
|||||||
rmSync(BUNX_CWD_PAGES_REWRITE_DIR, { recursive: true });
|
rmSync(BUNX_CWD_PAGES_REWRITE_DIR, { recursive: true });
|
||||||
} catch (error) {}
|
} catch (error) {}
|
||||||
|
|
||||||
// await rewritePagesModule();
|
|
||||||
await bunextInit();
|
await bunextInit();
|
||||||
|
await allPagesBunBundler();
|
||||||
|
|
||||||
await startServer();
|
await startServer();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,17 +2,18 @@ import { Command } from "commander";
|
|||||||
import startServer from "../../functions/server/start-server";
|
import startServer from "../../functions/server/start-server";
|
||||||
import { log } from "../../utils/log";
|
import { log } from "../../utils/log";
|
||||||
import bunextInit from "../../functions/bunext-init";
|
import bunextInit from "../../functions/bunext-init";
|
||||||
|
import allPagesBunBundler from "../../functions/bundler/all-pages-bun-bundler";
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
return new Command("start")
|
return new Command("start")
|
||||||
.description("Start production server")
|
.description("Start production server")
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
process.env.NODE_ENV = "production";
|
|
||||||
|
|
||||||
log.info("Starting production server ...");
|
log.info("Starting production server ...");
|
||||||
|
|
||||||
await bunextInit();
|
await bunextInit();
|
||||||
|
|
||||||
|
await allPagesBunBundler();
|
||||||
|
|
||||||
await startServer();
|
await startServer();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,17 +56,19 @@ export default async function allPagesBunBundler(params?: Params) {
|
|||||||
|
|
||||||
const buildStart = performance.now();
|
const buildStart = performance.now();
|
||||||
|
|
||||||
|
const define = {
|
||||||
|
"process.env.NODE_ENV": JSON.stringify(
|
||||||
|
dev ? "development" : "production",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
const result = await Bun.build({
|
const result = await Bun.build({
|
||||||
entrypoints: [...entryToPage.keys()],
|
entrypoints: [...entryToPage.keys()],
|
||||||
outdir: HYDRATION_DST_DIR,
|
outdir: HYDRATION_DST_DIR,
|
||||||
root: BUNX_HYDRATION_SRC_DIR,
|
root: BUNX_HYDRATION_SRC_DIR,
|
||||||
minify: true,
|
minify: !dev,
|
||||||
format: "esm",
|
format: "esm",
|
||||||
define: {
|
define,
|
||||||
"process.env.NODE_ENV": JSON.stringify(
|
|
||||||
dev ? "development" : "production",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
naming: {
|
naming: {
|
||||||
entry: "[dir]/[hash].[ext]",
|
entry: "[dir]/[hash].[ext]",
|
||||||
chunk: "chunks/[hash].[ext]",
|
chunk: "chunks/[hash].[ext]",
|
||||||
|
|||||||
@ -25,8 +25,8 @@ export default async function recordArtifacts({
|
|||||||
global.BUNDLER_CTX_MAP = _.merge(global.BUNDLER_CTX_MAP, artifacts_map);
|
global.BUNDLER_CTX_MAP = _.merge(global.BUNDLER_CTX_MAP, artifacts_map);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Bun.write(
|
// await Bun.write(
|
||||||
HYDRATION_DST_DIR_MAP_JSON_FILE,
|
// HYDRATION_DST_DIR_MAP_JSON_FILE,
|
||||||
JSON.stringify(artifacts_map, null, 4),
|
// JSON.stringify(artifacts_map, null, 4),
|
||||||
);
|
// );
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,14 +7,12 @@ import type {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
import type { FileSystemRouter, Server } from "bun";
|
import type { FileSystemRouter, Server } from "bun";
|
||||||
import grabDirNames from "../utils/grab-dir-names";
|
import grabDirNames from "../utils/grab-dir-names";
|
||||||
import { readFileSync, type FSWatcher } from "fs";
|
import { type FSWatcher } from "fs";
|
||||||
import init from "./init";
|
import init from "./init";
|
||||||
import isDevelopment from "../utils/is-development";
|
import isDevelopment from "../utils/is-development";
|
||||||
import allPagesBundler from "./bundler/all-pages-bundler";
|
|
||||||
import watcher from "./server/watcher";
|
import watcher from "./server/watcher";
|
||||||
import { log } from "../utils/log";
|
import { log } from "../utils/log";
|
||||||
import cron from "./server/cron";
|
import cron from "./server/cron";
|
||||||
import EJSON from "../utils/ejson";
|
|
||||||
import allPagesBunBundler from "./bundler/all-pages-bun-bundler";
|
import allPagesBunBundler from "./bundler/all-pages-bun-bundler";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -36,7 +34,6 @@ declare global {
|
|||||||
var PAGE_FILES: PageFiles[];
|
var PAGE_FILES: PageFiles[];
|
||||||
var ROOT_FILE_UPDATED: boolean;
|
var ROOT_FILE_UPDATED: boolean;
|
||||||
var SKIPPED_BROWSER_MODULES: Set<string>;
|
var SKIPPED_BROWSER_MODULES: Set<string>;
|
||||||
// var BUNDLER_CTX: BuildContext | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { PAGES_DIR, HYDRATION_DST_DIR_MAP_JSON_FILE } = grabDirNames();
|
const { PAGES_DIR, HYDRATION_DST_DIR_MAP_JSON_FILE } = grabDirNames();
|
||||||
@ -63,18 +60,8 @@ export default async function bunextInit() {
|
|||||||
const is_dev = isDevelopment();
|
const is_dev = isDevelopment();
|
||||||
|
|
||||||
if (is_dev) {
|
if (is_dev) {
|
||||||
// await allPagesBundler();
|
|
||||||
await allPagesBunBundler();
|
|
||||||
watcher();
|
watcher();
|
||||||
} else {
|
} else {
|
||||||
const artifacts = EJSON.parse(
|
|
||||||
readFileSync(HYDRATION_DST_DIR_MAP_JSON_FILE, "utf-8"),
|
|
||||||
) as { [k: string]: BundlerCTXMap } | undefined;
|
|
||||||
if (!artifacts) {
|
|
||||||
log.error("Please build first.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
global.BUNDLER_CTX_MAP = artifacts;
|
|
||||||
cron();
|
cron();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -82,9 +82,9 @@ async function fullRebuild(params?: { msg?: string }) {
|
|||||||
|
|
||||||
global.RECOMPILING = true;
|
global.RECOMPILING = true;
|
||||||
|
|
||||||
const target_file_paths = global.HMR_CONTROLLERS.map(
|
// const target_file_paths = global.HMR_CONTROLLERS.map(
|
||||||
(hmr) => hmr.target_map?.local_path,
|
// (hmr) => hmr.target_map?.local_path,
|
||||||
).filter((f) => typeof f == "string");
|
// ).filter((f) => typeof f == "string");
|
||||||
|
|
||||||
// await rewritePagesModule();
|
// await rewritePagesModule();
|
||||||
|
|
||||||
@ -92,7 +92,8 @@ async function fullRebuild(params?: { msg?: string }) {
|
|||||||
log.watch(msg);
|
log.watch(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
await rebuildBundler({ target_file_paths });
|
await rebuildBundler();
|
||||||
|
// await rebuildBundler({ target_file_paths });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
log.error(error);
|
log.error(error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -69,23 +69,22 @@ export default async function genWebHTML({
|
|||||||
|
|
||||||
const dev = isDevelopment();
|
const dev = isDevelopment();
|
||||||
const devSuffix = dev ? "?dev" : "";
|
const devSuffix = dev ? "?dev" : "";
|
||||||
|
const browser_imports: Record<string, string> = {
|
||||||
|
react: `https://esm.sh/react@${_reactVersion}`,
|
||||||
|
"react-dom": `https://esm.sh/react-dom@${_reactVersion}`,
|
||||||
|
"react-dom/client": `https://esm.sh/react-dom@${_reactVersion}/client`,
|
||||||
|
"react/jsx-runtime": `https://esm.sh/react@${_reactVersion}/jsx-runtime`,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dev) {
|
||||||
|
browser_imports["react/jsx-dev-runtime"] =
|
||||||
|
`https://esm.sh/react@${_reactVersion}/jsx-dev-runtime`;
|
||||||
|
}
|
||||||
|
|
||||||
const importMap = JSON.stringify({
|
const importMap = JSON.stringify({
|
||||||
imports: {
|
imports: browser_imports,
|
||||||
react: `https://esm.sh/react@${_reactVersion}${devSuffix}`,
|
|
||||||
"react-dom": `https://esm.sh/react-dom@${_reactVersion}${devSuffix}`,
|
|
||||||
"react-dom/client": `https://esm.sh/react-dom@${_reactVersion}/client${devSuffix}`,
|
|
||||||
"react/jsx-runtime": `https://esm.sh/react@${_reactVersion}/jsx-runtime${devSuffix}`,
|
|
||||||
"react/jsx-dev-runtime": `https://esm.sh/react@${_reactVersion}/jsx-dev-runtime${devSuffix}`,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// let skipped_modules_import_map: { [k: string]: string } = {};
|
|
||||||
|
|
||||||
// [...global.SKIPPED_BROWSER_MODULES].forEach((sk) => {
|
|
||||||
// skipped_modules_import_map[sk] =
|
|
||||||
// "data:text/javascript,export default {}";
|
|
||||||
// });
|
|
||||||
|
|
||||||
let final_component = (
|
let final_component = (
|
||||||
<html {...html_props}>
|
<html {...html_props}>
|
||||||
<head>
|
<head>
|
||||||
@ -151,12 +150,32 @@ export default async function genWebHTML({
|
|||||||
{Head ? <Head serverRes={pageProps} ctx={routeParams} /> : null}
|
{Head ? <Head serverRes={pageProps} ctx={routeParams} /> : null}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id={ClientRootElementIDName}>{component}</div>
|
<div
|
||||||
|
id={ClientRootElementIDName}
|
||||||
|
suppressHydrationWarning={!dev}
|
||||||
|
>
|
||||||
|
{component}
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
||||||
let html = `<!DOCTYPE html>\n`;
|
let html = `<!DOCTYPE html>\n`;
|
||||||
|
|
||||||
|
// const stream = await renderToReadableStream(final_component, {
|
||||||
|
// onError(error: any) {
|
||||||
|
// // This is where you "omit" or handle the errors
|
||||||
|
// // You can log it silently or ignore it
|
||||||
|
// if (error.message.includes('unique "key" prop')) return;
|
||||||
|
// console.error(error);
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// // 2. Convert the Web Stream to a String (Bun-optimized)
|
||||||
|
// const htmlBody = await new Response(stream).text();
|
||||||
|
|
||||||
|
// html += htmlBody;
|
||||||
|
|
||||||
html += renderToString(final_component);
|
html += renderToString(final_component);
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import AppNames from "./grab-app-names";
|
|||||||
const prefix = {
|
const prefix = {
|
||||||
info: chalk.bgCyan.bold(" ℹnfo "),
|
info: chalk.bgCyan.bold(" ℹnfo "),
|
||||||
success: chalk.green.bold("✓"),
|
success: chalk.green.bold("✓"),
|
||||||
|
zap: chalk.green.bold("⚡"),
|
||||||
error: chalk.red.bold("✗"),
|
error: chalk.red.bold("✗"),
|
||||||
warn: chalk.yellow.bold("⚠"),
|
warn: chalk.yellow.bold("⚠"),
|
||||||
build: chalk.magenta.bold("⚙"),
|
build: chalk.magenta.bold("⚙"),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user