Update watcher function. Add tests

This commit is contained in:
Benjamin Toby 2026-03-21 09:03:26 +01:00
parent 5c53e94a3e
commit cf010ad4f5
53 changed files with 1329 additions and 97 deletions

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,37 @@
import { describe, it, expect } from "bun:test";
import grabCacheNames from "../../../functions/cache/grab-cache-names";
describe("grabCacheNames", () => {
it("returns cache_name and cache_meta_name for a simple key", () => {
const { cache_name, cache_meta_name } = grabCacheNames({ key: "home" });
expect(cache_name).toBe("home.res.html");
expect(cache_meta_name).toBe("home.meta.json");
});
it("defaults paradigm to html", () => {
const { cache_name } = grabCacheNames({ key: "page" });
expect(cache_name).toEndWith(".res.html");
});
it("uses json paradigm when specified", () => {
const { cache_name } = grabCacheNames({ key: "api-data", paradigm: "json" });
expect(cache_name).toBe("api-data.res.json");
});
it("URL-encodes the key", () => {
const { cache_name, cache_meta_name } = grabCacheNames({
key: "/blog/hello world",
});
const encoded = encodeURIComponent("/blog/hello world");
expect(cache_name).toBe(`${encoded}.res.html`);
expect(cache_meta_name).toBe(`${encoded}.meta.json`);
});
it("handles keys with special characters", () => {
const key = "page?id=1&sort=asc";
const { cache_name } = grabCacheNames({ key });
expect(cache_name).toBe(`${encodeURIComponent(key)}.res.html`);
});
it("cache_meta_name always uses .meta.json regardless of paradigm", () => {
const { cache_meta_name } = grabCacheNames({
key: "test",
paradigm: "json",
});
expect(cache_meta_name).toBe("test.meta.json");
});
});

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,97 @@
import { describe, it, expect } from "bun:test";
import grabWebMetaHTML from "../../../functions/server/web-pages/grab-web-meta-html";
describe("grabWebMetaHTML", () => {
it("returns empty string for empty meta object", () => {
expect(grabWebMetaHTML({ meta: {} })).toBe("");
});
it("generates a title tag", () => {
const html = grabWebMetaHTML({ meta: { title: "My Page" } });
expect(html).toContain("<title>My Page</title>");
});
it("generates a description meta tag", () => {
const html = grabWebMetaHTML({ meta: { description: "A description" } });
expect(html).toContain('<meta name="description" content="A description"');
});
it("joins array keywords with comma", () => {
const html = grabWebMetaHTML({
meta: { keywords: ["react", "bun", "ssr"] },
});
expect(html).toContain('content="react, bun, ssr"');
});
it("uses string keywords directly", () => {
const html = grabWebMetaHTML({ meta: { keywords: "react, bun" } });
expect(html).toContain('content="react, bun"');
});
it("generates author meta tag", () => {
const html = grabWebMetaHTML({ meta: { author: "Alice" } });
expect(html).toContain('<meta name="author" content="Alice"');
});
it("generates robots meta tag", () => {
const html = grabWebMetaHTML({ meta: { robots: "noindex" } });
expect(html).toContain('<meta name="robots" content="noindex"');
});
it("generates canonical link tag", () => {
const html = grabWebMetaHTML({
meta: { canonical: "https://example.com/page" },
});
expect(html).toContain('<link rel="canonical" href="https://example.com/page"');
});
it("generates theme-color meta tag", () => {
const html = grabWebMetaHTML({ meta: { themeColor: "#ff0000" } });
expect(html).toContain('<meta name="theme-color" content="#ff0000"');
});
it("generates OG tags", () => {
const html = grabWebMetaHTML({
meta: {
og: {
title: "OG Title",
description: "OG Desc",
image: "https://example.com/img.png",
url: "https://example.com",
type: "website",
siteName: "Example",
locale: "en_US",
},
},
});
expect(html).toContain('<meta property="og:title" content="OG Title"');
expect(html).toContain('<meta property="og:description" content="OG Desc"');
expect(html).toContain('<meta property="og:image" content="https://example.com/img.png"');
expect(html).toContain('<meta property="og:url" content="https://example.com"');
expect(html).toContain('<meta property="og:type" content="website"');
expect(html).toContain('<meta property="og:site_name" content="Example"');
expect(html).toContain('<meta property="og:locale" content="en_US"');
});
it("generates Twitter card tags", () => {
const html = grabWebMetaHTML({
meta: {
twitter: {
card: "summary_large_image",
title: "Tweet Title",
description: "Tweet Desc",
image: "https://example.com/tw.png",
site: "@example",
creator: "@alice",
},
},
});
expect(html).toContain('<meta name="twitter:card" content="summary_large_image"');
expect(html).toContain('<meta name="twitter:title" content="Tweet Title"');
expect(html).toContain('<meta name="twitter:description" content="Tweet Desc"');
expect(html).toContain('<meta name="twitter:image" content="https://example.com/tw.png"');
expect(html).toContain('<meta name="twitter:site" content="@example"');
expect(html).toContain('<meta name="twitter:creator" content="@alice"');
});
it("skips undefined OG fields", () => {
const html = grabWebMetaHTML({ meta: { og: { title: "Only Title" } } });
expect(html).toContain("og:title");
expect(html).not.toContain("og:description");
expect(html).not.toContain("og:image");
});
it("does not emit tags for missing fields", () => {
const html = grabWebMetaHTML({ meta: { title: "Hello" } });
expect(html).not.toContain("description");
expect(html).not.toContain("og:");
expect(html).not.toContain("twitter:");
});
});

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,33 @@
import { describe, it, expect } from "bun:test";
import deserializeQuery from "../../utils/deserialize-query";
describe("deserializeQuery", () => {
it("passes through a plain object unchanged", () => {
const input = { foo: "bar" };
expect(deserializeQuery(input)).toEqual({ foo: "bar" });
});
it("parses a JSON string into an object", () => {
const input = JSON.stringify({ a: 1, b: "hello" });
expect(deserializeQuery(input)).toEqual({ a: 1, b: "hello" });
});
it("deep-parses string values that look like JSON objects", () => {
const nested = { filter: JSON.stringify({ status: "active" }) };
const result = deserializeQuery(nested);
expect(result.filter).toEqual({ status: "active" });
});
it("deep-parses string values that look like JSON arrays", () => {
const nested = { ids: JSON.stringify([1, 2, 3]) };
const result = deserializeQuery(nested);
expect(result.ids).toEqual([1, 2, 3]);
});
it("leaves plain string values alone", () => {
const input = { name: "alice", age: "30" };
expect(deserializeQuery(input)).toEqual({ name: "alice", age: "30" });
});
it("returns an empty object for an empty JSON string", () => {
expect(deserializeQuery("{}")).toEqual({});
});
it("returns an empty object for an invalid JSON string", () => {
// EJSON.parse returns undefined → Object(undefined) → {}
expect(deserializeQuery("not-json")).toEqual({});
});
});

1
dist/__tests__/utils/ejson.test.d.ts vendored Normal file
View File

@ -0,0 +1 @@
export {};

52
dist/__tests__/utils/ejson.test.js vendored Normal file
View File

@ -0,0 +1,52 @@
import { describe, it, expect } from "bun:test";
import EJSON from "../../utils/ejson";
describe("EJSON.parse", () => {
it("parses a valid JSON string", () => {
expect(EJSON.parse('{"a":1}')).toEqual({ a: 1 });
});
it("parses a JSON array string", () => {
expect(EJSON.parse('[1,2,3]')).toEqual([1, 2, 3]);
});
it("returns undefined for null input", () => {
expect(EJSON.parse(null)).toBeUndefined();
});
it("returns undefined for empty string", () => {
expect(EJSON.parse("")).toBeUndefined();
});
it("returns undefined for invalid JSON", () => {
expect(EJSON.parse("{bad json")).toBeUndefined();
});
it("returns the object directly when passed an object (typeof object)", () => {
const obj = { x: 1 };
expect(EJSON.parse(obj)).toBe(obj);
});
it("returns undefined for a number input", () => {
expect(EJSON.parse(42)).toBeUndefined();
});
it("applies a reviver function", () => {
const result = EJSON.parse('{"a":"2"}', (key, value) => key === "a" ? Number(value) : value);
expect(result).toEqual({ a: 2 });
});
});
describe("EJSON.stringify", () => {
it("stringifies an object", () => {
expect(EJSON.stringify({ a: 1 })).toBe('{"a":1}');
});
it("stringifies an array", () => {
expect(EJSON.stringify([1, 2, 3])).toBe("[1,2,3]");
});
it("applies spacing", () => {
expect(EJSON.stringify({ a: 1 }, null, 2)).toBe('{\n "a": 1\n}');
});
it("returns undefined for circular references", () => {
const obj = {};
obj.self = obj;
expect(EJSON.stringify(obj)).toBeUndefined();
});
it("stringifies null", () => {
expect(EJSON.stringify(null)).toBe("null");
});
it("stringifies a number", () => {
expect(EJSON.stringify(42)).toBe("42");
});
});

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,23 @@
import { describe, it, expect } from "bun:test";
import AppNames from "../../utils/grab-app-names";
describe("AppNames", () => {
it("has a defaultPort of 7000", () => {
expect(AppNames.defaultPort).toBe(7000);
});
it("has the correct defaultAssetPrefix", () => {
expect(AppNames.defaultAssetPrefix).toBe("_bunext/static");
});
it("has name Bunext", () => {
expect(AppNames.name).toBe("Bunext");
});
it("has a version string", () => {
expect(typeof AppNames.version).toBe("string");
expect(AppNames.version.length).toBeGreaterThan(0);
});
it("has defaultDistDir as .bunext", () => {
expect(AppNames.defaultDistDir).toBe(".bunext");
});
it("has RootPagesComponentName as __root", () => {
expect(AppNames.RootPagesComponentName).toBe("__root");
});
});

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,38 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import grabAppPort from "../../utils/grab-app-port";
const originalEnv = process.env.PORT;
beforeEach(() => {
delete process.env.PORT;
global.CONFIG = {};
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env.PORT = originalEnv;
}
else {
delete process.env.PORT;
}
});
describe("grabAppPort", () => {
it("returns the default port (7000) when no config or env set", () => {
expect(grabAppPort()).toBe(7000);
});
it("uses PORT env variable when set", () => {
process.env.PORT = "8080";
expect(grabAppPort()).toBe(8080);
});
it("uses config.port when PORT env is not set", () => {
global.CONFIG = { port: 3000 };
expect(grabAppPort()).toBe(3000);
});
it("PORT env takes precedence over config.port", () => {
process.env.PORT = "9000";
global.CONFIG = { port: 3000 };
expect(grabAppPort()).toBe(9000);
});
it("handles non-numeric PORT env gracefully via numberfy", () => {
process.env.PORT = "abc";
// numberfy strips non-numeric chars, "abc" → "" → 0
expect(grabAppPort()).toBe(0);
});
});

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,30 @@
import { describe, it, expect, beforeEach } from "bun:test";
import grabConstants from "../../utils/grab-constants";
beforeEach(() => {
global.CONFIG = {};
});
describe("grabConstants", () => {
it("has the correct ClientRootElementIDName", () => {
expect(grabConstants().ClientRootElementIDName).toBe("__bunext");
});
it("has the correct ClientWindowPagePropsName", () => {
expect(grabConstants().ClientWindowPagePropsName).toBe("__PAGE_PROPS__");
});
it("has the correct ClientRootComponentWindowName", () => {
expect(grabConstants().ClientRootComponentWindowName).toBe("BUNEXT_ROOT");
});
it("calculates MBInBytes as 1024 * 1024", () => {
expect(grabConstants().MBInBytes).toBe(1024 * 1024);
});
it("ServerDefaultRequestBodyLimitBytes is 10 MB", () => {
expect(grabConstants().ServerDefaultRequestBodyLimitBytes).toBe(10 * 1024 * 1024);
});
it("MaxBundlerRebuilds is 5", () => {
expect(grabConstants().MaxBundlerRebuilds).toBe(5);
});
it("returns the current global.CONFIG", () => {
const cfg = { port: 9000 };
global.CONFIG = cfg;
expect(grabConstants().config).toBe(cfg);
});
});

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,55 @@
import { describe, it, expect } from "bun:test";
import path from "path";
import grabDirNames from "../../utils/grab-dir-names";
describe("grabDirNames", () => {
it("derives all paths from process.cwd()", () => {
const cwd = process.cwd();
const dirs = grabDirNames();
expect(dirs.ROOT_DIR).toBe(cwd);
expect(dirs.SRC_DIR).toBe(path.join(cwd, "src"));
expect(dirs.PAGES_DIR).toBe(path.join(cwd, "src", "pages"));
expect(dirs.API_DIR).toBe(path.join(cwd, "src", "pages", "api"));
expect(dirs.PUBLIC_DIR).toBe(path.join(cwd, "public"));
});
it("nests HYDRATION_DST_DIR under public/__bunext/pages", () => {
const dirs = grabDirNames();
expect(dirs.HYDRATION_DST_DIR).toBe(path.join(dirs.PUBLIC_DIR, "__bunext", "pages"));
});
it("nests BUNEXT_CACHE_DIR under public/__bunext/cache", () => {
const dirs = grabDirNames();
expect(dirs.BUNEXT_CACHE_DIR).toBe(path.join(dirs.PUBLIC_DIR, "__bunext", "cache"));
});
it("places map JSON file inside HYDRATION_DST_DIR", () => {
const dirs = grabDirNames();
expect(dirs.HYDRATION_DST_DIR_MAP_JSON_FILE).toBe(path.join(dirs.HYDRATION_DST_DIR, "map.json"));
});
it("places CONFIG_FILE at root", () => {
const dirs = grabDirNames();
expect(dirs.CONFIG_FILE).toBe(path.join(dirs.ROOT_DIR, "bunext.config.ts"));
});
it("places BUNX_TMP_DIR inside .bunext", () => {
const dirs = grabDirNames();
expect(dirs.BUNX_TMP_DIR).toContain(".bunext");
expect(dirs.BUNX_TMP_DIR).toEndWith(".tmp");
});
it("places BUNX_HYDRATION_SRC_DIR under client/hydration-src", () => {
const dirs = grabDirNames();
expect(dirs.BUNX_HYDRATION_SRC_DIR).toContain(path.join("client", "hydration-src"));
});
it("sets 404 file name to not-found", () => {
const dirs = grabDirNames();
expect(dirs.BUNX_ROOT_404_FILE_NAME).toBe("not-found");
});
it("sets 500 file name to server-error", () => {
const dirs = grabDirNames();
expect(dirs.BUNX_ROOT_500_FILE_NAME).toBe("server-error");
});
it("preset 404 component path ends with not-found.tsx", () => {
const dirs = grabDirNames();
expect(dirs.BUNX_ROOT_404_PRESET_COMPONENT).toEndWith("not-found.tsx");
});
it("preset 500 component path ends with server-error.tsx", () => {
const dirs = grabDirNames();
expect(dirs.BUNX_ROOT_500_PRESET_COMPONENT).toEndWith("server-error.tsx");
});
});

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,32 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import grabOrigin from "../../utils/grab-origin";
const originalPort = process.env.PORT;
beforeEach(() => {
delete process.env.PORT;
global.CONFIG = {};
});
afterEach(() => {
if (originalPort !== undefined) {
process.env.PORT = originalPort;
}
else {
delete process.env.PORT;
}
});
describe("grabOrigin", () => {
it("returns config.origin when set", () => {
global.CONFIG = { origin: "https://example.com" };
expect(grabOrigin()).toBe("https://example.com");
});
it("falls back to http://localhost:<port> using default port", () => {
expect(grabOrigin()).toBe("http://localhost:7000");
});
it("falls back using PORT env variable", () => {
process.env.PORT = "8080";
expect(grabOrigin()).toBe("http://localhost:8080");
});
it("falls back using config.port", () => {
global.CONFIG = { port: 3700 };
expect(grabOrigin()).toBe("http://localhost:3700");
});
});

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,46 @@
import { describe, it, expect } from "bun:test";
import grabPageName from "../../utils/grab-page-name";
describe("grabPageName", () => {
it("returns the page name for a simple page path", () => {
expect(grabPageName({ path: "/home/user/project/src/pages/about.tsx" }))
.toBe("about");
});
it("returns 'index' for a root index file (no -index stripping at root)", () => {
// -index suffix is only stripped when joined: e.g. "blog-index" → "blog"
// A standalone "index" filename has no leading dash so stays as-is
expect(grabPageName({ path: "/home/user/project/src/pages/index.tsx" }))
.toBe("index");
});
it("handles nested page paths", () => {
expect(grabPageName({ path: "/home/user/project/src/pages/blog/post.tsx" })).toBe("blog-post");
});
it("strips -index suffix from nested index files", () => {
expect(grabPageName({ path: "/home/user/project/src/pages/blog/index.tsx" })).toBe("blog");
});
it("converts dynamic segments [slug] by replacing brackets", () => {
const result = grabPageName({
path: "/home/user/project/src/pages/blog/[slug].tsx",
});
// [ → - and ] is dropped (not a-z or -), so [slug] → -slug
expect(result).toBe("blog--slug");
});
it("converts spread [...params] segments", () => {
const result = grabPageName({
path: "/home/user/project/src/pages/[...params].tsx",
});
// "[...params]" → remove ext → "[...params]"
// [ → "-" → "-...params]"
// "..." → "-" → "--params]"
// strip non [a-z-] → "--params"
expect(result).toBe("--params");
});
it("strips uppercase letters (only a-z and - are kept)", () => {
// [^a-z\-] strips uppercase — 'A' is removed, 'bout' remains
expect(grabPageName({ path: "/home/user/project/src/pages/About.tsx" })).toBe("bout");
});
it("handles deeply nested paths", () => {
expect(grabPageName({
path: "/home/user/project/src/pages/admin/users/list.tsx",
})).toBe("admin-users-list");
});
});

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,32 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import isDevelopment from "../../utils/is-development";
const originalEnv = process.env.NODE_ENV;
beforeEach(() => {
// Reset global config before each test
global.CONFIG = {};
});
afterEach(() => {
process.env.NODE_ENV = originalEnv;
});
describe("isDevelopment", () => {
it("returns false when NODE_ENV is production", () => {
process.env.NODE_ENV = "production";
global.CONFIG = { development: true };
expect(isDevelopment()).toBe(false);
});
it("returns true when config.development is true and NODE_ENV is not production", () => {
process.env.NODE_ENV = "development";
global.CONFIG = { development: true };
expect(isDevelopment()).toBe(true);
});
it("returns false when config.development is false", () => {
process.env.NODE_ENV = "development";
global.CONFIG = { development: false };
expect(isDevelopment()).toBe(false);
});
it("returns false when config.development is undefined", () => {
process.env.NODE_ENV = "development";
global.CONFIG = {};
expect(isDevelopment()).toBe(false);
});
});

View File

@ -0,0 +1 @@
export {};

43
dist/__tests__/utils/numberfy.test.js vendored Normal file
View File

@ -0,0 +1,43 @@
import { describe, it, expect } from "bun:test";
import numberfy, { _n } from "../../utils/numberfy";
describe("numberfy", () => {
it("converts a plain integer string", () => {
expect(numberfy("42")).toBe(42);
});
it("converts a float string preserving decimals", () => {
expect(numberfy("3.14")).toBe(3.14);
});
it("strips non-numeric characters", () => {
expect(numberfy("$1,234.56")).toBe(1234.56);
});
it("returns 0 for an empty string", () => {
expect(numberfy("")).toBe(0);
});
it("returns 0 for undefined", () => {
expect(numberfy(undefined)).toBe(0);
});
it("returns 0 for null", () => {
expect(numberfy(null)).toBe(0);
});
it("passes through a number directly", () => {
expect(numberfy(7)).toBe(7);
});
it("rounds when no decimals specified and no decimal in input", () => {
expect(numberfy("5.0")).toBe(5);
});
it("respects decimals=0 by rounding", () => {
expect(numberfy("3.7", 0)).toBe(4);
});
it("respects explicit decimals parameter", () => {
expect(numberfy("3.14159", 2)).toBe(3.14);
});
it("preserves existing decimal places when no decimals arg given", () => {
expect(numberfy("1.500")).toBe(1.5);
});
it("strips a trailing dot", () => {
expect(numberfy("5.")).toBe(5);
});
it("_n alias works identically", () => {
expect(_n("10")).toBe(10);
});
});

View File

@ -1,12 +1,15 @@
import { Command } from "commander";
import allPagesBundler from "../../functions/bundler/all-pages-bundler";
import { log } from "../../utils/log";
import init from "../../functions/init";
export default function () {
return new Command("build")
.description("Build Project")
.action(async () => {
process.env.NODE_ENV = "production";
process.env.BUILD = "true";
await init();
log.banner();
log.build("Building Project ...");
allPagesBundler({
exit_after_first_build: true,

View File

@ -10,7 +10,6 @@ import EJSON from "../utils/ejson";
import { log } from "../utils/log";
import cron from "./server/cron";
export default async function bunextInit() {
log.banner();
global.ORA_SPINNER = ora();
global.ORA_SPINNER.clear();
global.HMR_CONTROLLERS = [];
@ -18,6 +17,7 @@ export default async function bunextInit() {
global.BUNDLER_REBUILDS = 0;
global.PAGE_FILES = [];
await init();
log.banner();
const { PAGES_DIR, HYDRATION_DST_DIR_MAP_JSON_FILE } = grabDirNames();
const router = new Bun.FileSystemRouter({
style: "nextjs",

View File

@ -8,7 +8,8 @@ export default async function () {
execSync(`rm -rf ${dirNames.BUNEXT_CACHE_DIR}`);
execSync(`rm -rf ${dirNames.BUNX_CWD_MODULE_CACHE_DIR}`);
try {
const current_version = (await Bun.file(path.resolve(__dirname, "../../package.json")).json()).version;
const package_json = await Bun.file(path.resolve(__dirname, "../../package.json")).json();
const current_version = package_json.version;
global.CURRENT_VERSION = current_version;
}
catch (error) { }

View File

@ -1,5 +1,5 @@
type Params = {
req: Request;
};
export default function bunextRequestHandler({ req, }: Params): Promise<Response>;
export default function bunextRequestHandler({ req: initial_req, }: Params): Promise<Response>;
export {};

View File

@ -7,20 +7,24 @@ import handleHmr from "./handle-hmr";
import handleHmrUpdate from "./handle-hmr-update";
import handlePublic from "./handle-public";
import handleFiles from "./handle-files";
export default async function bunextRequestHandler({ req, }) {
export default async function bunextRequestHandler({ req: initial_req, }) {
const is_dev = isDevelopment();
let req = initial_req.clone();
try {
const url = new URL(req.url);
const { config } = grabConstants();
let response = undefined;
if (config?.middleware) {
const middleware_res = await config.middleware({
req,
req: initial_req,
url,
});
if (typeof middleware_res == "object") {
if (middleware_res instanceof Response) {
return middleware_res;
}
if (middleware_res instanceof Request) {
req = middleware_res;
}
}
if (url.pathname == `/${AppData["ClientHMRPath"]}`) {
response = await handleHmrUpdate({ req });

View File

@ -3,14 +3,23 @@ import path from "path";
import grabDirNames from "../../utils/grab-dir-names";
import rebuildBundler from "./rebuild-bundler";
import { log } from "../../utils/log";
const { SRC_DIR } = grabDirNames();
const { ROOT_DIR } = grabDirNames();
export default function watcher() {
const pages_src_watcher = watch(SRC_DIR, {
const pages_src_watcher = watch(ROOT_DIR, {
recursive: true,
persistent: true,
}, async (event, filename) => {
if (!filename)
return;
const excluded_match = /node_modules\/|^public\/|^\.bunext\/|^\.git\/|^dist\/|bun\.lockb$/;
if (filename.match(excluded_match))
return;
if (filename.match(/bunext.config\.ts/)) {
await fullRebuild({
msg: `bunext.config.ts file changed. Rebuilding server ...`,
});
return;
}
if (event !== "rename") {
if (filename.match(/\.(tsx?|jsx?|css)$/) &&
global.BUNDLER_CTX) {
@ -21,17 +30,26 @@ export default function watcher() {
}
return;
}
if (!filename.match(/^pages\//))
if (!filename.match(/^src\/pages\//))
return;
if (filename.match(/\/(--|\()/))
return;
if (global.RECOMPILING)
return;
const fullPath = path.join(SRC_DIR, filename);
const fullPath = path.join(ROOT_DIR, filename);
const action = existsSync(fullPath) ? "created" : "deleted";
await fullRebuild({
msg: `Page ${action}: ${filename}. Rebuilding ...`,
});
});
global.PAGES_SRC_WATCHER = pages_src_watcher;
}
async function fullRebuild({ msg }) {
try {
global.RECOMPILING = true;
log.watch(`Page ${action}: ${filename}. Rebuilding ...`);
if (msg) {
log.watch(msg);
}
await rebuildBundler();
}
catch (error) {
@ -44,6 +62,4 @@ export default function watcher() {
global.PAGES_SRC_WATCHER.close();
watcher();
}
});
global.PAGES_SRC_WATCHER = pages_src_watcher;
}

View File

@ -48,7 +48,7 @@ export default async function (params) {
script += ` document.head.appendChild(newScript);\n\n`;
script += ` } catch (err) {\n`;
script += ` console.error("HMR update failed, falling back to reload:", err.message);\n`;
// script += ` window.location.reload();\n`;
script += ` window.location.reload();\n`;
script += ` }\n`;
script += ` }\n`;
script += `});\n`;

View File

@ -44,7 +44,7 @@ export type BunextConfig = {
};
port?: number;
development?: boolean;
middleware?: (params: BunextConfigMiddlewareParams) => Promise<Response | undefined> | Response | undefined;
middleware?: (params: BunextConfigMiddlewareParams) => Promise<Response | Request | undefined> | Response | Request | undefined;
defaultCacheExpiry?: number;
websocket?: WebSocketHandler<any>;
serverOptions?: ServeOptions;

View File

@ -14,7 +14,7 @@
],
"scripts": {
"dev": "tsc --watch",
"publish": "tsc --noEmit && tsc && git add . && git commit -m 'Bugfixes. Documentation update.' && git push",
"publish": "tsc --noEmit && tsc && git add . && git commit -m 'Update watcher function. Add tests' && git push",
"compile": "bun build ./src/commands/index.ts --compile --outfile bin/bunext",
"build": "tsc"
},

View File

@ -0,0 +1,43 @@
import { describe, it, expect } from "bun:test";
import grabCacheNames from "../../../functions/cache/grab-cache-names";
describe("grabCacheNames", () => {
it("returns cache_name and cache_meta_name for a simple key", () => {
const { cache_name, cache_meta_name } = grabCacheNames({ key: "home" });
expect(cache_name).toBe("home.res.html");
expect(cache_meta_name).toBe("home.meta.json");
});
it("defaults paradigm to html", () => {
const { cache_name } = grabCacheNames({ key: "page" });
expect(cache_name).toEndWith(".res.html");
});
it("uses json paradigm when specified", () => {
const { cache_name } = grabCacheNames({ key: "api-data", paradigm: "json" });
expect(cache_name).toBe("api-data.res.json");
});
it("URL-encodes the key", () => {
const { cache_name, cache_meta_name } = grabCacheNames({
key: "/blog/hello world",
});
const encoded = encodeURIComponent("/blog/hello world");
expect(cache_name).toBe(`${encoded}.res.html`);
expect(cache_meta_name).toBe(`${encoded}.meta.json`);
});
it("handles keys with special characters", () => {
const key = "page?id=1&sort=asc";
const { cache_name } = grabCacheNames({ key });
expect(cache_name).toBe(`${encodeURIComponent(key)}.res.html`);
});
it("cache_meta_name always uses .meta.json regardless of paradigm", () => {
const { cache_meta_name } = grabCacheNames({
key: "test",
paradigm: "json",
});
expect(cache_meta_name).toBe("test.meta.json");
});
});

View File

@ -0,0 +1,110 @@
import { describe, it, expect } from "bun:test";
import grabWebMetaHTML from "../../../functions/server/web-pages/grab-web-meta-html";
describe("grabWebMetaHTML", () => {
it("returns empty string for empty meta object", () => {
expect(grabWebMetaHTML({ meta: {} })).toBe("");
});
it("generates a title tag", () => {
const html = grabWebMetaHTML({ meta: { title: "My Page" } });
expect(html).toContain("<title>My Page</title>");
});
it("generates a description meta tag", () => {
const html = grabWebMetaHTML({ meta: { description: "A description" } });
expect(html).toContain('<meta name="description" content="A description"');
});
it("joins array keywords with comma", () => {
const html = grabWebMetaHTML({
meta: { keywords: ["react", "bun", "ssr"] },
});
expect(html).toContain('content="react, bun, ssr"');
});
it("uses string keywords directly", () => {
const html = grabWebMetaHTML({ meta: { keywords: "react, bun" } });
expect(html).toContain('content="react, bun"');
});
it("generates author meta tag", () => {
const html = grabWebMetaHTML({ meta: { author: "Alice" } });
expect(html).toContain('<meta name="author" content="Alice"');
});
it("generates robots meta tag", () => {
const html = grabWebMetaHTML({ meta: { robots: "noindex" } });
expect(html).toContain('<meta name="robots" content="noindex"');
});
it("generates canonical link tag", () => {
const html = grabWebMetaHTML({
meta: { canonical: "https://example.com/page" },
});
expect(html).toContain('<link rel="canonical" href="https://example.com/page"');
});
it("generates theme-color meta tag", () => {
const html = grabWebMetaHTML({ meta: { themeColor: "#ff0000" } });
expect(html).toContain('<meta name="theme-color" content="#ff0000"');
});
it("generates OG tags", () => {
const html = grabWebMetaHTML({
meta: {
og: {
title: "OG Title",
description: "OG Desc",
image: "https://example.com/img.png",
url: "https://example.com",
type: "website",
siteName: "Example",
locale: "en_US",
},
},
});
expect(html).toContain('<meta property="og:title" content="OG Title"');
expect(html).toContain('<meta property="og:description" content="OG Desc"');
expect(html).toContain('<meta property="og:image" content="https://example.com/img.png"');
expect(html).toContain('<meta property="og:url" content="https://example.com"');
expect(html).toContain('<meta property="og:type" content="website"');
expect(html).toContain('<meta property="og:site_name" content="Example"');
expect(html).toContain('<meta property="og:locale" content="en_US"');
});
it("generates Twitter card tags", () => {
const html = grabWebMetaHTML({
meta: {
twitter: {
card: "summary_large_image",
title: "Tweet Title",
description: "Tweet Desc",
image: "https://example.com/tw.png",
site: "@example",
creator: "@alice",
},
},
});
expect(html).toContain('<meta name="twitter:card" content="summary_large_image"');
expect(html).toContain('<meta name="twitter:title" content="Tweet Title"');
expect(html).toContain('<meta name="twitter:description" content="Tweet Desc"');
expect(html).toContain('<meta name="twitter:image" content="https://example.com/tw.png"');
expect(html).toContain('<meta name="twitter:site" content="@example"');
expect(html).toContain('<meta name="twitter:creator" content="@alice"');
});
it("skips undefined OG fields", () => {
const html = grabWebMetaHTML({ meta: { og: { title: "Only Title" } } });
expect(html).toContain("og:title");
expect(html).not.toContain("og:description");
expect(html).not.toContain("og:image");
});
it("does not emit tags for missing fields", () => {
const html = grabWebMetaHTML({ meta: { title: "Hello" } });
expect(html).not.toContain("description");
expect(html).not.toContain("og:");
expect(html).not.toContain("twitter:");
});
});

View File

@ -0,0 +1,40 @@
import { describe, it, expect } from "bun:test";
import deserializeQuery from "../../utils/deserialize-query";
describe("deserializeQuery", () => {
it("passes through a plain object unchanged", () => {
const input = { foo: "bar" };
expect(deserializeQuery(input)).toEqual({ foo: "bar" });
});
it("parses a JSON string into an object", () => {
const input = JSON.stringify({ a: 1, b: "hello" });
expect(deserializeQuery(input)).toEqual({ a: 1, b: "hello" });
});
it("deep-parses string values that look like JSON objects", () => {
const nested = { filter: JSON.stringify({ status: "active" }) };
const result = deserializeQuery(nested);
expect(result.filter).toEqual({ status: "active" });
});
it("deep-parses string values that look like JSON arrays", () => {
const nested = { ids: JSON.stringify([1, 2, 3]) };
const result = deserializeQuery(nested);
expect(result.ids).toEqual([1, 2, 3]);
});
it("leaves plain string values alone", () => {
const input = { name: "alice", age: "30" };
expect(deserializeQuery(input)).toEqual({ name: "alice", age: "30" });
});
it("returns an empty object for an empty JSON string", () => {
expect(deserializeQuery("{}")).toEqual({});
});
it("returns an empty object for an invalid JSON string", () => {
// EJSON.parse returns undefined → Object(undefined) → {}
expect(deserializeQuery("not-json")).toEqual({});
});
});

View File

@ -0,0 +1,68 @@
import { describe, it, expect } from "bun:test";
import EJSON from "../../utils/ejson";
describe("EJSON.parse", () => {
it("parses a valid JSON string", () => {
expect(EJSON.parse('{"a":1}')).toEqual({ a: 1 });
});
it("parses a JSON array string", () => {
expect(EJSON.parse('[1,2,3]')).toEqual([1, 2, 3]);
});
it("returns undefined for null input", () => {
expect(EJSON.parse(null)).toBeUndefined();
});
it("returns undefined for empty string", () => {
expect(EJSON.parse("")).toBeUndefined();
});
it("returns undefined for invalid JSON", () => {
expect(EJSON.parse("{bad json")).toBeUndefined();
});
it("returns the object directly when passed an object (typeof object)", () => {
const obj = { x: 1 };
expect(EJSON.parse(obj as any)).toBe(obj);
});
it("returns undefined for a number input", () => {
expect(EJSON.parse(42)).toBeUndefined();
});
it("applies a reviver function", () => {
const result = EJSON.parse('{"a":"2"}', (key, value) =>
key === "a" ? Number(value) : value,
);
expect(result).toEqual({ a: 2 });
});
});
describe("EJSON.stringify", () => {
it("stringifies an object", () => {
expect(EJSON.stringify({ a: 1 })).toBe('{"a":1}');
});
it("stringifies an array", () => {
expect(EJSON.stringify([1, 2, 3])).toBe("[1,2,3]");
});
it("applies spacing", () => {
expect(EJSON.stringify({ a: 1 }, null, 2)).toBe('{\n "a": 1\n}');
});
it("returns undefined for circular references", () => {
const obj: any = {};
obj.self = obj;
expect(EJSON.stringify(obj)).toBeUndefined();
});
it("stringifies null", () => {
expect(EJSON.stringify(null)).toBe("null");
});
it("stringifies a number", () => {
expect(EJSON.stringify(42)).toBe("42");
});
});

View File

@ -0,0 +1,29 @@
import { describe, it, expect } from "bun:test";
import AppNames from "../../utils/grab-app-names";
describe("AppNames", () => {
it("has a defaultPort of 7000", () => {
expect(AppNames.defaultPort).toBe(7000);
});
it("has the correct defaultAssetPrefix", () => {
expect(AppNames.defaultAssetPrefix).toBe("_bunext/static");
});
it("has name Bunext", () => {
expect(AppNames.name).toBe("Bunext");
});
it("has a version string", () => {
expect(typeof AppNames.version).toBe("string");
expect(AppNames.version.length).toBeGreaterThan(0);
});
it("has defaultDistDir as .bunext", () => {
expect(AppNames.defaultDistDir).toBe(".bunext");
});
it("has RootPagesComponentName as __root", () => {
expect(AppNames.RootPagesComponentName).toBe("__root");
});
});

View File

@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import grabAppPort from "../../utils/grab-app-port";
const originalEnv = process.env.PORT;
beforeEach(() => {
delete process.env.PORT;
(global as any).CONFIG = {};
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env.PORT = originalEnv;
} else {
delete process.env.PORT;
}
});
describe("grabAppPort", () => {
it("returns the default port (7000) when no config or env set", () => {
expect(grabAppPort()).toBe(7000);
});
it("uses PORT env variable when set", () => {
process.env.PORT = "8080";
expect(grabAppPort()).toBe(8080);
});
it("uses config.port when PORT env is not set", () => {
(global as any).CONFIG = { port: 3000 };
expect(grabAppPort()).toBe(3000);
});
it("PORT env takes precedence over config.port", () => {
process.env.PORT = "9000";
(global as any).CONFIG = { port: 3000 };
expect(grabAppPort()).toBe(9000);
});
it("handles non-numeric PORT env gracefully via numberfy", () => {
process.env.PORT = "abc";
// numberfy strips non-numeric chars, "abc" → "" → 0
expect(grabAppPort()).toBe(0);
});
});

View File

@ -0,0 +1,40 @@
import { describe, it, expect, beforeEach } from "bun:test";
import grabConstants from "../../utils/grab-constants";
beforeEach(() => {
(global as any).CONFIG = {};
});
describe("grabConstants", () => {
it("has the correct ClientRootElementIDName", () => {
expect(grabConstants().ClientRootElementIDName).toBe("__bunext");
});
it("has the correct ClientWindowPagePropsName", () => {
expect(grabConstants().ClientWindowPagePropsName).toBe("__PAGE_PROPS__");
});
it("has the correct ClientRootComponentWindowName", () => {
expect(grabConstants().ClientRootComponentWindowName).toBe("BUNEXT_ROOT");
});
it("calculates MBInBytes as 1024 * 1024", () => {
expect(grabConstants().MBInBytes).toBe(1024 * 1024);
});
it("ServerDefaultRequestBodyLimitBytes is 10 MB", () => {
expect(grabConstants().ServerDefaultRequestBodyLimitBytes).toBe(
10 * 1024 * 1024,
);
});
it("MaxBundlerRebuilds is 5", () => {
expect(grabConstants().MaxBundlerRebuilds).toBe(5);
});
it("returns the current global.CONFIG", () => {
const cfg = { port: 9000 };
(global as any).CONFIG = cfg;
expect(grabConstants().config).toBe(cfg);
});
});

View File

@ -0,0 +1,75 @@
import { describe, it, expect } from "bun:test";
import path from "path";
import grabDirNames from "../../utils/grab-dir-names";
describe("grabDirNames", () => {
it("derives all paths from process.cwd()", () => {
const cwd = process.cwd();
const dirs = grabDirNames();
expect(dirs.ROOT_DIR).toBe(cwd);
expect(dirs.SRC_DIR).toBe(path.join(cwd, "src"));
expect(dirs.PAGES_DIR).toBe(path.join(cwd, "src", "pages"));
expect(dirs.API_DIR).toBe(path.join(cwd, "src", "pages", "api"));
expect(dirs.PUBLIC_DIR).toBe(path.join(cwd, "public"));
});
it("nests HYDRATION_DST_DIR under public/__bunext/pages", () => {
const dirs = grabDirNames();
expect(dirs.HYDRATION_DST_DIR).toBe(
path.join(dirs.PUBLIC_DIR, "__bunext", "pages"),
);
});
it("nests BUNEXT_CACHE_DIR under public/__bunext/cache", () => {
const dirs = grabDirNames();
expect(dirs.BUNEXT_CACHE_DIR).toBe(
path.join(dirs.PUBLIC_DIR, "__bunext", "cache"),
);
});
it("places map JSON file inside HYDRATION_DST_DIR", () => {
const dirs = grabDirNames();
expect(dirs.HYDRATION_DST_DIR_MAP_JSON_FILE).toBe(
path.join(dirs.HYDRATION_DST_DIR, "map.json"),
);
});
it("places CONFIG_FILE at root", () => {
const dirs = grabDirNames();
expect(dirs.CONFIG_FILE).toBe(path.join(dirs.ROOT_DIR, "bunext.config.ts"));
});
it("places BUNX_TMP_DIR inside .bunext", () => {
const dirs = grabDirNames();
expect(dirs.BUNX_TMP_DIR).toContain(".bunext");
expect(dirs.BUNX_TMP_DIR).toEndWith(".tmp");
});
it("places BUNX_HYDRATION_SRC_DIR under client/hydration-src", () => {
const dirs = grabDirNames();
expect(dirs.BUNX_HYDRATION_SRC_DIR).toContain(
path.join("client", "hydration-src"),
);
});
it("sets 404 file name to not-found", () => {
const dirs = grabDirNames();
expect(dirs.BUNX_ROOT_404_FILE_NAME).toBe("not-found");
});
it("sets 500 file name to server-error", () => {
const dirs = grabDirNames();
expect(dirs.BUNX_ROOT_500_FILE_NAME).toBe("server-error");
});
it("preset 404 component path ends with not-found.tsx", () => {
const dirs = grabDirNames();
expect(dirs.BUNX_ROOT_404_PRESET_COMPONENT).toEndWith("not-found.tsx");
});
it("preset 500 component path ends with server-error.tsx", () => {
const dirs = grabDirNames();
expect(dirs.BUNX_ROOT_500_PRESET_COMPONENT).toEndWith("server-error.tsx");
});
});

View File

@ -0,0 +1,38 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import grabOrigin from "../../utils/grab-origin";
const originalPort = process.env.PORT;
beforeEach(() => {
delete process.env.PORT;
(global as any).CONFIG = {};
});
afterEach(() => {
if (originalPort !== undefined) {
process.env.PORT = originalPort;
} else {
delete process.env.PORT;
}
});
describe("grabOrigin", () => {
it("returns config.origin when set", () => {
(global as any).CONFIG = { origin: "https://example.com" };
expect(grabOrigin()).toBe("https://example.com");
});
it("falls back to http://localhost:<port> using default port", () => {
expect(grabOrigin()).toBe("http://localhost:7000");
});
it("falls back using PORT env variable", () => {
process.env.PORT = "8080";
expect(grabOrigin()).toBe("http://localhost:8080");
});
it("falls back using config.port", () => {
(global as any).CONFIG = { port: 3700 };
expect(grabOrigin()).toBe("http://localhost:3700");
});
});

View File

@ -0,0 +1,62 @@
import { describe, it, expect } from "bun:test";
import grabPageName from "../../utils/grab-page-name";
describe("grabPageName", () => {
it("returns the page name for a simple page path", () => {
expect(grabPageName({ path: "/home/user/project/src/pages/about.tsx" }))
.toBe("about");
});
it("returns 'index' for a root index file (no -index stripping at root)", () => {
// -index suffix is only stripped when joined: e.g. "blog-index" → "blog"
// A standalone "index" filename has no leading dash so stays as-is
expect(grabPageName({ path: "/home/user/project/src/pages/index.tsx" }))
.toBe("index");
});
it("handles nested page paths", () => {
expect(
grabPageName({ path: "/home/user/project/src/pages/blog/post.tsx" }),
).toBe("blog-post");
});
it("strips -index suffix from nested index files", () => {
expect(
grabPageName({ path: "/home/user/project/src/pages/blog/index.tsx" }),
).toBe("blog");
});
it("converts dynamic segments [slug] by replacing brackets", () => {
const result = grabPageName({
path: "/home/user/project/src/pages/blog/[slug].tsx",
});
// [ → - and ] is dropped (not a-z or -), so [slug] → -slug
expect(result).toBe("blog--slug");
});
it("converts spread [...params] segments", () => {
const result = grabPageName({
path: "/home/user/project/src/pages/[...params].tsx",
});
// "[...params]" → remove ext → "[...params]"
// [ → "-" → "-...params]"
// "..." → "-" → "--params]"
// strip non [a-z-] → "--params"
expect(result).toBe("--params");
});
it("strips uppercase letters (only a-z and - are kept)", () => {
// [^a-z\-] strips uppercase — 'A' is removed, 'bout' remains
expect(
grabPageName({ path: "/home/user/project/src/pages/About.tsx" }),
).toBe("bout");
});
it("handles deeply nested paths", () => {
expect(
grabPageName({
path: "/home/user/project/src/pages/admin/users/list.tsx",
}),
).toBe("admin-users-list");
});
});

View File

@ -0,0 +1,39 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import isDevelopment from "../../utils/is-development";
const originalEnv = process.env.NODE_ENV;
beforeEach(() => {
// Reset global config before each test
(global as any).CONFIG = {};
});
afterEach(() => {
process.env.NODE_ENV = originalEnv;
});
describe("isDevelopment", () => {
it("returns false when NODE_ENV is production", () => {
process.env.NODE_ENV = "production";
(global as any).CONFIG = { development: true };
expect(isDevelopment()).toBe(false);
});
it("returns true when config.development is true and NODE_ENV is not production", () => {
process.env.NODE_ENV = "development";
(global as any).CONFIG = { development: true };
expect(isDevelopment()).toBe(true);
});
it("returns false when config.development is false", () => {
process.env.NODE_ENV = "development";
(global as any).CONFIG = { development: false };
expect(isDevelopment()).toBe(false);
});
it("returns false when config.development is undefined", () => {
process.env.NODE_ENV = "development";
(global as any).CONFIG = {};
expect(isDevelopment()).toBe(false);
});
});

View File

@ -0,0 +1,56 @@
import { describe, it, expect } from "bun:test";
import numberfy, { _n } from "../../utils/numberfy";
describe("numberfy", () => {
it("converts a plain integer string", () => {
expect(numberfy("42")).toBe(42);
});
it("converts a float string preserving decimals", () => {
expect(numberfy("3.14")).toBe(3.14);
});
it("strips non-numeric characters", () => {
expect(numberfy("$1,234.56")).toBe(1234.56);
});
it("returns 0 for an empty string", () => {
expect(numberfy("")).toBe(0);
});
it("returns 0 for undefined", () => {
expect(numberfy(undefined)).toBe(0);
});
it("returns 0 for null", () => {
expect(numberfy(null)).toBe(0);
});
it("passes through a number directly", () => {
expect(numberfy(7)).toBe(7);
});
it("rounds when no decimals specified and no decimal in input", () => {
expect(numberfy("5.0")).toBe(5);
});
it("respects decimals=0 by rounding", () => {
expect(numberfy("3.7", 0)).toBe(4);
});
it("respects explicit decimals parameter", () => {
expect(numberfy("3.14159", 2)).toBe(3.14);
});
it("preserves existing decimal places when no decimals arg given", () => {
expect(numberfy("1.500")).toBe(1.5);
});
it("strips a trailing dot", () => {
expect(numberfy("5.")).toBe(5);
});
it("_n alias works identically", () => {
expect(_n("10")).toBe(10);
});
});

View File

@ -1,6 +1,7 @@
import { Command } from "commander";
import allPagesBundler from "../../functions/bundler/all-pages-bundler";
import { log } from "../../utils/log";
import init from "../../functions/init";
export default function () {
return new Command("build")
@ -9,6 +10,9 @@ export default function () {
process.env.NODE_ENV = "production";
process.env.BUILD = "true";
await init();
log.banner();
log.build("Building Project ...");
allPagesBundler({

View File

@ -40,8 +40,6 @@ declare global {
}
export default async function bunextInit() {
log.banner();
global.ORA_SPINNER = ora();
global.ORA_SPINNER.clear();
global.HMR_CONTROLLERS = [];
@ -50,6 +48,7 @@ export default async function bunextInit() {
global.PAGE_FILES = [];
await init();
log.banner();
const { PAGES_DIR, HYDRATION_DST_DIR_MAP_JSON_FILE } = grabDirNames();

View File

@ -12,9 +12,11 @@ export default async function () {
execSync(`rm -rf ${dirNames.BUNX_CWD_MODULE_CACHE_DIR}`);
try {
const current_version = (
await Bun.file(path.resolve(__dirname, "../../package.json")).json()
).version;
const package_json = await Bun.file(
path.resolve(__dirname, "../../package.json"),
).json();
const current_version = package_json.version;
global.CURRENT_VERSION = current_version;
} catch (error) {}

View File

@ -7,15 +7,15 @@ import handleHmr from "./handle-hmr";
import handleHmrUpdate from "./handle-hmr-update";
import handlePublic from "./handle-public";
import handleFiles from "./handle-files";
type Params = {
req: Request;
};
export default async function bunextRequestHandler({
req,
req: initial_req,
}: Params): Promise<Response> {
const is_dev = isDevelopment();
let req = initial_req.clone();
try {
const url = new URL(req.url);
@ -26,13 +26,17 @@ export default async function bunextRequestHandler({
if (config?.middleware) {
const middleware_res = await config.middleware({
req,
req: initial_req,
url,
});
if (typeof middleware_res == "object") {
if (middleware_res instanceof Response) {
return middleware_res;
}
if (middleware_res instanceof Request) {
req = middleware_res;
}
}
if (url.pathname == `/${AppData["ClientHMRPath"]}`) {

View File

@ -0,0 +1,78 @@
import { watch, existsSync } from "fs";
import path from "path";
import grabDirNames from "../../utils/grab-dir-names";
import rebuildBundler from "./rebuild-bundler";
import { log } from "../../utils/log";
const { ROOT_DIR } = grabDirNames();
export default function watcher() {
const pages_src_watcher = watch(
ROOT_DIR,
{
recursive: true,
persistent: true,
},
async (event, filename) => {
if (!filename) return;
const excluded_match =
/node_modules\/|^public\/|^\.bunext\/|^\.git\/|^dist\/|bun\.lockb$/;
if (filename.match(excluded_match)) return;
if (filename.match(/bunext.config\.ts/)) {
await fullRebuild({
msg: `bunext.config.ts file changed. Rebuilding server ...`,
});
return;
}
if (event !== "rename") {
if (
filename.match(/\.(tsx?|jsx?|css)$/) &&
global.BUNDLER_CTX
) {
if (global.RECOMPILING) return;
global.RECOMPILING = true;
await global.BUNDLER_CTX.rebuild();
}
return;
}
if (!filename.match(/^src\/pages\//)) return;
if (filename.match(/\/(--|\()/)) return;
if (global.RECOMPILING) return;
const fullPath = path.join(ROOT_DIR, filename);
const action = existsSync(fullPath) ? "created" : "deleted";
await fullRebuild({
msg: `Page ${action}: ${filename}. Rebuilding ...`,
});
},
);
global.PAGES_SRC_WATCHER = pages_src_watcher;
}
async function fullRebuild({ msg }: { msg?: string }) {
try {
global.RECOMPILING = true;
if (msg) {
log.watch(msg);
}
await rebuildBundler();
} catch (error: any) {
log.error(error);
} finally {
global.RECOMPILING = false;
}
if (global.PAGES_SRC_WATCHER) {
global.PAGES_SRC_WATCHER.close();
watcher();
}
}

View File

@ -1,58 +0,0 @@
import { watch, existsSync } from "fs";
import path from "path";
import grabDirNames from "../../utils/grab-dir-names";
import rebuildBundler from "./rebuild-bundler";
import { log } from "../../utils/log";
const { SRC_DIR } = grabDirNames();
export default function watcher() {
const pages_src_watcher = watch(
SRC_DIR,
{
recursive: true,
persistent: true,
},
async (event, filename) => {
if (!filename) return;
if (event !== "rename") {
if (
filename.match(/\.(tsx?|jsx?|css)$/) &&
global.BUNDLER_CTX
) {
if (global.RECOMPILING) return;
global.RECOMPILING = true;
await global.BUNDLER_CTX.rebuild();
}
return;
}
if (!filename.match(/^pages\//)) return;
if (filename.match(/\/(--|\()/)) return;
if (global.RECOMPILING) return;
const fullPath = path.join(SRC_DIR, filename);
const action = existsSync(fullPath) ? "created" : "deleted";
try {
global.RECOMPILING = true;
log.watch(`Page ${action}: ${filename}. Rebuilding ...`);
await rebuildBundler();
} catch (error: any) {
log.error(error);
} finally {
global.RECOMPILING = false;
}
if (global.PAGES_SRC_WATCHER) {
global.PAGES_SRC_WATCHER.close();
watcher();
}
},
);
global.PAGES_SRC_WATCHER = pages_src_watcher;
}

View File

@ -59,7 +59,7 @@ export default async function (params?: Params) {
script += ` document.head.appendChild(newScript);\n\n`;
script += ` } catch (err) {\n`;
script += ` console.error("HMR update failed, falling back to reload:", err.message);\n`;
// script += ` window.location.reload();\n`;
script += ` window.location.reload();\n`;
script += ` }\n`;
script += ` }\n`;
script += `});\n`;

View File

@ -50,7 +50,11 @@ export type BunextConfig = {
development?: boolean;
middleware?: (
params: BunextConfigMiddlewareParams,
) => Promise<Response | undefined> | Response | undefined;
) =>
| Promise<Response | Request | undefined>
| Response
| Request
| undefined;
defaultCacheExpiry?: number;
websocket?: WebSocketHandler<any>;
serverOptions?: ServeOptions;