From ec2dc0c4dcc3a60ec1b3fdf1c75e490c9326808d Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Sun, 15 Mar 2026 08:30:54 +0100 Subject: [PATCH] Updates --- bun.lock | 15 +- commands/build/index.ts | 26 ++++ index.ts | 9 +- info/how-it-works.md | 132 ++++++++++++++++++ package.json | 22 +-- src/functions/bundler/all-pages-bundler.ts | 81 +++++++++++ src/functions/init.ts | 2 - src/functions/router/get-route.ts | 2 +- src/functions/server/handle-routes.ts | 4 +- src/functions/server/server-params-gen.ts | 13 +- src/functions/server/start-server.ts | 3 + src/functions/server/watcher.tsx | 114 +++++---------- .../server/web-pages/generate-web-html.tsx | 27 ++-- .../server/web-pages/grab-page-component.tsx | 127 +++++++++++++++++ .../server/web-pages/handle-web-pages.tsx | 53 ++----- .../write-web-page-hydration-script.tsx | 18 +-- src/presets/server-error.tsx | 16 +++ src/types/index.ts | 35 ++++- src/utils/bundle.ts | 34 +++++ src/utils/grab-all-pages.ts | 87 ++++++++++++ src/utils/grab-app-names.ts | 1 + src/utils/grab-dir-names.ts | 70 +++------- src/utils/grab-page-name.ts | 14 +- src/utils/grab-route-params.ts | 3 - src/utils/grab-router.ts | 4 +- tsconfig.json | 8 -- 26 files changed, 673 insertions(+), 247 deletions(-) create mode 100644 commands/build/index.ts mode change 100644 => 100755 index.ts create mode 100644 info/how-it-works.md create mode 100644 src/functions/bundler/all-pages-bundler.ts create mode 100644 src/functions/server/web-pages/grab-page-component.tsx create mode 100644 src/presets/server-error.tsx create mode 100644 src/utils/bundle.ts create mode 100644 src/utils/grab-all-pages.ts diff --git a/bun.lock b/bun.lock index 1998789..0104663 100644 --- a/bun.lock +++ b/bun.lock @@ -4,11 +4,10 @@ "": { "name": "bun-next", "dependencies": { + "chalk": "^5.6.2", "commander": "^14.0.2", "micromatch": "^4.0.8", "ora": "^9.0.0", - "react": "^19.2.0", - "react-dom": "^19.2.0", }, "devDependencies": { "@types/bun": "latest", @@ -16,6 +15,8 @@ "@types/node": "^24.10.0", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", + "react": "^19.2.4", + "react-dom": "^19.2.4", }, "peerDependencies": { "typescript": "^5.0.0", @@ -31,9 +32,9 @@ "@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], - "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], - "@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="], + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], @@ -51,7 +52,7 @@ "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -75,9 +76,9 @@ "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], - "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], diff --git a/commands/build/index.ts b/commands/build/index.ts new file mode 100644 index 0000000..1d7d691 --- /dev/null +++ b/commands/build/index.ts @@ -0,0 +1,26 @@ +import { Command } from "commander"; +import grabConfig from "../../src/functions/grab-config"; +import startServer from "../../src/functions/server/start-server"; +import init from "../../src/functions/init"; +import type { BunextConfig } from "../../src/types"; +import grabAllPages from "../../src/utils/grab-all-pages"; +import allPagesBundler from "../../src/functions/bundler/all-pages-bundler"; + +export default function () { + return new Command("build") + .description("Build Project") + .action(async () => { + console.log(`Building Project ...`); + + await init(); + + const config: BunextConfig = (await grabConfig()) || {}; + + global.CONFIG = { + ...config, + development: true, + }; + + allPagesBundler(); + }); +} diff --git a/index.ts b/index.ts old mode 100644 new mode 100755 index 739ba48..4166d6a --- a/index.ts +++ b/index.ts @@ -8,6 +8,7 @@ import type { BunextConfig } from "./src/types"; import type { FileSystemRouter, Server } from "bun"; import init from "./src/functions/init"; import grabDirNames from "./src/utils/grab-dir-names"; +import build from "./commands/build"; /** * # Declare Global Variables @@ -20,6 +21,7 @@ declare global { var WATCHER_TIMEOUT: any; var ROUTER: FileSystemRouter; var HMR_CONTROLLERS: Set>; + var LAST_BUILD_TIME: number; } global.ORA_SPINNER = ora(); @@ -28,11 +30,11 @@ global.HMR_CONTROLLERS = new Set(); await init(); -const { ROUTES_DIR } = grabDirNames(); +const { PAGES_DIR } = grabDirNames(); const router = new Bun.FileSystemRouter({ style: "nextjs", - dir: ROUTES_DIR, + dir: PAGES_DIR, }); global.ROUTER = router; @@ -50,6 +52,7 @@ program */ program.addCommand(dev()); program.addCommand(start()); +program.addCommand(build()); /** * # Handle Unavailable Commands @@ -57,7 +60,7 @@ program.addCommand(start()); program.on("command:*", () => { console.error( "Invalid command: %s\nSee --help for a list of available commands.", - program.args.join(" ") + program.args.join(" "), ); process.exit(1); }); diff --git a/info/how-it-works.md b/info/how-it-works.md new file mode 100644 index 0000000..c24ec05 --- /dev/null +++ b/info/how-it-works.md @@ -0,0 +1,132 @@ +Here's the full flow, start to finish: + +--- + +1. Startup (index.ts → commands/dev/index.ts) + +Running bun ../../index.ts dev: + +- init() is called twice (once in index.ts, once in the dev command — redundant but harmless). It ensures all required + directories exist (src/pages/, .bunext/client/hydration-src/, public/pages/, etc.) and creates a blank bunext.config.ts + if missing. +- global.CONFIG is set with development: true. +- global.ROUTER is created as a Bun.FileSystemRouter pointing at src/pages/ using Next.js-style routing. + +--- + +2. Initial Build (start-server.ts → allPagesBundler) + +Before accepting requests, allPagesBundler() runs: + +1. grabAllPages({ exclude_api: true }) — recursively walks src/pages/, skipping api/ routes and directories with ( or ) + in the name, returning an array of { local_path, url_path, file_name }. +2. For each page, writeWebPageHydrationScript() generates a .tsx entrypoint in + .bunext/client/hydration-src/pageName.tsx. That file looks like: + import React from "react"; + import { hydrateRoot } from "react-dom/client"; + import App from "/absolute/path/to/src/pages/index.tsx"; + const container = document.getElementById("bunext-root"); + hydrateRoot(container, ); +3. Stale hydration files (for deleted pages) are cleaned up. +4. bundle() runs bun build .bunext/client/hydration-src/\*.tsx --outdir public/pages/ --minify via execSync. Bun bundles + each entrypoint for the browser, outputting public/pages/pageName.js (and public/pages/pageName.css if any CSS was + imported). + +--- + +3. Server Start (server-params-gen.ts) + +Bun.serve() is called with a single fetch handler that routes by URL pathname: + +┌─────────────────┬──────────────────────────┐ +│ Path │ Handler │ +├─────────────────┼──────────────────────────┤ +│ /\_\_hmr │ SSE stream for HMR │ +├─────────────────┼──────────────────────────┤ +│ /api/_ │ handleRoutes │ +├─────────────────┼──────────────────────────┤ +│ /public/_ │ Static file from public/ │ +├─────────────────┼──────────────────────────┤ +│ /favicon.\* │ Static file from public/ │ +├─────────────────┼──────────────────────────┤ +│ Everything else │ handleWebPages (SSR) │ +└─────────────────┴──────────────────────────┘ + +--- + +4. Incoming Page Request → handleWebPages + +4a. Route matching (grab-page-component.tsx) + +- A new Bun.FileSystemRouter is created from src/pages/ (in dev; in prod it uses the cached global.ROUTER). +- router.match(url.pathname) resolves the URL to an absolute file path (e.g. / → .../src/pages/index.tsx). +- grabRouteParams() builds a BunxRouteParams object containing req, url, query (deserialized from search params), and + body (parsed JSON for non-GET requests). + +4b. Module import + +const module = await import(`${file_path}?t=${global.LAST_BUILD_TIME ?? 0}`); + +The ?t= cache-buster forces Bun to re-import the module after a rebuild instead of serving a stale cached version. + +4c. server() function + +If the page exports a server function, it's called with routeParams and its return value becomes serverRes — the props +passed to the component. This is the data-fetching layer (equivalent to Next.js getServerSideProps). + +4d. Component instantiation + +const Component = module.default as FC; +const component = ; + +The default export is treated as the page component, instantiated with the server-fetched props. + +▎ If anything above throws (bad route, import error, etc.), the error path falls back to the /500 page (user-defined or +the preset). + +--- + +5. HTML Generation (generate-web-html.tsx) + +renderToString(component) is called — importing react-dom/server dynamically from process.cwd()/node_modules/ (the +consumer's React, avoiding the duplicate-instance bug). + +The resulting HTML is assembled: + + + + + + + + + +
+ + + + + +The pageProps (from server()) are serialized via EJSON and injected as window.**BUNEXT_PAGE_PROPS** so the client +hydration script can read them without an extra network request. + +--- + +6. Client Hydration (browser) + +The browser: + +1. Parses the server-rendered HTML and displays it immediately (no blank flash). +2. Loads /public/pages/index.js (the Bun-bundled client script). +3. That script calls hydrateRoot(container, ) — React attaches event handlers + to the existing DOM rather than re-rendering from scratch. + +At this point the page is fully interactive. + +--- + +7. HMR (dev only) + +The injected EventSource("/\_\_hmr") maintains a persistent SSE connection. When the watcher detects a file change, it +rebuilds all pages, updates LAST_BUILD_TIME, and sends event: update\ndata: reload down the SSE stream. The browser +calls window.location.reload(), which re-requests the page and repeats steps 4–6 with the fresh module. diff --git a/package.json b/package.json index 9f423fc..6d9230c 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,16 @@ "module": "index.ts", "type": "module", "bin": { - "bunext": "index.ts" + "bunext": "dist/index.js" }, "files": [ - "index.ts" + "dist", + "README.md", + "package.json" ], "scripts": { - "dev": "cd envs/development && docker compose down && docker compose up --build", + "dev": "tsc --watch", + "docker:dev": "cd envs/development && docker compose down && docker compose up --build", "start": "cd envs/production && docker compose down && docker compose up -d --build", "preview": "cd envs/preview && docker compose down && docker compose up -d --build" }, @@ -18,16 +21,19 @@ "@types/micromatch": "^4.0.10", "@types/node": "^24.10.0", "@types/react": "^19.2.2", - "@types/react-dom": "^19.2.2" + "@types/react-dom": "^19.2.2", + "react": "^19.2.4", + "react-dom": "^19.2.4" }, "peerDependencies": { - "typescript": "^5.0.0" + "typescript": "^5.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" }, "dependencies": { + "chalk": "^5.6.2", "commander": "^14.0.2", "micromatch": "^4.0.8", - "ora": "^9.0.0", - "react": "^19.2.0", - "react-dom": "^19.2.0" + "ora": "^9.0.0" } } diff --git a/src/functions/bundler/all-pages-bundler.ts b/src/functions/bundler/all-pages-bundler.ts new file mode 100644 index 0000000..b21ddfb --- /dev/null +++ b/src/functions/bundler/all-pages-bundler.ts @@ -0,0 +1,81 @@ +import { readdirSync, statSync, unlinkSync } from "fs"; +import grabAllPages from "../../utils/grab-all-pages"; +import grabDirNames from "../../utils/grab-dir-names"; +import grabPageName from "../../utils/grab-page-name"; +import writeWebPageHydrationScript from "../server/web-pages/write-web-page-hydration-script"; +import path from "path"; +import bundle from "../../utils/bundle"; +import AppNames from "../../utils/grab-app-names"; +import type { PageFiles } from "../../types"; + +const { BUNX_HYDRATION_SRC_DIR, HYDRATION_DST_DIR } = grabDirNames(); + +export default async function allPagesBundler() { + console.time("build"); + + const pages = grabAllPages({ exclude_api: true }); + + for (let i = 0; i < pages.length; i++) { + const page = pages[i]; + + if (!isPageValid(page)) { + continue; + } + + const pageName = grabPageName({ path: page.local_path }); + + writeWebPageHydrationScript({ + pageName, + page_file: page.local_path, + }); + } + + const hydration_files = readdirSync(BUNX_HYDRATION_SRC_DIR); + + for (let i = 0; i < hydration_files.length; i++) { + const hydration_file = hydration_files[i]; + + const valid_file = pages.find((p) => { + if (!isPageValid(p)) { + return false; + } + + const pageName = grabPageName({ path: p.local_path }); + + const file_tsx_name = `${pageName}.tsx`; + if (file_tsx_name == hydration_file) { + return true; + } + return false; + }); + + if (!valid_file) { + unlinkSync(path.join(BUNX_HYDRATION_SRC_DIR, hydration_file)); + } + } + + const entrypoints = readdirSync(BUNX_HYDRATION_SRC_DIR) + .filter((f) => f.endsWith(".tsx")) + .map((f) => path.join(BUNX_HYDRATION_SRC_DIR, f)) + .filter((f) => statSync(f).isFile()); + + bundle({ + src: entrypoints.join(" "), + out_dir: HYDRATION_DST_DIR, + exec_options: { stdio: "ignore" }, + }); + + console.timeEnd("build"); +} + +function isPageValid(page: PageFiles): boolean { + if (page.file_name == AppNames["RootPagesComponentName"]) { + return false; + } + + if (page.url_path.match(/\(|\)|--/)) { + return false; + } + + return true; +} diff --git a/src/functions/init.ts b/src/functions/init.ts index 7f0129c..7fa912c 100644 --- a/src/functions/init.ts +++ b/src/functions/init.ts @@ -12,8 +12,6 @@ export default async function () { const key = keys[i]; const dir = dirNames[key]; - // const stat = statSync(dir); - if (!existsSync(dir) && !dir.match(/\.\w+$/)) { mkdirSync(dir, { recursive: true }); continue; diff --git a/src/functions/router/get-route.ts b/src/functions/router/get-route.ts index 0ed58b4..a5fee83 100644 --- a/src/functions/router/get-route.ts +++ b/src/functions/router/get-route.ts @@ -11,7 +11,7 @@ type Params = { export default async function getRoute({ route, }: Params): Promise { - const { ROUTES_DIR } = grabDirNames(); + const {} = grabDirNames(); if (route.match(/\(/)) { return null; diff --git a/src/functions/server/handle-routes.ts b/src/functions/server/handle-routes.ts index a893e50..4315f31 100644 --- a/src/functions/server/handle-routes.ts +++ b/src/functions/server/handle-routes.ts @@ -38,7 +38,7 @@ export default async function ({ }; } - const routeParams: BunxRouteParams = await grabRouteParams({ req, server }); + const routeParams: BunxRouteParams = await grabRouteParams({ req }); const module = await import(match.filePath); const config = module.config as BunextServerRouteConfig | undefined; @@ -62,7 +62,7 @@ export default async function ({ } const res: APIResponseObject = await module["default"]( - routeParams as BunxRouteParams + routeParams as BunxRouteParams, ); return res; diff --git a/src/functions/server/server-params-gen.ts b/src/functions/server/server-params-gen.ts index 8d2932d..caea2f6 100644 --- a/src/functions/server/server-params-gen.ts +++ b/src/functions/server/server-params-gen.ts @@ -6,14 +6,14 @@ import handleWebPages from "./web-pages/handle-web-pages"; import handleRoutes from "./handle-routes"; import isDevelopment from "../../utils/is-development"; -const port = grabAppPort(); -const { PUBLIC_DIR } = grabDirNames(); - type Params = { dev?: boolean; }; export default async function (params?: Params): Promise { + const port = grabAppPort(); + const { PUBLIC_DIR } = grabDirNames(); + return { async fetch(req, server) { try { @@ -61,7 +61,7 @@ export default async function (params?: Params): Promise { return new Response(file); } else { - return await handleWebPages({ req, server }); + return await handleWebPages({ req }); } } catch (error: any) { return new Response(`Server Error: ${error.message}`, { @@ -70,9 +70,6 @@ export default async function (params?: Params): Promise { } }, port, - development: isDevelopment() && { - hmr: true, - console: true, - }, + idleTimeout: 0, } as ServeOptions; } diff --git a/src/functions/server/start-server.ts b/src/functions/server/start-server.ts index 2e44333..fc2155c 100644 --- a/src/functions/server/start-server.ts +++ b/src/functions/server/start-server.ts @@ -1,4 +1,5 @@ import AppNames from "../../utils/grab-app-names"; +import allPagesBundler from "../bundler/all-pages-bundler"; import serverParamsGen from "./server-params-gen"; import watcher from "./watcher"; @@ -15,6 +16,8 @@ export default async function startServer(params?: Params) { global.SERVER = server; + await allPagesBundler(); + console.log( `${name} Server Running on http://localhost:${server.port} ...`, ); diff --git a/src/functions/server/watcher.tsx b/src/functions/server/watcher.tsx index 8f9e452..ae1b463 100644 --- a/src/functions/server/watcher.tsx +++ b/src/functions/server/watcher.tsx @@ -1,101 +1,57 @@ import { watch } from "fs"; import grabDirNames from "../../utils/grab-dir-names"; -import grabPageName from "../../utils/grab-page-name"; -import path from "path"; -import { execSync } from "child_process"; import serverParamsGen from "./server-params-gen"; +import allPagesBundler from "../bundler/all-pages-bundler"; -const { ROOT_DIR, BUNX_HYDRATION_SRC_DIR, HYDRATION_DST_DIR, ROUTES_DIR } = +const { ROOT_DIR, BUNX_HYDRATION_SRC_DIR, HYDRATION_DST_DIR, PAGES_DIR } = grabDirNames(); export default function watcher() { watch( ROOT_DIR, - { recursive: true, persistent: true }, - async (event, filename) => { + { + recursive: true, + persistent: true, + }, + (event, filename) => { + // if (!filename) return; + // if (filename.match(/ /)) return; + // if (filename.match(/^node_modules\//)) return; + // if (filename.match(/\.bunext|\/?public\//)) return; + // if (!filename.match(/\.(tsx|ts|css|js|jsx)$/)) return; + if (global.RECOMPILING) return; - if (!filename) return; - if (filename.match(/ /)) return; - if (filename.match(/^node_modules\//)) return; - if (filename.match(/\.bunext|\/public\//)) return; - - if (filename.match(/\/routes\//)) { - if (event == "change") { - clearTimeout(global.WATCHER_TIMEOUT); + clearTimeout(global.WATCHER_TIMEOUT); + global.WATCHER_TIMEOUT = setTimeout(async () => { + try { global.RECOMPILING = true; - const fullPath = path.join(ROOT_DIR, filename); + await allPagesBundler(); - const pageName = grabPageName({ path: fullPath }); - - // const router = grabRouter(); - // const match = router.match(fullPath); - - // if (match?.filePath) { - // const module = await import(match.filePath); - - // const serverRes = await (async () => { - // try { - // return await module["server"](); - // } catch (error) { - // return {}; - // } - // })(); - - // const Component = module.default as FC; - // const component = ; - - // await writeWebPageHydrationScript({ - // pageName, - // component, - // }); - // } - - // await Bun.build({ - // entrypoints: [ - // `${BUNX_HYDRATION_SRC_DIR}/${pageName}.tsx`, - // ], - // outdir: HYDRATION_DST_DIR, - // minify: true, - // }); - - let cmd = `bun build`; - cmd += ` ${BUNX_HYDRATION_SRC_DIR}/${pageName}.tsx --outdir ${HYDRATION_DST_DIR}`; - cmd += ` --minify`; - // cmd += ` && bun pm cache rm`; - - execSync(cmd, { stdio: "inherit" }); - - global.ROUTER = new Bun.FileSystemRouter({ - style: "nextjs", - dir: ROUTES_DIR, - }); - - const encoder = new TextEncoder(); - const msg = encoder.encode( - `event: update\ndata: reload\n\n`, - ); + global.LAST_BUILD_TIME = Date.now(); for (const controller of global.HMR_CONTROLLERS) { - controller.enqueue(msg.toString()); + try { + controller.enqueue( + `event: update\ndata: ${global.LAST_BUILD_TIME}\n\n`, + ); + } catch { + global.HMR_CONTROLLERS.delete(controller); + } } - - // Let the SSE event flush before restarting the server. - // The server restart is required to clear Bun's module cache - // so the next request renders the updated route, not the - // stale cached module (which causes a hydration mismatch). - // await Bun.sleep(500); - - // await reloadServer(); + } catch (error: any) { + console.log(error); + } finally { global.RECOMPILING = false; - } else if (event == "rename") { - await reloadServer(); } - } else if (filename.match(/\.(js|ts|tsx|jsx)$/)) { - clearTimeout(global.WATCHER_TIMEOUT); - await reloadServer(); - } + }, 150); + + // if (filename.match(/\/pages\//)) { + // } else if (filename.match(/\.(js|ts|tsx|jsx)$/)) { + // clearTimeout(global.WATCHER_TIMEOUT); + // global.WATCHER_TIMEOUT = setTimeout(() => reloadServer(), 150); + // } }, ); diff --git a/src/functions/server/web-pages/generate-web-html.tsx b/src/functions/server/web-pages/generate-web-html.tsx index ce01024..2a05a7b 100644 --- a/src/functions/server/web-pages/generate-web-html.tsx +++ b/src/functions/server/web-pages/generate-web-html.tsx @@ -1,8 +1,8 @@ import path from "path"; -import { renderToString } from "react-dom/server"; import grabContants from "../../../utils/grab-constants"; +import grabDirNames from "../../../utils/grab-dir-names"; import EJSON from "../../../utils/ejson"; -import type { PageDistGenParams } from "../../../types"; +import type { LivePageDistGenParams } from "../../../types"; import isDevelopment from "../../../utils/is-development"; export default async function genWebHTML({ @@ -10,16 +10,28 @@ export default async function genWebHTML({ pageProps, pageName, module, -}: PageDistGenParams) { +}: LivePageDistGenParams) { const { ClientRootElementIDName, ClientWindowPagePropsName } = await grabContants(); + const { renderToString } = await import( + path.join(process.cwd(), "node_modules", "react-dom", "server") + ); + const componentHTML = renderToString(component); - const SCRIPT_SRC = path.join("/public/routes", pageName + ".js"); + const SCRIPT_SRC = path.join("/public/pages", pageName + ".js"); + const CSS_SRC = path.join("/public/pages", pageName + ".css"); + const { HYDRATION_DST_DIR } = grabDirNames(); + const cssExists = await Bun.file(path.join(HYDRATION_DST_DIR, pageName + ".css")).exists(); let html = `\n`; - + html += `\n`; + html += ` \n`; + html += ` \n`; + if (cssExists) { + html += ` \n`; + } if (isDevelopment()) { html += `\n`; } - - html += `\n`; - html += ` \n`; - html += ` \n`; - html += ` React SSR with Bun\n`; html += ` \n`; html += ` \n`; html += `
${componentHTML}
\n`; diff --git a/src/functions/server/web-pages/grab-page-component.tsx b/src/functions/server/web-pages/grab-page-component.tsx new file mode 100644 index 0000000..2f4128a --- /dev/null +++ b/src/functions/server/web-pages/grab-page-component.tsx @@ -0,0 +1,127 @@ +import type { FC } from "react"; +import grabDirNames from "../../../utils/grab-dir-names"; +import grabPageName from "../../../utils/grab-page-name"; +import grabRouteParams from "../../../utils/grab-route-params"; +import grabRouter from "../../../utils/grab-router"; +import type { BunextPageModule, GrabPageComponentRes } from "../../../types"; +import bundle from "../../../utils/bundle"; +import path from "path"; +import AppNames from "../../../utils/grab-app-names"; +import { existsSync } from "fs"; + +type Params = { + req?: Request; + file_path?: string; +}; + +export default async function grabPageComponent({ + req, + file_path: passed_file_path, +}: Params): Promise { + const url = req?.url ? new URL(req.url) : undefined; + const router = grabRouter(); + + const { + BUNX_ROOT_500_PRESET_COMPONENT, + HYDRATION_DST_DIR, + BUNX_ROOT_500_FILE_NAME, + PAGES_DIR, + } = grabDirNames(); + + const routeParams = req ? await grabRouteParams({ req }) : undefined; + + try { + const match = url ? router.match(url.pathname) : undefined; + + if (!match?.filePath && url?.pathname) { + const errMsg = `Page ${url.pathname} not found`; + console.error(errMsg); + throw new Error(errMsg); + } + + const file_path = match?.filePath || passed_file_path; + + if (!file_path) { + const errMsg = `No File Path (\`file_path\`) or Request Object (\`req\`) provided not found`; + console.error(errMsg); + throw new Error(errMsg); + } + + const pageName = grabPageName({ path: file_path }); + + const root_pages_component_ts_file = `${path.join(PAGES_DIR, AppNames["RootPagesComponentName"])}.ts`; + const root_pages_component_tsx_file = `${path.join(PAGES_DIR, AppNames["RootPagesComponentName"])}.tsx`; + const root_pages_component_js_file = `${path.join(PAGES_DIR, AppNames["RootPagesComponentName"])}.js`; + const root_pages_component_jsx_file = `${path.join(PAGES_DIR, AppNames["RootPagesComponentName"])}.jsx`; + + const root_file = existsSync(root_pages_component_tsx_file) + ? root_pages_component_tsx_file + : existsSync(root_pages_component_ts_file) + ? root_pages_component_ts_file + : existsSync(root_pages_component_jsx_file) + ? root_pages_component_jsx_file + : existsSync(root_pages_component_js_file) + ? root_pages_component_js_file + : undefined; + + const root_module = root_file + ? await import(`${root_file}?t=${global.LAST_BUILD_TIME ?? 0}`) + : undefined; + + const RootComponent = root_module?.default as FC | undefined; + + const component_file_path = root_module + ? `${file_path}` + : `${file_path}?t=${global.LAST_BUILD_TIME ?? 0}`; + + const module: BunextPageModule = await import(component_file_path); + + const serverRes = await (async () => { + try { + if (routeParams) { + return await module["server"]?.(routeParams); + } + return {}; + } catch (error) { + return {}; + } + })(); + + const Component = module.default as FC; + const component = RootComponent ? ( + + + + ) : ( + + ); + + return { component, serverRes, routeParams, pageName, module }; + } catch (error: any) { + const match = router.match("/500"); + + const filePath = match?.filePath || BUNX_ROOT_500_PRESET_COMPONENT; + + // if (!match?.filePath) { + // bundle({ + // out_dir: HYDRATION_DST_DIR, + // src: `${BUNX_ROOT_500_PRESET_COMPONENT}`, + // debug: true, + // }); + // } + + const module: BunextPageModule = await import( + `${filePath}?t=${global.LAST_BUILD_TIME ?? 0}` + ); + + const Component = module.default as FC; + const component = ; + + return { + component, + pageName: BUNX_ROOT_500_FILE_NAME, + routeParams, + module, + }; + } +} diff --git a/src/functions/server/web-pages/handle-web-pages.tsx b/src/functions/server/web-pages/handle-web-pages.tsx index 4c23bca..643ceba 100644 --- a/src/functions/server/web-pages/handle-web-pages.tsx +++ b/src/functions/server/web-pages/handle-web-pages.tsx @@ -1,47 +1,15 @@ -import type { FC } from "react"; -import grabDirNames from "../../../utils/grab-dir-names"; -import type { Server } from "bun"; -import grabPageName from "../../../utils/grab-page-name"; -import grabRouteParams from "../../../utils/grab-route-params"; import genWebHTML from "./generate-web-html"; -import grabRouter from "../../../utils/grab-router"; -import type { BunextPageModule } from "../../../types"; +import grabPageComponent from "./grab-page-component"; +import writeWebPageHydrationScript from "./write-web-page-hydration-script"; type Params = { req: Request; - server: Server; }; -export default async function ({ req, server }: Params): Promise { - const url = new URL(req.url); - +export default async function ({ req }: Params): Promise { try { - const router = grabRouter(); - const match = router.match(url.pathname); - - if (!match?.filePath) { - const errMsg = `Page ${url.pathname} not found`; - console.error(errMsg); - throw new Error(errMsg); - } - - const pageName = grabPageName({ path: match.filePath }); - - const module: BunextPageModule = await import(match.filePath); - // const config = module.config as ServerRouteConfig | undefined; - - const routeParams = await grabRouteParams({ req, server }); - - const serverRes = await (async () => { - try { - return await module["server"]?.(routeParams); - } catch (error) { - return {}; - } - })(); - - const Component = module.default as FC; - const component = ; + const { component, pageName, module, serverRes } = + await grabPageComponent({ req }); const html = await genWebHTML({ component, @@ -50,13 +18,20 @@ export default async function ({ req, server }: Params): Promise { module, }); + // writeWebPageHydrationScript({ + // component, + // pageName, + // module, + // pageProps: serverRes, + // }); + return new Response(html, { headers: { "Content-Type": "text/html", }, }); - } catch (error) { - return new Response(`Page Not Found`, { + } catch (error: any) { + return new Response(error.message || `Page Not Found`, { status: 404, }); } diff --git a/src/functions/server/web-pages/write-web-page-hydration-script.tsx b/src/functions/server/web-pages/write-web-page-hydration-script.tsx index 705839a..92a5f1b 100644 --- a/src/functions/server/web-pages/write-web-page-hydration-script.tsx +++ b/src/functions/server/web-pages/write-web-page-hydration-script.tsx @@ -2,24 +2,22 @@ import { writeFileSync } from "fs"; import path from "path"; import grabDirNames from "../../../utils/grab-dir-names"; import grabContants from "../../../utils/grab-constants"; -import genWebHTML from "./generate-web-html"; import type { PageDistGenParams } from "../../../types"; -const { BUNX_HYDRATION_SRC_DIR, HYDRATION_DST_DIR } = grabDirNames(); +const { BUNX_HYDRATION_SRC_DIR } = grabDirNames(); export default async function (params: PageDistGenParams) { + const { pageName, page_file } = params; const { ClientRootElementIDName, ClientWindowPagePropsName } = await grabContants(); - const PAGE_DIST_DIR = path.join(HYDRATION_DST_DIR, params.pageName); - - const pageSrcTs = `index.tsx`; + const pageSrcTsFileName = `${pageName}.tsx`; let script = ""; script += `import React from "react";\n`; script += `import { hydrateRoot } from "react-dom/client";\n`; - script += `import App from "./";\n`; + script += `import App from "${page_file}";\n`; script += `declare global {\n`; script += ` interface Window {\n`; @@ -30,12 +28,6 @@ export default async function (params: PageDistGenParams) { script += `const container = document.getElementById("${ClientRootElementIDName}");\n`; script += `hydrateRoot(container, );\n`; - const SRC_WRITE_FILE = path.join(PAGE_DIST_DIR, pageSrcTs); + const SRC_WRITE_FILE = path.join(BUNX_HYDRATION_SRC_DIR, pageSrcTsFileName); writeFileSync(SRC_WRITE_FILE, script, "utf-8"); - - let html = await genWebHTML(params); - const pageHtml = `index.html`; - - const HTML_WRITE_FILE = path.join(PAGE_DIST_DIR, pageHtml); - writeFileSync(HTML_WRITE_FILE, html, "utf-8"); } diff --git a/src/presets/server-error.tsx b/src/presets/server-error.tsx new file mode 100644 index 0000000..b76dff8 --- /dev/null +++ b/src/presets/server-error.tsx @@ -0,0 +1,16 @@ +export default function DefaultServerErrorPage() { + return ( +
+ 500 Internal Server Error +
+ ); +} diff --git a/src/types/index.ts b/src/types/index.ts index 316df5f..0d64ce8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -63,7 +63,6 @@ export type GetRouteReturn = { export type BunxRouteParams = { req: Request; url: URL; - server: Server; body?: any; query?: any; }; @@ -120,7 +119,13 @@ export type BunextServerRouteConfig = { }; export type PageDistGenParams = { + pageName: string; + page_file: string; +}; + +export type LivePageDistGenParams = { component: ReactNode; + head?: ReactNode; pageProps?: any; module?: BunextPageModule; pageName: string; @@ -128,16 +133,34 @@ export type PageDistGenParams = { export type BunextPageModule = { default: FC; - server?: ( - routeParams: BunxRouteParams, - ) => Promise; + server?: BunextPageServerFn; }; -export type BunextPageModuleServerReturn = { - props?: any; +export type BunextPageServerFn< + T extends { [k: string]: any } = { [k: string]: any }, +> = (routeParams: BunxRouteParams) => Promise>; + +export type BunextPageModuleServerReturn< + T extends { [k: string]: any } = { [k: string]: any }, +> = { + props?: T; }; export type BunextPageModuleMetadata = { title?: string; description?: string; }; + +export type GrabPageComponentRes = { + component: JSX.Element; + serverRes?: BunextPageModuleServerReturn; + routeParams?: BunxRouteParams; + pageName: string; + module: BunextPageModule; +}; + +export type PageFiles = { + local_path: string; + url_path: string; + file_name: string; +}; diff --git a/src/utils/bundle.ts b/src/utils/bundle.ts new file mode 100644 index 0000000..59ade2c --- /dev/null +++ b/src/utils/bundle.ts @@ -0,0 +1,34 @@ +import { execSync, type ExecSyncOptions } from "child_process"; + +type Params = { + src: string; + out_dir: string; + minify?: boolean; + exec_options?: ExecSyncOptions; + debug?: boolean; +}; + +export default function bundle({ + out_dir, + src, + minify = true, + exec_options, + debug, +}: Params) { + let cmd = `bun build`; + + cmd += ` ${src} --outdir ${out_dir}`; + + if (minify) { + cmd += ` --minify`; + } + + if (debug) { + console.log("cmd =>", cmd); + } + + execSync(cmd, { + stdio: "inherit", + ...exec_options, + }); +} diff --git a/src/utils/grab-all-pages.ts b/src/utils/grab-all-pages.ts new file mode 100644 index 0000000..fd1e067 --- /dev/null +++ b/src/utils/grab-all-pages.ts @@ -0,0 +1,87 @@ +import { existsSync, readdirSync, statSync } from "fs"; +import grabDirNames from "./grab-dir-names"; +import path from "path"; +import type { PageFiles } from "../types"; + +type Params = { + exclude_api?: boolean; +}; + +export default function grabAllPages(params?: Params) { + const { PAGES_DIR } = grabDirNames(); + + const pages = grabPageDirRecursively({ page_dir: PAGES_DIR }); + + if (params?.exclude_api) { + return pages.filter((p) => !Boolean(p.url_path.startsWith("/api/"))); + } + + return pages; +} + +function grabPageDirRecursively({ page_dir }: { page_dir: string }) { + const pages = readdirSync(page_dir); + const pages_files: PageFiles[] = []; + + const root_pages_file = grabPageFileObject({ file_path: `` }); + + if (root_pages_file) { + pages_files.push(root_pages_file); + } + + for (let i = 0; i < pages.length; i++) { + const page = pages[i]; + const full_page_path = path.join(page_dir, page); + + if (!existsSync(full_page_path)) { + continue; + } + + if (page.match(/__root\.(tx|js)x?/)) { + continue; + } + + if (page.match(/\(|\)/)) { + continue; + } + + const page_stat = statSync(full_page_path); + + if (page_stat.isDirectory()) { + if (page.match(/\(|\)/)) continue; + const new_page_files = grabPageDirRecursively({ + page_dir: full_page_path, + }); + pages_files.push(...new_page_files); + } else if (page.match(/\.(ts|js)x?$/)) { + const pages_file = grabPageFileObject({ + file_path: full_page_path, + }); + + if (pages_file) { + pages_files.push(pages_file); + } + } + } + + return pages_files; +} + +function grabPageFileObject({ + file_path, +}: { + file_path: string; +}): PageFiles | undefined { + let url_path = file_path + .replace(/.*\/pages\//, "/") + ?.replace(/\.(ts|js)x?$/, ""); + + let file_name = url_path.split("/").pop(); + if (!file_name) return; + + return { + local_path: file_path, + url_path, + file_name, + }; +} diff --git a/src/utils/grab-app-names.ts b/src/utils/grab-app-names.ts index 2751472..2d5317f 100644 --- a/src/utils/grab-app-names.ts +++ b/src/utils/grab-app-names.ts @@ -3,6 +3,7 @@ const AppNames = { defaultAssetPrefix: "_bunext/static", name: "Bunext", defaultDistDir: ".bunext", + RootPagesComponentName: "__root", } as const; export default AppNames; diff --git a/src/utils/grab-dir-names.ts b/src/utils/grab-dir-names.ts index 437820a..a7d67c7 100644 --- a/src/utils/grab-dir-names.ts +++ b/src/utils/grab-dir-names.ts @@ -3,10 +3,10 @@ import path from "path"; export default function grabDirNames() { const ROOT_DIR = process.cwd(); const SRC_DIR = path.join(ROOT_DIR, "src"); - const ROUTES_DIR = path.join(SRC_DIR, "routes"); - const API_DIR = path.join(ROUTES_DIR, "api"); + const PAGES_DIR = path.join(SRC_DIR, "pages"); + const API_DIR = path.join(PAGES_DIR, "api"); const PUBLIC_DIR = path.join(ROOT_DIR, "public"); - const HYDRATION_DST_DIR = path.join(PUBLIC_DIR, "routes"); + const HYDRATION_DST_DIR = path.join(PUBLIC_DIR, "pages"); const CONFIG_FILE = path.join(ROOT_DIR, "bunext.config.ts"); const BUNX_CWD_DIR = path.resolve(ROOT_DIR, ".bunext"); @@ -14,15 +14,22 @@ export default function grabDirNames() { const BUNX_HYDRATION_SRC_DIR = path.resolve( BUNX_CWD_DIR, "client", - "hydration-src" + "hydration-src", ); const BUNX_ROOT_DIR = path.resolve(__dirname, "../../"); + const BUNX_ROOT_SRC_DIR = path.join(BUNX_ROOT_DIR, "src"); + const BUNX_ROOT_PRESETS_DIR = path.join(BUNX_ROOT_SRC_DIR, "presets"); + const BUNX_ROOT_500_FILE_NAME = `server-error`; + const BUNX_ROOT_500_PRESET_COMPONENT = path.join( + BUNX_ROOT_PRESETS_DIR, + `${BUNX_ROOT_500_FILE_NAME}.tsx`, + ); return { ROOT_DIR, SRC_DIR, - ROUTES_DIR, + PAGES_DIR, API_DIR, PUBLIC_DIR, HYDRATION_DST_DIR, @@ -30,54 +37,9 @@ export default function grabDirNames() { CONFIG_FILE, BUNX_TMP_DIR, BUNX_HYDRATION_SRC_DIR, + BUNX_ROOT_SRC_DIR, + BUNX_ROOT_PRESETS_DIR, + BUNX_ROOT_500_PRESET_COMPONENT, + BUNX_ROOT_500_FILE_NAME, }; } - -// const rootDir = params?.dir || process.cwd(); -// const appDir = path.resolve(__dirname, ".."); -// const entrypoint = path.join( -// appDir, -// "functions", -// "server", -// "start-server.ts" -// ); - -// const bunextDir = path.join(rootDir, ".bunext"); -// const bunextClientDir = path.join(bunextDir, "client"); -// const bunextClientRoutesDir = path.join(bunextClientDir, "routes"); -// const bunextClientRoutesSrcDir = path.join(bunextClientRoutesDir, "src"); -// const bunextClientRoutesDstDir = path.join(bunextClientRoutesDir, "dst"); - -// const bunextServerDir = path.join(bunextDir, "server"); -// const bunextServerPagesDir = path.join(bunextServerDir, "pages"); - -// const publicDir = path.join(rootDir, "public"); -// const configFile = path.join(rootDir, "bunext.config.ts"); - -// const srcDir = path.join(rootDir, "src"); -// const pagesDir = path.join(srcDir, "pages"); -// const componentsDir = path.join(srcDir, "components"); -// const stylesDir = path.join(srcDir, "styles"); -// const utilsDir = path.join(srcDir, "utils"); -// const typesDir = path.join(srcDir, "types"); - -// return { -// rootDir, -// pagesDir, -// componentsDir, -// publicDir, -// stylesDir, -// utilsDir, -// typesDir, -// configFile, -// appDir, -// entrypoint, -// srcDir, -// bunextDir, -// bunextClientDir, -// bunextClientRoutesDir, -// bunextClientRoutesSrcDir, -// bunextClientRoutesDstDir, -// bunextServerDir, -// bunextServerPagesDir, -// }; diff --git a/src/utils/grab-page-name.ts b/src/utils/grab-page-name.ts index 96eb227..4b48e5f 100644 --- a/src/utils/grab-page-name.ts +++ b/src/utils/grab-page-name.ts @@ -5,14 +5,24 @@ type Params = { export default function grabPageName(params: Params) { const pathArr = params.path.split("/"); - const routesIndex = pathArr.findIndex((p) => p == "routes"); + const routesIndex = pathArr.findIndex((p) => p == "pages"); const newPathArr = [...pathArr].slice(routesIndex + 1); const filename = newPathArr .filter((p) => Boolean(p.match(/./))) - .map((p) => p.replace(/\.\w+$/, "").replace(/[^a-z]/g, "")) + .map((p) => + p + .replace(/\.\w+$/, "") + .replace(/\[/g, "-") + .replace(/\.\.\./g, "-") + .replace(/[^a-z\-]/g, ""), + ) .join("-"); + if (filename.endsWith(`-index`)) { + return filename.replace(/-index$/, ""); + } + return filename; } diff --git a/src/utils/grab-route-params.ts b/src/utils/grab-route-params.ts index a38e7fc..0acc5d9 100644 --- a/src/utils/grab-route-params.ts +++ b/src/utils/grab-route-params.ts @@ -4,12 +4,10 @@ import deserializeQuery from "./deserialize-query"; type Params = { req: Request; - server: Server; }; export default async function grabRouteParams({ req, - server, }: Params): Promise { const url = new URL(req.url); @@ -26,7 +24,6 @@ export default async function grabRouteParams({ const routeParams: BunxRouteParams = { req, url, - server, query, body, }; diff --git a/src/utils/grab-router.ts b/src/utils/grab-router.ts index 699efe7..2d497de 100644 --- a/src/utils/grab-router.ts +++ b/src/utils/grab-router.ts @@ -1,7 +1,7 @@ import grabDirNames from "./grab-dir-names"; export default function grabRouter() { - const { ROUTES_DIR } = grabDirNames(); + const { PAGES_DIR } = grabDirNames(); if (process.env.NODE_ENV == "production") { return global.ROUTER; @@ -9,6 +9,6 @@ export default function grabRouter() { return new Bun.FileSystemRouter({ style: "nextjs", - dir: ROUTES_DIR, + dir: PAGES_DIR, }); } diff --git a/tsconfig.json b/tsconfig.json index 48b634e..5e24ac3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,19 +7,11 @@ "moduleDetection": "force", "jsx": "react-jsx", "allowJs": true, - - // Bundler mode "moduleResolution": "bundler", - "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, - "noEmit": true, - - // Best practices "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, - - // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false,