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");
});
});
+14 -9
View File
@@ -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 });
+3 -2
View File
@@ -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) {}
+11 -6
View File
@@ -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 });
+4
View File
@@ -0,0 +1,4 @@
export const DistDirOptionFlags = "--dist-dir <path>";
export const DistDirOptionDescription =
"Build artifact directory, relative to the project root " +
"(default: `dist_dir` from bunext.config.ts, else .bunext)";
+16 -1
View File
@@ -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);
+5
View File
@@ -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 <url>", "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();
+7 -2
View File
@@ -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 });
@@ -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 });
+2 -2
View File
@@ -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();
@@ -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();
@@ -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<string, string> = {
react: `
import React from "react";
@@ -42,6 +41,8 @@ const VENDOR_ENTRIES: Record<string, string> = {
};
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: {
@@ -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<string, SSRVirtualEntry>;
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;
+4 -5
View File
@@ -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<string, string> = {
react: `
import React from "react";
@@ -57,10 +54,12 @@ const REACT_ENTRIES: Record<string, string> = {
// 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);
}
@@ -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 = {
@@ -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) {
@@ -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<string, string> = {
react: `
@@ -43,6 +44,8 @@ const VENDOR_ENTRIES: Record<string, string> = {
};
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: {
+10 -3
View File
@@ -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<void> | 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) {
+2 -2
View File
@@ -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();
+2 -1
View File
@@ -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 });
+3 -2
View File
@@ -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 `<dir>/<hash>.js` / `<dir>/<hash>.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
@@ -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<Response> {
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,
),
);
+49 -11
View File
@@ -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<RegExp | ((p: string) => 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;
@@ -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<GrabPageComponentRes | Response> {
const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames();
const {
req,
file_path: passed_file_path,
@@ -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: {
+50
View File
@@ -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;
}
}
+2 -2
View File
@@ -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;
+8 -1
View File
@@ -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;
+17
View File
@@ -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("/")}`;
}
+24 -1
View File
@@ -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",
+78
View File
@@ -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)
);
}
+3 -1
View File
@@ -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/, "");
+2 -2
View File
@@ -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}`;