Files
bun.sh/src/bake/hmr-runtime-client.ts
2025-05-02 12:55:57 -07:00

333 lines
9.6 KiB
TypeScript

// This file is the entrypoint to the hot-module-reloading runtime
// In the browser, this uses a WebSocket to communicate with the bundler.
import { editCssArray, editCssContent } from "./client/css-reloader";
import { DataViewReader } from "./client/data-view";
import { inspect } from "./client/inspect";
import { hasFatalError, onRuntimeError, onServerErrorPayload } from "./client/overlay";
import {
addMapping,
clearDisconnectedSourceMaps,
configureSourceMapGCSize,
getKnownSourceMaps,
SourceMapURL,
} from "./client/stack-trace";
import { initWebSocket } from "./client/websocket";
import "./debug";
import { MessageId } from "./generated";
import {
emitEvent,
fullReload,
loadModuleAsync,
onServerSideReload,
replaceModules,
setRefreshRuntime,
} from "./hmr-module";
import { td } from "./shared";
const consoleErrorWithoutInspector = console.error;
if (typeof IS_BUN_DEVELOPMENT !== "boolean") {
throw new Error("DCE is configured incorrectly");
}
let isPerformingRouteReload = false;
let shouldPerformAnotherRouteReload = false;
let currentRouteIndex: number = -1;
async function performRouteReload() {
console.info("[Bun] Server-side code changed, reloading!");
if (isPerformingRouteReload) {
shouldPerformAnotherRouteReload = true;
return;
}
if (onServerSideReload) {
try {
isPerformingRouteReload = true;
do {
shouldPerformAnotherRouteReload = false;
await onServerSideReload();
} while (shouldPerformAnotherRouteReload);
isPerformingRouteReload = false;
return;
} catch (err) {
console.error("Failed to perform Server-side reload.");
console.error(err);
console.error("The page will hard-reload now.");
}
}
// Fallback for when reloading fails or is not implemented by the framework is
// to hard-reload.
fullReload();
}
// HMR payloads are script tags that call this internal function.
// A previous version of this runtime used `eval`, but browser support around
// mapping stack traces of eval'd frames is poor (the case the error overlay).
const scriptTags = new Map<string, [script: HTMLScriptElement, size: number]>();
globalThis[Symbol.for("bun:hmr")] = (modules: any, id: string) => {
const entry = scriptTags.get(id);
if (!entry) throw new Error("Unknown HMR script: " + id);
const [script, size] = entry;
scriptTags.delete(id);
const url = script.src;
const map: SourceMapURL = {
id,
url,
refs: Object.keys(modules).length,
size,
};
addMapping(url, map);
script.remove();
replaceModules(modules, map).catch(e => {
console.error(e);
fullReload();
});
};
let isFirstRun = true;
const handlers = {
[MessageId.version](view) {
if (td.decode(view.buffer.slice(1)) !== config.version) {
console.error("Version mismatch, hard-reloading");
fullReload();
return;
}
if (isFirstRun) {
isFirstRun = false;
} else {
// It would be possible to use `performRouteReload` to do a hot-reload,
// but the issue lies in possibly outdated client files. For correctness,
// all client files have to be HMR reloaded or proven unchanged.
// Configuration changes are already handled by the `config.version` data.
fullReload();
return;
}
ws.sendBuffered("she"); // IncomingMessageId.subscribe with hot_update and errors
ws.sendBuffered("n" + location.pathname); // IncomingMessageId.set_url
const fn = globalThis[Symbol.for("bun:loadData")];
if (fn) {
document.removeEventListener("visibilitychange", fn);
ws.send("i" + config.generation);
}
},
[MessageId.hot_update](view) {
const reader = new DataViewReader(view, 1);
// The code genearting each list is annotated with equivalent "List n"
// comments in DevServer.zig's finalizeBundle function.
// List 1
const serverSideRoutesUpdated = new Set();
do {
const routeId = reader.i32();
if (routeId === -1 || routeId == undefined) break;
serverSideRoutesUpdated.add(routeId);
} while (true);
// List 2
let isServerSideRouteUpdate = false;
do {
const routeId = reader.i32();
if (routeId === -1 || routeId == undefined) break;
if (routeId === currentRouteIndex) {
isServerSideRouteUpdate = serverSideRoutesUpdated.has(routeId);
const cssCount = reader.i32();
if (cssCount !== -1) {
const cssArray = new Array<string>(cssCount);
for (let i = 0; i < cssCount; i++) {
cssArray[i] = reader.stringWithLength(16);
}
editCssArray(cssArray);
}
// Skip to the last route
let nextRouteId = reader.i32();
while (nextRouteId != null && nextRouteId !== -1) {
const i = reader.i32();
reader.cursor += 16 * Math.max(0, i);
nextRouteId = reader.i32();
}
break;
} else {
// Skip to the next route
const i = reader.i32();
reader.cursor += 16 * Math.max(0, i);
}
} while (true);
// List 3
{
let i = reader.u32();
while (i--) {
const identifier = reader.stringWithLength(16);
const code = reader.string32();
editCssContent(identifier, code);
}
}
if (hasFatalError && (isServerSideRouteUpdate || reader.hasMoreData())) {
fullReload();
return;
}
if (isServerSideRouteUpdate) {
performRouteReload();
return;
}
// JavaScript modules
if (reader.hasMoreData()) {
const sourceMapSize = reader.u32();
const rest = reader.rest();
const sourceMapId = td.decode(new Uint8Array(rest, rest.byteLength - 24, 16));
DEBUG.ASSERT(sourceMapId.match(/[a-f0-9]{16}/));
const blob = new Blob([rest], { type: "application/javascript" });
const url = URL.createObjectURL(blob);
const script = document.createElement("script");
scriptTags.set(sourceMapId, [script, sourceMapSize]);
script.className = "bun-hmr-script";
script.src = url;
script.onerror = onHmrLoadError;
document.head.appendChild(script);
} else {
// Needed for testing.
emitEvent("bun:afterUpdate", null);
}
},
[MessageId.set_url_response](view) {
const reader = new DataViewReader(view, 1);
currentRouteIndex = reader.u32();
},
[MessageId.errors]: onServerErrorPayload,
};
const ws = initWebSocket(handlers, {
onStatusChange(connected) {
emitEvent(connected ? "bun:ws:connect" : "bun:ws:disconnect", null);
},
});
function onHmrLoadError(event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error) {
if (typeof event === "string") {
console.error(event);
} else if (error) {
console.error(error);
} else {
console.error("Failed to load HMR script", event);
}
fullReload();
}
// Before loading user code, instrument some globals.
{
const truePushState = History.prototype.pushState;
History.prototype.pushState = function pushState(this: History, state: any, title: string, url?: string | null) {
truePushState.call(this, state, title, url);
ws.sendBuffered("n" + location.pathname);
};
const trueReplaceState = History.prototype.replaceState;
History.prototype.replaceState = function replaceState(
this: History,
state: any,
title: string,
url?: string | null,
) {
trueReplaceState.call(this, state, title, url);
ws.sendBuffered("n" + location.pathname);
};
}
window.addEventListener("error", event => {
onRuntimeError(event.error, true, false);
});
window.addEventListener("unhandledrejection", event => {
onRuntimeError(event.reason, true, true);
});
{
let reloadError: any = sessionStorage?.getItem?.("bun:hmr:message");
if (reloadError) {
sessionStorage.removeItem("bun:hmr:message");
reloadError = JSON.parse(reloadError);
if (reloadError.kind === "warn") {
console.warn(reloadError.message);
} else {
console.error(reloadError.message);
}
}
}
// This implements streaming console.log and console.error from the browser to the server.
//
// Bun.serve({
// development: {
// console: true,
// ^^^^^^^^^^^^^^^^
// },
// })
//
if (config.console) {
// Ensure it only runs once, and avoid the extra noise in the HTML.
const originalLog = console.log;
function websocketInspect(logLevel: "l" | "e", values: any[]) {
let str = "l" + logLevel;
let first = true;
for (const value of values) {
if (first) {
first = false;
} else {
str += " ";
}
if (typeof value === "string") {
str += value;
} else {
str += inspect(value);
}
}
ws.sendBuffered(str);
}
if (typeof originalLog === "function") {
console.log = function log(...args: any[]) {
originalLog(...args);
websocketInspect("l", args);
};
}
if (typeof consoleErrorWithoutInspector === "function") {
console.error = function error(...args: any[]) {
consoleErrorWithoutInspector(...args);
websocketInspect("e", args);
};
}
}
// The following API may be altered at any point.
// Thankfully, you can just call `import.meta.hot.on`
let testingHook = globalThis[Symbol.for("bun testing api, may change at any time")];
testingHook?.({
configureSourceMapGCSize,
clearDisconnectedSourceMaps,
getKnownSourceMaps,
});
try {
const { refresh } = config;
if (refresh) {
const refreshRuntime = await loadModuleAsync(refresh, false, null);
setRefreshRuntime(refreshRuntime);
}
await loadModuleAsync(config.main, false, null);
emitEvent("bun:ready", null);
} catch (e) {
// Use consoleErrorWithoutInspector to avoid double-reporting errors.
consoleErrorWithoutInspector(e);
onRuntimeError(e, true, false);
}