diff --git a/src/functions/init.ts b/src/functions/init.ts index 868ce24..a4ad74f 100644 --- a/src/functions/init.ts +++ b/src/functions/init.ts @@ -6,6 +6,7 @@ export default async function () { const dirNames = grabDirNames(); execSync(`rm -rf ${dirNames.BUNEXT_CACHE_DIR}`); + execSync(`rm -rf ${dirNames.BUNX_CWD_MODULE_CACHE_DIR}`); const keys = Object.keys(dirNames) as (keyof ReturnType< typeof grabDirNames diff --git a/src/functions/server/web-pages/grab-file-path-module.tsx b/src/functions/server/web-pages/grab-file-path-module.tsx new file mode 100644 index 0000000..db6e52e --- /dev/null +++ b/src/functions/server/web-pages/grab-file-path-module.tsx @@ -0,0 +1,63 @@ +import isDevelopment from "../../../utils/is-development"; +import * as esbuild from "esbuild"; +import postcss from "postcss"; +import tailwindcss from "@tailwindcss/postcss"; +import { readFile } from "fs/promises"; +import grabDirNames from "../../../utils/grab-dir-names"; +import path from "path"; + +type Params = { + file_path: string; +}; + +const tailwindPlugin: esbuild.Plugin = { + name: "tailwindcss", + setup(build) { + build.onLoad({ filter: /\.css$/ }, async (args) => { + const source = await readFile(args.path, "utf-8"); + const result = await postcss([tailwindcss()]).process(source, { + from: args.path, + }); + + return { + contents: result.css, + loader: "css", + }; + }); + }, +}; + +export default async function grabFilePathModule({ + file_path, +}: Params): Promise { + const dev = isDevelopment(); + const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames(); + const target_cache_file_path = path.join( + BUNX_CWD_MODULE_CACHE_DIR, + `${path.basename(file_path)}.js`, + ); + + await esbuild.build({ + entryPoints: [file_path], + bundle: true, + format: "esm", + target: "es2020", + platform: "node", + external: ["react", "react-dom"], + minify: true, + define: { + "process.env.NODE_ENV": JSON.stringify( + dev ? "development" : "production", + ), + }, + metafile: true, + plugins: [tailwindPlugin], + jsx: "automatic", + outfile: target_cache_file_path, + }); + + Loader.registry.delete(target_cache_file_path); + const module = await import(`${target_cache_file_path}?t=${Date.now()}`); + + return module as T; +} diff --git a/src/functions/server/web-pages/grab-page-bundled-react-component.tsx b/src/functions/server/web-pages/grab-page-bundled-react-component.tsx new file mode 100644 index 0000000..ef2b5d8 --- /dev/null +++ b/src/functions/server/web-pages/grab-page-bundled-react-component.tsx @@ -0,0 +1,51 @@ +import type { GrabPageReactBundledComponentRes } from "../../../types"; +import EJSON from "../../../utils/ejson"; +import grabTsxStringModule from "./grab-tsx-string-module"; + +type Params = { + file_path: string; + root_file?: string; + server_res?: any; +}; + +export default async function grabPageBundledReactComponent({ + file_path, + root_file, + server_res, +}: Params): Promise { + try { + let tsx = ``; + + const server_res_json = EJSON.stringify(server_res || {})?.replace( + /"/g, + '\\"', + ); + + if (root_file) { + tsx += `import Root from "${root_file}"\n`; + } + + tsx += `import Page from "${file_path}"\n`; + tsx += `export default function Main() {\n\n`; + tsx += `const props = JSON.parse("${server_res_json}")\n\n`; + tsx += ` return (\n`; + if (root_file) { + tsx += ` \n`; + } else { + tsx += ` \n`; + } + tsx += ` )\n`; + tsx += `}\n`; + + const mod = await grabTsxStringModule({ tsx, file_path }); + const Main = mod.default; + const component =
; + + return { + component, + server_res, + }; + } catch (error: any) { + return undefined; + } +} diff --git a/src/functions/server/web-pages/grab-page-component.tsx b/src/functions/server/web-pages/grab-page-component.tsx index fb5fcb6..f7ec857 100644 --- a/src/functions/server/web-pages/grab-page-component.tsx +++ b/src/functions/server/web-pages/grab-page-component.tsx @@ -11,6 +11,7 @@ import path from "path"; import AppNames from "../../../utils/grab-app-names"; import { existsSync } from "fs"; import grabPageErrorComponent from "./grab-page-error-component"; +import grabPageBundledReactComponent from "./grab-page-bundled-react-component"; class NotFoundError extends Error {} @@ -63,8 +64,6 @@ export default async function grabPageComponent({ 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`; @@ -82,17 +81,7 @@ export default async function grabPageComponent({ const now = Date.now(); - const root_module = root_file - ? await import(`${root_file}?t=${now}`) - : 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(`${file_path}?t=${now}`); + const module: BunextPageModule = await import(file_path); const serverRes: BunextPageModuleServerReturn = await (async () => { try { @@ -124,16 +113,18 @@ export default async function grabPageComponent({ : undefined : undefined; - const Component = module.default as FC; const Head = module.Head as FC; - const component = RootComponent ? ( - - - - ) : ( - - ); + const { component } = + (await grabPageBundledReactComponent({ + file_path, + root_file, + server_res: serverRes, + })) || {}; + + if (!component) { + throw new Error(`Couldn't grab page component`); + } return { component, @@ -152,3 +143,34 @@ export default async function grabPageComponent({ }); } } + +// let root_module: any; + +// if (root_file) { +// if (isDevelopment()) { +// root_module = await grabFilePathModule({ +// file_path: root_file, +// }); +// } else { +// root_module = root_file ? await import(root_file) : undefined; +// } +// } + +// const RootComponent = root_module?.default as FC | undefined; + +// let module: BunextPageModule; + +// if (isDevelopment()) { +// module = await grabFilePathModule({ file_path }); +// } else { +// module = await import(file_path); +// } + +// const Component = main_module.default as FC; +// const component = RootComponent ? ( +// +// +// +// ) : ( +// +// ); diff --git a/src/functions/server/web-pages/grab-tsx-string-module.tsx b/src/functions/server/web-pages/grab-tsx-string-module.tsx new file mode 100644 index 0000000..ec95525 --- /dev/null +++ b/src/functions/server/web-pages/grab-tsx-string-module.tsx @@ -0,0 +1,76 @@ +import isDevelopment from "../../../utils/is-development"; +import * as esbuild from "esbuild"; +import postcss from "postcss"; +import tailwindcss from "@tailwindcss/postcss"; +import { readFile } from "fs/promises"; +import grabDirNames from "../../../utils/grab-dir-names"; +import path from "path"; +import { execSync } from "child_process"; + +type Params = { + tsx: string; + file_path: string; +}; + +const tailwindPlugin: esbuild.Plugin = { + name: "tailwindcss", + setup(build) { + build.onLoad({ filter: /\.css$/ }, async (args) => { + const source = await readFile(args.path, "utf-8"); + const result = await postcss([tailwindcss()]).process(source, { + from: args.path, + }); + + return { + contents: result.css, + loader: "css", + }; + }); + }, +}; + +export default async function grabTsxStringModule({ + tsx, + file_path, +}: Params): Promise { + const dev = isDevelopment(); + const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames(); + + const trimmed_file_path = file_path + .replace(/.*\/src\/pages\//, "") + .replace(/\.tsx$/, ""); + + const out_file_path = path.join( + BUNX_CWD_MODULE_CACHE_DIR, + `${trimmed_file_path}.js`, + ); + + await esbuild.build({ + stdin: { + contents: tsx, + resolveDir: process.cwd(), + loader: "tsx", + }, + bundle: true, + format: "esm", + target: "es2020", + platform: "node", + external: ["react", "react-dom"], + minify: true, + define: { + "process.env.NODE_ENV": JSON.stringify( + dev ? "development" : "production", + ), + }, + metafile: true, + plugins: [tailwindPlugin], + jsx: "automatic", + write: true, + outfile: out_file_path, + }); + + Loader.registry.delete(out_file_path); + const module = await import(`${out_file_path}?t=${Date.now()}`); + + return module as T; +} diff --git a/src/presets/server-error.tsx b/src/presets/server-error.tsx index 25bfd37..5c7fade 100644 --- a/src/presets/server-error.tsx +++ b/src/presets/server-error.tsx @@ -17,7 +17,17 @@ export default function DefaultServerErrorPage({ }} >

500 Internal Server Error

- {children} +
+ {children} +
); } diff --git a/src/types/index.ts b/src/types/index.ts index 19e3fa9..9fbee02 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -243,6 +243,11 @@ export type GrabPageComponentRes = { head?: FC; }; +export type GrabPageReactBundledComponentRes = { + component: JSX.Element; + server_res?: BunextPageModuleServerReturn; +}; + export type PageFiles = { local_path: string; url_path: string; diff --git a/src/utils/grab-dir-names.ts b/src/utils/grab-dir-names.ts index 106dc70..62130e0 100644 --- a/src/utils/grab-dir-names.ts +++ b/src/utils/grab-dir-names.ts @@ -16,6 +16,10 @@ export default function grabDirNames() { const CONFIG_FILE = path.join(ROOT_DIR, "bunext.config.ts"); const BUNX_CWD_DIR = path.resolve(ROOT_DIR, ".bunext"); + const BUNX_CWD_MODULE_CACHE_DIR = path.resolve( + BUNX_CWD_DIR, + "module-cache", + ); const BUNX_TMP_DIR = path.resolve(BUNX_CWD_DIR, ".tmp"); const BUNX_HYDRATION_SRC_DIR = path.resolve( BUNX_CWD_DIR, @@ -57,5 +61,6 @@ export default function grabDirNames() { BUNX_ROOT_404_FILE_NAME, HYDRATION_DST_DIR_MAP_JSON_FILE, BUNEXT_CACHE_DIR, + BUNX_CWD_MODULE_CACHE_DIR, }; } diff --git a/src/utils/register-dev-plugin.ts b/src/utils/register-dev-plugin.ts new file mode 100644 index 0000000..085061e --- /dev/null +++ b/src/utils/register-dev-plugin.ts @@ -0,0 +1,68 @@ +import { resolve, dirname, extname } from "path"; +import { existsSync } from "fs"; + +const SOURCE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"]; + +function getLoader(filePath: string) { + const ext = extname(filePath).slice(1) as any; + return SOURCE_EXTENSIONS.map((e) => e.slice(1)).includes(ext) ? ext : "js"; +} + +function tryResolveSync(absPath: string): string | null { + if (existsSync(absPath)) return absPath; + for (const ext of SOURCE_EXTENSIONS) { + const p = absPath + ext; + if (existsSync(p)) return p; + } + for (const ext of SOURCE_EXTENSIONS) { + const p = resolve(absPath, "index" + ext); + if (existsSync(p)) return p; + } + return null; +} + +export default function registerDevPlugin() { + Bun.plugin({ + name: "bunext-dev-hmr", + setup(build) { + // Intercept absolute-path imports that already carry ?t= (our dynamic imports) + build.onResolve({ filter: /\?t=\d+$/ }, (args) => { + if (args.path.includes("node_modules")) return undefined; + const cleanPath = args.path.replace(/\?t=\d+$/, ""); + const resolved = tryResolveSync(cleanPath); + if (!resolved) return undefined; + if (!SOURCE_EXTENSIONS.some((e) => resolved.endsWith(e))) + return undefined; + return { + path: `${resolved}?t=${global.LAST_BUILD_TIME ?? 0}`, + namespace: "bunext-dev", + }; + }); + + // Intercept relative imports from within bunext-dev modules + build.onResolve({ filter: /^\./ }, (args) => { + if (!/\?t=\d+/.test(args.importer)) return undefined; + // Strip "namespace:" prefix (e.g. "bunext-dev:") Bun prepends to importer + const cleanImporter = args.importer + .replace(/^[^/]+:(?=\/)/, "") + .replace(/\?t=\d+$/, ""); + const base = resolve(dirname(cleanImporter), args.path); + const resolved = tryResolveSync(base); + if (!resolved) return undefined; + if (!SOURCE_EXTENSIONS.some((e) => resolved.endsWith(e))) + return undefined; + return { + path: `${resolved}?t=${global.LAST_BUILD_TIME ?? 0}`, + namespace: "bunext-dev", + }; + }); + + // Load files in the bunext-dev namespace from disk (async is fine in onLoad) + build.onLoad({ filter: /.*/, namespace: "bunext-dev" }, async (args) => { + const realPath = args.path.replace(/\?t=\d+$/, ""); + const source = await Bun.file(realPath).text(); + return { contents: source, loader: getLoader(realPath) }; + }); + }, + }); +}