diff --git a/packages/bun-types/devserver.d.ts b/packages/bun-types/devserver.d.ts index cfb0ebe9eb..53df578834 100644 --- a/packages/bun-types/devserver.d.ts +++ b/packages/bun-types/devserver.d.ts @@ -3,6 +3,7 @@ export {}; declare global { namespace Bun { type HMREventNames = + | "bun:ready" | "bun:beforeUpdate" | "bun:afterUpdate" | "bun:beforeFullReload" @@ -49,8 +50,6 @@ declare global { * * In production, `data` is inlined to be `{}`. This is handy because Bun * knows it can minify `{}.prop ??= value` into `value` in production. - * - * */ data: any; diff --git a/src/bake/DevServer.zig b/src/bake/DevServer.zig index afcea6413b..392f2b760b 100644 --- a/src/bake/DevServer.zig +++ b/src/bake/DevServer.zig @@ -100,6 +100,11 @@ server_register_update_callback: JSC.Strong, bun_watcher: *bun.Watcher, directory_watchers: DirectoryWatchStore, watcher_atomics: WatcherAtomics, +testing_batch_events: union(enum) { + disabled, + enable_after_bundle, + enabled: TestingBatch, +}, /// Number of bundles that have been executed. This is currently not read, but /// will be used later to determine when to invoke graph garbage collection. @@ -166,6 +171,9 @@ deferred_request_pool: bun.HiveArray(DeferredRequest.Node, DeferredRequest.max_p /// UWS can handle closing the websocket connections themselves active_websocket_connections: std.AutoHashMapUnmanaged(*HmrSocket, void), +relative_path_buf_lock: bun.DebugThreadLock, +relative_path_buf: bun.PathBuffer, + // Debugging dump_dir: if (bun.FeatureFlags.bake_debugging_features) ?std.fs.Dir else void, @@ -410,6 +418,8 @@ pub fn init(options: Options) bun.JSOOM!*DevServer { true else bun.getRuntimeFeatureFlag("BUN_ASSUME_PERFECT_INCREMENTAL"), + .relative_path_buf_lock = .unlocked, + .testing_batch_events = .disabled, .server_transpiler = undefined, .client_transpiler = undefined, @@ -420,6 +430,7 @@ pub fn init(options: Options) bun.JSOOM!*DevServer { .watcher_atomics = undefined, .log = undefined, .deferred_request_pool = undefined, + .relative_path_buf = undefined, }); errdefer bun.destroy(dev); const allocator = dev.allocation_scope.allocator(); @@ -646,6 +657,8 @@ pub fn deinit(dev: *DevServer) void { .framework = {}, .bundler_options = {}, .assume_perfect_incremental_bundling = {}, + .relative_path_buf = {}, + .relative_path_buf_lock = {}, .graph_safety_lock = dev.graph_safety_lock.lock(), .bun_watcher = dev.bun_watcher.deinit(true), @@ -740,6 +753,13 @@ pub fn deinit(dev: *DevServer) void { event.aligned.files.deinit(dev.allocator); event.aligned.extra_files.deinit(dev.allocator); }, + .testing_batch_events = switch (dev.testing_batch_events) { + .disabled => {}, + .enabled => |*batch| { + batch.entry_points.deinit(allocator); + }, + .enable_after_bundle => {}, + }, }; dev.allocation_scope.deinit(); bun.destroy(dev); @@ -776,6 +796,8 @@ pub fn memoryCost(dev: *DevServer) usize { .server_register_update_callback = {}, .deferred_request_pool = {}, .assume_perfect_incremental_bundling = {}, + .relative_path_buf = {}, + .relative_path_buf_lock = {}, // pointers that are not considered a part of DevServer .vm = {}, @@ -887,6 +909,13 @@ pub fn memoryCost(dev: *DevServer) usize { .route_lookup = { cost += memoryCostArrayHashMap(dev.route_lookup); }, + .testing_batch_events = switch (dev.testing_batch_events) { + .disabled => {}, + .enabled => |batch| { + cost += memoryCostArrayHashMap(batch.entry_points.set); + }, + .enable_after_bundle => {}, + }, }; return cost; } @@ -1360,6 +1389,7 @@ fn onFrameworkRequestWithBundle( router_type.server_file_string.get() orelse str: { const name = dev.server_graph.bundled_files.keys()[fromOpaqueFileId(.server, router_type.server_file).get()]; const str = bun.String.createUTF8ForJS(dev.vm.global, dev.relativePath(name)); + dev.releaseRelativePathBuf(); router_type.server_file_string = JSC.Strong.create(str, dev.vm.global); break :str str; }, @@ -1377,10 +1407,12 @@ fn onFrameworkRequestWithBundle( route = dev.router.routePtr(bundle.route_index); var route_name = bun.String.createUTF8(dev.relativePath(keys[fromOpaqueFileId(.server, route.file_page.unwrap().?).get()])); arr.putIndex(global, 0, route_name.transferToJS(global)); + dev.releaseRelativePathBuf(); n = 1; while (true) { if (route.file_layout.unwrap()) |layout| { var layout_name = bun.String.createUTF8(dev.relativePath(keys[fromOpaqueFileId(.server, layout).get()])); + defer dev.releaseRelativePathBuf(); arr.putIndex(global, @intCast(n), layout_name.transferToJS(global)); n += 1; } @@ -1861,7 +1893,7 @@ fn generateClientBundle(dev: *DevServer, route_bundle: *RouteBundle) bun.OOM![]u break :brk; if (!dev.client_graph.stale_files.isSet(rfr_index.get())) { try dev.client_graph.traceImports(rfr_index, >s, .find_client_modules); - react_fast_refresh_id = dev.relativePath(rfr.import_source); + react_fast_refresh_id = rfr.import_source; } } @@ -1884,7 +1916,7 @@ fn generateClientBundle(dev: *DevServer, route_bundle: *RouteBundle) bun.OOM![]u const client_bundle = dev.client_graph.takeJSBundle(.{ .kind = .initial_response, .initial_response_entry_point = if (client_file) |index| - dev.relativePath(dev.client_graph.bundled_files.keys()[index.get()]) + dev.client_graph.bundled_files.keys()[index.get()] else "", .react_refresh_entry_point = react_fast_refresh_id, @@ -1964,6 +1996,7 @@ fn makeArrayForServerComponentsPatch(dev: *DevServer, global: *JSC.JSGlobalObjec const names = dev.server_graph.bundled_files.keys(); for (items, 0..) |item, i| { const str = bun.String.createUTF8(dev.relativePath(names[item.get()])); + defer dev.releaseRelativePathBuf(); defer str.deref(); arr.putIndex(global, @intCast(i), str.toJS(global)); } @@ -2019,6 +2052,7 @@ pub fn finalizeBundle( bv2: *bun.bundle_v2.BundleV2, result: bun.bundle_v2.DevServerOutput, ) bun.OOM!void { + var had_sent_hmr_event = false; defer { bv2.deinit(); dev.current_bundle = null; @@ -2027,6 +2061,20 @@ pub fn finalizeBundle( // not fatal: the assets may be reindexed some time later. }; + // Signal for testing framework where it is in synchronization + if (dev.testing_batch_events == .enable_after_bundle) { + dev.testing_batch_events = .{ .enabled = .empty }; + dev.publish(.testing_watch_synchronization, &.{ + MessageId.testing_watch_synchronization.char(), + 0, + }, .binary); + } else { + dev.publish(.testing_watch_synchronization, &.{ + MessageId.testing_watch_synchronization.char(), + if (had_sent_hmr_event) 4 else 3, + }, .binary); + } + dev.startNextBundleIfPresent(); // Unref the ref added in `startAsyncBundle` @@ -2252,6 +2300,11 @@ pub fn finalizeBundle( } // Index all failed files now that the incremental graph has been updated. + if (dev.incremental_result.failures_removed.items.len > 0 or + dev.incremental_result.failures_added.items.len > 0) + { + had_sent_hmr_event = true; + } try dev.indexFailures(); try dev.client_graph.ensureStaleBitCapacity(false); @@ -2459,10 +2512,10 @@ pub fn finalizeBundle( } try w.writeInt(i32, -1, .little); - // Send CSS mutations const css_chunks = result.cssChunks(); if (will_hear_hot_update) { if (dev.client_graph.current_chunk_len > 0 or css_chunks.len > 0) { + // Send CSS mutations const asset_values = dev.assets.files.values(); try w.writeInt(u32, @intCast(css_chunks.len), .little); const sources = bv2.graph.input_files.items(.source); @@ -2474,6 +2527,7 @@ pub fn finalizeBundle( try w.writeAll(css_data); } + // Send the JS chunk if (dev.client_graph.current_chunk_len > 0) { const hash = hash: { var source_map_hash: bun.bundle_v2.ContentHasher.Hash = .init(0x4b12); // arbitrarily different seed than what .initial_response uses @@ -2499,6 +2553,7 @@ pub fn finalizeBundle( } dev.publish(.hot_update, hot_update_payload.items, .binary); + had_sent_hmr_event = true; } if (dev.incremental_result.failures_added.items.len > 0) { @@ -2578,6 +2633,7 @@ pub fn finalizeBundle( break :file_name dev.relativePath(abs_path); }, }; + defer dev.releaseRelativePathBuf(); const total_count = bv2.graph.entry_points.items.len; if (file_name) |name| { Output.prettyError(": {s}", .{name}); @@ -2645,7 +2701,9 @@ fn startNextBundleIfPresent(dev: *DevServer) void { dev.appendRouteEntryPointsIfNotStale(&entry_points, temp_alloc, route_bundle_index) catch bun.outOfMemory(); } - dev.startAsyncBundle(entry_points, is_reload, timer) catch bun.outOfMemory(); + if (entry_points.set.count() > 0) { + dev.startAsyncBundle(entry_points, is_reload, timer) catch bun.outOfMemory(); + } dev.next_bundle.route_queue.clearRetainingCapacity(); } @@ -4338,6 +4396,7 @@ pub fn IncrementalGraph(side: bake.Side) type { dev.relativePath(gop.key_ptr.*), log.msgs.items, ); + defer dev.releaseRelativePathBuf(); const fail_gop = try dev.bundling_failures.getOrPut(dev.allocator, failure); try dev.incremental_result.failures_added.append(dev.allocator, failure); if (fail_gop.found_existing) { @@ -4556,6 +4615,7 @@ pub fn IncrementalGraph(side: bake.Side) type { w, .utf8, ); + g.owner().releaseRelativePathBuf(); } else { try w.writeAll("null"); } @@ -4571,6 +4631,7 @@ pub fn IncrementalGraph(side: bake.Side) type { w, .utf8, ); + g.owner().releaseRelativePathBuf(); } try w.writeAll("\n"); }, @@ -4648,21 +4709,32 @@ pub fn IncrementalGraph(side: bake.Side) type { var source_map_strings = std.ArrayList(u8).init(arena); defer source_map_strings.deinit(); + const dev = g.owner(); + dev.relative_path_buf_lock.lock(); + defer dev.relative_path_buf_lock.unlock(); + + const buf = bun.PathBufferPool.get(); + defer bun.PathBufferPool.put(buf); + var path_count: usize = 0; for (g.current_chunk_parts.items) |entry| { path_count += 1; try source_map_strings.appendSlice(","); - const path = paths[entry.get()]; + const path = if (Environment.isWindows) + bun.path.pathToPosixBuf(u8, paths[entry.get()], buf) + else + paths[entry.get()]; + if (std.fs.path.isAbsolute(path)) { - const is_windows_drive_path = Environment.isWindows and bun.path.isSepAny(path[0]); + const is_windows_drive_path = Environment.isWindows and path[0] != '/'; try source_map_strings.appendSlice(if (is_windows_drive_path) - "file:///" + "\"file:///" else "\"file://"); if (Environment.isWindows and !is_windows_drive_path) { // UNC namespace -> file://server/share/path.ext bun.strings.percentEncodeWrite( - if (path.len > 2 and bun.path.isSepAny(path[0]) and bun.path.isSepAny(path[1])) + if (path.len > 2 and path[0] == '/' and path[1] == '/') path[2..] else path, // invalid but must not crash @@ -5639,6 +5711,7 @@ fn writeVisualizerMessage(dev: *DevServer, payload: *std.ArrayList(u8)) !void { 0.., ) |k, v, i| { const normalized_key = dev.relativePath(k); + defer dev.releaseRelativePathBuf(); try w.writeInt(u32, @intCast(normalized_key.len), .little); if (k.len == 0) continue; try w.writeAll(normalized_key); @@ -5704,12 +5777,13 @@ pub fn onWebSocketUpgrade( /// Every message is to use `.binary`/`ArrayBuffer` transport mode. The first byte /// indicates a Message ID; see comments on each type for how to interpret the rest. +/// Avoid changing message ID values, as some of these are hard-coded in tests. /// /// This format is only intended for communication via the browser and DevServer. /// Server-side HMR is implemented using a different interface. This API is not /// versioned alongside Bun; breaking changes may occur at any point. /// -/// All integers are sent in little-endian +/// All integers are sent in little-endian. pub const MessageId = enum(u8) { /// Version payload. Sent on connection startup. The client should issue a /// hard-reload when it mismatches with its `config.version`. @@ -5797,13 +5871,15 @@ pub const MessageId = enum(u8) { set_url_response = 'n', /// Used for synchronization in DevServer tests, to identify when a update was /// acknowledged by the watcher but intentionally took no action. - redundant_watch = 'r', + /// - `u8`: See bake-harness.ts WatchSynchronization enum. + testing_watch_synchronization = 'r', pub inline fn char(id: MessageId) u8 { return @intFromEnum(id); } }; +/// Avoid changing message ID values, as some of these are hard-coded in tests. pub const IncomingMessageId = enum(u8) { /// Subscribe to an event channel. Payload is a sequence of chars available /// in HmrTopic. @@ -5811,6 +5887,8 @@ pub const IncomingMessageId = enum(u8) { /// Emitted on client-side navigations. /// Rest of payload is a UTF-8 string. set_url = 'n', + /// Tells the DevServer to batch events together. + testing_batch_events = 'H', /// Invalid data _, @@ -5821,7 +5899,7 @@ const HmrTopic = enum(u8) { errors = 'e', browser_error = 'E', visualizer = 'v', - redundant_watch = 'r', + testing_watch_synchronization = 'r', /// Invalid data _, @@ -5926,6 +6004,44 @@ const HmrSocket = struct { var response: [5]u8 = .{MessageId.set_url_response.char()} ++ std.mem.toBytes(rbi.get()); _ = ws.send(&response, .binary, false, true); }, + .testing_batch_events => switch (s.dev.testing_batch_events) { + .disabled => { + if (s.dev.current_bundle != null) { + s.dev.testing_batch_events = .enable_after_bundle; + } else { + s.dev.testing_batch_events = .{ .enabled = .empty }; + s.dev.publish(.testing_watch_synchronization, &.{ + MessageId.testing_watch_synchronization.char(), + 0, + }, .binary); + } + }, + .enable_after_bundle => { + // do not expose a websocket event that panics a release build + bun.debugAssert(false); + ws.close(); + }, + .enabled => |event_const| { + var event = event_const; + s.dev.testing_batch_events = .disabled; + + if (event.entry_points.set.count() == 0) { + s.dev.publish(.testing_watch_synchronization, &.{ + MessageId.testing_watch_synchronization.char(), + 2, + }, .binary); + return; + } + + s.dev.startAsyncBundle( + event.entry_points, + true, + std.time.Timer.start() catch @panic("timers unsupported"), + ) catch bun.outOfMemory(); + + event.entry_points.deinit(s.dev.allocator); + }, + }, _ => ws.close(), } } @@ -6183,7 +6299,10 @@ pub const HotReloadEvent = struct { bun.fmt.fmtSlice(event.dirs.keys(), ", "), }); - dev.publish(.redundant_watch, &.{MessageId.redundant_watch.char()}, .binary); + dev.publish(.testing_watch_synchronization, &.{ + MessageId.testing_watch_synchronization.char(), + 1, + }, .binary); return; } @@ -6202,6 +6321,7 @@ pub const HotReloadEvent = struct { defer debug.log("HMR Task end", .{}); const dev = first.owner; + if (Environment.isDebug) { assert(first.debug_mutex.tryLock()); assert(first.contention_indicator.load(.seq_cst) == 0); @@ -6214,7 +6334,7 @@ pub const HotReloadEvent = struct { var sfb = std.heap.stackFallback(4096, dev.allocator); const temp_alloc = sfb.get(); - var entry_points: EntryPointList = EntryPointList.empty; + var entry_points: EntryPointList = .empty; defer entry_points.deinit(temp_alloc); first.processFileList(dev, &entry_points, temp_alloc); @@ -6233,6 +6353,19 @@ pub const HotReloadEvent = struct { return; } + switch (dev.testing_batch_events) { + .disabled => {}, + .enabled => |*ev| { + ev.append(dev, entry_points) catch bun.outOfMemory(); + dev.publish(.testing_watch_synchronization, &.{ + MessageId.testing_watch_synchronization.char(), + 1, + }, .binary); + return; + }, + .enable_after_bundle => bun.debugAssert(false), + } + dev.startAsyncBundle( entry_points, true, @@ -6514,6 +6647,8 @@ pub fn onRouterCollisionError(dev: *DevServer, rel_path: []const u8, other_id: O dev.relativePath(dev.server_graph.bundled_files.keys()[fromOpaqueFileId(.server, other_id).get()]), }); Output.flush(); + + dev.releaseRelativePathBuf(); } fn toOpaqueFileId(comptime side: bake.Side, index: IncrementalGraph(side).FileIndex) OpaqueFileId { @@ -6537,7 +6672,9 @@ fn fromOpaqueFileId(comptime side: bake.Side, id: OpaqueFileId) IncrementalGraph } /// Returns posix style path, suitible for URLs and reproducible hashes. -fn relativePath(dev: *const DevServer, path: []const u8) []const u8 { +/// To avoid overwriting memory, this has a lock for the buffer. +fn relativePath(dev: *DevServer, path: []const u8) []const u8 { + dev.relative_path_buf_lock.lock(); bun.assert(dev.root[dev.root.len - 1] != '/'); if (!std.fs.path.isAbsolute(path)) { @@ -6550,15 +6687,20 @@ fn relativePath(dev: *const DevServer, path: []const u8) []const u8 { { return path[dev.root.len + 1 ..]; } - const relative_path_buf = &struct { - threadlocal var buf: bun.PathBuffer = undefined; - }.buf; - const rel = bun.path.relativePlatformBuf(relative_path_buf, dev.root, path, .auto, true); - // @constCast: `rel` is owned by a mutable threadlocal buffer above + + const rel = bun.path.relativePlatformBuf(&dev.relative_path_buf, dev.root, path, .auto, true); + // @constCast: `rel` is owned by a buffer on `dev`, which is mutable bun.path.platformToPosixInPlace(u8, @constCast(rel)); return rel; } +fn releaseRelativePathBuf(dev: *DevServer) void { + dev.relative_path_buf_lock.unlock(); + if (bun.Environment.isDebug) { + dev.relative_path_buf = undefined; + } +} + fn dumpStateDueToCrash(dev: *DevServer) !void { comptime assert(bun.FeatureFlags.bake_debugging_features); @@ -7190,6 +7332,7 @@ const ErrorReportRequest = struct { const abs_path = result.file_paths[@intCast(index - 1)]; frame.source_url = .init(abs_path); const rel_path = ctx.dev.relativePath(abs_path); + defer ctx.dev.releaseRelativePathBuf(); if (bun.strings.eql(frame.function_name.value.ZigString.slice(), rel_path)) { frame.function_name = .empty; } @@ -7279,6 +7422,7 @@ const ErrorReportRequest = struct { const src_to_write = frame.source_url.value.ZigString.slice(); if (bun.strings.hasPrefixComptime(src_to_write, "/")) { const file = ctx.dev.relativePath(src_to_write); + defer ctx.dev.releaseRelativePathBuf(); try w.writeInt(u32, @intCast(file.len), .little); try w.writeAll(file); } else { @@ -7413,6 +7557,19 @@ fn readString32(reader: anytype, alloc: Allocator) ![]const u8 { return memory; } +const TestingBatch = struct { + entry_points: EntryPointList, + + const empty: @This() = .{ .entry_points = .empty }; + + pub fn append(self: *@This(), dev: *DevServer, entry_points: EntryPointList) !void { + assert(entry_points.set.count() > 0); + for (entry_points.set.keys(), entry_points.set.values()) |k, v| { + try self.entry_points.append(dev.allocator, k, v); + } + } +}; + /// userland implementation of https://github.com/ziglang/zig/issues/21879 fn VoidFieldTypes(comptime T: type) type { const fields = @typeInfo(T).@"struct".fields; diff --git a/src/bake/bake.private.d.ts b/src/bake/bake.private.d.ts index fced2377a6..c4b66b1d15 100644 --- a/src/bake/bake.private.d.ts +++ b/src/bake/bake.private.d.ts @@ -26,6 +26,14 @@ interface Config { roots: FileIndex[]; } +declare namespace DEBUG { + /** + * Set globally in debug builds. + * Removed using --drop=DEBUG.ASSERT in releases. + */ + declare function ASSERT(condition: any, message?: string): asserts condition; +} + /** All modules for the initial bundle. */ declare const unloadedModuleRegistry: Record; declare type UnloadedModule = UnloadedESM | UnloadedCommonJS; @@ -40,11 +48,12 @@ declare type EncodedDependencyArray = (string | number)[]; declare type UnloadedCommonJS = ( hmr: import("./hmr-module").HMRModule, module: import("./hmr-module").HMRModule["cjs"], -) => any; + exports: unknown, +) => unknown; declare type CommonJSModule = { id: Id; exports: any; - require: (id: Id) => any; + require: (id: Id) => unknown; }; declare const config: Config; diff --git a/src/bake/client/css-reloader.ts b/src/bake/client/css-reloader.ts index 7fb19976b1..c22530d8de 100644 --- a/src/bake/client/css-reloader.ts +++ b/src/bake/client/css-reloader.ts @@ -180,7 +180,8 @@ export function editCssContent(id: string, newContent: string) { // Disable the link tag if it exists const linkSheet = entry.link?.sheet; if (linkSheet) linkSheet.disabled = true; - return; + return false; } sheet!.replace(newContent); + return !sheet!.disabled; } diff --git a/src/bake/debug.ts b/src/bake/debug.ts new file mode 100644 index 0000000000..64c96497e4 --- /dev/null +++ b/src/bake/debug.ts @@ -0,0 +1,14 @@ +if (IS_BUN_DEVELOPMENT) { + // After 1.2.6 is released, this can just be `ASSERT` + globalThis.DEBUG = { + ASSERT: function ASSERT(condition: any, message?: string): asserts condition { + if (!condition) { + if (typeof Bun === "undefined") { + console.assert(false, "ASSERTION FAILED" + (message ? `: ${message}` : "")); + } else { + console.error("ASSERTION FAILED" + (message ? `: ${message}` : "")); + } + } + }, + }; +} diff --git a/src/bake/hmr-module.ts b/src/bake/hmr-module.ts index 05241ada89..940a209c64 100644 --- a/src/bake/hmr-module.ts +++ b/src/bake/hmr-module.ts @@ -58,6 +58,12 @@ interface HotAccept { single: boolean; } +interface CJSModule { + id: Id; + exports: unknown; + require: (id: Id) => unknown; +} + /** Implementation details must remain in sync with js_parser.zig and bundle_v2.zig */ export class HMRModule { /** Key in `registry` */ @@ -69,10 +75,10 @@ export class HMRModule { exports: any = null; /** For ESM, this is the converted CJS exports. * For CJS, this is the `module` object. */ - cjs: any; + cjs: CJSModule | any | null; /** When a module fails to load, trying to load it again * should throw the same error */ - failure: any = null; + failure: unknown = null; /** Two purposes: * 1. HMRModule[] - List of parsed imports. indexOf is used to go from HMRModule -> updater function * 2. any[] - List of module namespace objects. Read by the ESM module's load function. @@ -129,27 +135,6 @@ export class HMRModule { : import(id); } - /** - * Files which only export functions (and have no other statements) are - * implicitly `import.meta.hot.accept`ed, however it is done in a special way - * where functions are proxied. This is special behavior to make stuff "just - * work". - */ - implicitlyAccept(exports) { - if (IS_BUN_DEVELOPMENT) assert(this.esm); - this.selfAccept ??= implicitAcceptFunction; - const current = ((this.selfAccept as any).current ??= {}); - if (IS_BUN_DEVELOPMENT) assert(typeof exports === "object"); - const moduleExports = (this.exports = {}); - for (const exportName in exports) { - const source = (current[exportName] = exports[exportName]); - if (IS_BUN_DEVELOPMENT) assert(typeof source === "function"); - const proxied = (moduleExports[exportName] ??= proxyFn(current, exportName)); - Object.defineProperty(proxied, "name", { value: source.name }); - Object.defineProperty(proxied, "length", { value: source.length }); - } - } - reactRefreshAccept() { if (isReactRefreshBoundary(this.exports)) { this.accept(); @@ -297,15 +282,24 @@ export function loadModuleSync(id: Id, isUserDynamic: boolean, importer: HMRModu if (!mod) { mod = new HMRModule(id, true); registry.set(id, mod); + } else if (mod.esm) { + mod.esm = false; + mod.cjs = { + id, + exports: {}, + require: mod.require.bind(this), + }; + mod.exports = null; } if (importer) { mod.importers.add(importer); } try { - loadOrEsmModule(mod, mod.cjs); + const cjs = mod.cjs; + loadOrEsmModule(mod, cjs, cjs.exports); } catch (e) { - mod.state = State.Error; - mod.failure = e; + mod.state = State.Stale; + mod.cjs.exports = {}; throw e; } mod.state = State.Loaded; @@ -313,11 +307,11 @@ export function loadModuleSync(id: Id, isUserDynamic: boolean, importer: HMRModu // ESM if (IS_BUN_DEVELOPMENT) { try { - assert(Array.isArray(loadOrEsmModule[ESMProps.imports])); - assert(Array.isArray(loadOrEsmModule[ESMProps.exports])); - assert(Array.isArray(loadOrEsmModule[ESMProps.stars])); - assert(typeof loadOrEsmModule[ESMProps.load] === "function"); - assert(typeof loadOrEsmModule[ESMProps.isAsync] === "boolean"); + DEBUG.ASSERT(Array.isArray(loadOrEsmModule[ESMProps.imports])); + DEBUG.ASSERT(Array.isArray(loadOrEsmModule[ESMProps.exports])); + DEBUG.ASSERT(Array.isArray(loadOrEsmModule[ESMProps.stars])); + DEBUG.ASSERT(typeof loadOrEsmModule[ESMProps.load] === "function"); + DEBUG.ASSERT(typeof loadOrEsmModule[ESMProps.isAsync] === "boolean"); } catch (e) { console.warn(id, loadOrEsmModule); throw e; @@ -330,6 +324,10 @@ export function loadModuleSync(id: Id, isUserDynamic: boolean, importer: HMRModu if (!mod) { mod = new HMRModule(id, false); registry.set(id, mod); + } else if (!mod.esm) { + mod.esm = true; + mod.cjs = null; + mod.exports = null; } if (importer) { mod.importers.add(importer); @@ -384,29 +382,37 @@ export function loadModuleAsync( if (!mod) { mod = new HMRModule(id, true); registry.set(id, mod); + } else if (mod.esm) { + mod.esm = false; + mod.cjs = { + id, + exports: {}, + require: mod.require.bind(this), + }; + mod.exports = null; } if (importer) { mod.importers.add(importer); } try { - loadOrEsmModule(mod, mod.cjs); + const cjs = mod.cjs; + loadOrEsmModule(mod, cjs, cjs.exports); } catch (e) { - mod.state = State.Error; - mod.failure = e; + mod.state = State.Stale; + mod.cjs.exports = {}; throw e; } mod.state = State.Loaded; - return mod; } else { // ESM if (IS_BUN_DEVELOPMENT) { try { - assert(Array.isArray(loadOrEsmModule[0])); - assert(Array.isArray(loadOrEsmModule[1])); - assert(Array.isArray(loadOrEsmModule[2])); - assert(typeof loadOrEsmModule[3] === "function"); - assert(typeof loadOrEsmModule[4] === "boolean"); + DEBUG.ASSERT(Array.isArray(loadOrEsmModule[0])); + DEBUG.ASSERT(Array.isArray(loadOrEsmModule[1])); + DEBUG.ASSERT(Array.isArray(loadOrEsmModule[2])); + DEBUG.ASSERT(typeof loadOrEsmModule[3] === "function"); + DEBUG.ASSERT(typeof loadOrEsmModule[4] === "boolean"); } catch (e) { console.warn(id, loadOrEsmModule); throw e; @@ -417,19 +423,21 @@ export function loadModuleAsync( if (!mod) { mod = new HMRModule(id, false); registry.set(id, mod); + } else if (!mod.esm) { + mod.esm = true; + mod.exports = null; + mod.cjs = null; } if (importer) { mod.importers.add(importer); } const { list, isAsync } = parseEsmDependencies(mod, deps, loadModuleAsync); - if (IS_BUN_DEVELOPMENT) { - if (isAsync) { - assert(list.some(x => x instanceof Promise)); - } else { - assert(list.every(x => x instanceof HMRModule)); - } - } + DEBUG.ASSERT( + isAsync // + ? list.some(x => x instanceof Promise) + : list.every(x => x instanceof HMRModule) + ); // Running finishLoadModuleAsync synchronously when there are no promises is // not a performance optimization but a behavioral correctness issue. @@ -481,7 +489,7 @@ function finishLoadModuleAsync(mod: HMRModule, load: UnloadedESM[3], modules: HM type GenericModuleLoader = (id: Id, isUserDynamic: false, importer: HMRModule) => R; // TODO: This function is currently recursive. function parseEsmDependencies>( - mod: HMRModule, + parent: HMRModule, deps: (string | number)[], enqueueModuleLoad: T, ) { @@ -491,10 +499,11 @@ function parseEsmDependencies>( const { length } = deps; while (i < length) { const dep = deps[i] as string; - if (IS_BUN_DEVELOPMENT) assert(typeof dep === "string"); + DEBUG.ASSERT(typeof dep === "string"); let expectedExportKeyEnd = i + 2 + (deps[i + 1] as number); - if (IS_BUN_DEVELOPMENT) assert(typeof deps[i + 1] === "number"); - list.push(enqueueModuleLoad(dep, false, mod)); + DEBUG.ASSERT(typeof deps[i + 1] === "number"); + const promiseOrModule = enqueueModuleLoad(dep, false, parent); + list.push(promiseOrModule); const unloadedModule = unloadedModuleRegistry[dep]; if (!unloadedModule) { @@ -505,18 +514,27 @@ function parseEsmDependencies>( i += 2; while (i < expectedExportKeyEnd) { const key = deps[i] as string; - if (IS_BUN_DEVELOPMENT) assert(typeof key === "string"); - if (!availableExportKeys.includes(key)) { - if (!hasExportStar(unloadedModule[ESMProps.stars], key)) { - throw new SyntaxError(`Module "${dep}" does not export key "${key}"`); - } - } + DEBUG.ASSERT(typeof key === "string"); + // TODO: there is a bug in the way exports are verified. Additionally a + // possible performance issue. For the meantime, this is disabled since + // it was not shipped in the initial 1.2.3 HMR, and real issues will + // just throw 'undefined is not a function' or so on. + + // if (!availableExportKeys.includes(key)) { + // if (!hasExportStar(unloadedModule[ESMProps.stars], key)) { + // throw new SyntaxError(`Module "${dep}" does not export key "${key}"`); + // } + // } i++; } - isAsync ||= unloadedModule[ESMProps.isAsync]; + isAsync ||= promiseOrModule instanceof Promise; } else { - if (IS_BUN_DEVELOPMENT) assert(!registry.get(dep)?.esm); + DEBUG.ASSERT(!registry.get(dep)?.esm); i = expectedExportKeyEnd; + + if (IS_BUN_DEVELOPMENT) { + DEBUG.ASSERT(list[list.length - 1] as any instanceof HMRModule); + } } } return { list, isAsync }; @@ -531,7 +549,7 @@ function hasExportStar(starImports: Id[], key: string) { if (visited.has(starImport)) continue; visited.add(starImport); const mod = unloadedModuleRegistry[starImport]; - if (IS_BUN_DEVELOPMENT) assert(mod, `Module "${starImport}" not found`); + DEBUG.ASSERT(mod, `Module "${starImport}" not found`); if (typeof mod === "function") { return true; } @@ -562,6 +580,7 @@ type HotEventHandler = (data: any) => void; // If updating this, make sure the `devserver.d.ts` types are // kept in sync. type HMREvent = + | "bun:ready" | "bun:beforeUpdate" | "bun:afterUpdate" | "bun:beforeFullReload" @@ -651,11 +670,9 @@ export async function replaceModules(modules: Record) { for (const boundary of failures) { const path: Id[] = []; let current = registry.get(boundary)!; - if (IS_BUN_DEVELOPMENT) { - assert(!boundary.endsWith(".html")); // caller should have already reloaded - assert(current); - assert(current.selfAccept === null); - } + DEBUG.ASSERT(!boundary.endsWith(".html")); // caller should have already reloaded + DEBUG.ASSERT(current); + DEBUG.ASSERT(current.selfAccept === null); if (current.importers.size === 0) { message += `Module "${boundary}" is a root module that does not self-accept.\n`; continue; @@ -668,13 +685,11 @@ export async function replaceModules(modules: Record) { current = importer; continue outer; } - if (IS_BUN_DEVELOPMENT) assert(false); + DEBUG.ASSERT(false); break; } path.push(current.id); - if (IS_BUN_DEVELOPMENT) { - assert(path.length > 0); - } + DEBUG.ASSERT(path.length > 0); message += `Module "${boundary}" is not accepted by ${path[1]}${path.length > 1 ? "," : "."}\n`; for (let i = 2, len = path.length; i < len; i++) { const isLast = i === len - 1; @@ -728,7 +743,7 @@ export async function replaceModules(modules: Record) { selfAccept(getEsmExports(mod)); } } else { - if (IS_BUN_DEVELOPMENT) assert(modOrPromise instanceof Promise); + DEBUG.ASSERT(modOrPromise instanceof Promise); promises.push( (modOrPromise as Promise).then(mod => { if (selfAccept) { @@ -776,7 +791,7 @@ function createAcceptArray(modules: string[], key: Id) { const arr = new Array(modules.length); arr.fill(undefined); const i = modules.indexOf(key); - if (IS_BUN_DEVELOPMENT) assert(i !== -1); + DEBUG.ASSERT(i !== -1); arr[i] = getEsmExports(registry.get(key)!); return arr; } @@ -852,12 +867,6 @@ function registerSynthetic(id: Id, esmExports) { registry.set(id, module); } -function assert(condition: any, message?: string): asserts condition { - if (!condition) { - console.assert(false, "ASSERTION FAILED" + (message ? `: ${message}` : "")); - } -} - export function setRefreshRuntime(runtime: HMRModule) { refreshRuntime = getEsmExports(runtime); @@ -902,14 +911,6 @@ function isReactRefreshBoundary(esmExports): boolean { function implicitAcceptFunction() {} -const apply = Function.prototype.apply; -function proxyFn(target: any, key: string) { - const f = function () { - return apply.call(target[key], this, arguments); - }; - return f; -} - declare global { interface Error { asyncId?: string; @@ -940,3 +941,13 @@ if (side === "client") { onServerSideReload: cb => (onServerSideReload = cb), }); } + +// The following API may be altered at any point. +// Thankfully, you can just call `import.meta.hot.on` +let testingHook = globalThis['bun do not use this outside of internal testing or else i\'ll cry']; +testingHook?.({ + onEvent(event: HMREvent, cb) { + eventHandlers[event] ??= []; + eventHandlers[event]!.push(cb); + }, +}); \ No newline at end of file diff --git a/src/bake/hmr-runtime-client.ts b/src/bake/hmr-runtime-client.ts index d5ce1b4113..804d0aecf6 100644 --- a/src/bake/hmr-runtime-client.ts +++ b/src/bake/hmr-runtime-client.ts @@ -1,5 +1,6 @@ // This file is the entrypoint to the hot-module-reloading runtime // In the browser, this uses a WebSocket to communicate with the bundler. +import './debug'; import { loadModuleAsync, replaceModules, @@ -153,8 +154,12 @@ const handlers = { onRuntimeError(e, true, false); return; } + emitEvent("bun:error", e); throw e; } + } else { + // Needed for testing. + emitEvent("bun:afterUpdate", null); } }, [MessageId.set_url_response](view) { @@ -216,6 +221,8 @@ try { } await loadModuleAsync(config.main, false, null); + + emitEvent("bun:ready", null); } catch (e) { console.error(e); onRuntimeError(e, true, false); diff --git a/src/bake/hmr-runtime-error.ts b/src/bake/hmr-runtime-error.ts index ab0de38fac..20957a10c9 100644 --- a/src/bake/hmr-runtime-error.ts +++ b/src/bake/hmr-runtime-error.ts @@ -6,6 +6,7 @@ // This is embedded in `DevServer.sendSerializedFailures`. SSR is // left unused for simplicity; a flash of unstyled content is // stopped by the fact this script runs synchronously. +import './debug'; import { decodeAndAppendServerError, onServerErrorPayload, updateErrorOverlay } from "./client/overlay"; import { DataViewReader } from "./client/data-view"; import { initWebSocket } from "./client/websocket"; diff --git a/src/bake/hmr-runtime-server.ts b/src/bake/hmr-runtime-server.ts index eae7d974ec..cd76fea987 100644 --- a/src/bake/hmr-runtime-server.ts +++ b/src/bake/hmr-runtime-server.ts @@ -1,5 +1,6 @@ // This file is the entrypoint to the hot-module-reloading runtime. // On the server, communication is established with `server_exports`. +import './debug'; import type { Bake } from "bun"; import { loadExports, replaceModules, ssrManifest, serverManifest, HMRModule } from "./hmr-module"; diff --git a/src/bun.js/api/server.zig b/src/bun.js/api/server.zig index 36e3049e80..3521dab350 100644 --- a/src/bun.js/api/server.zig +++ b/src/bun.js/api/server.zig @@ -8706,7 +8706,7 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp } const result: JSValue = onNodeHTTPRequestFn( - @bitCast(AnyServer.from(this)), + @intFromPtr(AnyServer.from(this).ptr.ptr()), globalThis, thisObject, this.config.onNodeHTTPRequest, @@ -9610,7 +9610,7 @@ pub const HTTPServer = NewServer(JSC.Codegen.JSHTTPServer, false, false); pub const HTTPSServer = NewServer(JSC.Codegen.JSHTTPSServer, true, false); pub const DebugHTTPServer = NewServer(JSC.Codegen.JSDebugHTTPServer, false, true); pub const DebugHTTPSServer = NewServer(JSC.Codegen.JSDebugHTTPSServer, true, true); -pub const AnyServer = packed struct { +pub const AnyServer = struct { ptr: Ptr, const Ptr = bun.TaggedPointerUnion(.{ @@ -9862,7 +9862,7 @@ comptime { } extern fn NodeHTTPServer__onRequest_http( - any_server: u64, + any_server: usize, globalThis: *JSC.JSGlobalObject, this: JSC.JSValue, callback: JSC.JSValue, @@ -9873,7 +9873,7 @@ extern fn NodeHTTPServer__onRequest_http( ) JSC.JSValue; extern fn NodeHTTPServer__onRequest_https( - any_server: u64, + any_server: usize, globalThis: *JSC.JSGlobalObject, this: JSC.JSValue, callback: JSC.JSValue, diff --git a/src/bundler/bundle_v2.zig b/src/bundler/bundle_v2.zig index ee7951011c..5afdd4a7d7 100644 --- a/src/bundler/bundle_v2.zig +++ b/src/bundler/bundle_v2.zig @@ -3207,16 +3207,22 @@ pub const BundleV2 = struct { ) catch bun.outOfMemory(); } } else { + const buf = bun.PathBufferPool.get(); + defer bun.PathBufferPool.put(buf); + const specifier_to_use = if (loader == .html and bun.strings.hasPrefix(import_record.path.text, bun.fs.FileSystem.instance.top_level_dir)) brk: { + const specifier_to_use = import_record.path.text[bun.fs.FileSystem.instance.top_level_dir.len..]; + if (Environment.isWindows) { + break :brk bun.path.pathToPosixBuf(u8, specifier_to_use, buf); + } + break :brk specifier_to_use; + } else import_record.path.text; addError( log, source, import_record.range, this.graph.allocator, "Could not resolve: \"{s}\"", - .{if (loader == .html and bun.strings.hasPrefix(import_record.path.text, bun.fs.FileSystem.instance.top_level_dir)) - import_record.path.text[bun.fs.FileSystem.instance.top_level_dir.len..] - else - import_record.path.text}, + .{specifier_to_use}, import_record.kind, ) catch bun.outOfMemory(); } @@ -13790,33 +13796,18 @@ pub const LinkerContext = struct { }) catch unreachable; // is within bounds if (ast.flags.uses_module_ref or ast.flags.uses_exports_ref) { - clousure_args.appendAssumeCapacity( + clousure_args.appendSliceAssumeCapacity(&.{ .{ .binding = Binding.alloc(temp_allocator, B.Identifier{ .ref = ast.module_ref, }, Logger.Loc.Empty), - .default = Expr.allocate(temp_allocator, E.Dot, .{ - .target = Expr.initIdentifier(hmr_api_ref, Logger.Loc.Empty), - .name = "cjs", - .name_loc = Logger.Loc.Empty, - }, Logger.Loc.Empty), }, - ); - } - - if (ast.flags.uses_exports_ref) { - clousure_args.appendAssumeCapacity( .{ .binding = Binding.alloc(temp_allocator, B.Identifier{ .ref = ast.exports_ref, }, Logger.Loc.Empty), - .default = Expr.allocate(temp_allocator, E.Dot, .{ - .target = Expr.initIdentifier(ast.module_ref, Logger.Loc.Empty), - .name = "exports", - .name_loc = Logger.Loc.Empty, - }, Logger.Loc.Empty), }, - ); + }); } stmts.all_stmts.appendAssumeCapacity(Stmt.allocateExpr(temp_allocator, Expr.init(E.Function, .{ .func = .{ @@ -17764,7 +17755,6 @@ pub const AstBuilder = struct { try p.symbols.append(p.allocator, .{ .kind = kind, .original_name = identifier, - .debug_mode_source_index = if (Environment.allow_assert) @intCast(p.source_index) else 0, }); const ref: Ref = .{ .inner_index = inner_index, diff --git a/src/codegen/bake-codegen.ts b/src/codegen/bake-codegen.ts index ecb90c8652..c3da5d97bf 100644 --- a/src/codegen/bake-codegen.ts +++ b/src/codegen/bake-codegen.ts @@ -59,6 +59,7 @@ async function run() { syntax: !debug, }, target: side === "server" ? "bun" : "browser", + drop: debug ? [] : ["DEBUG"], }); if (!result.success) throw new AggregateError(result.logs); assert(result.outputs.length === 1, "must bundle to a single file"); @@ -90,6 +91,7 @@ async function run() { result = await Bun.build({ entrypoints: [generated_entrypoint], minify: !debug, + drop: debug ? [] : ["DEBUG"], }); if (!result.success) throw new AggregateError(result.logs); assert(result.outputs.length === 1, "must bundle to a single file"); @@ -131,7 +133,7 @@ async function run() { : `${code};return ${outName("server_exports")};`; const params = `${outName("$separateSSRGraph")},${outName("$importMeta")}`; - code = code.replaceAll("import.meta", outName("$importMeta")); + code = code.replaceAll("import.meta", outName("$importMeta")).replaceAll(outName("$importMeta") + ".hot", "import.meta.hot"); code = `let ${outName("unloadedModuleRegistry")}={},${outName("config")}={separateSSRGraph:${outName("$separateSSRGraph")}},${outName("server_exports")};${code}`; code = debug ? `((${params}) => {${code}})\n` : `((${params})=>{${code}})\n`; diff --git a/src/js_ast.zig b/src/js_ast.zig index c5a5a6d735..02d40cc204 100644 --- a/src/js_ast.zig +++ b/src/js_ast.zig @@ -969,7 +969,7 @@ pub const Symbol = struct { /// This is the name that came from the parser. Printed names may be renamed /// during minification or to avoid name collisions. Do not use the original /// name during printing. - original_name: string, + original_name: []const u8, /// This is used for symbols that represent items in the import clause of an /// ES6 import statement. These should always be referenced by EImportIdentifier @@ -1103,15 +1103,13 @@ pub const Symbol = struct { remove_overwritten_function_declaration: bool = false, - /// In debug mode, sometimes its helpful to know what source file - /// A symbol came from. This is used for that. - /// - /// We don't want this in non-debug mode because it increases the size of - /// the symbol table. - debug_mode_source_index: if (Environment.allow_assert) - Index.Int - else - u0 = 0, + /// Used in HMR to decide when live binding code is needed. + has_been_assigned_to: bool = false, + + comptime { + bun.assert_eql(@sizeOf(Symbol), 88); + bun.assert_eql(@alignOf(Symbol), @alignOf([]const u8)); + } const invalid_chunk_index = std.math.maxInt(u32); pub const invalid_nested_scope_slot = std.math.maxInt(u32); diff --git a/src/js_parser.zig b/src/js_parser.zig index 4e6f6a420a..f2bfc9ba87 100644 --- a/src/js_parser.zig +++ b/src/js_parser.zig @@ -9289,7 +9289,6 @@ fn NewParser_( try p.symbols.append(Symbol{ .kind = kind, .original_name = identifier, - .debug_mode_source_index = if (comptime Environment.allow_assert) p.source.index.get() else 0, }); if (is_typescript_enabled) { @@ -16232,6 +16231,8 @@ fn NewParser_( // `module.exports` -> `exports` optimization. p.commonjs_module_exports_assigned_deoptimized = true; } + + p.symbols.items[result.ref.innerIndex()].has_been_assigned_to = true; } var original_name: ?string = null; @@ -17318,7 +17319,7 @@ fn NewParser_( p.method_call_must_be_replaced_with_undefined = false; switch (e_.target.data) { // If we're removing this call, don't count any arguments as symbol uses - .e_index, .e_dot => { + .e_index, .e_dot, .e_identifier => { p.is_control_flow_dead = true; }, // Special case from `import.meta.hot.*` functions. @@ -18341,13 +18342,6 @@ fn NewParser_( } fn selectLocalKind(p: *P, kind: S.Local.Kind) S.Local.Kind { - // When using Kit's HMR implementation, we need to preserve the local kind - // if possible, as more efficient code can be generated if something is known - // not to be an ESM live binding. - if (p.options.features.hot_module_reloading) { - return kind; - } - // Use "var" instead of "let" and "const" if the variable declaration may // need to be separated from the initializer. This allows us to safely move // this declaration into a nested scope. @@ -24322,7 +24316,6 @@ pub const ConvertESMExportsForHmr = struct { export_star_props: std.ArrayListUnmanaged(G.Property) = .{}, export_props: std.ArrayListUnmanaged(G.Property) = .{}, stmts: std.ArrayListUnmanaged(Stmt) = .{}, - can_implicitly_accept: bool = true, const ImportRef = struct { /// Index into ConvertESMExportsForHmr.stmts @@ -24332,66 +24325,56 @@ pub const ConvertESMExportsForHmr = struct { fn convertStmt(ctx: *ConvertESMExportsForHmr, p: anytype, stmt: Stmt) !void { const new_stmt = switch (stmt.data) { else => brk: { - ctx.can_implicitly_accept = false; break :brk stmt; }, .s_local => |st| stmt: { if (!st.is_export) { - ctx.can_implicitly_accept = false; break :stmt stmt; } st.is_export = false; - if (st.kind.isReassignable()) { - ctx.can_implicitly_accept = false; - for (st.decls.slice()) |decl| { - try ctx.visitBindingToExport(p, decl.binding, true); - } - } else { - var new_len: usize = 0; - for (st.decls.slice()) |*decl_ptr| { - const decl = decl_ptr.*; // explicit copy to avoid aliasinng - bun.assert(decl.value != null); // const must be initialized + var new_len: usize = 0; + for (st.decls.slice()) |*decl_ptr| { + const decl = decl_ptr.*; // explicit copy to avoid aliasinng + const value = decl.value orelse { + st.decls.mut(new_len).* = decl; + new_len += 1; + try ctx.visitBindingToExport(p, decl.binding); + continue; + }; - switch (decl.binding.data) { - .b_missing => {}, + switch (decl.binding.data) { + .b_missing => {}, - .b_identifier => |id| { - const symbol = p.symbols.items[id.ref.inner_index]; + .b_identifier => |id| { + const symbol = p.symbols.items[id.ref.inner_index]; - if (ctx.can_implicitly_accept) switch (decl.value.?.data) { - .e_function, .e_arrow => {}, - else => ctx.can_implicitly_accept = false, - }; - - // if the symbol is not used, we don't need to preserve - // a binding in this scope. we can move it to the exports object. - if (symbol.use_count_estimate == 0 and decl.value.?.canBeMoved()) { - try ctx.export_props.append(p.allocator, .{ - .key = Expr.init(E.String, .{ .data = symbol.original_name }, decl.binding.loc), - .value = decl.value, - }); - } else { - st.decls.mut(new_len).* = decl; - new_len += 1; - try ctx.visitBindingToExport(p, decl.binding, false); - } - }, - - else => { - ctx.can_implicitly_accept = false; + // if the symbol is not used, we don't need to preserve + // a binding in this scope. we can move it to the exports object. + if (symbol.use_count_estimate == 0 and value.canBeMoved()) { + try ctx.export_props.append(p.allocator, .{ + .key = Expr.init(E.String, .{ .data = symbol.original_name }, decl.binding.loc), + .value = value, + }); + } else { st.decls.mut(new_len).* = decl; new_len += 1; - try ctx.visitBindingToExport(p, decl.binding, false); - }, - } + try ctx.visitBindingToExport(p, decl.binding); + } + }, + + else => { + st.decls.mut(new_len).* = decl; + new_len += 1; + try ctx.visitBindingToExport(p, decl.binding); + }, } - if (new_len == 0) { - return; - } - st.decls.len = @intCast(new_len); } + if (new_len == 0) { + return; + } + st.decls.len = @intCast(new_len); break :stmt stmt; }, @@ -24415,11 +24398,6 @@ pub const ConvertESMExportsForHmr = struct { // All other functions can be properly moved. } - if (ctx.can_implicitly_accept and - !((st.value == .stmt and st.value.stmt.data == .s_function) or - (st.value == .expr and st.value.expr.data == .e_arrow))) - ctx.can_implicitly_accept = false; - // Try to move the export default expression to the end. const can_be_moved_to_inner_scope = switch (st.value) { .stmt => |s| switch (s.data) { @@ -24484,7 +24462,6 @@ pub const ConvertESMExportsForHmr = struct { } }, .s_class => |st| stmt: { - ctx.can_implicitly_accept = false; // Strip the "export" keyword if (!st.is_export) { @@ -24509,13 +24486,13 @@ pub const ConvertESMExportsForHmr = struct { st.func.flags.remove(.is_export); - // Export as CommonJS - try ctx.export_props.append(p.allocator, .{ - .key = Expr.init(E.String, .{ - .data = p.symbols.items[st.func.name.?.ref.?.inner_index].original_name, - }, stmt.loc), - .value = Expr.initIdentifier(st.func.name.?.ref.?, stmt.loc), - }); + try ctx.visitRefToExport( + p, + st.func.name.?.ref.?, + null, + stmt.loc, + false, + ); break :stmt stmt; }, @@ -24523,21 +24500,11 @@ pub const ConvertESMExportsForHmr = struct { for (st.items) |item| { const ref = item.name.ref.?; try ctx.visitRefToExport(p, ref, item.alias, item.name.loc, false); - - if (ctx.can_implicitly_accept) { - const symbol: *Symbol = &p.symbols.items[ref.inner_index]; - switch (symbol.kind) { - .hoisted_function, .generator_or_async_function => {}, - else => ctx.can_implicitly_accept = false, - } - } } return; // do not emit a statement here }, .s_export_from => |st| { - ctx.can_implicitly_accept = false; - const namespace_ref = try ctx.deduplicatedImport( p, st.import_record_index, @@ -24577,8 +24544,6 @@ pub const ConvertESMExportsForHmr = struct { return; }, .s_export_star => |st| { - ctx.can_implicitly_accept = false; - const namespace_ref = try ctx.deduplicatedImport( p, st.import_record_index, @@ -24688,25 +24653,20 @@ pub const ConvertESMExportsForHmr = struct { return namespace_ref; } - fn visitBindingToExport( - ctx: *ConvertESMExportsForHmr, - p: anytype, - binding: Binding, - is_live_binding: bool, - ) !void { + fn visitBindingToExport(ctx: *ConvertESMExportsForHmr, p: anytype, binding: Binding) !void { switch (binding.data) { .b_missing => {}, .b_identifier => |id| { - try ctx.visitRefToExport(p, id.ref, null, binding.loc, is_live_binding); + try ctx.visitRefToExport(p, id.ref, null, binding.loc, false); }, .b_array => |array| { for (array.items) |item| { - try ctx.visitBindingToExport(p, item.binding, is_live_binding); + try ctx.visitBindingToExport(p, item.binding); } }, .b_object => |object| { for (object.properties) |item| { - try ctx.visitBindingToExport(p, item.value, is_live_binding); + try ctx.visitBindingToExport(p, item.value); } }, } @@ -24725,9 +24685,7 @@ pub const ConvertESMExportsForHmr = struct { Expr.init(E.ImportIdentifier, .{ .ref = ref }, loc) else Expr.initIdentifier(ref, loc); - if (is_live_binding_source or (symbol.kind == .import and !ctx.is_in_node_modules)) { - ctx.can_implicitly_accept = false; - + if (is_live_binding_source or (symbol.kind == .import and !ctx.is_in_node_modules) or symbol.has_been_assigned_to) { // TODO (2024-11-24) instead of requiring getters for live-bindings, // a callback propagation system should be considered. mostly // because here, these might not even be live bindings, and @@ -24749,7 +24707,6 @@ pub const ConvertESMExportsForHmr = struct { try ctx.last_part.symbol_uses.putNoClobber(p.allocator, arg1, .{ .count_estimate = 1 }); try p.current_scope.generated.push(p.allocator, arg1); - // Live bindings need to update the value internally and externally. // 'get abc() { return abc }' try ctx.export_props.append(p.allocator, .{ .kind = .get, @@ -24794,38 +24751,24 @@ pub const ConvertESMExportsForHmr = struct { .properties = G.Property.List.fromList(ctx.export_props), }, logger.Loc.Empty); - if (ctx.can_implicitly_accept) { - // `hmr.implicitlyAccept(...)` - try ctx.stmts.append(p.allocator, Stmt.alloc(S.SExpr, .{ - .value = Expr.init(E.Call, .{ - .target = Expr.init(E.Dot, .{ - .target = Expr.initIdentifier(p.hmr_api_ref, logger.Loc.Empty), - .name = "implicitlyAccept", - .name_loc = logger.Loc.Empty, - }, logger.Loc.Empty), - .args = try .fromSlice(p.allocator, &.{obj}), + // `hmr.exports = ...` + try ctx.stmts.append(p.allocator, Stmt.alloc(S.SExpr, .{ + .value = Expr.assign( + Expr.init(E.Dot, .{ + .target = Expr.initIdentifier(p.hmr_api_ref, logger.Loc.Empty), + .name = "exports", + .name_loc = logger.Loc.Empty, }, logger.Loc.Empty), - }, logger.Loc.Empty)); - } else { - // `hmr.exports = ...` - try ctx.stmts.append(p.allocator, Stmt.alloc(S.SExpr, .{ - .value = Expr.assign( - Expr.init(E.Dot, .{ - .target = Expr.initIdentifier(p.hmr_api_ref, logger.Loc.Empty), - .name = "exports", - .name_loc = logger.Loc.Empty, - }, logger.Loc.Empty), - obj, - ), - }, logger.Loc.Empty)); - } + obj, + ), + }, logger.Loc.Empty)); // mark a dependency on module_ref so it is renamed try ctx.last_part.symbol_uses.put(p.allocator, p.module_ref, .{ .count_estimate = 1 }); try ctx.last_part.declared_symbols.append(p.allocator, .{ .ref = p.module_ref, .is_top_level = true }); } - if (p.options.features.react_fast_refresh and p.react_refresh.register_used and !ctx.can_implicitly_accept) { + if (p.options.features.react_fast_refresh and p.react_refresh.register_used) { try ctx.stmts.append(p.allocator, Stmt.alloc(S.SExpr, .{ .value = Expr.init(E.Call, .{ .target = Expr.init(E.Dot, .{ diff --git a/src/ptr/CowSlice.zig b/src/ptr/CowSlice.zig index 81c61c46c4..224f6b5a12 100644 --- a/src/ptr/CowSlice.zig +++ b/src/ptr/CowSlice.zig @@ -189,7 +189,6 @@ pub fn CowSliceZ(T: type, comptime sentinel: ?T) type { pub fn deinit(str: Self, allocator: Allocator) void { if (comptime cow_str_assertions) if (str.debug) |debug| { debug.mutex.lock(); - defer debug.mutex.unlock(); bun.assertf( debug.allocator.ptr == allocator.ptr and debug.allocator.vtable == allocator.vtable, "CowSlice.deinit called with a different allocator than the one used to create it", @@ -205,6 +204,7 @@ pub fn CowSliceZ(T: type, comptime sentinel: ?T) type { bun.destroy(debug); } else { debug.borrows -= 1; // double deinit of a borrowed string + debug.mutex.unlock(); } }; if (str.flags.is_owned) { diff --git a/src/ptr/tagged_pointer.zig b/src/ptr/tagged_pointer.zig index 17409abe30..a14eac48b0 100644 --- a/src/ptr/tagged_pointer.zig +++ b/src/ptr/tagged_pointer.zig @@ -8,16 +8,15 @@ const strings = bun.strings; const default_allocator = bun.default_allocator; const C = bun.C; -const TagSize = u15; const AddressableSize = u49; pub const TaggedPointer = packed struct { _ptr: AddressableSize, - data: TagSize, + data: Tag, - pub const Tag = TagSize; + pub const Tag = u15; - pub inline fn init(ptr: anytype, data: TagSize) TaggedPointer { + pub inline fn init(ptr: anytype, data: Tag) TaggedPointer { const Ptr = @TypeOf(ptr); if (comptime Ptr == @TypeOf(null)) { return .{ ._ptr = 0, .data = data }; @@ -42,19 +41,19 @@ pub const TaggedPointer = packed struct { pub inline fn from(val: anytype) TaggedPointer { const ValueType = @TypeOf(val); return switch (ValueType) { - f64, i64, u64 => @as(TaggedPointer, @bitCast(val)), - ?*anyopaque, *anyopaque => @as(TaggedPointer, @bitCast(@intFromPtr(val))), + f64, i64, u64 => @bitCast(val), + ?*anyopaque, *anyopaque => @bitCast(@intFromPtr(val)), else => @compileError("Unsupported type: " ++ @typeName(ValueType)), }; } pub inline fn to(this: TaggedPointer) *anyopaque { - return @as(*anyopaque, @ptrFromInt(@as(u64, @bitCast(this)))); + return @ptrFromInt(@as(u64, @bitCast(this))); } }; const TypeMapT = struct { - value: TagSize, + value: TaggedPointer.Tag, ty: type, name: []const u8, }; @@ -84,7 +83,7 @@ pub fn TagTypeEnumWithTypeMap(comptime Types: anytype) struct { return .{ .tag_type = @Type(.{ .@"enum" = .{ - .tag_type = TagSize, + .tag_type = TaggedPointer.Tag, .fields = &enumFields, .decls = &.{}, .is_exhaustive = false, @@ -99,9 +98,9 @@ pub fn TaggedPointerUnion(comptime Types: anytype) type { const TagType: type = result.tag_type; - return packed struct { + return struct { pub const Tag = TagType; - pub const TagInt = TagSize; + pub const TagInt = TaggedPointer.Tag; pub const type_map: TypeMap(Types) = result.ty_map; repr: TaggedPointer, diff --git a/src/string_immutable.zig b/src/string_immutable.zig index 6eb62ba086..5ad2dd5616 100644 --- a/src/string_immutable.zig +++ b/src/string_immutable.zig @@ -894,7 +894,9 @@ pub fn withoutTrailingSlashWindowsPath(input: string) []const u8 { path.len -= 1; } - bun.assert(!isWindowsAbsolutePathMissingDriveLetter(u8, path)); + if (Environment.isDebug) + bun.debugAssert(!std.fs.path.isAbsolute(path) or + !isWindowsAbsolutePathMissingDriveLetter(u8, path)); return path; } @@ -4382,6 +4384,7 @@ pub fn indexOfNeedsURLEncode(slice: []const u8) ?u32 { if (remaining[0] >= 127 or remaining[0] < 0x20 or + remaining[0] == '%' or remaining[0] == '\\' or remaining[0] == '"' or remaining[0] == '#' or @@ -4402,6 +4405,7 @@ pub fn indexOfNeedsURLEncode(slice: []const u8) ?u32 { @as(AsciiVectorU1, @bitCast(vec > max_16_ascii)) | @as(AsciiVectorU1, @bitCast((vec < min_16_ascii))) | @as(AsciiVectorU1, @bitCast(vec == @as(AsciiVector, @splat('%')))) | + @as(AsciiVectorU1, @bitCast(vec == @as(AsciiVector, @splat('\\')))) | @as(AsciiVectorU1, @bitCast(vec == @as(AsciiVector, @splat('"')))) | @as(AsciiVectorU1, @bitCast(vec == @as(AsciiVector, @splat('#')))) | @as(AsciiVectorU1, @bitCast(vec == @as(AsciiVector, @splat('?')))) | @@ -4425,6 +4429,7 @@ pub fn indexOfNeedsURLEncode(slice: []const u8) ?u32 { const char = char_.*; if (char > 127 or char < 0x20 or char == '\\' or + char == '%' or char == '"' or char == '#' or char == '?' or diff --git a/test/bake/bake-harness.ts b/test/bake/bake-harness.ts index 930cac9e61..b6d6997f4e 100644 --- a/test/bake/bake-harness.ts +++ b/test/bake/bake-harness.ts @@ -24,6 +24,21 @@ import { exitCodeMapStrings } from "./exit-code-map.mjs"; const isDebugBuild = Bun.version.includes("debug"); +const verboseSynchronization = process.env.BUN_DEV_SERVER_VERBOSE_SYNC + ? (arg: string) => { + console.log("\x1b[36m" + arg + "\x1b[0m"); + } + : () => {}; + +/** + * Can be set in fast development environments to improve iteration time. + * In CI/Windows it appears that sometimes these tests dont wait enough + * for things to happen, so the extra delay reduces flakiness. + * + * Needs much more investigation. + */ +const fastBatches = !!process.env.BUN_DEV_SERVER_FAST_BATCHES; + /** For testing bundler related bugs in the DevServer */ export const minimalFramework: Bake.Framework = { fileSystemRouterTypes: [ @@ -104,6 +119,8 @@ export interface DevServerTest { * Avoid if possible, this is to reproduce specific bugs. */ mainDir?: string; + + skip?: ('win32'|'darwin'|'linux'|'ci')[], } let interactive = false; @@ -147,6 +164,19 @@ type ErrorSpec = string; type FileObject = Record; +enum WatchSynchronization { + // Callback for starting a batch + Started = 0, + // During a batch, files were seen. Batch is still running. + SeenFiles = 1, + // Batch no longer running, files seen! + ResultDidNotBundle = 2, + // Sent on every build finished: + AnyBuildFinished = 3, + // Sent on every build finished, you must wait for web sockets: + AnyBuildFinishedWaitForWebSockets = 4, +} + export class Dev extends EventEmitter { rootDir: string; port: number; @@ -155,6 +185,7 @@ export class Dev extends EventEmitter { connectedClients: Set = new Set(); options: { files: Record }; nodeEnv: "development" | "production"; + batchingChanges: { write?: () => void } | null = null; socket?: WebSocket; @@ -193,7 +224,8 @@ export class Dev extends EventEmitter { connected.resolve(); } if (data[0] === "r".charCodeAt(0)) { - this.emit("redundant_watch"); + verboseSynchronization("watch_synchronization: " + WatchSynchronization[data[1]]); + this.emit("watch_synchronization", data[1]); } this.emit("hmr", data); }; @@ -217,25 +249,105 @@ export class Dev extends EventEmitter { }); } + #waitForSyncEvent(event: WatchSynchronization) { + return new Promise((resolve, reject) => { + let dev = this; + function handle(kind: WatchSynchronization) { + if (kind === event) { + dev.off("watch_synchronization", handle); + resolve(); + } + } + dev.on("watch_synchronization", handle); + }); + } + + async batchChanges(options: { errors?: null | ErrorSpec[]; snapshot?: string } = {}) { + if (this.batchingChanges) { + this.batchingChanges.write?.(); + return null; + } + this.batchingChanges = {}; + + let dev = this; + const initWait = this.#waitForSyncEvent(WatchSynchronization.Started); + this.socket!.send("H"); + await initWait; + + let hasSeenFiles = true; + let seenFiles: PromiseWithResolvers; + function onSeenFiles(ev: WatchSynchronization) { + if (ev === WatchSynchronization.SeenFiles) { + hasSeenFiles = true; + seenFiles.resolve(); + dev.off("watch_synchronization", onSeenFiles); + } + } + function resetSeenFilesWithResolvers() { + if (!hasSeenFiles) return; + seenFiles = Promise.withResolvers(); + dev.on("watch_synchronization", onSeenFiles); + } + resetSeenFilesWithResolvers(); + + let wantsHmrEvent = true; + for (const client of dev.connectedClients) { + if (!client.webSocketMessagesAllowed) { + wantsHmrEvent = false; + break; + } + } + + const wait = this.waitForHotReload(wantsHmrEvent); + const b = { + write: resetSeenFilesWithResolvers, + [Symbol.asyncDispose]: async() => { + if (wantsHmrEvent && interactive) { + await seenFiles.promise; + } else if (wantsHmrEvent) { + await Promise.race([ + seenFiles.promise, + Bun.sleep(1000), + ]); + } + if (!fastBatches) { + // Wait an extra delay to avoid double-triggering events. + await Bun.sleep(300); + } + + dev.off("watch_synchronization", onSeenFiles); + + this.socket!.send("H"); + await wait; + + let errors = options.errors; + if (errors !== null) { + errors ??= []; + for (const client of this.connectedClients) { + await client.expectErrorOverlay(errors, options.snapshot); + } + } + this.batchingChanges = null; + }, + }; + this.batchingChanges = b; + return b; + } + write(file: string, contents: string, options: { errors?: null | ErrorSpec[]; dedent?: boolean } = {}) { const snapshot = snapshotCallerLocation(); return withAnnotatedStack(snapshot, async () => { await maybeWaitInteractive("write " + file); const isDev = this.nodeEnv === "development"; - const wait = isDev && this.waitForHotReload(); + await using _wait = isDev ? await this.batchChanges({ + errors: options.errors, + snapshot: snapshot, + }) : null; + await Bun.write( this.join(file), ((typeof contents === "string" && options.dedent) ?? true) ? dedent(contents) : contents, ); - await wait; - - let errors = options.errors; - if (isDev && errors !== null) { - errors ??= []; - for (const client of this.connectedClients) { - await client.expectErrorOverlay(errors, snapshot); - } - } }); } @@ -258,12 +370,15 @@ export class Dev extends EventEmitter { * @param options Options for handling errors after deletion * @returns Promise that resolves when the file is deleted and hot reload is complete (if applicable) */ - delete(file: string, options: { errors?: null | ErrorSpec[]; wait?: boolean } = {}) { + delete(file: string, options: { errors?: null | ErrorSpec[] } = {}) { const snapshot = snapshotCallerLocation(); return withAnnotatedStack(snapshot, async () => { await maybeWaitInteractive("delete " + file); const isDev = this.nodeEnv === "development"; - const wait = isDev && options.wait && this.waitForHotReload(); + await using _wait = isDev ? await this.batchChanges({ + errors: options.errors, + snapshot: snapshot, + }) : null; const filePath = this.join(file); if (!fs.existsSync(filePath)) { @@ -271,15 +386,6 @@ export class Dev extends EventEmitter { } fs.unlinkSync(filePath); - await wait; - - let errors = options.errors; - if (isDev && options.wait && errors !== null) { - errors ??= []; - for (const client of this.connectedClients) { - await client.expectErrorOverlay(errors, snapshot); - } - } }); } @@ -295,7 +401,12 @@ export class Dev extends EventEmitter { const snapshot = snapshotCallerLocation(); return withAnnotatedStack(snapshot, async () => { await maybeWaitInteractive("patch " + file); - const wait = this.waitForHotReload(); + const isDev = this.nodeEnv === "development"; + await using _wait = isDev ? await this.batchChanges({ + errors: errors, + snapshot: snapshot, + }) : null; + const filename = this.join(file); const source = fs.readFileSync(filename, "utf8"); const contents = source.replace(find, replace); @@ -303,14 +414,6 @@ export class Dev extends EventEmitter { throw new Error(`Couldn't find and replace ${JSON.stringify(find)} in ${file}`); } await Bun.write(filename, typeof contents === "string" && shouldDedent ? dedent(contents) : contents); - await wait; - - if (errors !== null) { - errors ??= []; - for (const client of this.connectedClients) { - await client.expectErrorOverlay(errors, snapshot); - } - } }); } @@ -318,22 +421,60 @@ export class Dev extends EventEmitter { return path.join(this.rootDir, file); } - async waitForHotReload() { + waitForHotReload(wantsHmrEvent: boolean) { if (this.nodeEnv !== "development") return Promise.resolve(); - const err = this.output.waitForLine(/error/i).catch(() => {}); - const success = this.output.waitForLine(/bundled page|bundled route|reloaded/i, isCI ? 1000 : 250).catch(() => {}); - const ctrl = new AbortController(); - await Promise.race([ - // On failure, give a little time in case a partial write caused a - // bundling error, and a success came in. - err.then( - () => Bun.sleep(500), - () => {}, - ), - success, - EventEmitter.once(this, "redundant_watch", { signal: ctrl.signal }), - ]); - ctrl.abort(); + let dev = this; + return new Promise((resolve, reject) => { + let timer: NodeJS.Timer | null = null; + let clientWaits = 0; + let seenMainEvent = false; + function cleanupAndResolve() { + verboseSynchronization("Cleaning up and resolving"); + timer !== null && clearTimeout(timer); + dev.off("watch_synchronization", onEvent); + for (const dispose of disposes) { + dispose(); + } + if (fastBatches) resolve(); + else setTimeout(resolve, 250); + } + const disposes = new Set<() => void>(); + for (const client of dev.connectedClients) { + const socketEventHandler = () => { + verboseSynchronization("Client received event"); + clientWaits++; + if (seenMainEvent && clientWaits === dev.connectedClients.size) { + client.off("received-hmr-event", socketEventHandler); + cleanupAndResolve(); + } + }; + client.on("received-hmr-event", socketEventHandler); + disposes.add(() => { + client.off("received-hmr-event", socketEventHandler); + }); + } + async function onEvent(kind: WatchSynchronization) { + assert(kind !== WatchSynchronization.Started, "WatchSynchronization.Started should not be emitted"); + if (kind === WatchSynchronization.AnyBuildFinished) { + seenMainEvent = true; + cleanupAndResolve(); + } else if (kind === WatchSynchronization.AnyBuildFinishedWaitForWebSockets) { + verboseSynchronization("Need to wait for (" + clientWaits + "/" + dev.connectedClients.size + ") clients"); + seenMainEvent = true; + if (clientWaits === dev.connectedClients.size) { + cleanupAndResolve(); + } + } else if (kind === WatchSynchronization.ResultDidNotBundle) { + if (wantsHmrEvent) { + await Bun.sleep(500); + if (seenMainEvent) return; + console.warn("\x1b[33mWARN: Dev Server did not pick up any changed files. Consider wrapping this call in expectNoWebSocketActivity\x1b[35m"); + } + cleanupAndResolve(); + } + }; + dev.on("watch_synchronization", onEvent); + }); } async client( @@ -552,6 +693,7 @@ export class Client extends EventEmitter { suppressInteractivePrompt: boolean = false; expectingReload = false; hmr = false; + webSocketMessagesAllowed = true; constructor(url: string, options: { storeHotChunks?: boolean; hmr: boolean; expectErrors?: boolean }) { super(); @@ -684,19 +826,37 @@ export class Client extends EventEmitter { } expectMessage(...x: any) { + return this.#expectMessageImpl(true, x); + } + + expectMessageInAnyOrder(...x: any) { + return this.#expectMessageImpl(false, x); + } + + #expectMessageImpl(strictOrdering: boolean, x: any[]) { return withAnnotatedStack(snapshotCallerLocation(), async () => { if (this.exited) throw new Error("Client exited while waiting for message"); if (this.messages.length !== x.length) { + if (interactive) { + console.log("Waiting for messages (have", this.messages.length, "expected", x.length, ")"); + } + const dev = this; // Wait up to a threshold before giving up + function cleanup() { + dev.off("message", onMessage); + dev.off("exit", onExit); + } const resolver = Promise.withResolvers(); function onMessage(message: any) { - if (this.messages.length === x.length) resolver.resolve(); + process.nextTick(() => { + if (dev.messages.length === x.length) resolver.resolve(); + }); } function onExit() { resolver.resolve(); } - this.once("message", onMessage); - this.once("exit", onExit); + this.on("message", onMessage); + this.on("exit", onExit); let t: any = setTimeout( () => { t = null; @@ -706,11 +866,15 @@ export class Client extends EventEmitter { ); await resolver.promise; if (t) clearTimeout(t); - this.off("message", onMessage); + cleanup(); } if (this.exited) throw new Error("Client exited while waiting for message"); - const m = this.messages; + let m = this.messages; this.messages = []; + if (!strictOrdering) { + m = m.sort(); + x = x.sort(); + } expect(m).toEqual(x); }); } @@ -935,12 +1099,14 @@ export class Client extends EventEmitter { // Block WebSocket messages this.#proc.send({ type: "set-allow-websocket-messages", args: [false] }); + this.webSocketMessagesAllowed = false; try { await cb(); } finally { // Re-enable WebSocket messages this.#proc.send({ type: "set-allow-websocket-messages", args: [true] }); + this.webSocketMessagesAllowed = true; } }); } @@ -1275,7 +1441,7 @@ class OutputLineStream extends EventEmitter { export function indexHtmlScript(htmlFiles: string[]) { return [ - ...htmlFiles.map((file, i) => `import html${i} from "./${file}";`), + ...htmlFiles.map((file, i) => `import html${i} from ${JSON.stringify("./" + file.replaceAll(path.sep, '/'))};`), "export default {", " static: {", ...(htmlFiles.length === 1 @@ -1298,6 +1464,11 @@ export function indexHtmlScript(htmlFiles: string[]) { ].join("\n"); } +const skipTargets = [ + process.platform, + isCI ? 'ci' : null, +].filter(Boolean); + function testImpl( description: string, options: T, @@ -1383,7 +1554,7 @@ function testImpl( fs.writeFileSync( path.join(root, "harness_start.ts"), dedent` - import appConfig from "${path.join(mainDir, "bun.app.ts")}"; + import appConfig from ${JSON.stringify(path.join(mainDir, "bun.app.ts"))}; import { fullGC } from "bun:jsc"; const routes = appConfig.static ?? (appConfig.routes ??= {}); @@ -1509,17 +1680,16 @@ function testImpl( : "PROD" }:${basename}-${count}: ${description}`; try { - // TODO: resolve ci flakiness. - if (isCI && isWindows) { - return jest.test.skip(name, run); + if (options.skip && options.skip.some(x => skipTargets.includes(x))) { + jest.test.todo(name, run); + return options; } - jest.test( name, run, interactive ? interactive_timeout - : (options.timeoutMultiplier ?? 1) * (isWindows ? 10_000 : 5_000) * (Bun.version.includes("debug") ? 3 : 1), + : (options.timeoutMultiplier ?? 1) * (isWindows ? 15_000 : 10_000) * (Bun.version.includes("debug") ? 2 : 1), ); return options; } catch { diff --git a/test/bake/client-fixture.mjs b/test/bake/client-fixture.mjs index 290a024b34..ce32a350e4 100644 --- a/test/bake/client-fixture.mjs +++ b/test/bake/client-fixture.mjs @@ -15,6 +15,7 @@ url = new URL(url, "http://localhost:3000"); const storeHotChunks = args.includes("--store-hot-chunks"); const expectErrors = args.includes("--expect-errors"); +const verboseWebSockets = args.includes("--verbose-web-sockets"); // Create a new window instance let window; @@ -23,6 +24,7 @@ let expectingReload = false; let webSockets = []; let pendingReload = null; let pendingReloadTimer = null; +let updatingTimer = null; function reset() { for (const ws of webSockets) { @@ -41,6 +43,7 @@ function reset() { warn: () => {}, info: () => {}, assert: () => {}, + trace: () => {}, }; } } @@ -54,6 +57,21 @@ function createWindow(windowUrl) { height: 768, }); + let hasReadyEventListener = false; + window["bun do not use this outside of internal testing or else i'll cry"] = ({ onEvent }) => { + onEvent("bun:afterUpdate", () => { + setTimeout(() => { + process.send({ type: "received-hmr-event", args: [] }); + }, 50); + }); + hasReadyEventListener = true; + onEvent("bun:ready", () => { + setTimeout(() => { + process.send({ type: "received-hmr-event", args: [] }); + }, 50); + }); + }; + window.fetch = async function (url, options) { if (typeof url === "string") { url = new URL(url, windowUrl).href; @@ -68,31 +86,25 @@ function createWindow(windowUrl) { super(url, protocols, options); webSockets.push(this); this.addEventListener("message", event => { - if (!allowWebSocketMessages) { - const data = new Uint8Array(event.data); - console.error( - "[E] WebSocket message received while messages are not allowed. Event type", - JSON.stringify(String.fromCharCode(data[0])), - ); - let hexDump = ""; - for (let i = 0; i < data.length; i += 16) { - // Print offset - hexDump += "\x1b[2m" + i.toString(16).padStart(4, "0") + "\x1b[0m "; - // Print hex values - const chunk = data.slice(i, i + 16); - const hexValues = Array.from(chunk) - .map(b => b.toString(16).padStart(2, "0")) - .join(" "); - hexDump += hexValues.padEnd(48, " "); - // Print ASCII - hexDump += "\x1b[2m| \x1b[0m"; - for (const byte of chunk) { - hexDump += byte >= 32 && byte <= 126 ? String.fromCharCode(byte) : "\x1b[2m.\x1b[0m"; - } - hexDump += "\n"; + const data = new Uint8Array(event.data); + if (data[0] === "e".charCodeAt(0)) { + if (updatingTimer) { + clearTimeout(updatingTimer); } - console.error(hexDump); + updatingTimer = setTimeout(() => { + process.send({ type: "received-hmr-event", args: [] }); + updatingTimer = null; + }, 250); + } + if (!allowWebSocketMessages) { + const allowedTypes = ["n", "r"]; + if (allowedTypes.includes(String.fromCharCode(data[0]))) { + return; + } + dumpWebSocketMessage("[E] WebSocket message received while messages are not allowed", data); process.exit(exitCodeMap.websocketMessagesAreBanned); + } else { + verboseWebSockets && dumpWebSocketMessage("[I] WebSocket", data); } }); } @@ -106,7 +118,7 @@ function createWindow(windowUrl) { const originalConsole = window.console; window.console = { log: (...args) => { - process?.send({ type: "message", args: args }); + process.send({ type: "message", args: args }); }, error: (...args) => { console.error("[E]", ...args); @@ -120,6 +132,14 @@ function createWindow(windowUrl) { originalConsole.warn(...args); }, info: (...args) => { + if (args[0]?.startsWith("[Bun] Hot-module-reloading socket connected")) { + // Wait for all CSS assets to be fully loaded before emitting the event + if (!hasReadyEventListener) { + setTimeout(() => { + process.send({ type: "received-hmr-event", args: [] }); + }, 50); + } + } if (args[0]?.startsWith("[WS] receive message")) return; if (args[0]?.startsWith("Updated modules:")) return; console.info("[I]", ...args); @@ -130,9 +150,14 @@ function createWindow(windowUrl) { console.trace(...args); process.exit(exitCodeMap.assertionFailed); }, + trace: console.trace, }; window.location.reload = async () => { + if (updatingTimer) { + clearTimeout(updatingTimer); + } + console.info("[I] location.reload()"); reset(); if (expectingReload) { // Permission already granted, proceed with reload @@ -166,6 +191,28 @@ function createWindow(windowUrl) { } } +function dumpWebSocketMessage(message, data) { + console.error(`${message}. Event type`, JSON.stringify(String.fromCharCode(data[0]))); + let hexDump = ""; + for (let i = 0; i < data.length; i += 16) { + // Print offset + hexDump += "\x1b[2m" + i.toString(16).padStart(4, "0") + "\x1b[0m "; + // Print hex values + const chunk = data.slice(i, i + 16); + const hexValues = Array.from(chunk) + .map(b => b.toString(16).padStart(2, "0")) + .join(" "); + hexDump += hexValues.padEnd(48, " "); + // Print ASCII + hexDump += "\x1b[2m| \x1b[0m"; + for (const byte of chunk) { + hexDump += byte >= 32 && byte <= 126 ? String.fromCharCode(byte) : "\x1b[2m.\x1b[0m"; + } + hexDump += "\n"; + } + console.error(hexDump); +} + async function handleReload() { expectingReload = false; pendingReload = null; diff --git a/test/bake/dev/bundle.test.ts b/test/bake/dev/bundle.test.ts index d7ac4e0d87..89c87d5d42 100644 --- a/test/bake/dev/bundle.test.ts +++ b/test/bake/dev/bundle.test.ts @@ -195,6 +195,18 @@ devTest("default export same-scope handling", { await dev.writeNoChanges("fixture7.ts"); const chunk = await c.getMostRecentHmrChunk(); expect(chunk).toMatch(/default:\s*function/); + + // Since fixture7.ts is not marked as accepting, it will bubble the update + // to `index.ts`, re-evaluate it and some of the dependencies. + c.expectMessage( + "TWO", + "FOUR", + "FIVE", + "SEVEN", + "EIGHT", + "NINE", + "ELEVEN", + ); }, }); devTest("directory cache bust case #17576", { @@ -231,6 +243,9 @@ devTest("directory cache bust case #17576", { }, }); devTest("deleting imported file shows error then recovers", { + skip: [ + 'win32', // unlinkSync is having weird behavior + ], files: { "index.html": emptyHtmlFile({ styles: [], @@ -326,3 +341,50 @@ devTest("import.meta.main", { await c.expectMessage(false); }, }); +devTest("commonjs forms", { + files: { + "index.html": emptyHtmlFile({ + styles: [], + scripts: ["index.ts"], + }), + "index.ts": ` + import cjs from "./cjs.js"; + console.log(cjs); + `, + "cjs.js": ` + module.exports.field = {}; + `, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.expectMessage({ field: {} }); + await c.expectReload(async () => { + await dev.write("cjs.js", `exports.field = "1";`); + }); + await c.expectMessage({ field: "1" }); + await c.expectReload(async () => { + await dev.write("cjs.js", `let theExports = exports; theExports.field = "2";`); + }); + await c.expectMessage({ field: "2" }); + await c.expectReload(async () => { + await dev.write("cjs.js", `let theModule = module; theModule.exports.field = "3";`); + }); + await c.expectMessage({ field: "3" }); + await c.expectReload(async () => { + await dev.write("cjs.js", `let { exports } = module; exports.field = "4";`); + }); + await c.expectMessage({ field: "4" }); + await c.expectReload(async () => { + await dev.write("cjs.js", `var { exports } = module; exports.field = "4.5";`); + }); + await c.expectMessage({ field: "4.5" }); + await c.expectReload(async () => { + await dev.write("cjs.js", `let theExports = module.exports; theExports.field = "5";`); + }); + await c.expectMessage({ field: "5" }); + await c.expectReload(async () => { + await dev.write("cjs.js", `require; eval("module.exports.field = '6'");`); + }); + await c.expectMessage({ field: "6" }); + }, +}); \ No newline at end of file diff --git a/test/bake/dev/css.test.ts b/test/bake/dev/css.test.ts index 4a83f00874..9a32d08fdb 100644 --- a/test/bake/dev/css.test.ts +++ b/test/bake/dev/css.test.ts @@ -477,10 +477,8 @@ devTest("css import before create project relative", { }, ); await c.expectNoWebSocketActivity(async () => { - await dev.write("assets/bun.png", imageFixtures.bun, { - errors: ['style/styles.css:2:21: error: Could not resolve: "/assets/bun.png"'], - }); - await dev.delete("assets/bun.png", { wait: false }); + await dev.write("assets/bun.png", imageFixtures.bun, { errors: null }); + await dev.delete("assets/bun.png", { errors: null }); }); await dev.fetch("/").expect.not.toContain("HELLO"); await dev.write( diff --git a/test/bake/dev/ecosystem.test.ts b/test/bake/dev/ecosystem.test.ts index fb5ad3feba..e93a39ff6d 100644 --- a/test/bake/dev/ecosystem.test.ts +++ b/test/bake/dev/ecosystem.test.ts @@ -23,20 +23,32 @@ devTest("svelte component islands example", { await using c = await dev.client("/"); expect(await c.elemText("button")).toBe("Clicked 5 times"); - await c.click("button"); - await Bun.sleep(500); // TODO: de-flake event ordering. - expect(await c.elemText("button")).toBe("Clicked 6 times"); + const result = await c.js` + document.querySelector("button").click(); + await new Promise(resolve => setTimeout(resolve, 10)); + return document.querySelector("button").textContent; + `; + expect(result).toBe("Clicked 6 times"); - await dev.patch("pages/index.svelte", { - find: "non-interactive", - replace: "awesome", + await c.expectReload(async () => { + await dev.patch("pages/index.svelte", { + find: "non-interactive", + replace: "awesome", + }); }); + await dev.patch("pages/_Counter.svelte", { + find: "interactive island", + replace: "magical", + }); + + expect(await c.elemText("#counter_text")).toInclude("magical"); + const html2 = await dev.fetch("/").text(); if (html2.includes("Bun__renderFallbackError")) throw new Error("failed"); // Expect SSR expect(html2).toContain(`

This is my svelte server component (awesome)

Bun v${Bun.version}

`); - expect(html2).toContain(`>This is a client component (interactive island)

`); + expect(html2).toContain(`>This is a client component (magical)

`); }, }); diff --git a/test/bake/dev/esm.test.ts b/test/bake/dev/esm.test.ts index b74eb841b4..05f637ba43 100644 --- a/test/bake/dev/esm.test.ts +++ b/test/bake/dev/esm.test.ts @@ -22,10 +22,12 @@ const liveBindingTest = devTest("live bindings with `var`", { await dev.fetch("/").equals("State: 1"); await dev.fetch("/").equals("State: 2"); await dev.fetch("/").equals("State: 3"); + console.log("patching"); await dev.patch("routes/index.ts", { find: "State", replace: "Value", }); + console.log("patching"); await dev.fetch("/").equals("Value: 4"); await dev.fetch("/").equals("Value: 5"); await dev.write( @@ -302,3 +304,58 @@ devTest("cannot require a module with top level await", { }); }, }); +devTest("function that is assigned to should become a live binding", { + files: { + "index.html": emptyHtmlFile({ + scripts: ["index.ts"], + }), + "index.ts": ` + // 1. basic test + import { live, change } from "./live.js"; + { + if (live() !== 1) throw new Error("live() should be 1"); + change(); + if (live() !== 2) throw new Error("live() should be 2"); + } + + // 2. integration test with @babel/runtime + import inheritsLoose from "./inheritsLoose.js"; + { + function A() {} + function B() {} + inheritsLoose(B, A); + } + + console.log('PASS'); + `, + "live.js": ` + export function live() { + return 1; + } + export function change() { + live = function() { + return 2; + } + } + `, + "inheritsLoose.js": ` + import setPrototypeOf from "./setPrototypeOf.js"; + function _inheritsLoose(t, o) { + t.prototype = Object.create(o.prototype), t.prototype.constructor = t, setPrototypeOf(t, o); + } + export { _inheritsLoose as default }; + `, + "setPrototypeOf.js": ` + function _setPrototypeOf(t, e) { + return _setPrototypeOf = Object.setPrototypeOf ? Object.setPrototypeOf.bind() : function (t, e) { + return t.__proto__ = e, t; + }, _setPrototypeOf(t, e); + } + export { _setPrototypeOf as default }; + `, + }, + async test(dev) { + await using c = await dev.client(); + await c.expectMessage("PASS"); + }, +}); diff --git a/test/bake/dev/hot.test.ts b/test/bake/dev/hot.test.ts index e9a6325fd7..6e83b13960 100644 --- a/test/bake/dev/hot.test.ts +++ b/test/bake/dev/hot.test.ts @@ -105,6 +105,7 @@ devTest("import.meta.hot.accept patches imports", { }, }); devTest("import.meta.hot.accept specifier", { + timeoutMultiplier: 3, files: { "index.html": emptyHtmlFile({ scripts: ["a.ts"], @@ -292,22 +293,23 @@ devTest("import.meta.hot.accept multiple modules", { await c.expectMessage("Name updated: Bob"); // Test updating both files - await Promise.all([ - dev.write( + { + await using batch = await dev.batchChanges(); + await dev.write( "counter.ts", ` export const count = 3; `, - ), - dev.write( + ); + await dev.write( "name.ts", ` export const name = "Charlie"; `, - ), - ]); + ); + } - await c.expectMessage("Counter updated: 3", "Name updated: Charlie"); + await c.expectMessageInAnyOrder("Counter updated: 3", "Name updated: Charlie"); }, }); devTest("import.meta.hot.data persistence", { diff --git a/test/bake/dev/react-spa.test.ts b/test/bake/dev/react-spa.test.ts index 2a3f37012e..8944361666 100644 --- a/test/bake/dev/react-spa.test.ts +++ b/test/bake/dev/react-spa.test.ts @@ -52,7 +52,7 @@ const reactAndRefreshStub = { exports.register = function(fn, name) { if (typeof name !== "string") throw new Error("name must be a string"); if (typeof fn !== "function") throw new Error("fn must be a function"); - if (components.has(name)) throw new Error("Component already registered: " + name + ". Read its hash from test harness first"); + if (components.has(name)) console.warn("WARNING: Component already registered: " + name + ". Read its hash from test harness first"); const entry = functionToComponent.get(fn) ?? { fn, calls: 0, hash: undefined, name: undefined, customHooks: undefined }; entry.name = name; components.set(name, entry); diff --git a/test/bake/dev/sourcemap.test.ts b/test/bake/dev/sourcemap.test.ts index 972e7d5833..7bbf3875d7 100644 --- a/test/bake/dev/sourcemap.test.ts +++ b/test/bake/dev/sourcemap.test.ts @@ -93,6 +93,10 @@ async function extractSourceMap(dev: Dev, scriptSource: string) { throw new Error("Source map URL not found in " + scriptSource); } const sourceMap = await dev.fetch(sourceMapUrl[1]).text(); + if (!sourceMap.startsWith('{')) { + throw new Error("Source map is not valid JSON: " + sourceMap); + } + console.log(sourceMap); return new Promise((resolve, reject) => { try { SourceMapConsumer.with(sourceMap, null, async (consumer: any) => { diff --git a/test/bake/fixtures/svelte-component-islands/pages/_Counter.svelte b/test/bake/fixtures/svelte-component-islands/pages/_Counter.svelte index 7c4a618edb..5fdb4d7062 100644 --- a/test/bake/fixtures/svelte-component-islands/pages/_Counter.svelte +++ b/test/bake/fixtures/svelte-component-islands/pages/_Counter.svelte @@ -8,7 +8,7 @@
-

This is a client component (interactive island)

+

This is a client component (interactive island)

diff --git a/test/bundler/bundler_drop.test.ts b/test/bundler/bundler_drop.test.ts index a50bda9f58..ee1ceffe7e 100644 --- a/test/bundler/bundler_drop.test.ts +++ b/test/bundler/bundler_drop.test.ts @@ -106,4 +106,12 @@ describe("bundler", () => { run: { stdout: "true" }, drop: ["Bun"], }); + itBundled("drop/IdentifierCall", { + files: { + "/a.js": `ASSERT("hello");`, + }, + run: { stdout: "" }, + drop: ["ASSERT"], + backend: "api", + }); });