Security fixes pass #2

This commit is contained in:
Benjamin Toby 2026-04-19 16:00:59 +01:00
parent 3b26292124
commit b702e26bf6
40 changed files with 305 additions and 93 deletions

View File

@ -1,6 +1,9 @@
import { Command } from "commander"; import { Command } from "commander";
import path from "path"; import path from "path";
import writeErrorFile from "../../functions/write-error-file"; import writeErrorFile from "../../functions/write-error-file";
let retries = 0;
let timeout;
const MAX_RETRIES = 5;
export default function () { export default function () {
return new Command("start") return new Command("start")
.description("Start production server") .description("Start production server")
@ -9,6 +12,11 @@ export default function () {
}); });
} }
async function start() { async function start() {
clearTimeout(timeout);
if (retries >= MAX_RETRIES) {
console.error(`Production server crashed ${MAX_RETRIES} times. Exiting.`);
process.exit(1);
}
const dev_spawn_file = path.resolve(__dirname, "prod-spawn.ts"); const dev_spawn_file = path.resolve(__dirname, "prod-spawn.ts");
const spawn_options = { const spawn_options = {
cmd: ["bun", dev_spawn_file], cmd: ["bun", dev_spawn_file],
@ -22,6 +30,10 @@ async function start() {
}, },
}; };
let dev_process = Bun.spawn(spawn_options); let dev_process = Bun.spawn(spawn_options);
retries++;
timeout = setTimeout(() => {
retries = 0;
}, 10000);
const exited = await dev_process.exited; const exited = await dev_process.exited;
if (exited) { if (exited) {
return await start(); return await start();

View File

@ -4,7 +4,7 @@ export default async function buildOnstartErrorHandler(params) {
global.BUNDLER_CTX_DISPOSED = true; global.BUNDLER_CTX_DISPOSED = true;
global.RECOMPILING = false; global.RECOMPILING = false;
global.IS_SERVER_COMPONENT = false; global.IS_SERVER_COMPONENT = false;
Promise.all([ await Promise.all([
global.SSR_BUNDLER_CTX?.dispose(), global.SSR_BUNDLER_CTX?.dispose(),
global.BUNDLER_CTX?.dispose(), global.BUNDLER_CTX?.dispose(),
]); ]);

View File

@ -1,6 +1,6 @@
import type { BundlerCTXMap, BunextConfig, GlobalHMRControllerObject, PageFiles } from "../types"; import type { BundlerCTXMap, BunextConfig, GlobalHMRControllerObject, PageFiles } from "../types";
import type { FileSystemRouter, Server } from "bun"; import type { FileSystemRouter, Server } from "bun";
import grabDirNames from "../utils/grab-dir-names"; import { type DirNames } from "../utils/grab-dir-names";
import { type FSWatcher } from "fs"; import { type FSWatcher } from "fs";
import type { BuildContext } from "esbuild"; import type { BuildContext } from "esbuild";
import grabConstants from "../utils/grab-constants"; import grabConstants from "../utils/grab-constants";
@ -31,7 +31,7 @@ declare global {
var SKIPPED_BROWSER_MODULES: Set<string>; var SKIPPED_BROWSER_MODULES: Set<string>;
var BUNDLER_CTX: BuildContext | undefined; var BUNDLER_CTX: BuildContext | undefined;
var SSR_BUNDLER_CTX: BuildContext | undefined; var SSR_BUNDLER_CTX: BuildContext | undefined;
var DIR_NAMES: ReturnType<typeof grabDirNames>; var DIR_NAMES: DirNames;
var REACT_IMPORTS_MAP: { var REACT_IMPORTS_MAP: {
imports: Record<string, string>; imports: Record<string, string>;
}; };

View File

@ -1,4 +1,4 @@
import grabDirNames from "../utils/grab-dir-names"; import grabDirNames, {} from "../utils/grab-dir-names";
import {} from "fs"; import {} from "fs";
import init from "./init"; import init from "./init";
import isDevelopment from "../utils/is-development"; import isDevelopment from "../utils/is-development";

View File

@ -13,6 +13,10 @@ export default async function trimAllCache() {
const trim_key = await trimCacheKey({ const trim_key = await trimCacheKey({
key: cache_key, key: cache_key,
}); });
if (trim_key.success) {
cached_items.splice(i, 1);
i--;
}
} }
} }
catch (error) { catch (error) {

View File

@ -8,6 +8,8 @@ import handleBunextPublicAssets from "./handle-bunext-public-assets";
import checkExcludedPatterns from "../../utils/check-excluded-patterns"; import checkExcludedPatterns from "../../utils/check-excluded-patterns";
import { AppData } from "../../data/app-data"; import { AppData } from "../../data/app-data";
import fullRebuild from "./full-rebuild"; import fullRebuild from "./full-rebuild";
const HMR_RETRY_COOLDOWN_MS = 5000;
let lastHmrRetryTime = 0;
export default async function bunextRequestHandler({ req: initial_req, server, }) { export default async function bunextRequestHandler({ req: initial_req, server, }) {
const is_dev = isDevelopment(); const is_dev = isDevelopment();
let req = initial_req.clone(); let req = initial_req.clone();
@ -30,6 +32,11 @@ export default async function bunextRequestHandler({ req: initial_req, server, }
} }
} }
if (is_dev && url.pathname == AppData["BunextHMRRetryRoute"]) { if (is_dev && url.pathname == AppData["BunextHMRRetryRoute"]) {
const now = Date.now();
if (now - lastHmrRetryTime < HMR_RETRY_COOLDOWN_MS) {
return new Response("Too Many Requests", { status: 429 });
}
lastHmrRetryTime = now;
await fullRebuild({ msg: `HMR Retry Rebuild ...` }); await fullRebuild({ msg: `HMR Retry Rebuild ...` });
return new Response("Modules Rebuilt"); return new Response("Modules Rebuilt");
} }
@ -60,8 +67,12 @@ export default async function bunextRequestHandler({ req: initial_req, server, }
return response; return response;
} }
catch (error) { catch (error) {
if (is_dev) {
return new Response(`Server Error: ${error.message}`, { return new Response(`Server Error: ${error.message}`, {
status: 500, status: 500,
}); });
} }
console.error(`Server Error: ${error.message}`, error);
return new Response("Internal Server Error", { status: 500 });
}
} }

View File

@ -2,13 +2,14 @@ import grabDirNames from "../../utils/grab-dir-names";
import path from "path"; import path from "path";
import isDevelopment from "../../utils/is-development"; import isDevelopment from "../../utils/is-development";
import { readFileResponse } from "./handle-public"; import { readFileResponse } from "./handle-public";
import isSafePath from "../../utils/is-safe-path";
const { BUNEXT_PUBLIC_DIR } = grabDirNames(); const { BUNEXT_PUBLIC_DIR } = grabDirNames();
export default async function ({ req }) { export default async function ({ req }) {
try { try {
const is_dev = isDevelopment(); const is_dev = isDevelopment();
const url = new URL(req.url); const url = new URL(req.url);
const file_path = path.join(BUNEXT_PUBLIC_DIR, url.pathname.replace(/\/\.bunext\/public\//, "")); const file_path = path.join(BUNEXT_PUBLIC_DIR, url.pathname.replace(/\/\.bunext\/public\//, ""));
if (!file_path.startsWith(BUNEXT_PUBLIC_DIR + path.sep)) { if (!isSafePath({ filePath: file_path, allowedDir: BUNEXT_PUBLIC_DIR })) {
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
return readFileResponse({ return readFileResponse({

View File

@ -2,13 +2,14 @@ import grabDirNames from "../../utils/grab-dir-names";
import path from "path"; import path from "path";
import isDevelopment from "../../utils/is-development"; import isDevelopment from "../../utils/is-development";
import { existsSync } from "fs"; import { existsSync } from "fs";
import isSafePath from "../../utils/is-safe-path";
const { PUBLIC_DIR } = grabDirNames(); const { PUBLIC_DIR } = grabDirNames();
export default async function ({ req }) { export default async function ({ req }) {
try { try {
const is_dev = isDevelopment(); const is_dev = isDevelopment();
const url = new URL(req.url); const url = new URL(req.url);
const file_path = path.join(PUBLIC_DIR, url.pathname); const file_path = path.join(PUBLIC_DIR, url.pathname);
if (!file_path.startsWith(PUBLIC_DIR + path.sep)) { if (!isSafePath({ filePath: file_path, allowedDir: PUBLIC_DIR })) {
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
if (!existsSync(file_path)) { if (!existsSync(file_path)) {
@ -17,7 +18,11 @@ export default async function ({ req }) {
}); });
} }
const file = Bun.file(file_path); const file = Bun.file(file_path);
return new Response(file); const headers = new Headers();
if (!is_dev) {
headers.set("Cache-Control", "public, max-age=3600");
}
return new Response(file, { headers });
} }
catch (error) { catch (error) {
return new Response(`File Not Found`, { return new Response(`File Not Found`, {

View File

@ -1,5 +1,21 @@
function removeController(controller) {
const idx = global.HMR_CONTROLLERS.findIndex((c) => c.controller == controller);
if (typeof idx == "number" && idx >= 0) {
global.HMR_CONTROLLERS.splice(idx, 1);
}
}
export default async function ({ req }) { export default async function ({ req }) {
const referer_url = new URL(req.headers.get("referer") || ""); const referer = req.headers.get("referer");
if (!referer) {
return new Response("Missing Referer Header", { status: 400 });
}
let referer_url;
try {
referer_url = new URL(referer);
}
catch {
return new Response("Invalid Referer Header", { status: 400 });
}
const match = global.ROUTER.match(referer_url.pathname); const match = global.ROUTER.match(referer_url.pathname);
const target_map = match?.filePath const target_map = match?.filePath
? global.BUNDLER_CTX_MAP?.[match.filePath] ? global.BUNDLER_CTX_MAP?.[match.filePath]
@ -20,16 +36,13 @@ export default async function ({ req }) {
} }
catch { catch {
clearInterval(heartbeat); clearInterval(heartbeat);
removeController(controller);
} }
}, 5000); }, 5000);
}, },
cancel() { cancel() {
clearInterval(heartbeat); clearInterval(heartbeat);
const targetControllerIndex = global.HMR_CONTROLLERS.findIndex((c) => c.controller == controller); removeController(controller);
if (typeof targetControllerIndex == "number" &&
targetControllerIndex >= 0) {
global.HMR_CONTROLLERS.splice(targetControllerIndex, 1);
}
}, },
}); });
return new Response(stream, { return new Response(stream, {

View File

@ -2,13 +2,14 @@ import grabDirNames from "../../utils/grab-dir-names";
import path from "path"; import path from "path";
import isDevelopment from "../../utils/is-development"; import isDevelopment from "../../utils/is-development";
import { existsSync } from "fs"; import { existsSync } from "fs";
import isSafePath from "../../utils/is-safe-path";
const { PUBLIC_DIR } = grabDirNames(); const { PUBLIC_DIR } = grabDirNames();
export default async function ({ req }) { export default async function ({ req }) {
try { try {
const is_dev = isDevelopment(); const is_dev = isDevelopment();
const url = new URL(req.url); const url = new URL(req.url);
const file_path = path.join(PUBLIC_DIR, url.pathname.replace(/^\/public/, "")); const file_path = path.join(PUBLIC_DIR, url.pathname.replace(/^\/public/, ""));
if (!file_path.startsWith(PUBLIC_DIR + path.sep)) { if (!isSafePath({ filePath: file_path, allowedDir: PUBLIC_DIR })) {
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
return readFileResponse({ file_path }); return readFileResponse({ file_path });
@ -33,6 +34,9 @@ export function readFileResponse({ file_path, cache }) {
else if (cache?.duration) { else if (cache?.duration) {
headers.set("Cache-Control", `public, max-age=${cache.duration}`); headers.set("Cache-Control", `public, max-age=${cache.duration}`);
} }
else if (!isDevelopment()) {
headers.set("Cache-Control", "public, max-age=3600");
}
return new Response(file, { return new Response(file, {
headers, headers,
}); });

View File

@ -41,12 +41,13 @@ export default async function ({ req }) {
module = await import(import_path); module = await import(import_path);
} }
const config = module.config; const config = module.config;
const maxBodyBytes = config?.max_request_body_mb
? config.max_request_body_mb * MBInBytes
: ServerDefaultRequestBodyLimitBytes;
const contentLength = req.headers.get("content-length"); const contentLength = req.headers.get("content-length");
if (contentLength) { if (contentLength) {
const size = parseInt(contentLength, 10); const size = parseInt(contentLength, 10);
if ((config?.max_request_body_mb && if (size > maxBodyBytes) {
size > config.max_request_body_mb * MBInBytes) ||
size > ServerDefaultRequestBodyLimitBytes) {
return Response.json({ return Response.json({
success: false, success: false,
msg: "Request Body Too Large!", msg: "Request Body Too Large!",
@ -58,6 +59,21 @@ export default async function ({ req }) {
}); });
} }
} }
else if (req.method !== "GET" && req.method !== "HEAD") {
const body = await req.arrayBuffer();
if (body.byteLength > maxBodyBytes) {
return Response.json({
success: false,
msg: "Request Body Too Large!",
}, {
status: 413,
headers: {
"Content-Type": "application/json",
},
});
}
routeParams.body = JSON.parse(new TextDecoder().decode(body) || "{}");
}
const target_module = (module["default"] || const target_module = (module["default"] ||
module["handler"]); module["handler"]);
const res = await target_module?.({ const res = await target_module?.({

View File

@ -30,8 +30,8 @@ export default async function serverPostBuildFn(params) {
controller.controller.enqueue(reload_enqueue); controller.controller.enqueue(reload_enqueue);
continue; continue;
} }
const mock_req = target_artifact.req const mock_req = target_artifact.req_url
? target_artifact.req.clone() ? new Request(target_artifact.req_url)
: new Request(controller.page_url); : new Request(controller.page_url);
const page_component = global.IS_SERVER_COMPONENT const page_component = global.IS_SERVER_COMPONENT
? await grabPageComponent({ ? await grabPageComponent({

View File

@ -76,6 +76,8 @@ export default async function genWebHTML({ component: Main, pageProps, bundledMa
console.error = () => { }; console.error = () => { };
console.info = () => { }; console.info = () => { };
console.debug = () => { }; console.debug = () => { };
let htmlBody;
try {
const stream = await renderToReadableStream(final_component, { const stream = await renderToReadableStream(final_component, {
onError(error) { onError(error) {
if (error.message.includes('unique "key" prop')) if (error.message.includes('unique "key" prop'))
@ -83,8 +85,11 @@ export default async function genWebHTML({ component: Main, pageProps, bundledMa
originalConsole.error(error); originalConsole.error(error);
}, },
}); });
const htmlBody = await new Response(stream).text(); htmlBody = await new Response(stream).text();
}
finally {
Object.assign(console, originalConsole); Object.assign(console, originalConsole);
}
html += htmlBody; html += htmlBody;
return html; return html;
} }

View File

@ -33,7 +33,7 @@ export default async function grabPageCombinedServerRes({ file_path, debug, url,
const page_server_ctx = global.SSR_BUNDLER_CTX_MAP[server_file_path || ""]; const page_server_ctx = global.SSR_BUNDLER_CTX_MAP[server_file_path || ""];
const final_page_server_path = page_server_ctx?.local_path const final_page_server_path = page_server_ctx?.local_path
? path.join(ROOT_DIR, page_server_ctx.path) ? path.join(ROOT_DIR, page_server_ctx.path)
: root_server_file_path; : server_file_path;
const server_module = final_page_server_path const server_module = final_page_server_path
? await import(`${final_page_server_path}?t=${now}`) ? await import(`${final_page_server_path}?t=${now}`)
: undefined; : undefined;

View File

@ -72,7 +72,7 @@ export default async function grabPageComponent(params) {
} }
} }
if (req && !is_hydration) { if (req && !is_hydration) {
global.BUNDLER_CTX_MAP[file_path].req = req; global.BUNDLER_CTX_MAP[file_path].req_url = req.url;
} }
if (debug) { if (debug) {
log.info(`bundledMap:`, bundledMap); log.info(`bundledMap:`, bundledMap);

View File

@ -306,7 +306,7 @@ export type BundlerCTXMap = {
url_path: string; url_path: string;
file_name: string; file_name: string;
css_path?: string; css_path?: string;
req?: Request; req_url?: string;
}; };
export type GlobalHMRControllerObject = { export type GlobalHMRControllerObject = {
controller: ReadableStreamDefaultController<string>; controller: ReadableStreamDefaultController<string>;

View File

@ -1,6 +1,3 @@
/**
* # Convert Serialized Query back to object
*/
export default function deserializeQuery(query: string | { export default function deserializeQuery(query: string | {
[s: string]: any; [s: string]: any;
}): { }): {

View File

@ -1,18 +1,33 @@
import EJSON from "./ejson"; import EJSON from "./ejson";
/** const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
* # Convert Serialized Query back to object function sanitize(value) {
*/ if (value === null || typeof value !== "object")
return value;
if (Array.isArray(value))
return value.map(sanitize);
const clean = Object.create(null);
for (const key of Object.keys(value)) {
if (DANGEROUS_KEYS.has(key))
continue;
clean[key] = sanitize(value[key]);
}
return clean;
}
export default function deserializeQuery(query) { export default function deserializeQuery(query) {
let queryObject = typeof query == "object" ? query : Object(EJSON.parse(query)); let queryObject = typeof query == "object" ? query : Object(EJSON.parse(query));
const keys = Object.keys(queryObject); const keys = Object.keys(queryObject);
for (let i = 0; i < keys.length; i++) { for (let i = 0; i < keys.length; i++) {
const key = keys[i]; const key = keys[i];
const value = queryObject[key]; const value = queryObject[key];
if (DANGEROUS_KEYS.has(key)) {
delete queryObject[key];
continue;
}
if (typeof value == "string") { if (typeof value == "string") {
if (value.match(/^\{|^\[/)) { if (value.match(/^\{|^\[/)) {
queryObject[key] = EJSON.parse(value); queryObject[key] = sanitize(EJSON.parse(value));
} }
} }
} }
return queryObject; return sanitize(queryObject);
} }

View File

@ -1,4 +1,4 @@
export default function grabDirNames(): { export type DirNames = {
ROOT_DIR: string; ROOT_DIR: string;
SRC_DIR: string; SRC_DIR: string;
PAGES_DIR: string; PAGES_DIR: string;
@ -27,3 +27,4 @@ export default function grabDirNames(): {
BUNX_ERROR_LOGS_DIR: string; BUNX_ERROR_LOGS_DIR: string;
BUNX_LOGS_DIR: string; BUNX_LOGS_DIR: string;
}; };
export default function grabDirNames(): DirNames;

View File

@ -1,5 +1,7 @@
import path from "path"; import path from "path";
export default function grabDirNames() { export default function grabDirNames() {
if (global.DIR_NAMES)
return global.DIR_NAMES;
const ROOT_DIR = process.cwd(); const ROOT_DIR = process.cwd();
const SRC_DIR = path.join(ROOT_DIR, "src"); const SRC_DIR = path.join(ROOT_DIR, "src");
const PAGES_DIR = path.join(SRC_DIR, "pages"); const PAGES_DIR = path.join(SRC_DIR, "pages");

View File

@ -1,10 +1,6 @@
export default function isDevelopment() { export default function isDevelopment() {
const config = global.CONFIG; if (process.env.NODE_ENV === "production") {
if (process.env.NODE_ENV == "production") {
return false; return false;
} }
if (config.development) { return Boolean(global.CONFIG?.development);
return true;
}
return false;
} }

4
dist/utils/is-safe-path.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
export default function isSafePath({ filePath, allowedDir, }: {
filePath: string;
allowedDir: string;
}): boolean;

15
dist/utils/is-safe-path.js vendored Normal file
View File

@ -0,0 +1,15 @@
import { realpathSync } from "fs";
import path from "path";
export default function isSafePath({ filePath, allowedDir, }) {
const resolved = path.resolve(filePath);
if (!resolved.startsWith(allowedDir + path.sep) && resolved !== allowedDir) {
return false;
}
try {
const real = realpathSync(resolved);
return (real.startsWith(allowedDir + path.sep) || real === allowedDir);
}
catch {
return false;
}
}

View File

@ -3,6 +3,10 @@ import path from "path";
import type { BunSpawnOptions } from "../../types"; import type { BunSpawnOptions } from "../../types";
import writeErrorFile from "../../functions/write-error-file"; import writeErrorFile from "../../functions/write-error-file";
let retries = 0;
let timeout: any;
const MAX_RETRIES = 5;
export default function () { export default function () {
return new Command("start") return new Command("start")
.description("Start production server") .description("Start production server")
@ -12,6 +16,13 @@ export default function () {
} }
async function start() { async function start() {
clearTimeout(timeout);
if (retries >= MAX_RETRIES) {
console.error(`Production server crashed ${MAX_RETRIES} times. Exiting.`);
process.exit(1);
}
const dev_spawn_file = path.resolve(__dirname, "prod-spawn.ts"); const dev_spawn_file = path.resolve(__dirname, "prod-spawn.ts");
const spawn_options: BunSpawnOptions = { const spawn_options: BunSpawnOptions = {
@ -28,6 +39,12 @@ async function start() {
let dev_process = Bun.spawn(spawn_options); let dev_process = Bun.spawn(spawn_options);
retries++;
timeout = setTimeout(() => {
retries = 0;
}, 10000);
const exited = await dev_process.exited; const exited = await dev_process.exited;
if (exited) { if (exited) {

View File

@ -9,7 +9,7 @@ export default async function buildOnstartErrorHandler(params?: Params) {
global.RECOMPILING = false; global.RECOMPILING = false;
global.IS_SERVER_COMPONENT = false; global.IS_SERVER_COMPONENT = false;
Promise.all([ await Promise.all([
global.SSR_BUNDLER_CTX?.dispose(), global.SSR_BUNDLER_CTX?.dispose(),
global.BUNDLER_CTX?.dispose(), global.BUNDLER_CTX?.dispose(),
]); ]);

View File

@ -5,7 +5,7 @@ import type {
PageFiles, PageFiles,
} from "../types"; } from "../types";
import type { FileSystemRouter, Server } from "bun"; import type { FileSystemRouter, Server } from "bun";
import grabDirNames from "../utils/grab-dir-names"; import grabDirNames, { type DirNames } from "../utils/grab-dir-names";
import { type FSWatcher } from "fs"; import { type FSWatcher } from "fs";
import init from "./init"; import init from "./init";
import isDevelopment from "../utils/is-development"; import isDevelopment from "../utils/is-development";
@ -43,7 +43,7 @@ declare global {
var BUNDLER_CTX: BuildContext | undefined; var BUNDLER_CTX: BuildContext | undefined;
var SSR_BUNDLER_CTX: BuildContext | undefined; var SSR_BUNDLER_CTX: BuildContext | undefined;
// var API_ROUTES_BUNDLER_CTX: BuildContext | undefined; // var API_ROUTES_BUNDLER_CTX: BuildContext | undefined;
var DIR_NAMES: ReturnType<typeof grabDirNames>; var DIR_NAMES: DirNames;
var REACT_IMPORTS_MAP: { imports: Record<string, string> }; var REACT_IMPORTS_MAP: { imports: Record<string, string> };
var REACT_DOM_SERVER: any; var REACT_DOM_SERVER: any;
var REACT_DOM_MODULE_CACHE: Map<string, { main: any; css: string }>; var REACT_DOM_MODULE_CACHE: Map<string, { main: any; css: string }>;

View File

@ -17,6 +17,11 @@ export default async function trimAllCache() {
const trim_key = await trimCacheKey({ const trim_key = await trimCacheKey({
key: cache_key, key: cache_key,
}); });
if (trim_key.success) {
cached_items.splice(i, 1);
i--;
}
} }
} catch (error) { } catch (error) {
return undefined; return undefined;

View File

@ -8,6 +8,10 @@ import handleBunextPublicAssets from "./handle-bunext-public-assets";
import checkExcludedPatterns from "../../utils/check-excluded-patterns"; import checkExcludedPatterns from "../../utils/check-excluded-patterns";
import { AppData } from "../../data/app-data"; import { AppData } from "../../data/app-data";
import fullRebuild from "./full-rebuild"; import fullRebuild from "./full-rebuild";
const HMR_RETRY_COOLDOWN_MS = 5000;
let lastHmrRetryTime = 0;
type Params = { type Params = {
req: Request; req: Request;
server: Bun.Server<any>; server: Bun.Server<any>;
@ -45,6 +49,11 @@ export default async function bunextRequestHandler({
} }
if (is_dev && url.pathname == AppData["BunextHMRRetryRoute"]) { if (is_dev && url.pathname == AppData["BunextHMRRetryRoute"]) {
const now = Date.now();
if (now - lastHmrRetryTime < HMR_RETRY_COOLDOWN_MS) {
return new Response("Too Many Requests", { status: 429 });
}
lastHmrRetryTime = now;
await fullRebuild({ msg: `HMR Retry Rebuild ...` }); await fullRebuild({ msg: `HMR Retry Rebuild ...` });
return new Response("Modules Rebuilt"); return new Response("Modules Rebuilt");
} }
@ -76,8 +85,12 @@ export default async function bunextRequestHandler({
return response; return response;
} catch (error: any) { } catch (error: any) {
if (is_dev) {
return new Response(`Server Error: ${error.message}`, { return new Response(`Server Error: ${error.message}`, {
status: 500, status: 500,
}); });
} }
console.error(`Server Error: ${error.message}`, error);
return new Response("Internal Server Error", { status: 500 });
}
} }

View File

@ -2,6 +2,7 @@ import grabDirNames from "../../utils/grab-dir-names";
import path from "path"; import path from "path";
import isDevelopment from "../../utils/is-development"; import isDevelopment from "../../utils/is-development";
import { readFileResponse } from "./handle-public"; import { readFileResponse } from "./handle-public";
import isSafePath from "../../utils/is-safe-path";
const { BUNEXT_PUBLIC_DIR } = grabDirNames(); const { BUNEXT_PUBLIC_DIR } = grabDirNames();
@ -19,7 +20,7 @@ export default async function ({ req }: Params): Promise<Response> {
url.pathname.replace(/\/\.bunext\/public\//, ""), url.pathname.replace(/\/\.bunext\/public\//, ""),
); );
if (!file_path.startsWith(BUNEXT_PUBLIC_DIR + path.sep)) { if (!isSafePath({ filePath: file_path, allowedDir: BUNEXT_PUBLIC_DIR })) {
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }

View File

@ -2,6 +2,7 @@ import grabDirNames from "../../utils/grab-dir-names";
import path from "path"; import path from "path";
import isDevelopment from "../../utils/is-development"; import isDevelopment from "../../utils/is-development";
import { existsSync } from "fs"; import { existsSync } from "fs";
import isSafePath from "../../utils/is-safe-path";
const { PUBLIC_DIR } = grabDirNames(); const { PUBLIC_DIR } = grabDirNames();
@ -15,7 +16,7 @@ export default async function ({ req }: Params): Promise<Response> {
const url = new URL(req.url); const url = new URL(req.url);
const file_path = path.join(PUBLIC_DIR, url.pathname); const file_path = path.join(PUBLIC_DIR, url.pathname);
if (!file_path.startsWith(PUBLIC_DIR + path.sep)) { if (!isSafePath({ filePath: file_path, allowedDir: PUBLIC_DIR })) {
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
@ -26,7 +27,13 @@ export default async function ({ req }: Params): Promise<Response> {
} }
const file = Bun.file(file_path); const file = Bun.file(file_path);
return new Response(file); const headers = new Headers();
if (!is_dev) {
headers.set("Cache-Control", "public, max-age=3600");
}
return new Response(file, { headers });
} catch (error) { } catch (error) {
return new Response(`File Not Found`, { return new Response(`File Not Found`, {
status: 404, status: 404,

View File

@ -2,8 +2,28 @@ type Params = {
req: Request; req: Request;
}; };
function removeController(controller: ReadableStreamDefaultController<string>) {
const idx = global.HMR_CONTROLLERS.findIndex(
(c) => c.controller == controller,
);
if (typeof idx == "number" && idx >= 0) {
global.HMR_CONTROLLERS.splice(idx, 1);
}
}
export default async function ({ req }: Params): Promise<Response> { export default async function ({ req }: Params): Promise<Response> {
const referer_url = new URL(req.headers.get("referer") || ""); const referer = req.headers.get("referer");
if (!referer) {
return new Response("Missing Referer Header", { status: 400 });
}
let referer_url: URL;
try {
referer_url = new URL(referer);
} catch {
return new Response("Invalid Referer Header", { status: 400 });
}
const match = global.ROUTER.match(referer_url.pathname); const match = global.ROUTER.match(referer_url.pathname);
const target_map = match?.filePath const target_map = match?.filePath
@ -25,21 +45,13 @@ export default async function ({ req }: Params): Promise<Response> {
c.enqueue(": keep-alive\n\n"); c.enqueue(": keep-alive\n\n");
} catch { } catch {
clearInterval(heartbeat); clearInterval(heartbeat);
removeController(controller);
} }
}, 5000); }, 5000);
}, },
cancel() { cancel() {
clearInterval(heartbeat); clearInterval(heartbeat);
const targetControllerIndex = global.HMR_CONTROLLERS.findIndex( removeController(controller);
(c) => c.controller == controller,
);
if (
typeof targetControllerIndex == "number" &&
targetControllerIndex >= 0
) {
global.HMR_CONTROLLERS.splice(targetControllerIndex, 1);
}
}, },
}); });

View File

@ -2,6 +2,7 @@ import grabDirNames from "../../utils/grab-dir-names";
import path from "path"; import path from "path";
import isDevelopment from "../../utils/is-development"; import isDevelopment from "../../utils/is-development";
import { existsSync } from "fs"; import { existsSync } from "fs";
import isSafePath from "../../utils/is-safe-path";
const { PUBLIC_DIR } = grabDirNames(); const { PUBLIC_DIR } = grabDirNames();
@ -19,7 +20,7 @@ export default async function ({ req }: Params): Promise<Response> {
url.pathname.replace(/^\/public/, ""), url.pathname.replace(/^\/public/, ""),
); );
if (!file_path.startsWith(PUBLIC_DIR + path.sep)) { if (!isSafePath({ filePath: file_path, allowedDir: PUBLIC_DIR })) {
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
@ -53,6 +54,8 @@ export function readFileResponse({ file_path, cache }: FileResponse) {
headers.set("Cache-Control", "public, max-age=31536000, immutable"); headers.set("Cache-Control", "public, max-age=31536000, immutable");
} else if (cache?.duration) { } else if (cache?.duration) {
headers.set("Cache-Control", `public, max-age=${cache.duration}`); headers.set("Cache-Control", `public, max-age=${cache.duration}`);
} else if (!isDevelopment()) {
headers.set("Cache-Control", "public, max-age=3600");
} }
return new Response(file, { return new Response(file, {

View File

@ -45,8 +45,8 @@ export default async function serverPostBuildFn(params?: Params) {
continue; continue;
} }
const mock_req = target_artifact.req const mock_req = target_artifact.req_url
? target_artifact.req.clone() ? new Request(target_artifact.req_url)
: new Request(controller.page_url); : new Request(controller.page_url);
const page_component = global.IS_SERVER_COMPONENT const page_component = global.IS_SERVER_COMPONENT

View File

@ -178,6 +178,8 @@ export default async function genWebHTML({
console.info = () => {}; console.info = () => {};
console.debug = () => {}; console.debug = () => {};
let htmlBody: string;
try {
const stream = await renderToReadableStream(final_component, { const stream = await renderToReadableStream(final_component, {
onError(error: any) { onError(error: any) {
if (error.message.includes('unique "key" prop')) return; if (error.message.includes('unique "key" prop')) return;
@ -185,9 +187,10 @@ export default async function genWebHTML({
}, },
}); });
const htmlBody = await new Response(stream).text(); htmlBody = await new Response(stream).text();
} finally {
Object.assign(console, originalConsole); Object.assign(console, originalConsole);
}
html += htmlBody; html += htmlBody;

View File

@ -63,7 +63,7 @@ export default async function grabPageCombinedServerRes({
const page_server_ctx = global.SSR_BUNDLER_CTX_MAP[server_file_path || ""]; const page_server_ctx = global.SSR_BUNDLER_CTX_MAP[server_file_path || ""];
const final_page_server_path = page_server_ctx?.local_path const final_page_server_path = page_server_ctx?.local_path
? path.join(ROOT_DIR, page_server_ctx.path) ? path.join(ROOT_DIR, page_server_ctx.path)
: root_server_file_path; : server_file_path;
const server_module: BunextPageServerModule = final_page_server_path const server_module: BunextPageServerModule = final_page_server_path
? await import(`${final_page_server_path}?t=${now}`) ? await import(`${final_page_server_path}?t=${now}`)

View File

@ -117,7 +117,7 @@ export default async function grabPageComponent(
} }
if (req && !is_hydration) { if (req && !is_hydration) {
global.BUNDLER_CTX_MAP[file_path].req = req; global.BUNDLER_CTX_MAP[file_path].req_url = req.url;
} }
if (debug) { if (debug) {

View File

@ -344,7 +344,7 @@ export type BundlerCTXMap = {
url_path: string; url_path: string;
file_name: string; file_name: string;
css_path?: string; css_path?: string;
req?: Request; req_url?: string;
}; };
export type GlobalHMRControllerObject = { export type GlobalHMRControllerObject = {

View File

@ -1,6 +1,38 @@
import path from "path"; import path from "path";
export default function grabDirNames() { export type DirNames = {
ROOT_DIR: string;
SRC_DIR: string;
PAGES_DIR: string;
API_DIR: string;
PUBLIC_DIR: string;
HYDRATION_DST_DIR: string;
BUNX_CWD_DIR: string;
BUNX_ROOT_DIR: string;
CONFIG_FILE: string;
BUNX_TMP_DIR: string;
BUNX_HYDRATION_SRC_DIR: string;
BUNX_ROOT_SRC_DIR: string;
BUNX_ROOT_PRESETS_DIR: string;
BUNX_ROOT_500_PRESET_COMPONENT: string;
BUNX_ROOT_500_FILE_NAME: string;
BUNX_ROOT_404_PRESET_COMPONENT: string;
BUNX_ROOT_404_FILE_NAME: string;
HYDRATION_DST_DIR_MAP_JSON_FILE: string;
BUNEXT_CACHE_DIR: string;
BUNX_CWD_MODULE_CACHE_DIR: string;
BUNX_CWD_PAGES_REWRITE_DIR: string;
HYDRATION_DST_DIR_MAP_JSON_FILE_NAME: string;
BUNEXT_VENDOR_DIR: string;
BUNEXT_PUBLIC_DIR: string;
BUNX_BUNDLER_ERROR_EXIT_FILE: string;
BUNX_ERROR_LOGS_DIR: string;
BUNX_LOGS_DIR: string;
};
export default function grabDirNames(): DirNames {
if (global.DIR_NAMES) return global.DIR_NAMES;
const ROOT_DIR = process.cwd(); const ROOT_DIR = process.cwd();
const SRC_DIR = path.join(ROOT_DIR, "src"); const SRC_DIR = path.join(ROOT_DIR, "src");
const PAGES_DIR = path.join(SRC_DIR, "pages"); const PAGES_DIR = path.join(SRC_DIR, "pages");

View File

@ -1,13 +1,7 @@
export default function isDevelopment() { export default function isDevelopment() {
const config = global.CONFIG; if (process.env.NODE_ENV === "production") {
if (process.env.NODE_ENV == "production") {
return false; return false;
} }
if (config.development) { return Boolean(global.CONFIG?.development);
return true;
}
return false;
} }

24
src/utils/is-safe-path.ts Normal file
View File

@ -0,0 +1,24 @@
import { realpathSync } from "fs";
import path from "path";
export default function isSafePath({
filePath,
allowedDir,
}: {
filePath: string;
allowedDir: string;
}): boolean {
const resolved = path.resolve(filePath);
if (!resolved.startsWith(allowedDir + path.sep) && resolved !== allowedDir) {
return false;
}
try {
const real = realpathSync(resolved);
return (
real.startsWith(allowedDir + path.sep) || real === allowedDir
);
} catch {
return false;
}
}