diff --git a/CLAUDE.md b/CLAUDE.md index 6524318..17dbe5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,4 +65,6 @@ API routes live in `src/pages/api/` and follow standard Bun `Request → Respons `bunext build` also updates `.bunext/page-lastmod.json` (`src/utils/page-lastmod-record.ts`): per page, a fingerprint of the bundle hash + CSS path and the build date it last changed. The sitemap uses these dates as `lastmod`. ### Config -Consumer projects define `bunext.config.ts` at their project root. The `BunextConfig` type fields: `distDir`, `assetsPrefix`, `origin`, `globalVars`, `port`, `development`. +Consumer projects define `bunext.config.ts` at their project root. The `BunextConfig` type fields include `dist_dir`, `assets_prefix`, `origin`, `global_vars`, `port`, `development`. + +The dist directory (default `.bunext`) is resolved by `setupDistDir()` (`src/functions/setup-dist-dir.ts`): `--dist-dir` > `BUNEXT_DIST_DIR` env > config `dist_dir`. It runs in the CLI's `preAction` hook and at the start of `bunextInit()` (so custom servers get it too), and stores the result in `BUNEXT_DIST_DIR`, which `grabDirNames()` reads and child processes inherit. Because it runs after modules are imported, **never read a dist-derived path from `grabDirNames()` at module level** — call it inside the function that uses it. Only root-derived paths (`ROOT_DIR`, `PAGES_DIR`, `PUBLIC_DIR`, ...) may be read at module level; `src/__tests__/utils/grab-dist-dir.test.ts` enforces this. Client bundle URLs are their project-relative paths (`grabBunextPublicUrlPath()`), so never hard-code `/.bunext` in URLs or paths. diff --git a/README.md b/README.md index fafb0f2..0ca6aaa 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ bun run dev | Command | Description | | -------------- | ------------------------------------------------------------------------------ | | `bunext dev` | Start the development server with HMR and file watching. | -| `bunext build` | Bundle all pages for production. Outputs artifacts to `.bunext/public/pages/` using paths relative to the project root. | +| `bunext build` | Bundle all pages for production. Outputs artifacts to `.bunext/public/pages/` (or the [dist directory](#dist-directory)) using paths relative to the project root. | | `bunext start` | Start the production server using pre-built artifacts. | | `bunext sitemap` | Generate `public/sitemap.xml` and `public/robots.txt` from your pages, using the `sitemap` and `robots` options in `bunext.config.ts`. See [Sitemap](#sitemap). | @@ -197,6 +197,19 @@ bunext start > **Note:** `bunext start` will exit with an error if `.bunext/public/pages/map.json` does not exist. Always run `bunext build` (or `bun run build`) before `bunext start`. +### Dist Directory + +Build artifacts go to `.bunext/` by default. To use another directory, set `dist_dir` in `bunext.config.ts` or pass `--dist-dir` to any command (the flag wins over the config): + +```bash +bunext build --dist-dir build +bunext start --dist-dir build +``` + +Use the same directory for every command: `bunext start` serves what `bunext build` wrote there, and `bunext sitemap` reads `page-lastmod.json` from it. Setting `dist_dir` in the config keeps them in sync. [Custom servers](#custom-server) read it too, through `bunextInit()`. + +The directory must be inside the project root and outside `src/`, `public/` and `node_modules/`. Its path is also the URL path of the client bundles (e.g. `/build/public/pages/...`), so it may only contain letters, digits, `.`, `_`, `-` and `/`. + --- ## Project Structure @@ -961,7 +974,7 @@ import type { BunextConfig } from "@moduletrace/bunext/types"; const config: BunextConfig = { port: 3000, // default: 7000 origin: "https://example.com", - distDir: ".bunext", // directory for internal build artifacts + dist_dir: ".bunext", // directory for build artifacts assetsPrefix: "_bunext/static", globalVars: { MY_API_URL: "https://api.example.com", @@ -979,7 +992,7 @@ export default config; | -------------------- | --------------------------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------- | | `port` | `number` | `7000` | HTTP server port | | `origin` | `string` | — | Canonical origin URL | -| `distDir` | `string` | `.bunext` | Internal artifact directory | +| `dist_dir` | `string` | `.bunext` | Build artifact directory, relative to the project root — see [Dist Directory](#dist-directory) | | `assetsPrefix` | `string` | `_bunext/static` | URL prefix for static assets | | `globalVars` | `{ [k: string]: any }` | — | Variables injected globally at build time | | `public_envs` | `Record` | — | Public env vars exposed to the client via `window.process.env` (see [Environment Variables](#environment-variables)) | @@ -1137,6 +1150,8 @@ 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. +> **Note:** `bunextInit()` reads `dist_dir` from `bunext.config.ts`, so a custom server uses the same directory as `bunext build`. + --- ## Environment Variables @@ -1144,6 +1159,7 @@ bun run server.ts | Variable | Description | | ---------------------- | ------------------------------------------------------------------------------------------------ | | `PORT` | Override the server port (takes precedence over config) | +| `BUNEXT_DIST_DIR` | Build artifact directory. Overrides `dist_dir` in the config; `--dist-dir` overrides both | | `BUNEXT_PUBLIC_*` | Any env var prefixed with `BUNEXT_PUBLIC_` is exposed to the client via `window.process.env` | ### Public Environment Variables diff --git a/src/__tests__/utils/grab-dist-dir.test.ts b/src/__tests__/utils/grab-dist-dir.test.ts new file mode 100644 index 0000000..456e27c --- /dev/null +++ b/src/__tests__/utils/grab-dist-dir.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, afterEach, beforeEach } from "bun:test"; +import path from "path"; +import os from "os"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs"; +import { DistDirEnvName, resolveDistDir } from "../../utils/grab-dist-dir"; +import grabDirNames from "../../utils/grab-dir-names"; +import grabBunextPublicUrlPath from "../../utils/grab-bunext-public-url-path"; +import setupDistDir from "../../functions/setup-dist-dir"; + +const root_dir = "/projects/app"; + +describe("resolveDistDir", () => { + it("defaults to .bunext", () => { + expect(resolveDistDir({ root_dir })).toBe( + path.join(root_dir, ".bunext"), + ); + expect(resolveDistDir({ dist_dir: " ", root_dir })).toBe( + path.join(root_dir, ".bunext"), + ); + }); + + it("resolves relative and nested paths against the root", () => { + expect(resolveDistDir({ dist_dir: "build", root_dir })).toBe( + path.join(root_dir, "build"), + ); + expect(resolveDistDir({ dist_dir: "./out/bunext", root_dir })).toBe( + path.join(root_dir, "out", "bunext"), + ); + }); + + it("accepts absolute paths inside the root", () => { + expect( + resolveDistDir({ dist_dir: `${root_dir}/build`, root_dir }), + ).toBe(path.join(root_dir, "build")); + }); + + it("rejects the root itself and paths outside it", () => { + for (const dist_dir of [".", "..", "../other", "/tmp/out", root_dir]) { + expect(() => resolveDistDir({ dist_dir, root_dir })).toThrow( + /inside the project root/, + ); + } + }); + + it("accepts names that only start with dots", () => { + expect(resolveDistDir({ dist_dir: "..out", root_dir })).toBe( + path.join(root_dir, "..out"), + ); + }); + + it("rejects src, public and node_modules and their subfolders", () => { + for (const dist_dir of [ + "src", + "src/out", + "public", + "public/out", + "node_modules/.cache", + ]) { + expect(() => resolveDistDir({ dist_dir, root_dir })).toThrow( + /can't be/, + ); + } + }); + + it("rejects characters that are not URL-safe", () => { + for (const dist_dir of ["my out", "out?x", "out#1", "dïst"]) { + expect(() => resolveDistDir({ dist_dir, root_dir })).toThrow( + /use only/, + ); + } + }); +}); + +describe("setupDistDir", () => { + const original_env = process.env[DistDirEnvName]; + const original_cwd = process.cwd(); + let project_dir: string; + + // A fresh temp dir per test, so the config module cache never collides. + function writeConfig(body: string) { + project_dir = mkdtempSync(path.join(os.tmpdir(), "bunext-dist-")); + writeFileSync(path.join(project_dir, "bunext.config.ts"), body); + process.chdir(project_dir); + } + + beforeEach(() => { + delete process.env[DistDirEnvName]; + }); + + afterEach(() => { + process.chdir(original_cwd); + if (project_dir) rmSync(project_dir, { recursive: true, force: true }); + if (original_env === undefined) delete process.env[DistDirEnvName]; + else process.env[DistDirEnvName] = original_env; + }); + + it("uses dist_dir from bunext.config.ts", async () => { + writeConfig(`export default { dist_dir: "build/out" };\n`); + await setupDistDir(); + + const dist = path.join(process.cwd(), "build", "out"); + expect(process.env[DistDirEnvName]).toBe(dist); + expect(grabDirNames().BUNX_CWD_DIR).toBe(dist); + }); + + it("prefers the explicit dist_dir (--dist-dir) over the config", async () => { + writeConfig(`export default { dist_dir: "from-config" };\n`); + await setupDistDir({ dist_dir: "from-flag" }); + + expect(grabDirNames().BUNX_CWD_DIR).toBe( + path.join(process.cwd(), "from-flag"), + ); + }); + + it("prefers an existing BUNEXT_DIST_DIR over the config", async () => { + writeConfig(`export default { dist_dir: "from-config" };\n`); + process.env[DistDirEnvName] = "from-env"; + await setupDistDir(); + + expect(grabDirNames().BUNX_CWD_DIR).toBe( + path.join(process.cwd(), "from-env"), + ); + }); + + it("keeps the default without dist_dir", async () => { + writeConfig(`export default {};\n`); + await setupDistDir(); + + expect(process.env[DistDirEnvName]).toBeUndefined(); + expect(grabDirNames().BUNX_CWD_DIR).toBe( + path.join(process.cwd(), ".bunext"), + ); + }); + + it("throws on an invalid dist_dir", async () => { + writeConfig(`export default { dist_dir: "../outside" };\n`); + expect(setupDistDir()).rejects.toThrow(/inside the project root/); + }); +}); + +/** + * `bunextInit()` resolves `dist_dir` from the config after the library is + * imported (custom servers), so no module may read a dist-dependent path at + * import time. Paths derived only from the project root are fine. + */ +describe("no import-time reads of dist paths", () => { + const RootOnlyKeys = new Set([ + "ROOT_DIR", + "SRC_DIR", + "PAGES_DIR", + "API_DIR", + "PUBLIC_DIR", + "CONFIG_FILE", + "BUNX_ROOT_DIR", + "BUNX_ROOT_SRC_DIR", + "BUNX_ROOT_PRESETS_DIR", + "BUNX_ROOT_500_PRESET_COMPONENT", + "BUNX_ROOT_500_FILE_NAME", + "BUNX_ROOT_404_PRESET_COMPONENT", + "BUNX_ROOT_404_FILE_NAME", + ]); + + function listSourceFiles(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const full = path.join(dir, name); + if (name === "__tests__") return []; + if (statSync(full).isDirectory()) return listSourceFiles(full); + return /\.tsx?$/.test(name) ? [full] : []; + }); + } + + it("only reads root-derived paths at module level", () => { + const src_dir = path.resolve(import.meta.dir, "../.."); + const offenders: string[] = []; + const top_level_read = + /^(?:export )?(?:const|let|var)\s+(\{[^}]*\}|\w+)\s*=\s*(grabDirNames|grabBunextPublicUrlPath)\(\)/gm; + + for (const file of listSourceFiles(src_dir)) { + const source = readFileSync(file, "utf-8"); + + for (const match of source.matchAll(top_level_read)) { + const [, binding, fn] = match; + const keys = binding.startsWith("{") + ? binding + .slice(1, -1) + .split(",") + .map((k) => k.trim().split(":")[0].trim()) + .filter(Boolean) + : [binding]; + const bad = keys.filter( + (k) => fn !== "grabDirNames" || !RootOnlyKeys.has(k), + ); + + if (bad.length) { + offenders.push( + `${path.relative(src_dir, file)}: ${bad.join(", ")}`, + ); + } + } + } + + expect(offenders).toEqual([]); + }); +}); + +describe("grabDirNames with BUNEXT_DIST_DIR", () => { + const original = process.env[DistDirEnvName]; + + afterEach(() => { + if (original === undefined) delete process.env[DistDirEnvName]; + else process.env[DistDirEnvName] = original; + }); + + it("places all artifact paths under the configured directory", () => { + process.env[DistDirEnvName] = "build/bunext"; + const dirs = grabDirNames(); + const dist = path.join(process.cwd(), "build", "bunext"); + + expect(dirs.BUNX_CWD_DIR).toBe(dist); + expect(dirs.HYDRATION_DST_DIR).toBe(path.join(dist, "public", "pages")); + expect(dirs.BUNDLER_CTX_MAP_JSON.startsWith(dist + path.sep)).toBe(true); + expect(dirs.PAGE_LASTMOD_JSON).toBe(path.join(dist, "page-lastmod.json")); + expect(grabBunextPublicUrlPath()).toBe("/build/bunext/public"); + }); + + it("serves the default dist directory at /.bunext/public", () => { + delete process.env[DistDirEnvName]; + expect(grabBunextPublicUrlPath()).toBe("/.bunext/public"); + }); +}); diff --git a/src/commands/build/index.ts b/src/commands/build/index.ts index b07e03e..fad19d2 100644 --- a/src/commands/build/index.ts +++ b/src/commands/build/index.ts @@ -1,4 +1,8 @@ import { Command } from "commander"; +import { + DistDirOptionDescription, + DistDirOptionFlags, +} from "../dist-dir-option"; import { log } from "../../utils/log"; import grabDirNames from "../../utils/grab-dir-names"; import { rmSync, writeFileSync } from "fs"; @@ -8,19 +12,20 @@ import { v4 } from "uuid"; import { updatePageLastmodRecord } from "../../utils/page-lastmod-record"; import disposeBundlerContexts from "../../functions/bundler/dispose-bundler-contexts"; -const { - HYDRATION_DST_DIR, - BUNX_CWD_PAGES_REWRITE_DIR, - BUNDLER_CTX_MAP_JSON, - SSR_BUNDLER_CTX_MAP_JSON, - BUNX_CWD_MODULE_CACHE_DIR, - BUILD_INFO_JSON, -} = grabDirNames(); - export default function () { return new Command("build") .description("Build Project") + .option(DistDirOptionFlags, DistDirOptionDescription) .action(async () => { + const { + HYDRATION_DST_DIR, + BUNX_CWD_PAGES_REWRITE_DIR, + BUNDLER_CTX_MAP_JSON, + SSR_BUNDLER_CTX_MAP_JSON, + BUNX_CWD_MODULE_CACHE_DIR, + BUILD_INFO_JSON, + } = grabDirNames(); + try { rmSync(HYDRATION_DST_DIR, { recursive: true }); rmSync(BUNX_CWD_PAGES_REWRITE_DIR, { recursive: true }); diff --git a/src/commands/dev/dev-spawn.ts b/src/commands/dev/dev-spawn.ts index ff9a785..52726c4 100644 --- a/src/commands/dev/dev-spawn.ts +++ b/src/commands/dev/dev-spawn.ts @@ -5,8 +5,6 @@ import grabDirNames from "../../utils/grab-dir-names"; import writeErrorFile from "../../functions/write-error-file"; import { rmSync } from "fs"; -const { HYDRATION_DST_DIR, BUNX_CWD_PAGES_REWRITE_DIR } = grabDirNames(); - // The supervisor only restarts the dev server when this process actually // exits with a code. Registering a listener that merely logs (the previous // behavior) keeps Bun from exiting: the crash is swallowed, no error file is @@ -27,6 +25,9 @@ process.on("unhandledRejection", (reason) => { log.info("Running development server ..."); try { + // The dist directory comes from the parent (`BUNEXT_DIST_DIR`). + const { HYDRATION_DST_DIR, BUNX_CWD_PAGES_REWRITE_DIR } = grabDirNames(); + rmSync(HYDRATION_DST_DIR, { recursive: true }); rmSync(BUNX_CWD_PAGES_REWRITE_DIR, { recursive: true }); } catch (error) {} diff --git a/src/commands/dev/index.ts b/src/commands/dev/index.ts index abf183b..0066ae2 100644 --- a/src/commands/dev/index.ts +++ b/src/commands/dev/index.ts @@ -1,23 +1,28 @@ import { Command } from "commander"; +import { + DistDirOptionDescription, + DistDirOptionFlags, +} from "../dist-dir-option"; import path from "path"; import type { BunSpawnOptions } from "../../types"; import grabDirNames from "../../utils/grab-dir-names"; import writeErrorFile from "../../functions/write-error-file"; import { existsSync, rmSync } from "fs"; -const { - BUNDLER_CTX_MAP_JSON, - SSR_BUNDLER_CTX_MAP_JSON, - BUNX_BUNDLER_ERROR_EXIT_FILE, -} = grabDirNames(); - const MAX_RETRIES = 5; const RETRY_RESET_AFTER_MS = 10000; export default function () { return new Command("dev") .description("Run development server") + .option(DistDirOptionFlags, DistDirOptionDescription) .action(async () => { + const { + BUNDLER_CTX_MAP_JSON, + SSR_BUNDLER_CTX_MAP_JSON, + BUNX_BUNDLER_ERROR_EXIT_FILE, + } = grabDirNames(); + try { rmSync(SSR_BUNDLER_CTX_MAP_JSON, { recursive: true }); rmSync(BUNDLER_CTX_MAP_JSON, { recursive: true }); diff --git a/src/commands/dist-dir-option.ts b/src/commands/dist-dir-option.ts new file mode 100644 index 0000000..1ebf4e4 --- /dev/null +++ b/src/commands/dist-dir-option.ts @@ -0,0 +1,4 @@ +export const DistDirOptionFlags = "--dist-dir "; +export const DistDirOptionDescription = + "Build artifact directory, relative to the project root " + + "(default: `dist_dir` from bunext.config.ts, else .bunext)"; diff --git a/src/commands/index.ts b/src/commands/index.ts index 35cef33..ecf1551 100755 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -6,6 +6,7 @@ import dev from "./dev"; import build from "./build"; import sitemap from "./sitemap"; import { log } from "../utils/log"; +import setupDistDir from "../functions/setup-dist-dir"; /** * # Describe Program @@ -23,6 +24,20 @@ program.addCommand(start()); program.addCommand(build()); program.addCommand(sitemap()); +/** + * # Resolve the Dist Directory + * Before any command runs, from `--dist-dir` or the config. Child server + * processes inherit it through `BUNEXT_DIST_DIR`. + */ +program.hook("preAction", async (_program, action_command) => { + try { + await setupDistDir({ dist_dir: action_command.opts().distDir }); + } catch (error: any) { + log.error(error?.message || String(error)); + process.exit(1); + } +}); + /** * # Handle Unavailable Commands */ @@ -39,4 +54,4 @@ program.on("command:*", () => { /** * # Parse Arguments */ -program.parse(Bun.argv); +await program.parseAsync(Bun.argv); diff --git a/src/commands/sitemap/index.ts b/src/commands/sitemap/index.ts index 163b91d..443b09b 100644 --- a/src/commands/sitemap/index.ts +++ b/src/commands/sitemap/index.ts @@ -1,4 +1,8 @@ import { Command } from "commander"; +import { + DistDirOptionDescription, + DistDirOptionFlags, +} from "../dist-dir-option"; import { existsSync, mkdirSync, @@ -40,6 +44,7 @@ export default function () { ) .option("--origin ", "Base URL for sitemap links") .option("--no-robots", "Skip generating robots.txt") + .option(DistDirOptionFlags, DistDirOptionDescription) .action(async (options: Options) => { const { ROOT_DIR, PUBLIC_DIR } = grabDirNames(); diff --git a/src/commands/start/index.ts b/src/commands/start/index.ts index cbb4e3b..9bbcf87 100644 --- a/src/commands/start/index.ts +++ b/src/commands/start/index.ts @@ -1,19 +1,24 @@ import { Command } from "commander"; +import { + DistDirOptionDescription, + DistDirOptionFlags, +} from "../dist-dir-option"; import path from "path"; import type { BunSpawnOptions } from "../../types"; import writeErrorFile from "../../functions/write-error-file"; import grabDirNames from "../../utils/grab-dir-names"; import { existsSync, rmSync } from "fs"; -const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames(); - const MAX_RETRIES = 5; const RETRY_RESET_AFTER_MS = 10000; export default function () { return new Command("start") .description("Start production server") + .option(DistDirOptionFlags, DistDirOptionDescription) .action(async () => { + const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames(); + // A stale error file from a previous session makes the bundler // run in "silent" mode and pages fail with misleading messages. rmSync(BUNX_BUNDLER_ERROR_EXIT_FILE, { force: true }); diff --git a/src/functions/bundler/all-pages-esbuild-context-bundler.ts b/src/functions/bundler/all-pages-esbuild-context-bundler.ts index ca38344..bf545e8 100644 --- a/src/functions/bundler/all-pages-esbuild-context-bundler.ts +++ b/src/functions/bundler/all-pages-esbuild-context-bundler.ts @@ -14,8 +14,6 @@ import { import { log } from "../../utils/log"; import grabRootFilePath from "../server/web-pages/grab-root-file-path"; -const { HYDRATION_DST_DIR, BUNX_HYDRATION_SRC_DIR } = grabDirNames(); - type Params = { post_build_fn?: (params: { artifacts: BundlerCTXMap[]; @@ -58,6 +56,8 @@ function arraysEqual(a: string[], b: string[]): boolean { } export default async function allPagesESBuildContextBundler(params?: Params) { + const { HYDRATION_DST_DIR, BUNX_HYDRATION_SRC_DIR } = grabDirNames(); + try { const pages = grabAllPages({ exclude_api: true }); diff --git a/src/functions/bundler/api-routes-bundler.ts b/src/functions/bundler/api-routes-bundler.ts index e54213c..9b68989 100644 --- a/src/functions/bundler/api-routes-bundler.ts +++ b/src/functions/bundler/api-routes-bundler.ts @@ -3,9 +3,9 @@ import grabDirNames from "../../utils/grab-dir-names"; import isDevelopment from "../../utils/is-development"; import tailwindcss from "bun-plugin-tailwind"; -const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames(); - export default async function apiRoutesBundler() { + const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames(); + const api_routes = grabAllPages({ api_only: true }); const dev = isDevelopment(); diff --git a/src/functions/bundler/api-routes-context-bundler.ts b/src/functions/bundler/api-routes-context-bundler.ts index 3eddfa6..82091a4 100644 --- a/src/functions/bundler/api-routes-context-bundler.ts +++ b/src/functions/bundler/api-routes-context-bundler.ts @@ -1,12 +1,9 @@ import * as esbuild from "esbuild"; import grabAllPages from "../../utils/grab-all-pages"; -import grabDirNames from "../../utils/grab-dir-names"; import isDevelopment from "../../utils/is-development"; import tailwindEsbuildPlugin from "../server/web-pages/tailwind-esbuild-plugin"; import apiRoutesCTXArtifactTracker from "./plugins/api-routes-ctx-artifact-tracker"; -const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames(); - export default async function apiRoutesContextBundler() { const pages = grabAllPages({ api_only: true }); const dev = isDevelopment(); diff --git a/src/functions/bundler/bun-react-modules-bundler.ts b/src/functions/bundler/bun-react-modules-bundler.ts index e0b55ba..1794dde 100644 --- a/src/functions/bundler/bun-react-modules-bundler.ts +++ b/src/functions/bundler/bun-react-modules-bundler.ts @@ -1,10 +1,9 @@ import grabDirNames from "../../utils/grab-dir-names"; +import grabBunextPublicUrlPath from "../../utils/grab-bunext-public-url-path"; import isDevelopment from "../../utils/is-development"; import path from "path"; import { rmSync, mkdirSync, writeFileSync } from "fs"; -const { BUNEXT_VENDOR_DIR, BUNX_CWD_DIR } = grabDirNames(); - const VENDOR_ENTRIES: Record = { react: ` import React from "react"; @@ -42,6 +41,8 @@ const VENDOR_ENTRIES: Record = { }; export default async function bunReactModulesBundler() { + const { BUNEXT_VENDOR_DIR } = grabDirNames(); + const dev = isDevelopment(); rmSync(BUNEXT_VENDOR_DIR, { force: true, recursive: true }); @@ -72,7 +73,7 @@ export default async function bunReactModulesBundler() { rmSync(tmpDir, { force: true, recursive: true }); - const PUBLIC_ROOT = BUNEXT_VENDOR_DIR.replace(BUNX_CWD_DIR, "/.bunext"); + const PUBLIC_ROOT = `${grabBunextPublicUrlPath()}/vendor`; global.BUNEXT_REACT_IMPORTS_MAP = { imports: { diff --git a/src/functions/bundler/pages-ssr-context-bundler.ts b/src/functions/bundler/pages-ssr-context-bundler.ts index dbec0ef..df60bc0 100644 --- a/src/functions/bundler/pages-ssr-context-bundler.ts +++ b/src/functions/bundler/pages-ssr-context-bundler.ts @@ -16,8 +16,6 @@ import { log } from "../../utils/log"; import { logBuildErrorsOnce } from "../../utils/build-error-log"; import shouldMinifySSR from "../../utils/should-minify-ssr"; -const { BUNX_CWD_MODULE_CACHE_DIR, BUNX_TMP_DIR } = grabDirNames(); - type SSRVirtualEntry = PageFiles & { tsx: string }; // Current virtual entry contents. The SSR virtual plugin resolves its @@ -53,6 +51,8 @@ async function computeEntryToPage(): Promise<{ entryToPage: Map; root_file_path: string | undefined; }> { + const { BUNX_TMP_DIR } = grabDirNames(); + const pages = grabAllPages({ include_server: true, }); @@ -131,6 +131,8 @@ export async function awaitSSRRebuild() { } async function doSSRRebuild(params?: RebuildParams) { + const { BUNX_CWD_MODULE_CACHE_DIR, BUNX_TMP_DIR } = grabDirNames(); + const force = Boolean(params?.force); const dev = isDevelopment(); const config = global.BUNEXT_CONFIG; diff --git a/src/functions/bundler/plugins/chunk-react.ts b/src/functions/bundler/plugins/chunk-react.ts index df27213..df050fc 100644 --- a/src/functions/bundler/plugins/chunk-react.ts +++ b/src/functions/bundler/plugins/chunk-react.ts @@ -2,8 +2,7 @@ import * as esbuild from "esbuild"; import path from "path"; import grabDirNames from "../../../utils/grab-dir-names"; - -const { BUNEXT_VENDOR_DIR } = grabDirNames(); +import grabBunextPublicUrlPath from "../../../utils/grab-bunext-public-url-path"; const REACT_MODULES = new Set([ "react", @@ -13,8 +12,6 @@ const REACT_MODULES = new Set([ "react/jsx-dev-runtime", ]); -const VENDOR_BASE = "/.bunext/public/vendor"; - const REACT_ENTRIES: Record = { react: ` import React from "react"; @@ -57,10 +54,12 @@ const REACT_ENTRIES: Record = { // Map bare specifier -> browser path function vendorPath(specifier: string): string { const filename = specifier.replace(/\//g, "_") + ".js"; - return `${VENDOR_BASE}/${filename}`; + return `${grabBunextPublicUrlPath()}/vendor/${filename}`; } function vendorOutfile(specifier: string): string { + const { BUNEXT_VENDOR_DIR } = grabDirNames(); + const filename = specifier.replace(/\//g, "_") + ".js"; return path.join(BUNEXT_VENDOR_DIR, filename); } diff --git a/src/functions/bundler/plugins/esbuild-ctx-artifact-tracker.ts b/src/functions/bundler/plugins/esbuild-ctx-artifact-tracker.ts index a682e98..c92eef8 100644 --- a/src/functions/bundler/plugins/esbuild-ctx-artifact-tracker.ts +++ b/src/functions/bundler/plugins/esbuild-ctx-artifact-tracker.ts @@ -17,8 +17,6 @@ import grabRelativePath from "../../../utils/grab-relative-path"; import cleanupArtifacts from "../../server/cleanup-artifacts"; import { pushClientUpdate } from "../../server/server-post-build-fn"; -const { BUNX_BUNDLER_ERROR_EXIT_FILE, BUNX_ERROR_LOGS_DIR } = grabDirNames(); - let build_start = 0; type Params = { @@ -47,6 +45,8 @@ export default function esbuildCTXArtifactTracker({ build_only, refresh_props_on_first_build, }: Params) { + const { BUNX_BUNDLER_ERROR_EXIT_FILE, BUNX_ERROR_LOGS_DIR } = grabDirNames(); + let force_props_refresh = Boolean(refresh_props_on_first_build); const artifactTracker: Plugin = { diff --git a/src/functions/bundler/plugins/ssr-ctx-artifact-tracker.ts b/src/functions/bundler/plugins/ssr-ctx-artifact-tracker.ts index 3408c0f..3477f44 100644 --- a/src/functions/bundler/plugins/ssr-ctx-artifact-tracker.ts +++ b/src/functions/bundler/plugins/ssr-ctx-artifact-tracker.ts @@ -13,8 +13,6 @@ import { log } from "../../../utils/log"; let build_start = 0; -const { BUNX_TMP_DIR } = grabDirNames(); - type Params = { entryToPage: Map< string, @@ -25,6 +23,8 @@ type Params = { }; export default function ssrCTXArtifactTracker({ entryToPage }: Params) { + const { BUNX_TMP_DIR } = grabDirNames(); + const artifactTracker: Plugin = { name: "ssr-artifact-tracker", setup(build) { diff --git a/src/functions/bundler/react-modules-bundler.ts b/src/functions/bundler/react-modules-bundler.ts index 90c2e97..bbbc140 100644 --- a/src/functions/bundler/react-modules-bundler.ts +++ b/src/functions/bundler/react-modules-bundler.ts @@ -1,10 +1,11 @@ import * as esbuild from "esbuild"; import grabDirNames from "../../utils/grab-dir-names"; +import grabBunextPublicUrlPath from "../../utils/grab-bunext-public-url-path"; import isDevelopment from "../../utils/is-development"; import path from "path"; import { rmSync, mkdirSync, writeFileSync } from "fs"; -const { BUNEXT_VENDOR_DIR, BUNX_CWD_DIR, ROOT_DIR } = grabDirNames(); +const { ROOT_DIR } = grabDirNames(); const VENDOR_ENTRIES: Record = { react: ` @@ -43,6 +44,8 @@ const VENDOR_ENTRIES: Record = { }; export default async function reactModulesBundler() { + const { BUNEXT_VENDOR_DIR } = grabDirNames(); + const dev = isDevelopment(); rmSync(BUNEXT_VENDOR_DIR, { force: true, recursive: true }); @@ -75,7 +78,7 @@ export default async function reactModulesBundler() { rmSync(tmpDir, { force: true, recursive: true }); - const PUBLIC_ROOT = BUNEXT_VENDOR_DIR.replace(BUNX_CWD_DIR, "/.bunext"); + const PUBLIC_ROOT = `${grabBunextPublicUrlPath()}/vendor`; global.BUNEXT_REACT_IMPORTS_MAP = { imports: { diff --git a/src/functions/bunext-init.ts b/src/functions/bunext-init.ts index cc0710e..ea7d3e1 100644 --- a/src/functions/bunext-init.ts +++ b/src/functions/bunext-init.ts @@ -18,6 +18,7 @@ import grabConstants from "../utils/grab-constants"; import watcherEsbuildCTX from "./server/watcher-esbuild-ctx"; import disposeBundlerContexts from "./bundler/dispose-bundler-contexts"; import { readFileSync } from "fs"; +import setupDistDir from "./setup-dist-dir"; /** * # Declare Global Variables @@ -64,15 +65,21 @@ declare global { var BUNEXT_INITIAL_BUILD_PROMISE: Promise | undefined; } -const dirNames = grabDirNames(); -const { PAGES_DIR, BUNDLER_CTX_MAP_JSON, SSR_BUNDLER_CTX_MAP_JSON } = dirNames; - type Params = { build_only?: boolean; start?: boolean; }; export default async function bunextInit(params?: Params) { + // First: everything below reads the artifact paths. A no-op under the + // CLI, which already resolved it; a custom server gets `dist_dir` from + // the config here. + await setupDistDir(); + + const dirNames = grabDirNames(); + const { PAGES_DIR, BUNDLER_CTX_MAP_JSON, SSR_BUNDLER_CTX_MAP_JSON } = + dirNames; + const is_dev = isDevelopment(); if (!is_dev || params?.start) { diff --git a/src/functions/cleanup-logs-dir.ts b/src/functions/cleanup-logs-dir.ts index d03326e..4001e84 100644 --- a/src/functions/cleanup-logs-dir.ts +++ b/src/functions/cleanup-logs-dir.ts @@ -4,9 +4,9 @@ import grabDirNames from "../utils/grab-dir-names"; import grabConstants from "../utils/grab-constants"; import { AppData } from "../data/app-data"; -const { BUNX_LOGS_DIR } = grabDirNames(); - export default function cleanupLogsDirs() { + const { BUNX_LOGS_DIR } = grabDirNames(); + const logs_dirs = readdirSync(BUNX_LOGS_DIR); const { config } = grabConstants(); diff --git a/src/functions/server/bunext-req-handler.ts b/src/functions/server/bunext-req-handler.ts index 417dd47..d20f607 100644 --- a/src/functions/server/bunext-req-handler.ts +++ b/src/functions/server/bunext-req-handler.ts @@ -10,6 +10,7 @@ import { AppData } from "../../data/app-data"; import { log } from "../../utils/log"; import fullRebuild from "./full-rebuild"; import grabRouter from "../../utils/grab-router"; +import grabBunextPublicUrlPath from "../../utils/grab-bunext-public-url-path"; import { NoSEORobotsValue } from "../../utils/disable-seo"; import generateRobotsTxt from "../../utils/generate-robots-txt"; @@ -79,7 +80,7 @@ export default async function bunextRequestHandler({ response = new Response(generateRobotsTxt({ disallow_all: true }), { headers: { "Content-Type": "text/plain; charset=utf-8" }, }); - } else if (url.pathname.startsWith("/.bunext")) { + } else if (url.pathname.startsWith(`${grabBunextPublicUrlPath()}/`)) { response = await handleBunextPublicAssets({ req }); } else if (url.pathname.startsWith("/api/")) { response = await handleRoutes({ req }); diff --git a/src/functions/server/cleanup-artifacts.tsx b/src/functions/server/cleanup-artifacts.tsx index 0ec1151..dcd303e 100644 --- a/src/functions/server/cleanup-artifacts.tsx +++ b/src/functions/server/cleanup-artifacts.tsx @@ -7,8 +7,7 @@ type Params = { paradigm: "hydration" | "ssr"; }; -const { ROOT_DIR, HYDRATION_DST_DIR, BUNX_CWD_MODULE_CACHE_DIR } = - grabDirNames(); +const { ROOT_DIR } = grabDirNames(); // esbuild's content hashes are 8-char base64-ish (e.g. `CMSI7VSQ`). Entry // outputs are `/.js` / `/.css`. Shared chunks are @@ -27,6 +26,8 @@ const ENTRY_HASH_FILE = /^[A-Za-z0-9]{8}\.(js|css)$/; * left untouched. */ export default async function cleanupArtifacts({ paradigm }: Params) { + const { HYDRATION_DST_DIR, BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames(); + const is_hydration = paradigm === "hydration"; const root_dir = is_hydration ? HYDRATION_DST_DIR diff --git a/src/functions/server/handle-bunext-public-assets.ts b/src/functions/server/handle-bunext-public-assets.ts index c62041c..610c3b9 100644 --- a/src/functions/server/handle-bunext-public-assets.ts +++ b/src/functions/server/handle-bunext-public-assets.ts @@ -1,25 +1,26 @@ import grabDirNames from "../../utils/grab-dir-names"; +import grabBunextPublicUrlPath from "../../utils/grab-bunext-public-url-path"; import path from "path"; import isDevelopment from "../../utils/is-development"; import { readFileResponse } from "./handle-public"; import isSafePath from "../../utils/is-safe-path"; -const { BUNEXT_PUBLIC_DIR } = grabDirNames(); - type Params = { req: Request; }; export default async function ({ req }: Params): Promise { + const { BUNEXT_PUBLIC_DIR } = grabDirNames(); + const BUNEXT_PUBLIC_URL_PATH = grabBunextPublicUrlPath(); + try { const is_dev = isDevelopment(); const url = new URL(req.url); const file_path = path.join( BUNEXT_PUBLIC_DIR, - decodeURIComponent(url.pathname).replace( - /\/\.bunext\/public\//, - "", + decodeURIComponent(url.pathname).slice( + BUNEXT_PUBLIC_URL_PATH.length, ), ); diff --git a/src/functions/server/watcher-esbuild-ctx.ts b/src/functions/server/watcher-esbuild-ctx.ts index c4954dc..22e0080 100644 --- a/src/functions/server/watcher-esbuild-ctx.ts +++ b/src/functions/server/watcher-esbuild-ctx.ts @@ -13,13 +13,18 @@ import serverPostBuildFn from "./server-post-build-fn"; import { log } from "../../utils/log"; import { logBuildErrorsOnce } from "../../utils/build-error-log"; -const { ROOT_DIR, BUNX_CWD_DIR, BUNX_BUNDLER_ERROR_EXIT_FILE } = - grabDirNames(); +const { ROOT_DIR } = grabDirNames(); // The watchdog writes this file and expects the watcher to report it. It -// sits under `.bunext` (otherwise ignored), so only this path is watched. -const WATCH_PROBE_DIR = path.join(BUNX_CWD_DIR, "watch-probe"); -const WATCH_PROBE_FILE = path.join(WATCH_PROBE_DIR, "probe"); +// sits under the dist directory (otherwise ignored), so only this path is +// watched. +function grabWatchPaths() { + const { BUNX_CWD_DIR } = grabDirNames(); + const WATCH_PROBE_DIR = path.join(BUNX_CWD_DIR, "watch-probe"); + const WATCH_PROBE_FILE = path.join(WATCH_PROBE_DIR, "probe"); + + return { BUNX_CWD_DIR, WATCH_PROBE_DIR, WATCH_PROBE_FILE }; +} // Dedupes near-simultaneous restarts (fullRebuild, watchdog). let last_watcher_restart = 0; @@ -70,6 +75,8 @@ function grabPagesSnapshot(): string | undefined { * project tree. */ export function watcherWatchdogTick() { + const { WATCH_PROBE_DIR, WATCH_PROBE_FILE } = grabWatchPaths(); + if (!global.BUNEXT_PAGES_SRC_WATCHER || !watcher_ready) return; if (probe_pending) { @@ -85,11 +92,34 @@ export function watcherWatchdogTick() { } function isWatchProbePath(p: string) { + const { WATCH_PROBE_DIR, WATCH_PROBE_FILE } = grabWatchPaths(); + return ( - p === WATCH_PROBE_FILE || p === WATCH_PROBE_DIR || p === BUNX_CWD_DIR + p === WATCH_PROBE_FILE || + p === WATCH_PROBE_DIR || + isDistDirOrAncestor(p) ); } +/** + * The dist directory and the folders between it and the project root must + * stay watched for the watcher to reach the probe, even when an ignore rule + * matches them (dot folders, `dist`, a custom `dist_dir`). + */ +function isDistDirOrAncestor(p: string) { + const { BUNX_CWD_DIR } = grabDirNames(); + + if (p === ROOT_DIR) return false; + + return p === BUNX_CWD_DIR || BUNX_CWD_DIR.startsWith(p + path.sep); +} + +function isInDistDir(p: string) { + const { BUNX_CWD_DIR } = grabDirNames(); + + return p === BUNX_CWD_DIR || p.startsWith(BUNX_CWD_DIR + path.sep); +} + const TARGET_FILES_MATCH = /\.(tsx?|jsx?|css)$/; // Rebuild coalescing: editors emit bursts of events per save (change + @@ -219,15 +249,18 @@ function isPathExcluded(filename: string, full_file_path: string): boolean { // Heavy directories are filtered at the watcher level so inotify doesn't // install watches (and deliver events) for trees the bundler never reads. function buildIgnoredPatterns() { - const ignored: Array boolean)> = [ - (p: string) => !isWatchProbePath(p) && /(^|[\/\\])\../.test(p), + const builtin_patterns: RegExp[] = [ + /(^|[\/\\])\../, /(^|[\/\\])node_modules([\/\\]|$)/, /(^|[\/\\])public([\/\\]|$)/, - (p: string) => - !isWatchProbePath(p) && /(^|[\/\\])\.bunext([\/\\]|$)/.test(p), - /(^|[\/\\])\.git([\/\\]|$)/, /(^|[\/\\])dist([\/\\]|$)/, /bun\.lockb$/, + ]; + + const ignored: Array<(p: string) => boolean> = [ + (p: string) => + !isWatchProbePath(p) && + (isInDistDir(p) || builtin_patterns.some((re) => re.test(p))), (p: string) => p.endsWith(AppData["BunextTmpFileExt"]), ]; @@ -252,6 +285,8 @@ function buildIgnoredPatterns() { export default async function watcherEsbuildCTX(params?: { pages_snapshot?: string; }) { + const { WATCH_PROBE_DIR } = grabWatchPaths(); + try { mkdirSync(WATCH_PROBE_DIR, { recursive: true }); } catch (error) {} @@ -292,6 +327,9 @@ function handleEvent( event: "add" | "change" | "unlink" | "addDir" | "unlinkDir", filePath: string, ) { + const { WATCH_PROBE_FILE } = grabWatchPaths(); + const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames(); + if (isWatchProbePath(filePath)) { if (filePath === WATCH_PROBE_FILE) { probe_pending = false; diff --git a/src/functions/server/web-pages/grab-page-component.tsx b/src/functions/server/web-pages/grab-page-component.tsx index ec907b3..5b6f3b6 100644 --- a/src/functions/server/web-pages/grab-page-component.tsx +++ b/src/functions/server/web-pages/grab-page-component.tsx @@ -14,7 +14,7 @@ import grabRelativePath from "../../../utils/grab-relative-path"; import { logBuildErrorsOnce } from "../../../utils/build-error-log"; import { RequestBodyTooLargeError } from "../../../utils/read-request-body"; -const { BUNX_BUNDLER_ERROR_EXIT_FILE, ROOT_DIR } = grabDirNames(); +const { ROOT_DIR } = grabDirNames(); class NotFoundError extends Error { status = 404; @@ -44,6 +44,8 @@ type Params = { export default async function grabPageComponent( params: Params, ): Promise { + const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames(); + const { req, file_path: passed_file_path, diff --git a/src/functions/server/web-pages/grab-tsx-string-module.tsx b/src/functions/server/web-pages/grab-tsx-string-module.tsx index 8409a24..3865fbe 100644 --- a/src/functions/server/web-pages/grab-tsx-string-module.tsx +++ b/src/functions/server/web-pages/grab-tsx-string-module.tsx @@ -9,11 +9,13 @@ import type { } from "../../../types"; import { existsSync, rmSync, unlinkSync } from "fs"; -const { PAGES_DIR, BUNX_CWD_MODULE_CACHE_DIR, ROOT_DIR } = grabDirNames(); +const { PAGES_DIR, ROOT_DIR } = grabDirNames(); type Params = GrabTSXModuleSingleParams | GrabTSXModuleBatchParams; function toModPath(page_file_path: string) { + const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames(); + return path.join( BUNX_CWD_MODULE_CACHE_DIR, page_file_path.replace(PAGES_DIR, "").replace(/\.(t|j)sx?$/, ".js"), @@ -30,6 +32,8 @@ type BuildEntriesParams = { }; async function buildEntries({ entries, clean_cache }: BuildEntriesParams) { + const { BUNX_CWD_MODULE_CACHE_DIR } = grabDirNames(); + const dev = isDevelopment(); const toBuild: { diff --git a/src/functions/setup-dist-dir.ts b/src/functions/setup-dist-dir.ts new file mode 100644 index 0000000..81c3270 --- /dev/null +++ b/src/functions/setup-dist-dir.ts @@ -0,0 +1,50 @@ +import { existsSync } from "fs"; +import grabDirNames from "../utils/grab-dir-names"; +import { DistDirEnvName, resolveDistDir } from "../utils/grab-dist-dir"; +import type { BunextConfig } from "../types"; + +type Params = { + /** + * Takes precedence over the env var and the config (`--dist-dir`) + */ + dist_dir?: string; +}; + +/** + * # Set the dist directory for this process and its children + * Precedence: `params.dist_dir` (the CLI's `--dist-dir`), then an existing + * `BUNEXT_DIST_DIR` env var, then `dist_dir` from bunext.config.ts. Stores + * the absolute path in `BUNEXT_DIST_DIR`, which `grabDirNames()` reads and + * child processes inherit. Runs at the start of every CLI command and of + * `bunextInit()`, before anything reads the artifact paths. Throws on an + * invalid directory. + */ +export default async function setupDistDir(params?: Params) { + const root_dir = process.cwd(); + + const dist_dir = + params?.dist_dir || + process.env[DistDirEnvName] || + (await grabConfigDistDir()); + + if (!dist_dir) return; + + process.env[DistDirEnvName] = resolveDistDir({ dist_dir, root_dir }); +} + +async function grabConfigDistDir() { + const { CONFIG_FILE } = grabDirNames(); + + if (!existsSync(CONFIG_FILE)) return; + + try { + const config = (await import(CONFIG_FILE)).default as + | BunextConfig + | undefined; + + return config?.dist_dir; + } catch (error) { + // The config is loaded again later, which reports the error. + return; + } +} diff --git a/src/functions/write-error-file.ts b/src/functions/write-error-file.ts index 053fc80..1df775b 100644 --- a/src/functions/write-error-file.ts +++ b/src/functions/write-error-file.ts @@ -2,8 +2,6 @@ import path from "path"; import { existsSync, mkdirSync, writeFileSync } from "fs"; import grabDirNames from "../utils/grab-dir-names"; -const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames(); - type Params = { /** * Only include when the exiting process actually crashed with an error. @@ -30,6 +28,8 @@ export default function writeErrorFile({ error, exitCode, }: Params) { + const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames(); + const has_error = error != null; const abnormal_exit = exitCode != null && exitCode !== 0; diff --git a/src/types/index.ts b/src/types/index.ts index cb6b8b1..880e8e6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -49,6 +49,12 @@ export type PageModule = { }; export type BunextConfig = { + /** + * Build artifact directory, relative to the project root. Defaults to + * `.bunext`. Must be inside the project root, outside `src/`, `public/` + * and `node_modules/`. Overridden by the `BUNEXT_DIST_DIR` env var and + * by `--dist-dir` on the CLI. + */ dist_dir?: string; assets_prefix?: string; origin?: string; @@ -553,7 +559,8 @@ export type BunextGenerateSitemapParams = { default_lastmod?: Date | string; /** * Page last-modified record written by `bunext build`, relative to the - * project root. Defaults to `.bunext/page-lastmod.json`. Point it at a + * project root. Defaults to `page-lastmod.json` in the dist directory + * (`.bunext/page-lastmod.json`). Point it at a * cached or committed location when builds start from a fresh checkout. */ lastmod_file?: string; diff --git a/src/utils/grab-bunext-public-url-path.ts b/src/utils/grab-bunext-public-url-path.ts new file mode 100644 index 0000000..2a23a96 --- /dev/null +++ b/src/utils/grab-bunext-public-url-path.ts @@ -0,0 +1,17 @@ +import path from "path"; +import grabDirNames from "./grab-dir-names"; + +/** + * # URL path the dist `public` directory is served at + * Its path relative to the project root, e.g. `/.bunext/public`. Bundle + * paths in the bundler maps are project-relative, so `/${path}` is their + * URL. Kept out of `grabDirNames()`: `init` creates every path listed there. + */ +export default function grabBunextPublicUrlPath() { + const { ROOT_DIR, BUNEXT_PUBLIC_DIR } = grabDirNames(); + + return `/${path + .relative(ROOT_DIR, BUNEXT_PUBLIC_DIR) + .split(path.sep) + .join("/")}`; +} diff --git a/src/utils/grab-dir-names.ts b/src/utils/grab-dir-names.ts index 4ad8604..4f0b903 100644 --- a/src/utils/grab-dir-names.ts +++ b/src/utils/grab-dir-names.ts @@ -1,4 +1,5 @@ import path from "path"; +import { DistDirEnvName, resolveDistDir } from "./grab-dist-dir"; export type DirNames = { ROOT_DIR: string; @@ -34,9 +35,28 @@ export type DirNames = { PAGE_LASTMOD_JSON: string; }; +let cached: { key: string; dir_names: DirNames } | undefined; + +/** + * # Project and artifact paths + * The dist directory comes from `BUNEXT_DIST_DIR`, which `setupDistDir()` + * sets from `--dist-dir` / `dist_dir` in the config. Call this where the + * paths are used, not at module load: a module loaded before + * `setupDistDir()` runs would keep the default directory. + */ export default function grabDirNames(): DirNames { if (global.BUNEXT_DIR_NAMES) return global.BUNEXT_DIR_NAMES; + const key = `${process.cwd()}\0${process.env[DistDirEnvName] || ""}`; + + if (cached?.key !== key) { + cached = { key, dir_names: computeDirNames() }; + } + + return cached.dir_names; +} + +function computeDirNames(): DirNames { const ROOT_DIR = process.cwd(); const SRC_DIR = path.join(ROOT_DIR, "src"); const PAGES_DIR = path.join(SRC_DIR, "pages"); @@ -44,7 +64,10 @@ export default function grabDirNames(): DirNames { const PUBLIC_DIR = path.join(ROOT_DIR, "public"); const CONFIG_FILE = path.join(ROOT_DIR, "bunext.config.ts"); - const BUNX_CWD_DIR = path.resolve(ROOT_DIR, ".bunext"); + const BUNX_CWD_DIR = resolveDistDir({ + dist_dir: process.env[DistDirEnvName], + root_dir: ROOT_DIR, + }); const BUNX_CWD_MODULE_CACHE_DIR = path.resolve( BUNX_CWD_DIR, "module-cache", diff --git a/src/utils/grab-dist-dir.ts b/src/utils/grab-dist-dir.ts new file mode 100644 index 0000000..8812243 --- /dev/null +++ b/src/utils/grab-dist-dir.ts @@ -0,0 +1,78 @@ +import path from "path"; +import AppNames from "./grab-app-names"; + +/** + * Env var holding the dist directory. The CLI sets it from `--dist-dir` or + * `dist_dir` in bunext.config.ts before any other module loads: most + * modules read the directory names once, at import time. Child processes + * (dev/start servers) inherit it. + */ +export const DistDirEnvName = "BUNEXT_DIST_DIR"; + +const ReservedDirs = ["src", "public", "node_modules"]; + +type Params = { + dist_dir?: string; + root_dir: string; +}; + +/** + * # Resolve the dist directory + * Returns the absolute path of the build artifact directory. `dist_dir` is + * relative to the project root (or absolute) and must sit inside it: the + * dev watcher only watches the project root, and checks it is alive through + * a probe file in the dist directory. Its relative path is also the URL + * path of the client bundles, so it is limited to URL-safe characters. + */ +export function resolveDistDir({ dist_dir, root_dir }: Params): string { + const resolved = path.resolve( + root_dir, + dist_dir?.trim() || AppNames.defaultDistDir, + ); + + if (!isInsideDir(resolved, root_dir)) { + throw new Error( + `Invalid dist directory \`${dist_dir}\`: it must be a directory ` + + `inside the project root (${root_dir}).`, + ); + } + + const url_path = path + .relative(root_dir, resolved) + .split(path.sep) + .join("/"); + + if (!/^[\w.\-\/]+$/.test(url_path)) { + throw new Error( + `Invalid dist directory \`${dist_dir}\`: use only letters, ` + + `digits, \`.\`, \`_\`, \`-\` and \`/\`.`, + ); + } + + for (const reserved of ReservedDirs) { + const reserved_dir = path.join(root_dir, reserved); + + if (resolved === reserved_dir || isInsideDir(resolved, reserved_dir)) { + throw new Error( + `Invalid dist directory \`${dist_dir}\`: it can't be ` + + `\`${reserved}/\` or inside it.`, + ); + } + } + + return resolved; +} + +/** + * Whether `target` is strictly inside `dir` (not `dir` itself) + */ +function isInsideDir(target: string, dir: string) { + const relative = path.relative(dir, target); + + return ( + Boolean(relative) && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} diff --git a/src/utils/page-path-transform.ts b/src/utils/page-path-transform.ts index 9f2d2a8..567c32c 100644 --- a/src/utils/page-path-transform.ts +++ b/src/utils/page-path-transform.ts @@ -5,13 +5,15 @@ type Params = { page_path: string; }; -const { ROOT_DIR, BUNX_CWD_PAGES_REWRITE_DIR } = grabDirNames(); +const { ROOT_DIR } = grabDirNames(); /** * # Transform a page path to the destination * path in the .bunext directory */ export default function pagePathTransform({ page_path }: Params) { + const { BUNX_CWD_PAGES_REWRITE_DIR } = grabDirNames(); + const page_path_relative_dir = page_path .replace(ROOT_DIR, "") .replace(/\/src\/pages/, ""); diff --git a/src/utils/write-logs.ts b/src/utils/write-logs.ts index 8fa6105..8e32b23 100644 --- a/src/utils/write-logs.ts +++ b/src/utils/write-logs.ts @@ -2,9 +2,9 @@ import { writeFileSync } from "fs"; import grabDirNames from "./grab-dir-names"; import path from "path"; -const { BUNX_LOGS_DIR } = grabDirNames(); - export default function writeLogs(log: any) { + const { BUNX_LOGS_DIR } = grabDirNames(); + try { const now = Date.now(); let new_log_name = `${now}`;