Make the dist directory configurable

Set it with `dist_dir` in bunext.config.ts or `--dist-dir` on any CLI
command (precedence: --dist-dir > BUNEXT_DIST_DIR > dist_dir > .bunext).
setupDistDir() resolves it in the CLI's preAction hook and at the start
of bunextInit(), so custom servers get the config value too.

- Read dist-derived paths from grabDirNames() inside functions instead of
  at import time; a guard test enforces this.
- Derive the client asset URL prefix from the dist dir instead of
  hard-coding /.bunext.
- Make the dev watcher ignore rules and probe follow the dist dir.
- Validate the dir: inside the project root, outside src/, public/ and
  node_modules/, URL-safe characters only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-11-09 03:55:54 +01:00
co-authored by Claude Opus 5.5
parent 4d4a79a6dd
commit b410b92f9f
35 changed files with 601 additions and 80 deletions
+230
View File
@@ -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");
});
});