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:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user