- Server functions can return not_found: true to render the 404 page with status 404. - Page components and window.__PAGE_PROPS__ (and HMR updates) only get props, query, url and error. response_options, redirect, cache settings and res_transform stay on the server, so cookies and private headers no longer end up in the HTML. BunextPageProps is narrowed to match (breaking for code that read server-only fields from component props). - response_options headers go through a Headers object, so repeated headers such as several Set-Cookie survive. - Redirects: status_code wins over permanent; response_options headers and res_transform apply to redirects too. - Gzip for compressible text responses; compress: false to turn it off. - Files the router matches but never bundles (.server.ts, __root, excluded paths and directories) return 404 instead of looping through dev rebuild retries. - Missing public files return 404 rather than 403, so dotted page paths like /users/john.doe fall through to pages again. - importFresh detects rewrites within the filesystem's mtime granularity. - README: self-hosted React (not esm.sh), snake_case option names, static file serving, cache headers, the new options. Co-Authored-By: Claude Opus 5.5 <[email protected]>
178 lines
8.5 KiB
JavaScript
178 lines
8.5 KiB
JavaScript
import {} from "esbuild";
|
|
import { log } from "../../../utils/log";
|
|
import grabArtifactsFromBundledResults from "../grab-artifacts-from-bundled-result";
|
|
import _ from "lodash";
|
|
import enqueueSSRRebuild from "../pages-ssr-context-bundler";
|
|
import grabDirNames from "../../../utils/grab-dir-names";
|
|
import { logBuildErrorsOnce, resetBuildErrorLog, } from "../../../utils/build-error-log";
|
|
import { cpSync, existsSync, mkdirSync, rmSync } from "fs";
|
|
import fullRebuild from "../../server/full-rebuild";
|
|
import path from "path";
|
|
import cleanupLogsDirs from "../../cleanup-logs-dir";
|
|
import grabRelativePath from "../../../utils/grab-relative-path";
|
|
import cleanupArtifacts from "../../server/cleanup-artifacts";
|
|
import { pushClientUpdate } from "../../server/server-post-build-fn";
|
|
let build_start = 0;
|
|
export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, build_only, refresh_props_on_first_build, }) {
|
|
const { BUNX_BUNDLER_ERROR_EXIT_FILE, BUNX_ERROR_LOGS_DIR } = grabDirNames();
|
|
let force_props_refresh = Boolean(refresh_props_on_first_build);
|
|
const artifactTracker = {
|
|
name: "artifact-tracker",
|
|
setup(build) {
|
|
build.onStart(async () => {
|
|
build_start = performance.now();
|
|
});
|
|
build.onEnd(async (result) => {
|
|
if (result.errors.length > 0) {
|
|
global.BUNEXT_BUILD_FAILED = true;
|
|
global.BUNEXT_RECOMPILING = false;
|
|
global.BUNEXT_IS_SERVER_COMPONENT = false;
|
|
// 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; i >= 0; i--) {
|
|
const controller = global.BUNEXT_HMR_CONTROLLERS[i];
|
|
try {
|
|
controller?.controller?.enqueue(`event: update\ndata: ${JSON.stringify({ reload: true })}\n\n`);
|
|
}
|
|
catch {
|
|
global.BUNEXT_HMR_CONTROLLERS.splice(i, 1);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
const artifacts = grabArtifactsFromBundledResults({
|
|
result,
|
|
entryToPage,
|
|
});
|
|
// Pages whose entry output hash changed since the previous
|
|
// build. With `splitting`, a change to any shared chunk also
|
|
// re-hashes every page entry that imports it, so this set
|
|
// covers all pages whose client graph actually changed.
|
|
const changed_pages = [];
|
|
if (artifacts?.[0] && artifacts.length > 0) {
|
|
for (let i = 0; i < artifacts.length; i++) {
|
|
const artifact = artifacts[i];
|
|
if (artifact?.local_path &&
|
|
global.BUNEXT_BUNDLER_CTX_MAP) {
|
|
const relative_path = grabRelativePath(artifact.local_path);
|
|
const prior = global.BUNEXT_BUNDLER_CTX_MAP[relative_path];
|
|
if (!prior?.path ||
|
|
prior.path !== artifact.path ||
|
|
prior.css_path !== artifact.css_path) {
|
|
changed_pages.push(relative_path);
|
|
}
|
|
global.BUNEXT_BUNDLER_CTX_MAP[relative_path] =
|
|
_.merge(prior, {
|
|
...artifact,
|
|
local_path: relative_path,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
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_BUNDLER_CTX_DISPOSED = false;
|
|
const props_refresh_forced = force_props_refresh;
|
|
force_props_refresh = false;
|
|
const does_error_file_exist = existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE);
|
|
// Client-only changes are pushed before the SSR rebuild;
|
|
// anything that may change server props waits for SSR so
|
|
// props are fresh. The rebuild is single-flighted: concurrent hydration
|
|
// rebuilds join the in-flight SSR rebuild instead of
|
|
// starting overlapping ones.
|
|
if (build_only) {
|
|
try {
|
|
await enqueueSSRRebuild();
|
|
}
|
|
catch (error) {
|
|
log.error(`SSR Bundler Error: ${error}`);
|
|
}
|
|
}
|
|
else if (does_error_file_exist) {
|
|
mkdirSync(BUNX_ERROR_LOGS_DIR, { recursive: true });
|
|
cpSync(BUNX_BUNDLER_ERROR_EXIT_FILE, path.join(BUNX_ERROR_LOGS_DIR, `${Date.now()}.log`));
|
|
rmSync(BUNX_BUNDLER_ERROR_EXIT_FILE, { force: true });
|
|
cleanupLogsDirs();
|
|
await fullRebuild();
|
|
}
|
|
else if (changed_pages.length > 0 &&
|
|
post_build_fn &&
|
|
!props_refresh_forced) {
|
|
// Client graph changed: push the new bundle right away.
|
|
// Server functions live in `.server.ts` files, outside
|
|
// the client graph, so the browser's current props are
|
|
// still valid and it needn't wait for the SSR rebuild.
|
|
pushClientUpdate(changed_pages);
|
|
const server_hashes_before = grabServerEntryPaths();
|
|
try {
|
|
await enqueueSSRRebuild();
|
|
}
|
|
catch (error) {
|
|
log.error(`SSR Bundler Error: ${error}`);
|
|
}
|
|
// A module shared by a page and its server file changed:
|
|
// props may differ, so re-fetch them for every tab.
|
|
if (server_hashes_before !== grabServerEntryPaths()) {
|
|
try {
|
|
await post_build_fn({
|
|
artifacts: artifacts || [],
|
|
changed_pages: [],
|
|
});
|
|
}
|
|
catch (error) {
|
|
log.error(`Post-build Error: ${error}`);
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
try {
|
|
await enqueueSSRRebuild();
|
|
}
|
|
catch (error) {
|
|
log.error(`SSR Bundler Error: ${error}`);
|
|
}
|
|
if (artifacts?.[0] && artifacts.length > 0) {
|
|
try {
|
|
await post_build_fn?.({
|
|
artifacts,
|
|
changed_pages,
|
|
});
|
|
}
|
|
catch (error) {
|
|
log.error(`Post-build Error: ${error}`);
|
|
}
|
|
}
|
|
}
|
|
// Prune orphaned entry outputs from previous rebuilds.
|
|
await cleanupArtifacts({ paradigm: "hydration" });
|
|
global.BUNEXT_RECOMPILING = false;
|
|
global.BUNEXT_IS_SERVER_COMPONENT = false;
|
|
});
|
|
},
|
|
};
|
|
return artifactTracker;
|
|
}
|
|
/**
|
|
* Fingerprint of the SSR outputs of all `.server.ts(x)` entries. Outputs are
|
|
* content-hashed, so a changed fingerprint means some server function's code
|
|
* (or a module it imports) changed.
|
|
*/
|
|
function grabServerEntryPaths() {
|
|
const map = global.BUNEXT_SSR_BUNDLER_CTX_MAP;
|
|
if (!map)
|
|
return "";
|
|
return Object.keys(map)
|
|
.filter((key) => key.match(/\.server\.tsx?$/))
|
|
.sort()
|
|
.map((key) => `${key}:${map[key]?.path || ""}`)
|
|
.join("\n");
|
|
}
|