diff --git a/dist/__tests__/functions/cache/grab-cache-names.test.d.ts b/dist/__tests__/functions/cache/grab-cache-names.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/dist/__tests__/functions/cache/grab-cache-names.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/dist/__tests__/functions/cache/grab-cache-names.test.js b/dist/__tests__/functions/cache/grab-cache-names.test.js
new file mode 100644
index 0000000..5b3e3ba
--- /dev/null
+++ b/dist/__tests__/functions/cache/grab-cache-names.test.js
@@ -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");
+ });
+});
diff --git a/dist/__tests__/functions/server/grab-web-meta-html.test.d.ts b/dist/__tests__/functions/server/grab-web-meta-html.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/dist/__tests__/functions/server/grab-web-meta-html.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/dist/__tests__/functions/server/grab-web-meta-html.test.js b/dist/__tests__/functions/server/grab-web-meta-html.test.js
new file mode 100644
index 0000000..f5a8b67
--- /dev/null
+++ b/dist/__tests__/functions/server/grab-web-meta-html.test.js
@@ -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("
My Page");
+ });
+ it("generates a description meta tag", () => {
+ const html = grabWebMetaHTML({ meta: { description: "A description" } });
+ expect(html).toContain(' {
+ 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(' {
+ const html = grabWebMetaHTML({ meta: { robots: "noindex" } });
+ expect(html).toContain(' {
+ const html = grabWebMetaHTML({
+ meta: { canonical: "https://example.com/page" },
+ });
+ expect(html).toContain(' {
+ const html = grabWebMetaHTML({ meta: { themeColor: "#ff0000" } });
+ expect(html).toContain(' {
+ 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(' {
+ 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(' {
+ 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:");
+ });
+});
diff --git a/dist/__tests__/utils/deserialize-query.test.d.ts b/dist/__tests__/utils/deserialize-query.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/dist/__tests__/utils/deserialize-query.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/dist/__tests__/utils/deserialize-query.test.js b/dist/__tests__/utils/deserialize-query.test.js
new file mode 100644
index 0000000..f1bc5bd
--- /dev/null
+++ b/dist/__tests__/utils/deserialize-query.test.js
@@ -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({});
+ });
+});
diff --git a/dist/__tests__/utils/ejson.test.d.ts b/dist/__tests__/utils/ejson.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/dist/__tests__/utils/ejson.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/dist/__tests__/utils/ejson.test.js b/dist/__tests__/utils/ejson.test.js
new file mode 100644
index 0000000..120908c
--- /dev/null
+++ b/dist/__tests__/utils/ejson.test.js
@@ -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");
+ });
+});
diff --git a/dist/__tests__/utils/grab-app-names.test.d.ts b/dist/__tests__/utils/grab-app-names.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/dist/__tests__/utils/grab-app-names.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/dist/__tests__/utils/grab-app-names.test.js b/dist/__tests__/utils/grab-app-names.test.js
new file mode 100644
index 0000000..3b971c4
--- /dev/null
+++ b/dist/__tests__/utils/grab-app-names.test.js
@@ -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");
+ });
+});
diff --git a/dist/__tests__/utils/grab-app-port.test.d.ts b/dist/__tests__/utils/grab-app-port.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/dist/__tests__/utils/grab-app-port.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/dist/__tests__/utils/grab-app-port.test.js b/dist/__tests__/utils/grab-app-port.test.js
new file mode 100644
index 0000000..02c6b91
--- /dev/null
+++ b/dist/__tests__/utils/grab-app-port.test.js
@@ -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);
+ });
+});
diff --git a/dist/__tests__/utils/grab-constants.test.d.ts b/dist/__tests__/utils/grab-constants.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/dist/__tests__/utils/grab-constants.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/dist/__tests__/utils/grab-constants.test.js b/dist/__tests__/utils/grab-constants.test.js
new file mode 100644
index 0000000..a8f0c6f
--- /dev/null
+++ b/dist/__tests__/utils/grab-constants.test.js
@@ -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);
+ });
+});
diff --git a/dist/__tests__/utils/grab-dir-names.test.d.ts b/dist/__tests__/utils/grab-dir-names.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/dist/__tests__/utils/grab-dir-names.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/dist/__tests__/utils/grab-dir-names.test.js b/dist/__tests__/utils/grab-dir-names.test.js
new file mode 100644
index 0000000..c76456d
--- /dev/null
+++ b/dist/__tests__/utils/grab-dir-names.test.js
@@ -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");
+ });
+});
diff --git a/dist/__tests__/utils/grab-origin.test.d.ts b/dist/__tests__/utils/grab-origin.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/dist/__tests__/utils/grab-origin.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/dist/__tests__/utils/grab-origin.test.js b/dist/__tests__/utils/grab-origin.test.js
new file mode 100644
index 0000000..56f66b9
--- /dev/null
+++ b/dist/__tests__/utils/grab-origin.test.js
@@ -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: 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");
+ });
+});
diff --git a/dist/__tests__/utils/grab-page-name.test.d.ts b/dist/__tests__/utils/grab-page-name.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/dist/__tests__/utils/grab-page-name.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/dist/__tests__/utils/grab-page-name.test.js b/dist/__tests__/utils/grab-page-name.test.js
new file mode 100644
index 0000000..97d4c4d
--- /dev/null
+++ b/dist/__tests__/utils/grab-page-name.test.js
@@ -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");
+ });
+});
diff --git a/dist/__tests__/utils/is-development.test.d.ts b/dist/__tests__/utils/is-development.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/dist/__tests__/utils/is-development.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/dist/__tests__/utils/is-development.test.js b/dist/__tests__/utils/is-development.test.js
new file mode 100644
index 0000000..6474a2d
--- /dev/null
+++ b/dist/__tests__/utils/is-development.test.js
@@ -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);
+ });
+});
diff --git a/dist/__tests__/utils/numberfy.test.d.ts b/dist/__tests__/utils/numberfy.test.d.ts
new file mode 100644
index 0000000..cb0ff5c
--- /dev/null
+++ b/dist/__tests__/utils/numberfy.test.d.ts
@@ -0,0 +1 @@
+export {};
diff --git a/dist/__tests__/utils/numberfy.test.js b/dist/__tests__/utils/numberfy.test.js
new file mode 100644
index 0000000..e8469ae
--- /dev/null
+++ b/dist/__tests__/utils/numberfy.test.js
@@ -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);
+ });
+});
diff --git a/dist/commands/build/index.js b/dist/commands/build/index.js
index e4e694b..0381fda 100644
--- a/dist/commands/build/index.js
+++ b/dist/commands/build/index.js
@@ -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,
diff --git a/dist/functions/bunext-init.js b/dist/functions/bunext-init.js
index a01be6e..63fe6ff 100644
--- a/dist/functions/bunext-init.js
+++ b/dist/functions/bunext-init.js
@@ -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",
diff --git a/dist/functions/init.js b/dist/functions/init.js
index 3fa9149..6354291 100644
--- a/dist/functions/init.js
+++ b/dist/functions/init.js
@@ -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) { }
diff --git a/dist/functions/server/bunext-req-handler.d.ts b/dist/functions/server/bunext-req-handler.d.ts
index da210f8..9c363c6 100644
--- a/dist/functions/server/bunext-req-handler.d.ts
+++ b/dist/functions/server/bunext-req-handler.d.ts
@@ -1,5 +1,5 @@
type Params = {
req: Request;
};
-export default function bunextRequestHandler({ req, }: Params): Promise;
+export default function bunextRequestHandler({ req: initial_req, }: Params): Promise;
export {};
diff --git a/dist/functions/server/bunext-req-handler.js b/dist/functions/server/bunext-req-handler.js
index 175c86b..306a4af 100644
--- a/dist/functions/server/bunext-req-handler.js
+++ b/dist/functions/server/bunext-req-handler.js
@@ -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 });
diff --git a/dist/functions/server/watcher.js b/dist/functions/server/watcher.js
index 653a00d..c7696e6 100644
--- a/dist/functions/server/watcher.js
+++ b/dist/functions/server/watcher.js
@@ -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,29 +30,36 @@ 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";
- try {
- global.RECOMPILING = true;
- log.watch(`Page ${action}: ${filename}. Rebuilding ...`);
- await rebuildBundler();
- }
- catch (error) {
- log.error(error);
- }
- finally {
- global.RECOMPILING = false;
- }
- if (global.PAGES_SRC_WATCHER) {
- global.PAGES_SRC_WATCHER.close();
- watcher();
- }
+ await fullRebuild({
+ msg: `Page ${action}: ${filename}. Rebuilding ...`,
+ });
});
global.PAGES_SRC_WATCHER = pages_src_watcher;
}
+async function fullRebuild({ msg }) {
+ try {
+ global.RECOMPILING = true;
+ if (msg) {
+ log.watch(msg);
+ }
+ await rebuildBundler();
+ }
+ catch (error) {
+ log.error(error);
+ }
+ finally {
+ global.RECOMPILING = false;
+ }
+ if (global.PAGES_SRC_WATCHER) {
+ global.PAGES_SRC_WATCHER.close();
+ watcher();
+ }
+}
diff --git a/dist/functions/server/web-pages/grab-web-page-hydration-script.js b/dist/functions/server/web-pages/grab-web-page-hydration-script.js
index ee5c5cb..1b9eab8 100644
--- a/dist/functions/server/web-pages/grab-web-page-hydration-script.js
+++ b/dist/functions/server/web-pages/grab-web-page-hydration-script.js
@@ -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`;
diff --git a/dist/types/index.d.ts b/dist/types/index.d.ts
index b07c3e5..d646c3e 100644
--- a/dist/types/index.d.ts
+++ b/dist/types/index.d.ts
@@ -44,7 +44,7 @@ export type BunextConfig = {
};
port?: number;
development?: boolean;
- middleware?: (params: BunextConfigMiddlewareParams) => Promise | Response | undefined;
+ middleware?: (params: BunextConfigMiddlewareParams) => Promise | Response | Request | undefined;
defaultCacheExpiry?: number;
websocket?: WebSocketHandler;
serverOptions?: ServeOptions;
diff --git a/package.json b/package.json
index 9094781..ed2ada7 100644
--- a/package.json
+++ b/package.json
@@ -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"
},
diff --git a/src/__tests__/functions/cache/grab-cache-names.test.ts b/src/__tests__/functions/cache/grab-cache-names.test.ts
new file mode 100644
index 0000000..aa49d30
--- /dev/null
+++ b/src/__tests__/functions/cache/grab-cache-names.test.ts
@@ -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");
+ });
+});
diff --git a/src/__tests__/functions/server/grab-web-meta-html.test.ts b/src/__tests__/functions/server/grab-web-meta-html.test.ts
new file mode 100644
index 0000000..38fe516
--- /dev/null
+++ b/src/__tests__/functions/server/grab-web-meta-html.test.ts
@@ -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("My Page");
+ });
+
+ it("generates a description meta tag", () => {
+ const html = grabWebMetaHTML({ meta: { description: "A description" } });
+ expect(html).toContain(' {
+ 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(' {
+ const html = grabWebMetaHTML({ meta: { robots: "noindex" } });
+ expect(html).toContain(' {
+ const html = grabWebMetaHTML({
+ meta: { canonical: "https://example.com/page" },
+ });
+ expect(html).toContain(' {
+ const html = grabWebMetaHTML({ meta: { themeColor: "#ff0000" } });
+ expect(html).toContain(' {
+ 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(' {
+ 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(' {
+ 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:");
+ });
+});
diff --git a/src/__tests__/utils/deserialize-query.test.ts b/src/__tests__/utils/deserialize-query.test.ts
new file mode 100644
index 0000000..cbf7b0f
--- /dev/null
+++ b/src/__tests__/utils/deserialize-query.test.ts
@@ -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({});
+ });
+});
diff --git a/src/__tests__/utils/ejson.test.ts b/src/__tests__/utils/ejson.test.ts
new file mode 100644
index 0000000..7dba3a3
--- /dev/null
+++ b/src/__tests__/utils/ejson.test.ts
@@ -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");
+ });
+});
diff --git a/src/__tests__/utils/grab-app-names.test.ts b/src/__tests__/utils/grab-app-names.test.ts
new file mode 100644
index 0000000..f070da3
--- /dev/null
+++ b/src/__tests__/utils/grab-app-names.test.ts
@@ -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");
+ });
+});
diff --git a/src/__tests__/utils/grab-app-port.test.ts b/src/__tests__/utils/grab-app-port.test.ts
new file mode 100644
index 0000000..b929910
--- /dev/null
+++ b/src/__tests__/utils/grab-app-port.test.ts
@@ -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);
+ });
+});
diff --git a/src/__tests__/utils/grab-constants.test.ts b/src/__tests__/utils/grab-constants.test.ts
new file mode 100644
index 0000000..c9db9dd
--- /dev/null
+++ b/src/__tests__/utils/grab-constants.test.ts
@@ -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);
+ });
+});
diff --git a/src/__tests__/utils/grab-dir-names.test.ts b/src/__tests__/utils/grab-dir-names.test.ts
new file mode 100644
index 0000000..2e18387
--- /dev/null
+++ b/src/__tests__/utils/grab-dir-names.test.ts
@@ -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");
+ });
+});
diff --git a/src/__tests__/utils/grab-origin.test.ts b/src/__tests__/utils/grab-origin.test.ts
new file mode 100644
index 0000000..79d8a99
--- /dev/null
+++ b/src/__tests__/utils/grab-origin.test.ts
@@ -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: 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");
+ });
+});
diff --git a/src/__tests__/utils/grab-page-name.test.ts b/src/__tests__/utils/grab-page-name.test.ts
new file mode 100644
index 0000000..d9712f4
--- /dev/null
+++ b/src/__tests__/utils/grab-page-name.test.ts
@@ -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");
+ });
+});
diff --git a/src/__tests__/utils/is-development.test.ts b/src/__tests__/utils/is-development.test.ts
new file mode 100644
index 0000000..3591982
--- /dev/null
+++ b/src/__tests__/utils/is-development.test.ts
@@ -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);
+ });
+});
diff --git a/src/__tests__/utils/numberfy.test.ts b/src/__tests__/utils/numberfy.test.ts
new file mode 100644
index 0000000..73ce357
--- /dev/null
+++ b/src/__tests__/utils/numberfy.test.ts
@@ -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);
+ });
+});
diff --git a/src/commands/build/index.ts b/src/commands/build/index.ts
index 4456027..b1daccb 100644
--- a/src/commands/build/index.ts
+++ b/src/commands/build/index.ts
@@ -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({
diff --git a/src/functions/bunext-init.ts b/src/functions/bunext-init.ts
index 820e82a..915051e 100644
--- a/src/functions/bunext-init.ts
+++ b/src/functions/bunext-init.ts
@@ -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();
diff --git a/src/functions/init.ts b/src/functions/init.ts
index 0f12b4e..4e5c70d 100644
--- a/src/functions/init.ts
+++ b/src/functions/init.ts
@@ -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) {}
diff --git a/src/functions/server/bunext-req-handler.ts b/src/functions/server/bunext-req-handler.ts
index c1acaf0..5272d44 100644
--- a/src/functions/server/bunext-req-handler.ts
+++ b/src/functions/server/bunext-req-handler.ts
@@ -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 {
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"]}`) {
diff --git a/src/functions/server/watcher.ts b/src/functions/server/watcher.ts
new file mode 100644
index 0000000..046f7fe
--- /dev/null
+++ b/src/functions/server/watcher.ts
@@ -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();
+ }
+}
diff --git a/src/functions/server/watcher.tsx b/src/functions/server/watcher.tsx
deleted file mode 100644
index 7a9b8a3..0000000
--- a/src/functions/server/watcher.tsx
+++ /dev/null
@@ -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;
-}
diff --git a/src/functions/server/web-pages/grab-web-page-hydration-script.tsx b/src/functions/server/web-pages/grab-web-page-hydration-script.tsx
index 7930502..7b1dbef 100644
--- a/src/functions/server/web-pages/grab-web-page-hydration-script.tsx
+++ b/src/functions/server/web-pages/grab-web-page-hydration-script.tsx
@@ -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`;
diff --git a/src/types/index.ts b/src/types/index.ts
index 54f9a08..5a1f9ef 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -50,7 +50,11 @@ export type BunextConfig = {
development?: boolean;
middleware?: (
params: BunextConfigMiddlewareParams,
- ) => Promise | Response | undefined;
+ ) =>
+ | Promise
+ | Response
+ | Request
+ | undefined;
defaultCacheExpiry?: number;
websocket?: WebSocketHandler;
serverOptions?: ServeOptions;