Error handling bugfix: pass #1

This commit is contained in:
2026-10-02 07:04:22 +01:00
parent 39a5e91eeb
commit d8ea4f8e02
53 changed files with 1179 additions and 410 deletions
+7
View File
@@ -34,6 +34,13 @@ export default function () {
await bunextInit({ build_only: true });
// await pagesSSRBundler();
// A failed bundle must fail the command: CI and scripts rely on
// the exit code instead of grepping logs.
if (global.BUNEXT_BUILD_FAILED) {
log.error("Build failed with errors. Exiting.");
process.exit(1);
}
if (global.BUNEXT_BUNDLER_CTX_MAP) {
let bundler_ctx_json_str = JSON.stringify(
global.BUNEXT_BUNDLER_CTX_MAP,
+14
View File
@@ -2,16 +2,26 @@ import startServer from "../../functions/server/start-server";
import { log } from "../../utils/log";
import bunextInit from "../../functions/bunext-init";
import grabDirNames from "../../utils/grab-dir-names";
import writeErrorFile from "../../functions/write-error-file";
import { rmSync } from "fs";
const { HYDRATION_DST_DIR, BUNX_CWD_PAGES_REWRITE_DIR } = grabDirNames();
// The supervisor only restarts the dev server when this process actually
// exits with a code. Registering a listener that merely logs (the previous
// behavior) keeps Bun from exiting: the crash is swallowed, no error file is
// written, and the supervisor never restarts the server. Log, record the real
// error, and exit non-zero so recovery actually runs.
process.on("uncaughtException", (error) => {
log.error(`Uncaught exception: ${error}`);
writeErrorFile({ error, exitCode: 1, force: true });
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
log.error(`Unhandled rejection: ${reason}`);
writeErrorFile({ error: reason, exitCode: 1, force: true });
process.exit(1);
});
log.info("Running development server ...");
@@ -25,5 +35,9 @@ try {
await bunextInit();
await startServer();
} catch (error) {
// Startup failures (e.g. port in use, config errors) must exit non-zero;
// otherwise the process hangs alive with no server and no restart.
log.error(`Failed to start development server: ${error}`);
writeErrorFile({ error, exitCode: 1, force: true });
process.exit(1);
}
+13 -2
View File
@@ -5,7 +5,11 @@ import grabDirNames from "../../utils/grab-dir-names";
import writeErrorFile from "../../functions/write-error-file";
import { existsSync, rmSync } from "fs";
const { BUNDLER_CTX_MAP_JSON, SSR_BUNDLER_CTX_MAP_JSON } = grabDirNames();
const {
BUNDLER_CTX_MAP_JSON,
SSR_BUNDLER_CTX_MAP_JSON,
BUNX_BUNDLER_ERROR_EXIT_FILE,
} = grabDirNames();
let retries = 0;
let timeout: any;
@@ -18,6 +22,10 @@ export default function () {
try {
rmSync(SSR_BUNDLER_CTX_MAP_JSON, { recursive: true });
rmSync(BUNDLER_CTX_MAP_JSON, { recursive: true });
// A stale error file from a previous session makes the bundler
// run in "silent" mode and pages fail with misleading
// "Application Error" messages until the first rebuild.
rmSync(BUNX_BUNDLER_ERROR_EXIT_FILE, { force: true });
} catch (error) {}
await dev();
@@ -65,7 +73,10 @@ async function dev() {
const exited = await dev_process.exited;
if (exited) {
// Restart on any numeric exit (0 included: the server should never exit
// cleanly). A null exit means the child was killed by a signal (e.g.
// Ctrl+C in the shared terminal), which we must not restart.
if (exited != null) {
retries++;
timeout = setTimeout(() => {
retries = 0;
+11 -2
View File
@@ -2,7 +2,10 @@ import { Command } from "commander";
import path from "path";
import type { BunSpawnOptions } from "../../types";
import writeErrorFile from "../../functions/write-error-file";
import { existsSync } from "fs";
import grabDirNames from "../../utils/grab-dir-names";
import { existsSync, rmSync } from "fs";
const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames();
let retries = 0;
let timeout: any;
@@ -12,6 +15,10 @@ export default function () {
return new Command("start")
.description("Start production server")
.action(async () => {
// A stale error file from a previous session makes the bundler
// run in "silent" mode and pages fail with misleading messages.
rmSync(BUNX_BUNDLER_ERROR_EXIT_FILE, { force: true });
await start();
});
}
@@ -59,7 +66,9 @@ async function start() {
const exited = await dev_process.exited;
if (exited) {
// Restart on any numeric exit (0 included). A null exit means the child
// was killed by a signal, which we must not restart.
if (exited != null) {
retries++;
timeout = setTimeout(() => {
retries = 0;
+24 -3
View File
@@ -1,9 +1,30 @@
import bunextInit from "../../functions/bunext-init";
import startServer from "../../functions/server/start-server";
import { log } from "../../utils/log";
import writeErrorFile from "../../functions/write-error-file";
// Same crash contract as the dev child: log the real error, record it in the
// error file, and exit non-zero so the supervisor can restart and so the
// bundler pipeline sees the failure.
process.on("uncaughtException", (error) => {
log.error(`Uncaught exception: ${error}`);
writeErrorFile({ error, exitCode: 1, force: true });
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
log.error(`Unhandled rejection: ${reason}`);
writeErrorFile({ error: reason, exitCode: 1, force: true });
process.exit(1);
});
log.info("Starting production server ...");
await bunextInit();
await startServer();
try {
await bunextInit();
await startServer();
} catch (error) {
log.error(`Failed to start production server: ${error}`);
writeErrorFile({ error, exitCode: 1, force: true });
process.exit(1);
}
@@ -8,13 +8,12 @@ import type { BundlerCTXMap, PageFiles } from "../../types";
import path from "path";
import virtualFilesPlugin from "./plugins/virtual-files-plugin";
import esbuildCTXArtifactTracker from "./plugins/esbuild-ctx-artifact-tracker";
import { existsSync } from "fs";
import {
logBuildErrorsOnce,
} from "../../utils/build-error-log";
import { log } from "../../utils/log";
const {
HYDRATION_DST_DIR,
BUNX_HYDRATION_SRC_DIR,
BUNX_BUNDLER_ERROR_EXIT_FILE,
} = grabDirNames();
const { HYDRATION_DST_DIR, BUNX_HYDRATION_SRC_DIR } = grabDirNames();
type Params = {
post_build_fn?: (params: {
@@ -25,11 +24,8 @@ type Params = {
};
export default async function allPagesESBuildContextBundler(params?: Params) {
const _t0 = Date.now();
try {
const did_process_exit_because_of_bundler_error = existsSync(
BUNX_BUNDLER_ERROR_EXIT_FILE,
);
const pages = grabAllPages({ exclude_api: true });
global.BUNEXT_PAGE_FILES = pages;
@@ -60,6 +56,10 @@ export default async function allPagesESBuildContextBundler(params?: Params) {
(e) => `hydration-virtual:${e}`,
);
// This is a brand-new context: wipe the previous context's failure
// history so the "2 failed starts => dispose" guard starts fresh.
global.BUNEXT_MAIN_CTX_BUILD_STARTS = 0;
global.BUNEXT_BUNDLER_CTX = await esbuild.context({
entryPoints,
outdir: HYDRATION_DST_DIR,
@@ -98,14 +98,33 @@ export default async function allPagesESBuildContextBundler(params?: Params) {
"react/jsx-dev-runtime",
...(global.BUNEXT_CONFIG.page_compiler_excludes || []),
],
logLevel: did_process_exit_because_of_bundler_error
? "silent"
: undefined,
// esbuild's own error logging prints per attempt; the
// artifact-tracker plugin reports errors (deduped, once) itself.
logLevel: "silent",
});
await global.BUNEXT_BUNDLER_CTX.rebuild();
} catch (error) {
console.log(`ESBUILD Error =>`, error);
} catch (error: any) {
global.BUNEXT_BUILD_FAILED = true;
// When the build fails with esbuild errors, the artifact-tracker's
// onEnd already reported them (deduped). "Build failed with N errors"
// is that same failure rethrown by esbuild's API — re-logging it here
// would double every incident.
if (String(error?.message || "").startsWith("Build failed with")) {
return;
}
logBuildErrorsOnce(
[
{
text: `ESBUILD Error => ${
error?.message || String(error)
}`,
},
],
(line) => log.error(line),
);
}
}
@@ -1,26 +1,22 @@
type Params = {};
export default async function buildOnstartErrorHandler(params?: Params) {
// const error_msg = `Build Failed. Please check all your components and imports.`;
// log.error(error_msg);
if (global.BUNEXT_BUNDLER_CTX_DISPOSED) {
return;
}
// console.log(`Killing Bundler ...`);
// console.log(`global.BUNEXT_BUNDLER_CTX_DISPOSED`, global.BUNEXT_BUNDLER_CTX_DISPOSED);
global.BUNEXT_BUNDLER_CTX_DISPOSED = true;
global.BUNEXT_RECOMPILING = false;
global.BUNEXT_IS_SERVER_COMPONENT = false;
await Promise.all([
global.BUNEXT_SSR_BUNDLER_CTX?.dispose(),
global.BUNEXT_BUNDLER_CTX?.dispose(),
]);
global.BUNEXT_SSR_BUNDLER_CTX = undefined;
global.BUNEXT_BUNDLER_CTX = undefined;
// NOTE: must NOT dispose the currently-building context here. This
// handler runs from the artifact-tracker plugin's onStart, i.e. while the
// context's own build is in flight. esbuild's `dispose()` waits for the
// in-flight build to finish, so disposing self = deadlock, which left the
// dev server permanently stuck after a failed rebuild.
//
// Marking the context as disposed is enough: the next rebuild attempt
// (watcher / fullRebuild) sees BUNEXT_BUNDLER_CTX_DISPOSED and recreates a
// fresh context, whose cleanup disposes the old (now idle) one.
}
+16 -2
View File
@@ -11,6 +11,9 @@ import ssrCTXArtifactTracker from "./plugins/ssr-ctx-artifact-tracker";
import { writeFileSync } from "fs";
import path from "path";
import { log } from "../../utils/log";
import {
logBuildErrorsOnce,
} from "../../utils/build-error-log";
const { BUNX_CWD_MODULE_CACHE_DIR, BUNX_TMP_DIR } = grabDirNames();
@@ -114,9 +117,20 @@ export default async function pagesSSRBundler(params?: Params) {
...(config.ssr_compiler_excludes || []),
],
splitting: true,
logLevel: "silent",
});
} catch (error) {
} catch (error: any) {
global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = true;
log.error(`SSR Bundler Error: ${error}`);
global.BUNEXT_BUILD_FAILED = true;
logBuildErrorsOnce(
[
{
text: `SSR Bundler Error: ${
error?.message || String(error)
}`,
},
],
(line) => log.error(line),
);
}
}
@@ -6,6 +6,10 @@ import buildOnstartErrorHandler from "../build-on-start-error-handler";
import _ from "lodash";
import pagesSSRBundler from "../pages-ssr-bundler";
import grabDirNames from "../../../utils/grab-dir-names";
import {
logBuildErrorsOnce,
resetBuildErrorLog,
} from "../../../utils/build-error-log";
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "fs";
import fullRebuild from "../../server/full-rebuild";
import path from "path";
@@ -55,15 +59,15 @@ export default function esbuildCTXArtifactTracker({
build.onEnd(async (result) => {
if (result.errors.length > 0) {
global.BUNEXT_BUILD_FAILED = true;
global.BUNEXT_RECOMPILING = false;
global.BUNEXT_IS_SERVER_COMPONENT = false;
log.error(`Build errors:`);
for (const err of result.errors) {
log.error(
` ${err.text}${err.location ? ` (${err.location.file}:${err.location.line}:${err.location.column})` : ""}`,
);
}
// Rebuild attempts repeat for every file edit / request
// while the error persists; only log the error once.
logBuildErrorsOnce(result.errors, (line) =>
log.error(` ${line}`),
);
for (
let i = global.BUNEXT_HMR_CONTROLLERS.length - 1;
@@ -114,6 +118,17 @@ export default function esbuildCTXArtifactTracker({
const elapsed = (performance.now() - build_start).toFixed(0);
log.success(`[Built] in ${elapsed}ms`);
// A successful build clears the "last error" so a NEW incident
// with the same signature is reported again.
resetBuildErrorLog();
if (result.warnings.length > 0) {
logBuildErrorsOnce(result.warnings, (line) =>
log.warn(line),
);
}
global.BUNEXT_BUILD_FAILED = false;
global.BUNEXT_MAIN_CTX_BUILD_STARTS = 0;
global.BUNEXT_BUNDLER_CTX_DISPOSED = false;
@@ -5,6 +5,11 @@ import { writeFileSync } from "fs";
import path from "path";
import grabDirNames from "../../../utils/grab-dir-names";
import grabRelativePath from "../../../utils/grab-relative-path";
import {
logBuildErrorsOnce,
resetBuildErrorLog,
} from "../../../utils/build-error-log";
import { log } from "../../../utils/log";
let build_start = 0;
let build_starts = 0;
@@ -41,17 +46,16 @@ export default function ssrCTXArtifactTracker({
build.onEnd(async (result) => {
if (result.errors.length > 0) {
global.BUNEXT_BUILD_FAILED = true;
global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = true;
try {
await global.BUNEXT_SSR_BUNDLER_CTX?.dispose();
} catch {}
global.BUNEXT_SSR_BUNDLER_CTX = undefined;
build_starts = 0;
for (const err of result.errors) {
console.error(
`SSR Build error: ${err.text}${err.location ? ` (${err.location.file}:${err.location.line}:${err.location.column})` : ""}`,
);
}
logBuildErrorsOnce(result.errors, (line) =>
log.error(`SSR Build error: ${line}`),
);
return;
}
@@ -100,6 +104,8 @@ export default function ssrCTXArtifactTracker({
} catch (error) {}
global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = false;
global.BUNEXT_BUILD_FAILED = false;
resetBuildErrorLog();
});
},
};
+2
View File
@@ -51,6 +51,7 @@ declare global {
var BUNEXT_SSR_BUNDLER_CTX_DISPOSED: boolean | undefined;
var BUNEXT_REBUILD_RETRIES: number;
var BUNEXT_IS_404_PAGE: boolean;
var BUNEXT_BUILD_FAILED: boolean;
var BUNEXT_CONSTANTS: ReturnType<typeof grabConstants>;
var BUNEXT_MAIN_CTX_BUILD_STARTS: number;
}
@@ -101,6 +102,7 @@ export default async function bunextInit(params?: Params) {
// global.BUNEXT_API_ROUTES_BUNDLER_CTX_MAP = {};
global.BUNEXT_BUNDLER_REBUILDS = 0;
global.BUNEXT_REBUILD_RETRIES = 0;
global.BUNEXT_BUILD_FAILED = false;
global.BUNEXT_PAGE_FILES = [];
global.BUNEXT_SKIPPED_BROWSER_MODULES = new Set<string>();
global.BUNEXT_DIR_NAMES = dirNames;
+10 -2
View File
@@ -20,7 +20,15 @@ export default async function grabConfig(): Promise<BunextConfig | undefined> {
}
return config;
} catch (error) {
return undefined;
} catch (error: any) {
// A config that fails to load (syntax error, bad export) must not be
// silently replaced by defaults — the app would run with the wrong
// configuration and nothing would explain why.
const { CONFIG_FILE } = grabDirNames();
exitWithError(
`Failed to load config file \`${CONFIG_FILE}\`: ${
error?.message || error
}`,
);
}
}
+6 -2
View File
@@ -7,6 +7,7 @@ import handleFiles from "./handle-files";
import handleBunextPublicAssets from "./handle-bunext-public-assets";
import checkExcludedPatterns from "../../utils/check-excluded-patterns";
import { AppData } from "../../data/app-data";
import { log } from "../../utils/log";
import fullRebuild from "./full-rebuild";
const HMR_RETRY_COOLDOWN_MS = 5000;
@@ -86,12 +87,15 @@ export default async function bunextRequestHandler({
return response;
} catch (error: any) {
log.error(
`Server Error: ${error?.message || error}\n${error?.stack || ""}`,
);
if (is_dev) {
return new Response(`Server Error: ${error.message}`, {
return new Response(`Server Error: ${error?.message || error}`, {
status: 500,
});
}
console.error(`Server Error: ${error.message}`, error);
return new Response("Internal Server Error", { status: 500 });
}
}
+33 -6
View File
@@ -1,9 +1,36 @@
import { log } from "../../utils/log";
import allPagesESBuildContextBundler from "../bundler/all-pages-esbuild-context-bundler";
import serverPostBuildFn from "./server-post-build-fn";
import watcherEsbuildCTX from "./watcher-esbuild-ctx";
import { restartWatcher } from "./watcher-esbuild-ctx";
import { logBuildErrorsOnce } from "../../utils/build-error-log";
// Single-flight: error recovery paths (grab-page-component retries, the
// watcher, post-build HMR pushes) all call fullRebuild, and each rebuild
// triggers serverPostBuildFn which can re-enter error handling. Without this
// guard one broken page could fan out into cascading, overlapping rebuilds
// that leave the dev server unresponsive. Concurrent callers await the same
// in-flight rebuild instead of starting a new one.
let rebuild_in_progress: Promise<void> | undefined;
export default async function fullRebuild(params?: { msg?: string }) {
if (rebuild_in_progress) {
if (params?.msg) {
log.watch(`Rebuild already in progress, skipping: ${params.msg}`);
}
return rebuild_in_progress;
}
const run = doFullRebuild(params).finally(() => {
if (rebuild_in_progress === run) {
rebuild_in_progress = undefined;
}
});
rebuild_in_progress = run;
return run;
}
async function doFullRebuild(params?: { msg?: string }) {
try {
const { msg } = params || {};
@@ -29,14 +56,14 @@ export default async function fullRebuild(params?: { msg?: string }) {
},
});
} catch (error: any) {
log.error(error);
logBuildErrorsOnce(
[{ text: error?.message || String(error) }],
(line) => log.error(line),
);
} finally {
global.BUNEXT_RECOMPILING = false;
global.BUNEXT_IS_SERVER_COMPONENT = false;
}
if (global.BUNEXT_PAGES_SRC_WATCHER) {
global.BUNEXT_PAGES_SRC_WATCHER.close();
watcherEsbuildCTX();
}
restartWatcher();
}
+180 -118
View File
@@ -7,6 +7,7 @@ import grabRouteParams from "../../utils/grab-route-params";
import grabConstants from "../../utils/grab-constants";
import grabRouter from "../../utils/grab-router";
import isDevelopment from "../../utils/is-development";
import { log } from "../../utils/log";
import _ from "lodash";
import path from "path";
import grabDirNames from "../../utils/grab-dir-names";
@@ -19,138 +20,199 @@ type Params = {
};
export default async function ({ req }: Params): Promise<Response> {
const url = new URL(req.url);
const is_dev = isDevelopment();
try {
const url = new URL(req.url);
const is_dev = isDevelopment();
const { MBInBytes, ServerDefaultRequestBodyLimitBytes } = grabConstants();
const { MBInBytes, ServerDefaultRequestBodyLimitBytes } =
grabConstants();
const router = grabRouter();
const router = grabRouter();
const match = router.match(url.pathname);
const match = router.match(url.pathname);
if (!match?.filePath) {
const errMsg = `Route ${url.pathname} not found`;
if (!match?.filePath) {
const errMsg = `Route ${url.pathname} not found`;
return Response.json(
{
success: false,
msg: errMsg,
},
{
status: 404,
headers: {
"Content-Type": "application/json",
},
},
);
}
const routeParams: BunxRouteParams = await grabRouteParams({
req,
query: match.query,
});
let module: any;
const now = Date.now();
const relative_match_path = grabRelativePath(match.filePath);
if (
is_dev &&
global.BUNEXT_SSR_BUNDLER_CTX_MAP?.[relative_match_path]?.path
) {
const target_import = path.join(
ROOT_DIR,
global.BUNEXT_SSR_BUNDLER_CTX_MAP[relative_match_path].path,
);
module = await import(`${target_import}?t=${now}`);
} else {
const import_path = is_dev
? `${match.filePath}?t=${now}`
: match.filePath;
module = await import(import_path);
}
const config = module.config as BunextServerRouteConfig | undefined;
const maxBodyBytes = config?.max_request_body_mb
? config.max_request_body_mb * MBInBytes
: ServerDefaultRequestBodyLimitBytes;
const contentLength = req.headers.get("content-length");
if (contentLength) {
const size = parseInt(contentLength, 10);
if (size > maxBodyBytes) {
return Response.json(
{
success: false,
msg: "Request Body Too Large!",
},
{
status: 413,
headers: {
"Content-Type": "application/json",
},
},
);
}
} 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",
},
},
);
}
try {
routeParams.body = JSON.parse(
new TextDecoder().decode(body) || "{}",
);
} catch (error: any) {
log.error(
`Invalid JSON body for ${url.pathname}: ${error?.message}`,
);
return Response.json(
{
success: false,
msg: "Invalid JSON body",
},
{
status: 400,
headers: {
"Content-Type": "application/json",
},
},
);
}
}
const target_module = (module["default"] ||
module["handler"]) as BunextAPIRouteHandler;
let res: any;
try {
res = await target_module?.({
...routeParams,
});
} catch (error: any) {
log.error(
`API Route Error (${url.pathname}): ${error?.message}\n${error?.stack || ""}`,
);
return Response.json(
{
success: false,
msg: `Route handler error: ${error?.message || "Internal Server Error"}`,
},
{
status: 500,
headers: {
"Content-Type": "application/json",
},
},
);
}
if (res instanceof Response) {
if (is_dev) {
res.headers.set(
"Cache-Control",
"no-cache, no-store, must-revalidate",
);
}
return res;
}
if (res) {
let final_res = Response.json(
_.omit(res, [
"bunext_api_route_res_options",
"bunext_api_route_res_transform_fn",
]),
{
...(res.bunext_api_route_res_options || undefined),
},
);
if (res.bunext_api_route_res_transform_fn) {
final_res = await res.bunext_api_route_res_transform_fn(
final_res,
);
}
return final_res;
}
return Response.json({ err: `Route handler error` });
} catch (error: any) {
const url = new URL(req.url);
log.error(
`API Route Error (${url.pathname}): ${error?.message}\n${error?.stack || ""}`,
);
return Response.json(
{
success: false,
msg: errMsg,
msg: `Route handler error: ${error?.message || "Internal Server Error"}`,
},
{
status: 404,
status: 500,
headers: {
"Content-Type": "application/json",
},
},
);
}
const routeParams: BunxRouteParams = await grabRouteParams({
req,
query: match.query,
});
let module: any;
const now = Date.now();
const relative_match_path = grabRelativePath(match.filePath);
if (
is_dev &&
global.BUNEXT_SSR_BUNDLER_CTX_MAP?.[relative_match_path]?.path
) {
const target_import = path.join(
ROOT_DIR,
global.BUNEXT_SSR_BUNDLER_CTX_MAP[relative_match_path].path,
);
module = await import(`${target_import}?t=${now}`);
} else {
const import_path = is_dev
? `${match.filePath}?t=${now}`
: match.filePath;
module = await import(import_path);
}
const config = module.config as BunextServerRouteConfig | undefined;
const maxBodyBytes = config?.max_request_body_mb
? config.max_request_body_mb * MBInBytes
: ServerDefaultRequestBodyLimitBytes;
const contentLength = req.headers.get("content-length");
if (contentLength) {
const size = parseInt(contentLength, 10);
if (size > maxBodyBytes) {
return Response.json(
{
success: false,
msg: "Request Body Too Large!",
},
{
status: 413,
headers: {
"Content-Type": "application/json",
},
},
);
}
} 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"] ||
module["handler"]) as BunextAPIRouteHandler;
const res = await target_module?.({
...routeParams,
});
if (res instanceof Response) {
if (is_dev) {
res.headers.set(
"Cache-Control",
"no-cache, no-store, must-revalidate",
);
}
return res;
}
if (res) {
let final_res = Response.json(
_.omit(res, [
"bunext_api_route_res_options",
"bunext_api_route_res_transform_fn",
]),
{
...(res.bunext_api_route_res_options || undefined),
},
);
if (res.bunext_api_route_res_transform_fn) {
final_res = await res.bunext_api_route_res_transform_fn(final_res);
}
return final_res;
}
return Response.json({ err: `Route handler error` });
}
@@ -58,6 +58,9 @@ export default async function serverPostBuildFn(params?: Params) {
req: mock_req,
return_server_res_only: true,
is_hydration: true,
// This runs inside serverPostBuildFn (post-build of fullRebuild);
// letting it trigger another rebuild/retry would cascade.
no_retry: true,
});
if (page_component instanceof Response) {
+10 -4
View File
@@ -2,7 +2,10 @@ import _ from "lodash";
import { log } from "../../utils/log";
import serverParamsGen from "./server-params-gen";
import isDevelopment from "../../utils/is-development";
import watcherEsbuildCTX from "./watcher-esbuild-ctx";
import {
restartWatcher,
watcherRestartAvailable,
} from "./watcher-esbuild-ctx";
export default async function startServer() {
const serverParams = await serverParamsGen();
@@ -15,10 +18,13 @@ export default async function startServer() {
log.server(`http://${server.hostname}:${server.port}`);
if (is_dev) {
// Watchdog: periodically re-create the FS watcher in case it went
// stale and stopped receiving events. Skipped while a recent restart
// (e.g. by fullRebuild) is still fresh so the watcher isn't churned
// twice in a row.
setInterval(() => {
if (global.BUNEXT_PAGES_SRC_WATCHER) {
global.BUNEXT_PAGES_SRC_WATCHER.close();
watcherEsbuildCTX();
if (watcherRestartAvailable()) {
restartWatcher();
}
}, 5000);
}
+50 -7
View File
@@ -6,9 +6,38 @@ import { AppData } from "../../data/app-data";
import checkExcludedPatterns from "../../utils/check-excluded-patterns";
import pagesSSRBundler from "../bundler/pages-ssr-bundler";
import { log } from "../../utils/log";
import { logBuildErrorsOnce } from "../../utils/build-error-log";
const { ROOT_DIR, BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames();
// Tracks the last watcher re-creation so the 5s watchdog in start-server
// doesn't fight fullRebuild (which also restarts the watcher after a rebuild).
let last_watcher_restart = 0;
/**
* Closes and re-creates the FS watcher. Deduped: callers that restart the
* watcher around the same time (fullRebuild, reloadWatcher, watchdog) only
* trigger a single re-creation.
*/
export function restartWatcher() {
if (!global.BUNEXT_PAGES_SRC_WATCHER) return;
if (Date.now() - last_watcher_restart < 1000) return;
global.BUNEXT_PAGES_SRC_WATCHER.close();
global.BUNEXT_PAGES_SRC_WATCHER = undefined;
last_watcher_restart = Date.now();
watcherEsbuildCTX();
}
/**
* True when the watcher was last (re)created long enough ago that the
* watchdog should consider re-creating it again.
*/
export function watcherRestartAvailable() {
return Date.now() - last_watcher_restart > 6000;
}
export default async function watcherEsbuildCTX() {
const pages_src_watcher = watch(
ROOT_DIR,
@@ -114,8 +143,16 @@ export default async function watcherEsbuildCTX() {
global.BUNEXT_IS_SERVER_COMPONENT = true;
}
if (global.BUNEXT_BUNDLER_CTX) {
if (
global.BUNEXT_BUNDLER_CTX &&
!global.BUNEXT_BUNDLER_CTX_DISPOSED
) {
await global.BUNEXT_BUNDLER_CTX.rebuild();
} else if (global.BUNEXT_BUNDLER_CTX_DISPOSED) {
await fullRebuild({
msg: `Restarting Bundler ...`,
});
return;
}
if (filename.match(/(404|500)\.tsx?/)) {
@@ -169,8 +206,17 @@ export default async function watcherEsbuildCTX() {
await fullRebuild({
msg: `${type} ${action}: ${filename}. Rebuilding ...`,
});
} catch (error) {
log.error(`Watcher rebuild failed: ${error}`);
} catch (error: any) {
logBuildErrorsOnce(
[
{
text: `Watcher rebuild failed: ${
error?.message || String(error)
}`,
},
],
(line) => log.error(line),
);
} finally {
if (owns_recompile) {
global.BUNEXT_RECOMPILING = false;
@@ -184,8 +230,5 @@ export default async function watcherEsbuildCTX() {
}
function reloadWatcher() {
if (global.BUNEXT_PAGES_SRC_WATCHER) {
global.BUNEXT_PAGES_SRC_WATCHER.close();
watcherEsbuildCTX();
}
restartWatcher();
}
@@ -185,10 +185,19 @@ export default async function genWebHTML({
console.debug = () => {};
let htmlBody: string;
let render_error: any;
let has_render_error = false;
try {
const stream = await renderToReadableStream(final_component, {
onError(error: any) {
if (error.message.includes('unique "key" prop')) return;
if (error?.message?.includes('unique "key" prop')) return;
if (!has_render_error) {
has_render_error = true;
render_error = error;
}
originalConsole.error(error);
},
});
@@ -198,6 +207,13 @@ export default async function genWebHTML({
Object.assign(console, originalConsole);
}
// A component crash during SSR must surface as a proper error (handled
// upstream as a 500 page). React only reports these to `onError`; without
// rethrowing, the user gets a 200 with broken/blank markup.
if (has_render_error) {
throw render_error;
}
html += htmlBody;
return html;
@@ -11,6 +11,7 @@ import isDevelopment from "../../../utils/is-development";
import { existsSync } from "fs";
import grabDirNames from "../../../utils/grab-dir-names";
import grabRelativePath from "../../../utils/grab-relative-path";
import { logBuildErrorsOnce } from "../../../utils/build-error-log";
const { BUNX_BUNDLER_ERROR_EXIT_FILE, ROOT_DIR } = grabDirNames();
@@ -28,6 +29,12 @@ type Params = {
file_path?: string;
debug?: boolean;
retry?: boolean;
/**
* Skip the dev auto-rebuild/retry recovery path. Used by internal callers
* (e.g. HMR post-build) where triggering yet another fullRebuild on top
* of the rebuild that's already in progress would cascade.
*/
no_retry?: boolean;
return_server_res_only?: boolean;
skip_server_res?: boolean;
is_hydration?: boolean;
@@ -43,6 +50,7 @@ export default async function grabPageComponent(
return_server_res_only,
skip_server_res,
is_hydration,
no_retry,
} = params;
const url = req?.url ? new URL(req.url) : undefined;
@@ -104,13 +112,21 @@ export default async function grabPageComponent(
let retries = 0;
const MAX_RETRIES = 2;
while (retries < MAX_RETRIES) {
// Only retry transient failures: if the last rebuild failed
// (BUNEXT_BUILD_FAILED), the file is broken and more rebuilds
// will just repeat the same error.
while (
retries < MAX_RETRIES &&
!global.BUNEXT_BUILD_FAILED
) {
await fullRebuild({
msg: `Retrying Bundle map for file \`${relative_file_path}\``,
});
await Bun.sleep(1000);
bundledMap = global.BUNEXT_BUNDLER_CTX_MAP[relative_file_path];
bundledMap =
global.BUNEXT_BUNDLER_CTX_MAP[relative_file_path];
if (bundledMap?.path) break;
retries++;
}
if (!bundledMap?.path) {
@@ -170,8 +186,13 @@ export default async function grabPageComponent(
error?.name === "NotFoundError" ||
error?.status === 404;
if (!params.retry && is_dev) {
while (global.BUNEXT_REBUILD_RETRIES < 2) {
if (!params.retry && !params.no_retry && is_dev) {
// Skip the retry cascade when the last rebuild failed: the error
// is persistent and more rebuilds would repeat it endlessly.
while (
!global.BUNEXT_BUILD_FAILED &&
global.BUNEXT_REBUILD_RETRIES < 2
) {
global.BUNEXT_REBUILD_RETRIES =
global.BUNEXT_REBUILD_RETRIES + 1;
@@ -198,10 +219,18 @@ export default async function grabPageComponent(
if (is404) {
global.BUNEXT_IS_404_PAGE = true;
} else {
log.error(`Error Grabbing Page Component: ${error.message}`);
log.error(`Page: ${passed_file_path || url?.pathname}`);
logBuildErrorsOnce(
[
{
text: `Error Grabbing Page Component: ${error.message}; Page: ${
passed_file_path || url?.pathname
}`,
},
],
(line) => log.error(line),
);
if (is_dev) {
if (is_dev && !params.no_retry && !global.BUNEXT_BUILD_FAILED) {
fullRebuild();
}
}
@@ -1,5 +1,6 @@
import isDevelopment from "../../../utils/is-development";
import { log } from "../../../utils/log";
import { logBuildErrorsOnce } from "../../../utils/build-error-log";
import getCache from "../../cache/get-cache";
import generateWebPageResponseFromComponentReturn from "./generate-web-page-response-from-component-return";
import grabPageComponent from "./grab-page-component";
@@ -45,7 +46,16 @@ export default async function handleWebPages({
...componentRes,
});
} catch (error: any) {
log.error(`Error Handling Web Page: ${error.message}`, error);
logBuildErrorsOnce(
[
{
text: `Error Handling Web Page: ${
error?.message || String(error)
}`,
},
],
(line) => log.error(line),
);
const componentRes = await grabPageErrorComponent({
error,
+50 -22
View File
@@ -1,34 +1,62 @@
import path from "path";
import { mkdirSync, writeFileSync } from "fs";
import { existsSync, mkdirSync, writeFileSync } from "fs";
import grabDirNames from "../utils/grab-dir-names";
const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames();
type Params = {
/**
* Only include when the exiting process actually crashed with an error.
* Bun's spawn `onExit` `error` is only set on spawn failures, so callers
* that DO have the error (crash handlers) should pass `force: true`.
*/
force?: boolean;
error?: unknown;
exitCode?: number | null;
};
/**
* Writes the `BUNDLER_EXIT.txt` flag that the bundler pipeline watches to
* detect that the dev/prod child process previously crashed.
*
* Guardrails:
* - Never writes for clean exits (exitCode === 0) or signal deaths where no
* error is available (Ctrl+C must not leave a stale error file behind).
* - The supervisor writes only when the file does not already exist, so the
* crash handler's richer error/stack payload is not clobbered.
*/
export default function writeErrorFile({
exitCode,
force,
error,
}: {
error?: Bun.ErrorLike;
exitCode: number | null;
}) {
exitCode,
}: Params) {
const has_error = error != null;
const abnormal_exit = exitCode != null && exitCode !== 0;
if (!has_error && !abnormal_exit) return;
if (!force && existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE)) return;
const err =
error instanceof Error
? error
: typeof error === "string"
? new Error(error)
: undefined;
const message = err?.message || "Unknown error";
const stack = err?.stack || "N/A";
let txt = ``;
txt += `Bunext Error\n`;
txt += `============================================\n`;
txt += `ERROR: ${error?.message}\n`;
txt += `EXIT_CODE: ${exitCode || 0}\n`;
txt += `CALL_STACK: ${error?.stack}\n`;
txt += `ERROR: ${message}\n`;
txt += `EXIT_CODE: ${exitCode ?? "N/A"}\n`;
txt += `CALL_STACK:\n${stack}\n`;
mkdirSync(path.dirname(BUNX_BUNDLER_ERROR_EXIT_FILE), { recursive: true });
writeFileSync(BUNX_BUNDLER_ERROR_EXIT_FILE, txt);
try {
mkdirSync(path.dirname(BUNX_BUNDLER_ERROR_EXIT_FILE), {
recursive: true,
});
writeFileSync(BUNX_BUNDLER_ERROR_EXIT_FILE, txt);
} catch {}
}
// log.info("Running development server ...");
// try {
// rmSync(HYDRATION_DST_DIR, { recursive: true });
// rmSync(BUNX_CWD_PAGES_REWRITE_DIR, { recursive: true });
// } catch (error) {}
// await bunextInit();
// await startServer();
+64
View File
@@ -0,0 +1,64 @@
// Logs build/render errors exactly once while the same error state persists.
//
// Dev recovery paths (watcher, request retries, post-build HMR) re-attempt a
// rebuild for every file edit and request while a syntax error is still in the
// file. Without deduplication that one error is printed again on every
// attempt — an endless spiral of identical log lines. The signature is
// cleared after a successful build, so the same error later (after a fix and
// regression) is logged again as a new incident.
export type BuildErrorLike = {
text?: string;
message?: string;
id?: string;
location?: { file?: string; line?: number; column?: number } | null;
};
let last_build_error_signature: string | undefined;
function errorSignature(errors: BuildErrorLike[]): string {
return errors
.map((e) => {
const text = e.text || e.message || e.id || "";
const loc = e.location
? `${e.location.file}:${e.location.line}:${e.location.column}`
: "";
return `${text}${loc}`;
})
.join("\n---\n");
}
/**
* Logs each error once — identical error sets are suppressed until the
* signature changes or `resetBuildErrorLog()` is called (after a successful
* build). Returns true when something was logged.
*/
export function logBuildErrorsOnce(
errors: BuildErrorLike[],
logLine: (line: string) => void = (line) => console.error(line),
): boolean {
if (!errors || errors.length === 0) return false;
const signature = errorSignature(errors);
if (signature === last_build_error_signature) return false;
last_build_error_signature = signature;
for (const e of errors) {
const text = e.text || e.message || e.id || "Build Error";
const loc = e.location
? ` (${e.location.file}:${e.location.line}:${e.location.column})`
: "";
logLine(`${text}${loc}`);
}
return true;
}
/**
* Clears the dedup state so a new incident with the same signature is logged
* again. Call after a successful build.
*/
export function resetBuildErrorLog(): void {
last_build_error_signature = undefined;
}