From cf010ad4f52f35c644c28c1c2f344269a9acc0db Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Sat, 21 Mar 2026 09:03:26 +0100 Subject: [PATCH] Update watcher function. Add tests --- .../cache/grab-cache-names.test.d.ts | 1 + .../functions/cache/grab-cache-names.test.js | 37 ++++++ .../server/grab-web-meta-html.test.d.ts | 1 + .../server/grab-web-meta-html.test.js | 97 +++++++++++++++ .../utils/deserialize-query.test.d.ts | 1 + .../__tests__/utils/deserialize-query.test.js | 33 ++++++ dist/__tests__/utils/ejson.test.d.ts | 1 + dist/__tests__/utils/ejson.test.js | 52 +++++++++ dist/__tests__/utils/grab-app-names.test.d.ts | 1 + dist/__tests__/utils/grab-app-names.test.js | 23 ++++ dist/__tests__/utils/grab-app-port.test.d.ts | 1 + dist/__tests__/utils/grab-app-port.test.js | 38 ++++++ dist/__tests__/utils/grab-constants.test.d.ts | 1 + dist/__tests__/utils/grab-constants.test.js | 30 +++++ dist/__tests__/utils/grab-dir-names.test.d.ts | 1 + dist/__tests__/utils/grab-dir-names.test.js | 55 +++++++++ dist/__tests__/utils/grab-origin.test.d.ts | 1 + dist/__tests__/utils/grab-origin.test.js | 32 +++++ dist/__tests__/utils/grab-page-name.test.d.ts | 1 + dist/__tests__/utils/grab-page-name.test.js | 46 ++++++++ dist/__tests__/utils/is-development.test.d.ts | 1 + dist/__tests__/utils/is-development.test.js | 32 +++++ dist/__tests__/utils/numberfy.test.d.ts | 1 + dist/__tests__/utils/numberfy.test.js | 43 +++++++ dist/commands/build/index.js | 3 + dist/functions/bunext-init.js | 2 +- dist/functions/init.js | 3 +- dist/functions/server/bunext-req-handler.d.ts | 2 +- dist/functions/server/bunext-req-handler.js | 10 +- dist/functions/server/watcher.js | 54 ++++++--- .../grab-web-page-hydration-script.js | 2 +- dist/types/index.d.ts | 2 +- package.json | 2 +- .../functions/cache/grab-cache-names.test.ts | 43 +++++++ .../server/grab-web-meta-html.test.ts | 110 ++++++++++++++++++ src/__tests__/utils/deserialize-query.test.ts | 40 +++++++ src/__tests__/utils/ejson.test.ts | 68 +++++++++++ src/__tests__/utils/grab-app-names.test.ts | 29 +++++ src/__tests__/utils/grab-app-port.test.ts | 45 +++++++ src/__tests__/utils/grab-constants.test.ts | 40 +++++++ src/__tests__/utils/grab-dir-names.test.ts | 75 ++++++++++++ src/__tests__/utils/grab-origin.test.ts | 38 ++++++ src/__tests__/utils/grab-page-name.test.ts | 62 ++++++++++ src/__tests__/utils/is-development.test.ts | 39 +++++++ src/__tests__/utils/numberfy.test.ts | 56 +++++++++ src/commands/build/index.ts | 4 + src/functions/bunext-init.ts | 3 +- src/functions/init.ts | 8 +- src/functions/server/bunext-req-handler.ts | 12 +- src/functions/server/watcher.ts | 78 +++++++++++++ src/functions/server/watcher.tsx | 58 --------- .../grab-web-page-hydration-script.tsx | 2 +- src/types/index.ts | 6 +- 53 files changed, 1329 insertions(+), 97 deletions(-) create mode 100644 dist/__tests__/functions/cache/grab-cache-names.test.d.ts create mode 100644 dist/__tests__/functions/cache/grab-cache-names.test.js create mode 100644 dist/__tests__/functions/server/grab-web-meta-html.test.d.ts create mode 100644 dist/__tests__/functions/server/grab-web-meta-html.test.js create mode 100644 dist/__tests__/utils/deserialize-query.test.d.ts create mode 100644 dist/__tests__/utils/deserialize-query.test.js create mode 100644 dist/__tests__/utils/ejson.test.d.ts create mode 100644 dist/__tests__/utils/ejson.test.js create mode 100644 dist/__tests__/utils/grab-app-names.test.d.ts create mode 100644 dist/__tests__/utils/grab-app-names.test.js create mode 100644 dist/__tests__/utils/grab-app-port.test.d.ts create mode 100644 dist/__tests__/utils/grab-app-port.test.js create mode 100644 dist/__tests__/utils/grab-constants.test.d.ts create mode 100644 dist/__tests__/utils/grab-constants.test.js create mode 100644 dist/__tests__/utils/grab-dir-names.test.d.ts create mode 100644 dist/__tests__/utils/grab-dir-names.test.js create mode 100644 dist/__tests__/utils/grab-origin.test.d.ts create mode 100644 dist/__tests__/utils/grab-origin.test.js create mode 100644 dist/__tests__/utils/grab-page-name.test.d.ts create mode 100644 dist/__tests__/utils/grab-page-name.test.js create mode 100644 dist/__tests__/utils/is-development.test.d.ts create mode 100644 dist/__tests__/utils/is-development.test.js create mode 100644 dist/__tests__/utils/numberfy.test.d.ts create mode 100644 dist/__tests__/utils/numberfy.test.js create mode 100644 src/__tests__/functions/cache/grab-cache-names.test.ts create mode 100644 src/__tests__/functions/server/grab-web-meta-html.test.ts create mode 100644 src/__tests__/utils/deserialize-query.test.ts create mode 100644 src/__tests__/utils/ejson.test.ts create mode 100644 src/__tests__/utils/grab-app-names.test.ts create mode 100644 src/__tests__/utils/grab-app-port.test.ts create mode 100644 src/__tests__/utils/grab-constants.test.ts create mode 100644 src/__tests__/utils/grab-dir-names.test.ts create mode 100644 src/__tests__/utils/grab-origin.test.ts create mode 100644 src/__tests__/utils/grab-page-name.test.ts create mode 100644 src/__tests__/utils/is-development.test.ts create mode 100644 src/__tests__/utils/numberfy.test.ts create mode 100644 src/functions/server/watcher.ts delete mode 100644 src/functions/server/watcher.tsx 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;