diff --git a/README.md b/README.md index 9defe4f..433e33b 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ The goal is a framework that is: - [Cache Behavior and Limitations](#cache-behavior-and-limitations) - [Configuration](#configuration) - [Middleware](#middleware) + - [WebSocket](#websocket) + - [Server Options](#server-options) +- [Custom Server](#custom-server) - [Environment Variables](#environment-variables) - [How It Works](#how-it-works) - [Development Server](#development-server) @@ -650,6 +653,8 @@ export default config; | `development` | `boolean` | — | Overridden to `true` by `bunext dev` automatically | | `defaultCacheExpiry`| `number` | `3600` | Global page cache expiry in seconds | | `middleware` | `(params: BunextConfigMiddlewareParams) => Response \| undefined \| Promise<...>` | — | Global middleware — see [Middleware](#middleware) | +| `websocket` | `WebSocketHandler` | — | Bun WebSocket handler — see [WebSocket](#websocket) | +| `serverOptions` | `ServeOptions` | — | Extra options passed to `Bun.serve()` (excluding `fetch`) — see [Server Options](#server-options) | ### Middleware @@ -666,7 +671,7 @@ import type { } from "bunext/src/types"; const config: BunextConfig = { - middleware: async ({ req, url, server }) => { + middleware: async ({ req, url }) => { // Example: protect all /dashboard/* routes if (url.pathname.startsWith("/dashboard")) { const token = req.headers.get("authorization"); @@ -697,6 +702,104 @@ middleware: async ({ req, url }) => { }, ``` +### WebSocket + +Add a `websocket` field to `bunext.config.ts` to handle WebSocket connections. The value is passed directly to `Bun.serve()` as the `websocket` option and accepts the full [`WebSocketHandler`](https://bun.sh/docs/api/websockets) interface. + +Define the handler in its own file and import it into the config: + +```ts +// websocket.ts +import type { WebSocketHandler } from "bun"; + +export const BunextWebsocket: WebSocketHandler = { + message(ws, message) { + console.log(`WS Message => ${message}`); + }, +}; +``` + +```ts +// bunext.config.ts +import type { BunextConfig } from "bunext/src/types"; +import { BunextWebsocket } from "./websocket"; + +const config: BunextConfig = { + websocket: BunextWebsocket, +}; + +export default config; +``` + +### Server Options + +Pass additional options to the underlying `Bun.serve()` call via `serverOptions`. All standard [`ServeOptions`](https://bun.sh/docs/api/http) fields are accepted except `fetch`, which Bunext manages internally. + +```ts +// bunext.config.ts +import type { BunextConfig } from "bunext/src/types"; + +const config: BunextConfig = { + serverOptions: { + tls: { + cert: Bun.file("./certs/cert.pem"), + key: Bun.file("./certs/key.pem"), + }, + maxRequestBodySize: 64 * 1024 * 1024, // 64 MB + error(err) { + console.error("Server error:", err); + return new Response("Internal Server Error", { status: 500 }); + }, + }, +}; + +export default config; +``` + +--- + +## Custom Server + +For full control over the `Bun.serve()` instance — custom WebSocket upgrade logic, multi-protocol handling, or integrating Bunext alongside other handlers — you can skip `bunext dev` / `bunext start` and run your own server using Bunext's exported primitives. + +```ts +// server.ts +import bunext from "bunext"; + +const development = process.env.NODE_ENV === "development"; +const port = process.env.PORT || 3700; + +await bunext.bunextInit(); + +const server = Bun.serve({ + routes: { + "/*": { + async GET(req) { + return await bunext.bunextRequestHandler({ req }); + }, + }, + }, + development, + port, +}); + +bunext.bunextLog.info(`Server running on http://localhost:${server.port} ...`); +``` + +| Export | Type | Description | +|--------|------|-------------| +| `bunextInit()` | `() => Promise` | Initializes config, router, and bundler. Must be called before handling requests. | +| `bunextRequestHandler({ req })` | `(params: { req: Request }) => Promise` | The main Bunext request dispatcher — middleware, routing, SSR, static files. Only `req` is needed; the server instance is managed internally. | +| `bunextLog` | Logger | Framework logger (`info`, `error`, `success`, `server`, `watch`). | + +Run the custom server directly with Bun: + +```bash +bun run server.ts +``` + +> **Note:** When using a custom server, HMR and file watching are still driven by `bunextInit()`. Pass `development: true` in your `Bun.serve()` call to enable them. + --- ## Environment Variables diff --git a/comparisons/NEXTJS.md b/comparisons/NEXTJS.md index 086447e..8170b66 100644 --- a/comparisons/NEXTJS.md +++ b/comparisons/NEXTJS.md @@ -134,6 +134,10 @@ This report compares the two on their overlapping surface — server-side render | Prefetching | — | ✅ | ✅ | | `useRouter` / `usePathname` hooks | — | ✅ | ✅ | | `useSearchParams` hook | — | ✅ | ✅ | +| **Server** | | | | +| WebSocket support | ✅ | ❌ | ❌ | +| Custom server (bring your own `Bun.serve()`) | ✅ | ✅ (custom `server.js`) | ✅ | +| Extra server options (`tls`, `error`, etc.) | ✅ (`serverOptions`) | ✅ | ✅ | | **Deployment** | | | | | Self-hosted (any server with runtime) | ✅ | ✅ | ✅ | | No vendor lock-in | ✅ | ❌ (Vercel-optimised) | ❌ | @@ -358,7 +362,7 @@ The key distinction from SSG: Bunext's cache is **demand-driven**. A site with 1 | `Request` | *(planned)* Replaces the request and continues through the pipeline | | `undefined` | Passes through unchanged | -The planned `Request` return allows header injection, auth token forwarding, locale detection, or any other request mutation without terminating the pipeline — a clean, standard-API alternative to Next.js's custom `NextResponse.next({ headers: ... })` pattern. +The planned `Request` return allows header injection, auth token forwarding, locale detection, or any other request mutation without terminating the pipeline — a clean, standard-API alternative to Next.js's custom `NextResponse.next({ headers: ... })` pattern. The middleware function receives `{ req, url }`. The server instance is managed internally and does not need to be passed explicitly. **Next.js** middleware runs per-request in a lightweight Edge Runtime (V8 isolate), with matched route patterns configured via `matcher`. It uses `NextRequest`/`NextResponse` extensions for rewriting, redirecting, and injecting headers without returning a full response. @@ -426,7 +430,7 @@ Next.js provides additional type-safety features: ### Configuration -**Bunext** (`bunext.config.ts`) exposes: `port`, `origin`, `distDir`, `assetsPrefix`, `globalVars`, `development`, `defaultCacheExpiry`, `middleware`. +**Bunext** (`bunext.config.ts`) exposes: `port`, `origin`, `distDir`, `assetsPrefix`, `globalVars`, `development`, `defaultCacheExpiry`, `middleware`, `websocket`, `serverOptions`. **Next.js** (`next.config.js`) additionally supports: - `redirects()` — array of redirect rules evaluated at the server level. @@ -592,6 +596,39 @@ Bunext runs on any server where Bun is installed. There is no Vercel platform de Next.js is technically self-hostable but is architecturally optimised for Vercel — features like ISR, image optimisation, Edge Middleware, and Analytics are either Vercel-only or degraded outside it. +### WebSocket and Custom Server + +Bunext ships native WebSocket support via a `websocket` field in `bunext.config.ts`. The value is a Bun `WebSocketHandler` passed directly to `Bun.serve()` — no third-party library, no adapter, no separate process. Upgrade requests are triggered from any API route — only `req` is needed, as the server instance is managed internally by the framework. + +Next.js has no built-in WebSocket support. Upgrading a connection requires a custom Node.js server (`server.js`) outside the Next.js framework, which loses access to Next.js's built-in routing and middleware for that connection. + +For projects that need full control over `Bun.serve()` — custom TLS, multi-protocol handling, integrating Bunext alongside other handlers — Bunext exports `bunextInit()` and `bunextRequestHandler()` as first-class primitives. The developer owns the server; Bunext handles the request processing: + +```ts +// server.ts +import bunext from "bunext"; + +const development = process.env.NODE_ENV === "development"; + +await bunext.bunextInit(); + +const server = Bun.serve({ + routes: { + "/*": { + async GET(req) { + return await bunext.bunextRequestHandler({ req }); + }, + }, + }, + development, + port: process.env.PORT || 3700, +}); + +bunext.bunextLog.info(`Server running on http://localhost:${server.port} ...`); +``` + +Next.js requires a `server.js` file that wraps `next()` — a documented but officially discouraged pattern that disables some platform-specific features on Vercel. + ### Bun-Native APIs Server functions and API routes have direct access to Bun's native APIs: `Bun.file()`, `Bun.write()`, the native SQLite driver, `Bun.password`, `Bun.serve` WebSocket support, etc. — without any Node.js compatibility shim or polyfill layer. diff --git a/dist/functions/server/handle-hmr.js b/dist/functions/server/handle-hmr.js index 9a45446..8c4177a 100644 --- a/dist/functions/server/handle-hmr.js +++ b/dist/functions/server/handle-hmr.js @@ -8,6 +8,7 @@ export default async function ({ req }) { ? global.BUNDLER_CTX_MAP?.find((m) => m.local_path == match.filePath) : undefined; let controller; + let heartbeat; const stream = new ReadableStream({ start(c) { controller = c; @@ -16,8 +17,17 @@ export default async function ({ req }) { page_url: referer_url.href, target_map, }); + heartbeat = setInterval(() => { + try { + c.enqueue(": keep-alive\n\n"); + } + catch { + clearInterval(heartbeat); + } + }, 5000); }, cancel() { + clearInterval(heartbeat); const targetControllerIndex = global.HMR_CONTROLLERS.findIndex((c) => c.controller == controller); if (typeof targetControllerIndex == "number" && targetControllerIndex >= 0) { diff --git a/dist/functions/server/web-pages/grab-web-page-hydration-script.js b/dist/functions/server/web-pages/grab-web-page-hydration-script.js index 0aa622c..ee5c5cb 100644 --- a/dist/functions/server/web-pages/grab-web-page-hydration-script.js +++ b/dist/functions/server/web-pages/grab-web-page-hydration-script.js @@ -19,6 +19,7 @@ export default async function (params) { script += `window.addEventListener("error", (e) => __bunext_show_error(e.message, e.filename ? e.filename + ":" + e.lineno + ":" + e.colno : "", e.error?.stack ?? ""));\n`; script += `window.addEventListener("unhandledrejection", (e) => __bunext_show_error(String(e.reason?.message ?? e.reason), "", e.reason?.stack ?? ""));\n\n`; script += `const hmr = new EventSource("/__hmr");\n`; + script += `window.BUNEXT_HMR = hmr;\n`; script += `window.addEventListener("beforeunload", () => hmr.close());\n`; script += `hmr.addEventListener("update", async (event) => {\n`; script += ` if (event?.data) {\n`; diff --git a/examples/custom-server/server.ts b/examples/custom-server/server.ts index 84bbc1a..9fc1802 100644 --- a/examples/custom-server/server.ts +++ b/examples/custom-server/server.ts @@ -19,11 +19,6 @@ const server = Bun.serve({ }, }, }, - /** - * Set this to prevent HMR timeout warnings in the - * browser console in development mode. - */ - idleTimeout: development ? 0 : undefined, development, port, }); diff --git a/features/FEATURES.md b/features/FEATURES.md index e067755..7dadc9d 100644 --- a/features/FEATURES.md +++ b/features/FEATURES.md @@ -21,7 +21,7 @@ The full return contract: ```ts // bunext.config.ts const config: BunextConfig = { - middleware: async ({ req, url, server }) => { + middleware: async ({ req, url }) => { // Inject an auth header and continue const token = await verifySession(req); if (token) { @@ -49,11 +49,36 @@ const config: BunextConfig = { ## Custom Server -**Status:** In development +**Status:** Shipped -Allow consumer projects to create and fully customize the underlying `Bun.serve()` instance. Instead of Bunext owning the server entirely, the developer can provide their own server setup and integrate Bunext's request handler into it. +Consumer projects can create and fully customize the underlying `Bun.serve()` instance by using Bunext's exported primitives directly. Instead of Bunext owning the server, the developer provides their own `Bun.serve()` call and integrates Bunext's request handler into it. -This enables use cases that require low-level server control: +```ts +import bunext from "bunext"; + +await bunext.bunextInit(); + +const server = Bun.serve({ + routes: { + "/*": { + async GET(req) { + return await bunext.bunextRequestHandler({ req }); + }, + }, + }, + port: 3000, +}); +``` + +Exported primitives: + +| Export | Description | +|--------|-------------| +| `bunextInit()` | Initializes config, router, and bundler | +| `bunextRequestHandler({ req })` | Main Bunext request dispatcher | +| `bunextLog` | Framework logger | + +Use cases: - Custom WebSocket upgrade handling - Custom TLS/SSL configuration - Integrating Bunext into an existing Bun server alongside other handlers @@ -83,31 +108,31 @@ A server is a fundamental requirement for Bunext — like WordPress, it is desig ## WebSocket Support via Config -**Status:** Planned +**Status:** Shipped -Add a `websocket` parameter to `bunext.config.ts` to handle WebSocket connections without requiring a custom server. This gives most projects a zero-config path to WebSockets while the custom server feature covers advanced use cases. +The `websocket` field in `bunext.config.ts` accepts a Bun [`WebSocketHandler`](https://bun.sh/docs/api/websockets) and passes it directly to `Bun.serve()`. This gives most projects a zero-config path to WebSockets; the custom server feature covers advanced upgrade routing. -Proposed config shape: +```ts +// websocket.ts +import type { WebSocketHandler } from "bun"; + +export const BunextWebsocket: WebSocketHandler = { + message(ws, message) { + console.log(`WS Message => ${message}`); + }, +}; +``` ```ts // bunext.config.ts import type { BunextConfig } from "bunext/src/types"; +import { BunextWebsocket } from "./websocket"; const config: BunextConfig = { - websocket: { - message(ws, message) { - ws.send(`echo: ${message}`); - }, - open(ws) { - console.log("Client connected"); - }, - close(ws, code, reason) { - console.log("Client disconnected"); - }, - }, + websocket: BunextWebsocket, }; export default config; ``` -The `websocket` field maps directly to Bun's [`WebSocketHandler`](https://bun.sh/docs/api/websockets) interface, passed through to `Bun.serve()`. WebSocket upgrade requests are handled automatically by the framework before the normal request pipeline runs. +A companion `serverOptions` field is also available to pass any other `Bun.serve()` options (TLS, error handler, `maxRequestBodySize`, etc.) without needing a custom server. diff --git a/package.json b/package.json index 5ab754b..9094781 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ ], "scripts": { "dev": "tsc --watch", - "publish": "tsc --noEmit && tsc && git add . && git commit -m 'Update Version' && git push", + "publish": "tsc --noEmit && tsc && git add . && git commit -m 'Bugfixes. Documentation update.' && git push", "compile": "bun build ./src/commands/index.ts --compile --outfile bin/bunext", "build": "tsc" }, diff --git a/src/functions/server/handle-hmr.ts b/src/functions/server/handle-hmr.ts index 1ad68ee..3b25244 100644 --- a/src/functions/server/handle-hmr.ts +++ b/src/functions/server/handle-hmr.ts @@ -17,6 +17,7 @@ export default async function ({ req }: Params): Promise { : undefined; let controller: ReadableStreamDefaultController; + let heartbeat: ReturnType; const stream = new ReadableStream({ start(c) { controller = c; @@ -25,8 +26,16 @@ export default async function ({ req }: Params): Promise { page_url: referer_url.href, target_map, }); + heartbeat = setInterval(() => { + try { + c.enqueue(": keep-alive\n\n"); + } catch { + clearInterval(heartbeat); + } + }, 5000); }, cancel() { + clearInterval(heartbeat); const targetControllerIndex = global.HMR_CONTROLLERS.findIndex( (c) => c.controller == controller, ); diff --git a/src/functions/server/web-pages/grab-web-page-hydration-script.tsx b/src/functions/server/web-pages/grab-web-page-hydration-script.tsx index ef44b84..7930502 100644 --- a/src/functions/server/web-pages/grab-web-page-hydration-script.tsx +++ b/src/functions/server/web-pages/grab-web-page-hydration-script.tsx @@ -28,6 +28,7 @@ export default async function (params?: Params) { script += `window.addEventListener("unhandledrejection", (e) => __bunext_show_error(String(e.reason?.message ?? e.reason), "", e.reason?.stack ?? ""));\n\n`; script += `const hmr = new EventSource("/__hmr");\n`; + script += `window.BUNEXT_HMR = hmr;\n`; script += `window.addEventListener("beforeunload", () => hmr.close());\n`; script += `hmr.addEventListener("update", async (event) => {\n`; script += ` if (event?.data) {\n`;