diff --git a/src/bake/DevServer.zig b/src/bake/DevServer.zig index 2d24c3ce55..b89bb9aed3 100644 --- a/src/bake/DevServer.zig +++ b/src/bake/DevServer.zig @@ -108,6 +108,7 @@ bundler_options: bake.SplitBundlerOptions, server_transpiler: Transpiler, client_transpiler: Transpiler, ssr_transpiler: Transpiler, +watcher_thread_resolver: bun.resolver.Resolver, /// The log used by all `server_transpiler`, `client_transpiler` and `ssr_transpiler`. /// Note that it is rarely correct to write messages into it. Instead, associate /// messages with the IncrementalGraph file or Route using `SerializedFailure` @@ -392,6 +393,7 @@ pub fn init(options: Options) bun.JSOOM!*DevServer { .server_transpiler = undefined, .client_transpiler = undefined, .ssr_transpiler = undefined, + .watcher_thread_resolver = undefined, .bun_watcher = undefined, .configuration_hash_key = undefined, .router = undefined, @@ -444,6 +446,9 @@ pub fn init(options: Options) bun.JSOOM!*DevServer { dev.ssr_transpiler.resolver.watcher = dev.bun_watcher.getResolveWatcher(); } + dev.watcher_thread_resolver = dev.server_transpiler.resolver; + dev.watcher_thread_resolver.watcher = null; + assert(dev.server_transpiler.resolver.opts.target != .browser); assert(dev.client_transpiler.resolver.opts.target == .browser); @@ -731,6 +736,7 @@ pub fn memoryCost(dev: *DevServer) usize { .server_register_update_callback = {}, .deferred_request_pool = {}, .assume_perfect_incremental_bundling = {}, + .watcher_thread_resolver = {}, // pointers that are not considered a part of DevServer .vm = {}, @@ -1296,9 +1302,9 @@ fn onHtmlRequestWithBundle(dev: *DevServer, route_bundle_index: RouteBundle.Inde const payload = generateHTMLPayload(dev, route_bundle_index, route_bundle, html) catch bun.outOfMemory(); errdefer dev.allocator.free(payload); html.cached_response = StaticRoute.initFromAnyBlob( - .fromOwnedSlice(dev.allocator, payload), + &.fromOwnedSlice(dev.allocator, payload), .{ - .mime_type = .html, + .mime_type = &.html, .server = dev.server orelse unreachable, }, ); @@ -1426,9 +1432,9 @@ pub fn onJsRequestWithBundle(dev: *DevServer, bundle_index: RouteBundle.Index, r const payload = dev.generateClientBundle(route_bundle) catch bun.outOfMemory(); errdefer dev.allocator.free(payload); route_bundle.client_bundle = StaticRoute.initFromAnyBlob( - .fromOwnedSlice(dev.allocator, payload), + &.fromOwnedSlice(dev.allocator, payload), .{ - .mime_type = .javascript, + .mime_type = &.javascript, .server = dev.server orelse unreachable, }, ); @@ -1602,7 +1608,7 @@ fn startAsyncBundle( dev.next_bundle.requests = .{}; } -fn indexFailures(dev: *DevServer) !void { +fn prepareAndLogResolutionFailures(dev: *DevServer) !void { // Since resolution failures can be asynchronous, their logs are not inserted // until the very end. const resolution_failures = dev.current_bundle.?.resolution_failure_entries; @@ -1626,7 +1632,9 @@ fn indexFailures(dev: *DevServer) !void { } dev.log.print(Output.errorWriter()) catch {}; } +} +fn indexFailures(dev: *DevServer) !void { // After inserting failures into the IncrementalGraphs, they are traced to their routes. var sfa_state = std.heap.stackFallback(65536, dev.allocator); const sfa = sfa_state.get(); @@ -1660,8 +1668,8 @@ fn indexFailures(dev: *DevServer) !void { switch (added.getOwner()) { .none, .route => unreachable, - .server => |index| try dev.server_graph.traceDependencies(index, >s, .no_stop, {}), - .client => |index| try dev.client_graph.traceDependencies(index, >s, .no_stop, {}), + .server => |index| try dev.server_graph.traceDependencies(index, >s, .no_stop, index), + .client => |index| try dev.client_graph.traceDependencies(index, >s, .no_stop, index), } } @@ -1752,9 +1760,9 @@ fn generateClientBundle(dev: *DevServer, route_bundle: *RouteBundle) bun.OOM![]u // TODO: this asset is never unreferenced const source_map = try dev.client_graph.takeSourceMap(.initial_response, sfa, dev.allocator); errdefer dev.allocator.free(source_map); - static_route_ptr.* = StaticRoute.initFromAnyBlob(.fromOwnedSlice(dev.allocator, source_map), .{ + static_route_ptr.* = StaticRoute.initFromAnyBlob(&.fromOwnedSlice(dev.allocator, source_map), .{ .server = dev.server.?, - .mime_type = .json, + .mime_type = &.json, }); } @@ -2009,15 +2017,15 @@ pub fn finalizeBundle( // TODO: use a hash mix with the first half being a path hash (to identify files) and // the second half to be the content hash (to know if the file has changed) const hash = bun.hash(key); - const asset_index = (try dev.assets.replacePath( + const asset_index = try dev.assets.replacePath( key, - .fromOwnedSlice(dev.allocator, code.buffer), - .css, + &.fromOwnedSlice(dev.allocator, code.buffer), + &.css, hash, - )).index; + ); // Later code needs to retrieve the CSS content // The hack is to use `entry_point_id`, which is otherwise unused, to store an index. - chunk.entry_point.entry_point_id = asset_index; + chunk.entry_point.entry_point_id = asset_index.get(); // Track css files that look like tailwind files. if (dev.has_tailwind_plugin_hack) |*map| { @@ -2088,6 +2096,8 @@ pub fn finalizeBundle( dev.incremental_result.had_adjusted_edges = false; + try prepareAndLogResolutionFailures(dev); + // Pass 2, update the graph's edges by performing import diffing on each // changed file, removing dependencies. This pass also flags what routes // have been modified. @@ -2225,10 +2235,12 @@ pub fn finalizeBundle( has_route_bits_set = true; dev.incremental_result.framework_routes_affected.clearRetainingCapacity(); + dev.incremental_result.html_routes_hard_affected.clearRetainingCapacity(); + dev.incremental_result.html_routes_soft_affected.clearRetainingCapacity(); gts.clear(); for (dev.incremental_result.client_components_affected.items) |index| { - try dev.server_graph.traceDependencies(index, >s, .no_stop, {}); + try dev.server_graph.traceDependencies(index, >s, .no_stop, index); } // A bit-set is used to avoid duplicate entries. This is not a problem @@ -2266,8 +2278,12 @@ pub fn finalizeBundle( // Softly affected HTML routes only need the bundle invalidated. WebSocket // connections don't have to be told anything. - for (dev.incremental_result.html_routes_soft_affected.items) |index| { - dev.routeBundlePtr(index).invalidateClientBundle(); + if (dev.incremental_result.html_routes_soft_affected.items.len > 0) { + for (dev.incremental_result.html_routes_soft_affected.items) |index| { + dev.routeBundlePtr(index).invalidateClientBundle(); + route_bits.set(index.get()); + } + has_route_bits_set = true; } // `route_bits` will have all of the routes that were modified. If any of @@ -2340,9 +2356,9 @@ pub fn finalizeBundle( if (try dev.assets.putOrIncrementRefCount(hash, hot_update_subscribers)) |static_route_ptr| { const source_map = try dev.client_graph.takeSourceMap(.hmr_chunk, bv2.graph.allocator, dev.allocator); errdefer dev.allocator.free(source_map); - static_route_ptr.* = StaticRoute.initFromAnyBlob(.fromOwnedSlice(dev.allocator, source_map), .{ + static_route_ptr.* = StaticRoute.initFromAnyBlob(&.fromOwnedSlice(dev.allocator, source_map), .{ .server = dev.server.?, - .mime_type = .json, + .mime_type = &.json, }); } // Build and send the source chunk @@ -2544,20 +2560,10 @@ pub fn handleParseTaskFailure( .client => try dev.client_graph.onFileDeleted(abs_path, log), } } else { - Output.prettyErrorln("Error{s} while bundling \"{s}\":", .{ - if (log.errors +| log.warnings != 1) "s" else "", - dev.relativePath(abs_path), - }); - log.print(Output.errorWriterBuffered()) catch {}; - Output.flush(); - - // Do not index css errors - if (!bun.strings.hasSuffixComptime(abs_path, ".css")) { - switch (graph) { - .server => try dev.server_graph.insertFailure(.abs_path, abs_path, log, false), - .ssr => try dev.server_graph.insertFailure(.abs_path, abs_path, log, true), - .client => try dev.client_graph.insertFailure(.abs_path, abs_path, log, false), - } + switch (graph) { + .server => try dev.server_graph.insertFailure(.abs_path, abs_path, log, false), + .ssr => try dev.server_graph.insertFailure(.abs_path, abs_path, log, true), + .client => try dev.client_graph.insertFailure(.abs_path, abs_path, log, false), } } } @@ -3543,15 +3549,14 @@ pub fn IncrementalGraph(side: bake.Side) type { const TraceDependencyGoal = enum { stop_at_boundary, no_stop, - css_to_route, }; fn traceDependencies( g: *@This(), file_index: FileIndex, gts: *GraphTraceState, - comptime goal: TraceDependencyGoal, - from_file_index: if (goal == .stop_at_boundary) FileIndex else void, + goal: TraceDependencyGoal, + from_file_index: FileIndex, ) !void { g.owner().graph_safety_lock.assertLocked(); @@ -3585,12 +3590,12 @@ pub fn IncrementalGraph(side: bake.Side) type { }, .client => { const dev = g.owner(); - if (file.flags.is_hmr_root or (file.flags.kind == .css and goal == .css_to_route)) { + if (file.flags.is_hmr_root) { const key = g.bundled_files.keys()[file_index.get()]; const index = dev.server_graph.getFileIndex(key) orelse Output.panic("Server Incremental Graph is missing component for {}", .{bun.fmt.quote(key)}); - try dev.server_graph.traceDependencies(index, gts, goal, if (goal == .stop_at_boundary) index else {}); - } else if (goal == .stop_at_boundary and file.flags.is_html_route) { + try dev.server_graph.traceDependencies(index, gts, goal, index); + } else if (file.flags.is_html_route) { const route_bundle_index = dev.client_graph.htmlRouteBundleIndex(file_index); // If the HTML file itself was modified, or an asset was @@ -3629,7 +3634,7 @@ pub fn IncrementalGraph(side: bake.Side) type { edge.dependency, gts, goal, - if (goal == .stop_at_boundary) file_index, + file_index, ); } } @@ -3790,18 +3795,27 @@ pub fn IncrementalGraph(side: bake.Side) type { /// Returns the key that was inserted. pub fn insertEmpty(g: *@This(), abs_path: []const u8) bun.OOM![]const u8 { - comptime assert(side == .client); // not implemented g.owner().graph_safety_lock.assertLocked(); const gop = try g.bundled_files.getOrPut(g.owner().allocator, abs_path); if (!gop.found_existing) { gop.key_ptr.* = try bun.default_allocator.dupe(u8, abs_path); - gop.value_ptr.* = File.initUnknown(.{ - .failed = false, - .is_hmr_root = false, - .is_special_framework_file = false, - .is_html_route = false, - .kind = .unknown, - }); + gop.value_ptr.* = switch (side) { + .client => File.initUnknown(.{ + .failed = false, + .is_hmr_root = false, + .is_special_framework_file = false, + .is_html_route = false, + .kind = .unknown, + }), + .server => .{ + .is_rsc = false, + .is_ssr = false, + .is_route = false, + .is_client_component_boundary = false, + .failed = false, + .kind = .unknown, + }, + }; try g.first_dep.append(g.owner().allocator, .none); try g.first_import.append(g.owner().allocator, .none); if (side == .client) @@ -4170,7 +4184,7 @@ pub fn IncrementalGraph(side: bake.Side) type { } /// Uses `arena` as a temporary allocator, returning a string owned by `gpa` - pub fn takeSourceMap(g: *@This(), kind: ChunkKind, arena: std.mem.Allocator, gpa: Allocator) ![]u8 { + pub fn takeSourceMap(g: *@This(), kind: ChunkKind, arena: std.mem.Allocator, gpa: Allocator) bun.OOM![]u8 { if (side == .server) @compileError("not implemented"); const paths = g.bundled_files.keys(); @@ -4185,12 +4199,11 @@ pub fn IncrementalGraph(side: bake.Side) type { }; j.pushStatic( - \\{"version":3,"sourceRoot":"/_bun/src","sources":[ + \\{"version":3,"sources":[ ); var source_map_strings = std.ArrayList(u8).init(arena); defer source_map_strings.deinit(); - const smw = source_map_strings.writer(); var needs_comma = false; for (g.current_chunk_parts.items) |entry| { if (source_maps[entry.get()].vlq_chunk.len == 0) @@ -4198,12 +4211,59 @@ pub fn IncrementalGraph(side: bake.Side) type { if (needs_comma) try source_map_strings.appendSlice(","); needs_comma = true; - try bun.js_printer.writeJSONString( - g.owner().relativePath(paths[entry.get()]), - @TypeOf(smw), - smw, - .utf8, - ); + const path = paths[entry.get()]; + if (std.fs.path.isAbsolute(path)) { + const is_windows_drive_path = Environment.isWindows and bun.path.isSepAny(path[0]); + try source_map_strings.appendSlice(if (is_windows_drive_path) + "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])) + path[2..] + else + path, // invalid but must not crash + &source_map_strings, + ) catch |err| { + switch (err) { + error.IncompleteUTF8 => { + @panic("Unexpected: asset with incomplete UTF-8 as file path"); + }, + // TODO: file an issue. This shouldn't be necessary. + else => |e| return e, + } + }; + } else { + // posix paths always start with '/' + // -> file:///path/to/file.js + // windows drive letter paths have the extra slash added + // -> file:///C:/path/to/file.js + bun.strings.percentEncodeWrite(path, &source_map_strings) catch |err| { + switch (err) { + error.IncompleteUTF8 => { + @panic("Unexpected: asset with incomplete UTF-8 as file path"); + }, + // TODO: file an issue. This shouldn't be necessary. + else => |e| return e, + } + }; + } + try source_map_strings.appendSlice("\""); + } else { + try source_map_strings.appendSlice("\"bun://"); + bun.strings.percentEncodeWrite(path, &source_map_strings) catch |err| { + switch (err) { + error.IncompleteUTF8 => { + @panic("Unexpected: asset with incomplete UTF-8 as file path"); + }, + // TODO: file an issue. This shouldn't be necessary. + else => |e| return e, + } + }; + try source_map_strings.appendSlice("\""); + } } j.pushStatic(source_map_strings.items); j.pushStatic( @@ -4509,14 +4569,8 @@ const DirectoryWatchStore = struct { dev.graph_safety_lock.lock(); defer dev.graph_safety_lock.unlock(); const owned_file_path = switch (renderer) { - .client => path: { - const index = try dev.client_graph.insertStale(import_source, false); - break :path dev.client_graph.bundled_files.keys()[index.get()]; - }, - .server, .ssr => path: { - const index = try dev.client_graph.insertStale(import_source, renderer == .ssr); - break :path dev.client_graph.bundled_files.keys()[index.get()]; - }, + .client => try dev.client_graph.insertEmpty(import_source), + .server, .ssr => try dev.server_graph.insertEmpty(import_source), }; store.insert(dir, owned_file_path, specifier) catch |err| switch (err) { @@ -5374,6 +5428,9 @@ pub fn startReloadBundle(dev: *DevServer, event: *HotReloadEvent) bun.OOM!void { event.processFileList(dev, &entry_points, temp_alloc); if (entry_points.set.count() == 0) { Output.debugWarn("nothing to bundle. watcher may potentially be watching too many files.", .{}); + Output.debugWarn("modified files: {s}", .{ + bun.fmt.fmtSlice(event.files.keys(), ", "), + }); return; } @@ -5519,6 +5576,9 @@ pub const HotReloadEvent = struct { if (entry_points.set.count() == 0) { Output.debugWarn("nothing to bundle. watcher may potentially be watching too many files.", .{}); + Output.debugWarn("modified files: {s}", .{ + bun.fmt.fmtSlice(first.files.keys(), ", "), + }); return; } @@ -5747,11 +5807,7 @@ pub fn onFileUpdate(dev: *DevServer, events: []Watcher.Event, changed_files: []? const dep = &dev.directory_watchers.dependencies.items[index.get()]; it = dep.next.unwrap(); - const prev_watcher = dev.server_transpiler.resolver.watcher; - dev.server_transpiler.resolver.watcher = null; - defer dev.server_transpiler.resolver.watcher = prev_watcher; - - if ((dev.server_transpiler.resolver.resolve( + if ((dev.watcher_thread_resolver.resolve( bun.path.dirname(dep.source_file_path, .auto), dep.specifier, .stmt, @@ -6015,20 +6071,21 @@ const HTMLRouter = struct { pub fn putOrOverwriteAsset( dev: *DevServer, - abs_path: []const u8, - contents: AnyBlob, + path: *const bun.fs.Path, + /// Ownership is transferred to this function. + contents: *const AnyBlob, content_hash: u64, ) !void { dev.graph_safety_lock.lock(); defer dev.graph_safety_lock.unlock(); - _ = try dev.assets.replacePath(abs_path, contents, .detectFromPath(abs_path), content_hash); + _ = try dev.assets.replacePath(path.text, contents, &.byExtension(path.name.extWithoutLeadingDot()), content_hash); } /// Storage for hashed assets on `/_bun/asset/{hash}.ext` pub const Assets = struct { /// Keys are absolute paths, sharing memory with the keys in IncrementalGraph(.client) /// Values are indexes into files - path_map: bun.StringArrayHashMapUnmanaged(u32), + path_map: bun.StringArrayHashMapUnmanaged(EntryIndex), /// Content-addressable store. Multiple paths can point to the same content /// hash, which is tracked by the `refs` array. One reference is held to /// contained StaticRoute instances when they are stored. @@ -6038,13 +6095,15 @@ pub const Assets = struct { needs_reindex: bool = false, + pub const EntryIndex = bun.GenericIndex(u30, Assets); + fn owner(assets: *Assets) *DevServer { return @alignCast(@fieldParentPtr("assets", assets)); } pub fn getHash(assets: *Assets, path: []const u8) ?u64 { return if (assets.path_map.get(path)) |idx| - assets.files.keys()[idx] + assets.files.keys()[idx.get()] else null; } @@ -6055,18 +6114,20 @@ pub const Assets = struct { assets: *Assets, /// not allocated abs_path: []const u8, - contents: AnyBlob, - mime_type: MimeType, + /// Ownership is transferred to this function + contents: *const AnyBlob, + mime_type: *const MimeType, /// content hash of the asset content_hash: u64, - ) !struct { index: u30 } { + ) !EntryIndex { defer assert(assets.files.count() == assets.refs.items.len); const alloc = assets.owner().allocator; - debug.log("replacePath {} {} - {s}/{s}", .{ + debug.log("replacePath {} {} - {s}/{s} ({s})", .{ bun.fmt.quote(abs_path), content_hash, asset_prefix, &std.fmt.bytesToHex(std.mem.asBytes(&content_hash), .lower), + mime_type.value, }); const gop = try assets.path_map.getOrPut(alloc, abs_path); @@ -6075,22 +6136,22 @@ pub const Assets = struct { const stable_abs_path = try assets.owner().client_graph.insertEmpty(abs_path); gop.key_ptr.* = stable_abs_path; } else { - const i = gop.value_ptr.*; + const entry_index = gop.value_ptr.*; // When there is one reference to the asset, the entry can be // replaced in-place with the new asset. - if (assets.refs.items[i] == 1) { + if (assets.refs.items[entry_index.get()] == 1) { const slice = assets.files.entries.slice(); - slice.items(.key)[i] = content_hash; - slice.items(.value)[i] = StaticRoute.initFromAnyBlob(contents, .{ + slice.items(.key)[entry_index.get()] = content_hash; + slice.items(.value)[entry_index.get()] = StaticRoute.initFromAnyBlob(contents, .{ .mime_type = mime_type, .server = assets.owner().server orelse unreachable, }); comptime assert(@TypeOf(slice.items(.hash)[0]) == void); assets.needs_reindex = true; - return .{ .index = @intCast(i) }; + return entry_index; } else { - assets.refs.items[i] -= 1; - assert(assets.refs.items[i] > 0); + assets.refs.items[entry_index.get()] -= 1; + assert(assets.refs.items[entry_index.get()] > 0); } } @@ -6104,12 +6165,11 @@ pub const Assets = struct { }); } else { assets.refs.items[file_index_gop.index] += 1; - var contents_mut = contents; + var contents_mut = contents.*; contents_mut.detach(); } - gop.value_ptr.* = @intCast(file_index_gop.index); - - return .{ .index = @intCast(gop.value_ptr.*) }; + gop.value_ptr.* = .init(@intCast(file_index_gop.index)); + return gop.value_ptr.*; } /// Returns a pointer to insert the *StaticRoute. If `null` is returned, then it @@ -6127,20 +6187,24 @@ pub const Assets = struct { } pub fn unrefByHash(assets: *Assets, content_hash: u64, dec_count: u32) void { - defer assert(assets.files.count() == assets.refs.items.len); - assert(dec_count > 0); const index = assets.files.getIndex(content_hash) orelse Output.panic("Asset double unref: {s}", .{std.fmt.fmtSliceHexLower(std.mem.asBytes(&content_hash))}); - assets.refs.items[index] -= dec_count; - if (assets.refs.items[index] == 0) { - assets.files.swapRemoveAt(index); - _ = assets.refs.swapRemove(index); + assets.unrefByIndex(.init(@intCast(index)), dec_count); + } + + pub fn unrefByIndex(assets: *Assets, index: EntryIndex, dec_count: u32) void { + defer assert(assets.files.count() == assets.refs.items.len); + assert(dec_count > 0); + assets.refs.items[index.get()] -= dec_count; + if (assets.refs.items[index.get()] == 0) { + assets.files.swapRemoveAt(index.get()); + _ = assets.refs.swapRemove(index.get()); } } pub fn unrefByPath(assets: *Assets, path: []const u8) void { const entry = assets.path_map.fetchSwapRemove(path) orelse return; - assets.unrefByHash(entry.value, 1); + assets.unrefByIndex(entry.value, 1); } pub fn reindexIfNeeded(assets: *Assets, alloc: Allocator) !void { diff --git a/src/bake/client/overlay.ts b/src/bake/client/overlay.ts index 2c2f6dd717..a7f4ae8310 100644 --- a/src/bake/client/overlay.ts +++ b/src/bake/client/overlay.ts @@ -253,7 +253,7 @@ function renderErrorMessageLine(level: BundlerMessageLevel, text: string) { throw new Error("Unknown log level: " + level); } return elem("div", { class: "message-text" }, [ - elemText("span", { class: "log-" + levelName }, levelName), + elemText("span", { class: "log-label log-" + levelName }, levelName), elemText("span", { class: "log-colon" }, ": "), elemText("span", { class: "log-text" }, text), ]); diff --git a/src/bun.js/api/server/StaticRoute.zig b/src/bun.js/api/server/StaticRoute.zig index 6fe8562379..093de6cfd7 100644 --- a/src/bun.js/api/server/StaticRoute.zig +++ b/src/bun.js/api/server/StaticRoute.zig @@ -18,18 +18,19 @@ pub usingnamespace bun.NewRefCounted(@This(), deinit, null); pub const InitFromBytesOptions = struct { server: AnyServer, - mime_type: ?bun.http.MimeType = null, + mime_type: ?*const bun.http.MimeType = null, }; -pub fn initFromAnyBlob(blob: AnyBlob, options: InitFromBytesOptions) *StaticRoute { - var headers = Headers.from(null, bun.default_allocator, .{ .body = &blob }) catch bun.outOfMemory(); +/// Ownership of `blob` is transferred to this function. +pub fn initFromAnyBlob(blob: *const AnyBlob, options: InitFromBytesOptions) *StaticRoute { + var headers = Headers.from(null, bun.default_allocator, .{ .body = blob }) catch bun.outOfMemory(); if (options.mime_type) |mime_type| { if (headers.getContentType() == null) { headers.append("Content-Type", mime_type.value) catch bun.outOfMemory(); } } return StaticRoute.new(.{ - .blob = blob, + .blob = blob.*, .cached_blob_size = blob.size(), .has_content_disposition = false, .headers = headers, diff --git a/src/bundler/bundle_v2.zig b/src/bundler/bundle_v2.zig index 74b03665e5..92d2b545c9 100644 --- a/src/bundler/bundle_v2.zig +++ b/src/bundler/bundle_v2.zig @@ -2385,7 +2385,7 @@ pub const BundleV2 = struct { this.graph.heap.helpCatchMemoryIssues(); - this.dynamic_import_entry_points = std.AutoArrayHashMap(Index.Int, void).init(this.graph.allocator); + this.dynamic_import_entry_points = .init(this.graph.allocator); var html_files: std.AutoArrayHashMapUnmanaged(Index, void) = .{}; // Separate non-failing files into two lists: JS and CSS @@ -2444,6 +2444,20 @@ pub const BundleV2 = struct { } } + // Find CSS entry points. Originally, this was computed up front, but + // failed files do not remember their loader, and plugins can + // asynchronously decide a file is CSS. + const css = asts.items(.css); + for (this.graph.entry_points.items) |entry_point| { + if (css[entry_point.get()] != null) { + try start.css_entry_points.put( + this.graph.allocator, + entry_point, + .{ .imported_on_server = false }, + ); + } + } + break :reachable_files js_files.items; }; @@ -3141,10 +3155,10 @@ pub const BundleV2 = struct { if (result.unique_key_for_additional_file.len > 0 and result.loader.shouldCopyForBundling()) { if (this.transpiler.options.dev_server) |dev| { dev.putOrOverwriteAsset( - result.source.path.text, + &result.source.path, // SAFETY: when shouldCopyForBundling is true, the // contents are allocated by bun.default_allocator - .fromOwnedSlice(bun.default_allocator, @constCast(result.source.contents)), + &.fromOwnedSlice(bun.default_allocator, @constCast(result.source.contents)), result.content_hash_for_additional_file, ) catch bun.outOfMemory(); } diff --git a/src/fs.zig b/src/fs.zig index 09efbbd4e2..e2ddb57fdb 100644 --- a/src/fs.zig +++ b/src/fs.zig @@ -1517,6 +1517,10 @@ pub const PathName = struct { ext: string, filename: string, + pub fn extWithoutLeadingDot(self: *const PathName) string { + return if (self.ext.len > 0 and self.ext[0] == '.') self.ext[1..] else self.ext; + } + pub fn nonUniqueNameStringBase(self: *const PathName) string { // /bar/foo/index.js -> foo if (self.dir.len > 0 and strings.eqlComptime(self.base, "index")) { diff --git a/src/http/mime_type.zig b/src/http/mime_type.zig index 084ebfe1a7..8cf8b1ac88 100644 --- a/src/http/mime_type.zig +++ b/src/http/mime_type.zig @@ -231,17 +231,12 @@ pub fn byLoader(loader: Loader, ext: string) MimeType { } } -pub fn byExtension(ext: string) MimeType { - return byExtensionNoDefault(ext) orelse MimeType.other; +pub fn byExtension(ext_without_leading_dot: string) MimeType { + return byExtensionNoDefault(ext_without_leading_dot) orelse MimeType.other; } -pub fn byExtensionNoDefault(ext: string) ?MimeType { - return extensions.get(ext); -} - -pub fn detectFromPath(path: string) MimeType { - const ext = std.fs.path.extension(path); - return byExtension(ext); +pub fn byExtensionNoDefault(ext_without_leading_dot: string) ?MimeType { + return extensions.get(ext_without_leading_dot); } // this is partially auto-generated diff --git a/src/string_immutable.zig b/src/string_immutable.zig index 74fc0bb424..2613d5c5df 100644 --- a/src/string_immutable.zig +++ b/src/string_immutable.zig @@ -4328,6 +4328,7 @@ pub fn containsNewlineOrNonASCIIOrQuote(slice_: []const u8) bool { return false; } +/// JSON escape pub fn indexOfNeedsEscape(slice: []const u8, comptime quote_char: u8) ?u32 { var remaining = slice; if (remaining.len == 0) @@ -4374,6 +4375,72 @@ pub fn indexOfNeedsEscape(slice: []const u8, comptime quote_char: u8) ?u32 { return null; } +pub fn indexOfNeedsURLEncode(slice: []const u8) ?u32 { + var remaining = slice; + if (remaining.len == 0) + return null; + + if (remaining[0] >= 127 or + remaining[0] < 0x20 or + remaining[0] == '\\' or + remaining[0] == '"' or + remaining[0] == '#' or + remaining[0] == '?' or + remaining[0] == '[' or + remaining[0] == ']' or + remaining[0] == '^' or + remaining[0] == '|' or + remaining[0] == '~') + { + return 0; + } + + if (comptime Environment.enableSIMD) { + while (remaining.len >= ascii_vector_size) { + const vec: AsciiVector = remaining[0..ascii_vector_size].*; + const cmp: AsciiVectorU1 = + @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('[')))) | + @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('~')))); + + if (@reduce(.Max, cmp) > 0) { + const bitmask = @as(AsciiVectorInt, @bitCast(cmp)); + const first = @ctz(bitmask); + return @as(u32, first) + @as(u32, @truncate(@intFromPtr(remaining.ptr) - @intFromPtr(slice.ptr))); + } + + remaining = remaining[ascii_vector_size..]; + } + } + + for (remaining) |*char_| { + const char = char_.*; + if (char > 127 or char < 0x20 or + char == '\\' or + char == '"' or + char == '#' or + char == '?' or + char == '[' or + char == ']' or + char == '^' or + char == '|' or + char == '~') + { + return @as(u32, @truncate(@intFromPtr(char_) - @intFromPtr(slice.ptr))); + } + } + + return null; +} + pub fn indexOfCharZ(sliceZ: [:0]const u8, char: u8) ?u63 { const ptr = bun.C.strchr(sliceZ.ptr, char) orelse return null; const pos = @intFromPtr(ptr) - @intFromPtr(sliceZ.ptr); @@ -6678,3 +6745,39 @@ pub fn splitFirstWithExpected(self: string, comptime expected: u8) ?[]const u8 { } return null; } + +pub fn percentEncodeWrite( + utf8_input: []const u8, + writer: *std.ArrayList(u8), +) error{ OutOfMemory, IncompleteUTF8 }!void { + var remaining = utf8_input; + while (indexOfNeedsURLEncode(remaining)) |j| { + const safe = remaining[0..j]; + remaining = remaining[j..]; + const code_point_len: usize = wtf8ByteSequenceLengthWithInvalid(remaining[0]); + if (remaining.len < code_point_len) { + @branchHint(.unlikely); + return error.IncompleteUTF8; + } + + const to_encode = remaining[0..code_point_len]; + remaining = remaining[code_point_len..]; + + try writer.ensureUnusedCapacity(safe.len + ("%FF".len) * code_point_len); + + // Write the safe bytes + writer.appendSliceAssumeCapacity(safe); + + // URL encode the code point + for (to_encode) |byte| { + writer.appendSliceAssumeCapacity(&.{ + '%', + byte2hex((byte >> 4) & 0xF), + byte2hex(byte & 0xF), + }); + } + } + + // Write the rest of the string + try writer.appendSlice(remaining); +} diff --git a/test/bake/client-fixture.mjs b/test/bake/client-fixture.mjs index ffff3a8ed0..3daa68a958 100644 --- a/test/bake/client-fixture.mjs +++ b/test/bake/client-fixture.mjs @@ -4,15 +4,19 @@ import { Window } from "happy-dom"; import util from "node:util"; -let url = process.argv[2]; +const args = process.argv.slice(2); +let url = args.find(arg => !arg.startsWith("-")); if (!url) { - console.error("Usage: node client-fixture.mjs "); + console.error("Usage: node client-fixture.mjs [...]"); process.exit(1); } url = new URL(url, "http://localhost:3000"); +const storeHotChunks = args.includes("--store-hot-chunks"); + // Create a new window instance let window; +let nativeEval; let expectingReload = false; let webSockets = []; let pendingReload = null; @@ -35,9 +39,6 @@ function reset() { warn: () => {}, info: () => {}, }; - window.harness = { - send: () => {}, - }; } } @@ -104,6 +105,22 @@ function createWindow(windowUrl) { }, 1000); } }; + + let hasHadCssReplace = false; + const originalCSSStyleSheetReplace = window.CSSStyleSheet.prototype.replaceSync; + window.CSSStyleSheet.prototype.replace = function (newContent) { + const result = originalCSSStyleSheetReplace.apply(this, [newContent]); + hasHadCssReplace = true; + return result; + }; + + nativeEval = window.eval; + if (storeHotChunks) { + window.eval = code => { + process.send({ type: "hmr-chunk", args: [code] }); + return nativeEval.call(window, code); + }; + } } async function handleReload() { @@ -170,10 +187,10 @@ process.on("message", async message => { // Evaluate the code in the window context let result; try { - result = await window.eval(code); + result = await nativeEval(`(async () => ${code})()`); } catch (error) { - if (error.message === "Illegal return statement") { - result = await window.eval(`(async () => { ${code} })()`); + if (error.message === "Illegal return statement" || error.message.includes("Unexpected token")) { + result = await nativeEval(`(async () => { ${code} })()`); } else { throw error; } @@ -207,6 +224,101 @@ process.on("message", async message => { if (message.type === "exit") { process.exit(0); } + if (message.type === "get-style") { + const [messageId, selector] = message.args; + try { + for (const sheet of [...window.document.styleSheets, ...window.document.adoptedStyleSheets]) { + if (sheet.disabled) continue; + for (const rule of sheet.cssRules) { + if (rule.selectorText === selector) { + const style = {}; + for (let i = 0; i < rule.style.length; i++) { + const prop = rule.style[i]; + const camelCase = prop.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + style[camelCase] = rule.style.getPropertyValue(prop); + } + process.send({ + type: `get-style-result-${messageId}`, + args: [ + { + value: style, + }, + ], + }); + return; + } + } + } + + process.send({ + type: `get-style-result-${messageId}`, + args: [ + { + value: undefined, + }, + ], + }); + } catch (error) { + process.send({ + type: `get-style-result-${messageId}`, + args: [ + { + error: error.message, + }, + ], + }); + } + } + if (message.type === "get-errors") { + const [messageId] = message.args; + try { + const overlay = window.document.querySelector("bun-hmr"); + if (!overlay) { + process.send({ + type: `get-errors-result-${messageId}`, + args: [{ value: [] }], + }); + return; + } + + const errors = []; + const messages = overlay.shadowRoot.querySelectorAll(".message"); + + for (const message of messages) { + const fileName = message.closest(".message-group").querySelector(".file-name").textContent; + const label = message.querySelector(".log-label").textContent; + const text = message.querySelector(".log-text").textContent; + + const lineNumElem = message.querySelector(".line-num"); + const spaceElem = message.querySelector(".highlight-wrap > .space"); + + let formatted; + if (lineNumElem && spaceElem) { + const line = lineNumElem.textContent; + const col = spaceElem.textContent.length; + formatted = `${fileName}:${line}:${col}: ${label}: ${text}`; + } else { + formatted = `${fileName}: ${label}: ${text}`; + } + + errors.push(formatted); + } + + process.send({ + type: `get-errors-result-${messageId}`, + args: [{ value: errors.sort() }], + }); + } catch (error) { + console.error(error); + process.send({ + type: `get-errors-result-${messageId}`, + args: [{ error: error.message }], + }); + } + } +}); +process.on("disconnect", () => { + process.exit(0); }); process.on("exit", () => { if (expectingReload) { diff --git a/test/bake/dev-server-harness.ts b/test/bake/dev-server-harness.ts index 69af9d6ee6..8ce48d87c7 100644 --- a/test/bake/dev-server-harness.ts +++ b/test/bake/dev-server-harness.ts @@ -1,6 +1,6 @@ /// import { Bake, Subprocess } from "bun"; -import fs from "node:fs"; +import fs, { realpathSync } from "node:fs"; import path from "node:path"; import os from "node:os"; import assert from "node:assert"; @@ -27,6 +27,14 @@ export const minimalFramework: Bake.Framework = { }, }; +/// Workaround to enable hot-module-reloading +export const reactRefreshStub = { + "node_modules/react-refresh/runtime.js": ` + export const performReactRefresh = () => {}; + export const injectIntoGlobalHook = () => {}; + `, +}; + export function emptyHtmlFile({ styles = [], scripts = [], @@ -90,6 +98,7 @@ async function maybeWaitInteractive(message: string) { const input = prompt("\x1b[32mPress return to " + message + "; JS>\x1b[0m"); if (input === "q" || input === "exit") { process.exit(0); + return; } if (input === "" || input == null) return; const result = await activeClient.jsInteractive(input); @@ -125,13 +134,14 @@ export class Dev { port: number; baseUrl: string; panicked = false; + connectedClients: Set = new Set(); // These properties are not owned by this class devProcess: Subprocess<"pipe", "pipe", "pipe">; output: OutputLineStream; constructor(root: string, port: number, process: Subprocess<"pipe", "pipe", "pipe">, stream: OutputLineStream) { - this.rootDir = root; + this.rootDir = realpathSync(root); this.port = port; this.baseUrl = `http://localhost:${port}`; this.devProcess = process; @@ -158,26 +168,53 @@ export class Dev { }); } - async write(file: string, contents: string) { - await maybeWaitInteractive("write " + file); - const wait = this.waitForHotReload(); - // TODO: consider using IncomingMessageId.virtual_file_change to reduce theoretical flakiness. - fs.writeFileSync(this.join(file), contents); - return wait; + write(file: string, contents: string, options: { errors?: null | ErrorSpec[]; dedent?: boolean } = {}) { + const snapshot = snapshotCallerLocation(); + return withAnnotatedStack(snapshot, async () => { + await maybeWaitInteractive("write " + file); + const wait = this.waitForHotReload(); + await Bun.write(this.join(file), (options.dedent ?? true) ? dedent(contents) : contents); + await wait; + + let errors = options.errors; + if (errors !== null) { + errors ??= []; + for (const client of this.connectedClients) { + await client.expectErrorOverlay(errors, null); + } + } + }); } - async patch(file: string, { find, replace }: { find: string; replace: string }) { - await maybeWaitInteractive("patch " + file); - const wait = this.waitForHotReload(); - const filename = this.join(file); - const source = fs.readFileSync(filename, "utf8"); - const contents = source.replace(find, replace); - if (contents === source) { - throw new Error(`Couldn't find and replace ${JSON.stringify(find)} in ${file}`); - } - // TODO: consider using IncomingMessageId.virtual_file_change to reduce theoretical flakiness. - fs.writeFileSync(filename, contents); - return wait; + patch( + file: string, + { + find, + replace, + errors, + dedent: shouldDedent = true, + }: { find: string; replace: string; errors?: null | ErrorSpec[]; dedent?: boolean }, + ) { + const snapshot = snapshotCallerLocation(); + return withAnnotatedStack(snapshot, async () => { + await maybeWaitInteractive("patch " + file); + const wait = this.waitForHotReload(); + const filename = this.join(file); + const source = fs.readFileSync(filename, "utf8"); + const contents = source.replace(find, replace); + if (contents === source) { + throw new Error(`Couldn't find and replace ${JSON.stringify(find)} in ${file}`); + } + await Bun.write(filename, shouldDedent ? dedent(contents) : contents); + await wait; + + if (errors !== null) { + errors ??= []; + for (const client of this.connectedClients) { + await client.expectErrorOverlay(errors, null); + } + } + }); } join(file: string) { @@ -198,25 +235,29 @@ export class Dev { ]); } - async client(url = "/", options: { errors?: ErrorSpec[] } = {}) { + async client( + url = "/", + options: { + errors?: ErrorSpec[]; + /** Allow using `getMostRecentHmrChunk` */ + storeHotChunks?: boolean; + } = {}, + ) { await maybeWaitInteractive("open client " + url); - const client = new Client(new URL(url, this.baseUrl).href); + const client = new Client(new URL(url, this.baseUrl).href, { + storeHotChunks: options.storeHotChunks, + }); try { await client.output.waitForLine(hmrClientInitRegex); } catch (e) { client[Symbol.asyncDispose](); throw e; } - const hasVisibleModal = await client.js`document.querySelector("bun-hmr")?.style.display === "block"`; - if (options.errors) { - if (!hasVisibleModal) { - throw new Error("Expected errors, but none found"); - } - } else { - if (hasVisibleModal) { - throw new Error("Bundle failures!"); - } - } + await client.expectErrorOverlay(options.errors ?? []); + this.connectedClients.add(client); + client.on("exit", () => { + this.connectedClients.delete(client); + }); return client; } } @@ -300,9 +341,44 @@ class DevFetchPromise extends Promise { } } +class StylePromise extends Promise> { + selector: string; + capturedStack: string; + + constructor( + executor: ( + resolve: (value: Record | PromiseLike>) => void, + reject: (reason?: any) => void, + ) => void, + selector: string, + capturedStack: string, + ) { + super(executor); + this.selector = selector; + this.capturedStack = capturedStack; + } + + notFound() { + const snapshot = snapshotCallerLocation(); + return withAnnotatedStack(snapshot, () => { + return new Promise((done, reject) => { + this.then(style => { + if (style === undefined) { + done(); + } else { + reject(new Error(`Selector '${this.selector}' was found: ${JSON.stringify(style)}`)); + } + }); + }); + }); + } +} + const node = process.env.DEV_SERVER_CLIENT_EXECUTABLE ?? Bun.which("node"); expect(node, "test will fail if this is not node").not.toBe(process.execPath); +const danglingProcesses = new Set(); + /** * Controls a subprocess that uses happy-dom as a lightweight browser. It is * sandboxed in a separate process because happy-dom is a terrible mess to work @@ -314,8 +390,11 @@ export class Client extends EventEmitter { exited = false; exitCode: string | null = null; messages: any[] = []; + #hmrChunk: string | null = null; + suppressInteractivePrompt: boolean = false; + expectingReload = false; - constructor(url: string) { + constructor(url: string, options: { storeHotChunks?: boolean } = {}) { super(); activeClient = this; const proc = Bun.spawn({ @@ -325,15 +404,15 @@ export class Client extends EventEmitter { "--experimental-websocket", // support node 20 path.join(import.meta.dir, "client-fixture.mjs"), url, - ], - env: { - ...process.env, - }, + options.storeHotChunks ? "--store-hot-chunks" : "", + ].filter(Boolean) as string[], + env: bunEnv, serialization: "json", ipc: (message, subprocess) => { this.emit(message.type, ...message.args); }, onExit: (subprocess, exitCode, signalCode, error) => { + danglingProcesses.delete(subprocess); if (exitCode !== null) { this.exitCode = exitCode.toString(); } else if (signalCode !== null) { @@ -349,12 +428,16 @@ export class Client extends EventEmitter { }, stdio: ["pipe", "pipe", "pipe"], }); + danglingProcesses.add(proc); this.on("message", (message: any) => { this.messages.push(message); }); + this.on("hmr-chunk", (chunk: string) => { + this.#hmrChunk = chunk; + }); this.#proc = proc; // @ts-expect-error - this.output = new OutputLineStream("browser", proc.stdout, proc.stderr); + this.output = new OutputLineStream("web", proc.stdout, proc.stderr); } hardReload() { @@ -397,19 +480,22 @@ export class Client extends EventEmitter { expectReload(cb: () => Promise) { return withAnnotatedStack(snapshotCallerLocation(), async () => { + this.expectingReload = true; if (this.exited) throw new Error("Client exited while waiting for reload"); let emitted = false; const resolver = Promise.withResolvers(); this.#proc.send({ type: "expect-reload" }); - function onEvent() { + const onEvent = () => { emitted = true; resolver.resolve(); - } + this.expectingReload = false; + }; this.once("reload", onEvent); this.once("exit", onEvent); let t: any = setTimeout(() => { t = null; resolver.resolve(); + this.expectingReload = false; }, 1000); await cb(); await resolver.promise; @@ -452,6 +538,66 @@ export class Client extends EventEmitter { }); } + /** + * Expect the page to have errors. Empty array asserts the modal is not + * visible. + * @example + * ```ts + * errors: [ + * "index.ts:1:21: error: Could not resolve: "./second"", + * ], + * ``` + */ + expectErrorOverlay(errors: ErrorSpec[], caller: string | null = null) { + return withAnnotatedStack(caller ?? snapshotCallerLocationMayFail(), async () => { + this.suppressInteractivePrompt = true; + const hasVisibleModal = await this.js`document.querySelector("bun-hmr")?.style.display === "block"`; + this.suppressInteractivePrompt = false; + if (errors && errors.length > 0) { + if (!hasVisibleModal) { + throw new Error("Expected errors, but none found"); + } + + // Create unique message ID for this evaluation + const messageId = Math.random().toString(36).slice(2); + + // Send the evaluation request and wait for response + this.#proc.send({ + type: "get-errors", + args: [messageId], + }); + + const [result] = await EventEmitter.once(this, `get-errors-result-${messageId}`); + + if (result.error) { + throw new Error(result.error); + } + const actualErrors = result.value; + const expectedErrors = [...errors].sort(); + expect(actualErrors).toEqual(expectedErrors); + } else { + if (hasVisibleModal) { + // Create unique message ID for this evaluation + const messageId = Math.random().toString(36).slice(2); + + // Send the evaluation request and wait for response + this.#proc.send({ + type: "get-errors", + args: [messageId], + }); + + const [result] = await EventEmitter.once(this, `get-errors-result-${messageId}`); + + if (result.error) { + throw new Error(result.error); + } + const actualErrors = result.value; + expect(actualErrors).toEqual([]); + } + } + }); + } + getStringMessage(): Promise { return withAnnotatedStack(snapshotCallerLocation(), async () => { if (this.messages.length === 0) { @@ -486,7 +632,7 @@ export class Client extends EventEmitter { "", ); return withAnnotatedStack(snapshotCallerLocationMayFail(), async () => { - await maybeWaitInteractive("js"); + if (!this.suppressInteractivePrompt) await maybeWaitInteractive("js"); return new Promise((resolve, reject) => { // Create unique message ID for this evaluation const messageId = Math.random().toString(36).slice(2); @@ -535,12 +681,71 @@ export class Client extends EventEmitter { }); } - click(selector: string) { - this.js` + async click(selector: string) { + await maybeWaitInteractive("click " + selector); + this.suppressInteractivePrompt = true; + await this.js` const elem = document.querySelector(${selector}); if (!elem) throw new Error("Element not found: " + ${selector}); elem.click(); `; + this.suppressInteractivePrompt = false; + } + + async getMostRecentHmrChunk() { + if (!this.#hmrChunk) { + // Wait up to a threshold before giving up + const resolver = Promise.withResolvers(); + this.once("hmr-chunk", () => resolver.resolve()); + this.once("exit", () => resolver.reject(new Error("Client exited while waiting for HMR chunk"))); + let t: any = setTimeout(() => { + t = null; + resolver.reject(new Error("Timeout waiting for HMR chunk")); + }, 1000); + await resolver.promise; + if (t) clearTimeout(t); + } + if (!this.#hmrChunk) { + throw new Error("No HMR chunks received. Make sure storeHotChunks is true"); + } + const chunk = this.#hmrChunk; + this.#hmrChunk = null; + return chunk; + } + + /** + * Looks through loaded stylesheets to find a rule with this EXACT selector, + * then it returns the values in it. + */ + style(selector: string): LazyStyle { + return new Proxy( + new StylePromise( + (resolve, reject) => { + // Create unique message ID for this evaluation + const messageId = Math.random().toString(36).slice(2); + + // Set up one-time handler for the response + const handler = (result: any) => { + if (result.error) { + reject(new Error(result.error)); + } else { + resolve(result.value); + } + }; + + this.once(`get-style-result-${messageId}`, handler); + + // Send the evaluation request + this.#proc.send({ + type: "get-style", + args: [messageId, selector], + }); + }, + selector, + snapshotCallerLocation(), + ), + styleProxyHandler, + ); } } @@ -581,6 +786,43 @@ const fetchExpectProxyHandler: ProxyHandler = { }, }; +type CssPropertyName = keyof React.CSSProperties; +type LazyStyle = { + [K in CssPropertyName]: LazyStyleProp; +} & { + /** Assert that the selector was not found */ + notFound(): Promise; +}; +interface LazyStyleProp extends Promise { + expect: Matchers; +} + +const styleProxyHandler: ProxyHandler = { + get(target, prop, receiver) { + if (prop === "then") { + return Promise.prototype.then.bind(target); + } + const existing = Reflect.get(target, prop, receiver); + if (existing !== undefined) { + return existing; + } + const subpromise = target.then(style => { + if (style === undefined) { + console.error(target.capturedStack); + throw new Error(`Selector '${target.selector}' was not found`); + } + return style[prop]; + }); + Object.defineProperty(subpromise, "expect", { + get: expectOnPromise, + }); + return subpromise; + }, +}; + +function expectOnPromise(this: Promise) { + return expectProxy(this, [], expect("")); +} function snapshotCallerLocation(): string { const stack = new Error().stack!; const lines = stack.replaceAll("\r\n", "\n").split("\n"); @@ -699,7 +941,13 @@ class OutputLineStream extends EventEmitter { const { done, value } = (await reader.read()) as { done: boolean; value: Uint8Array }; if (done) break; const clearScreenCode = "\x1B[2J\x1B[3J\x1B[H"; - const text = last + td.decode(value, { stream: true }).replace(clearScreenCode, "").replaceAll("\r", ""); + const text = + last + + td + .decode(value, { stream: true }) + .replace(clearScreenCode, "") // no screen clears + .replaceAll("\r", "") // windows hell + .replaceAll("\x1b[31m", "\x1b[39m"); // remove red because it looks like an error const lines = text.split("\n"); last = lines.pop()!; for (const line of lines) { @@ -881,9 +1129,15 @@ export function devTest(description: string, options: T }, ]), stdio: ["pipe", "pipe", "pipe"], + onExit: (subprocess, exitCode, signalCode, error) => { + danglingProcesses.delete(subprocess); + }, }); + danglingProcesses.add(devProcess); + // @ts-expect-error using stream = new OutputLineStream("dev", devProcess.stdout, devProcess.stderr); const port = parseInt((await stream.waitForLine(/localhost:(\d+)/))[1], 10); + // @ts-expect-error const dev = new Dev(root, port, devProcess, stream); await maybeWaitInteractive("start"); @@ -942,3 +1196,9 @@ export function devTest(description: string, options: T } return options; } + +process.on("exit", () => { + for (const proc of danglingProcesses) { + proc.kill("SIGKILL"); + } +}); diff --git a/test/bake/dev/bundle.test.ts b/test/bake/dev/bundle.test.ts index 00b1cc11a6..2f6d3c2e56 100644 --- a/test/bake/dev/bundle.test.ts +++ b/test/bake/dev/bundle.test.ts @@ -88,7 +88,7 @@ devTest("importing a file before it is created", { }, async test(dev) { const c = await dev.client("/", { - errors: [`index.ts:1:21 error: Could not resolve: "./second"`], + errors: [`index.ts:1:21: error: Could not resolve: "./second"`], }); await c.expectReload(async () => { diff --git a/test/bake/dev/css.test.ts b/test/bake/dev/css.test.ts index 749cc3267e..c52db9aa29 100644 --- a/test/bake/dev/css.test.ts +++ b/test/bake/dev/css.test.ts @@ -1,9 +1,8 @@ // CSS tests concern bundling bugs with CSS files import { expect } from "bun:test"; -import { devTest, emptyHtmlFile, minimalFramework } from "../dev-server-harness"; +import { devTest, emptyHtmlFile, minimalFramework, reactRefreshStub } from "../dev-server-harness"; devTest("css file with syntax error does not kill old styles", { - framework: minimalFramework, files: { "styles.css": ` body { @@ -17,7 +16,7 @@ devTest("css file with syntax error does not kill old styles", { }, async test(dev) { await using client = await dev.client("/"); - expect(await client.js`getComputedStyle(document.body).color`).toBe("red"); + await client.style("body").color.expect.toBe("red"); await dev.write( "styles.css", ` @@ -26,8 +25,11 @@ devTest("css file with syntax error does not kill old styles", { background-color } `, + { + errors: ["styles.css:4:1: error: Unexpected end of input"], + }, ); - expect(await client.js`getComputedStyle(document.body).color`).toBe("red"); + await client.style("body").color.expect.toBe("red"); await dev.write( "styles.css", ` @@ -37,35 +39,89 @@ devTest("css file with syntax error does not kill old styles", { } `, ); - - // Disabled because css updates are flaky. getComputedStyle doesnt update because replacements are async - - // expect(await client.js`getComputedStyle(document.body).backgroundColor`).toBe("#00f"); - // await dev.write("routes/styles.css", ` `); - // expect(await client.js`getComputedStyle(document.body).backgroundColor`).toBe(""); + await client.style("body").backgroundColor.expect.toBe("#00f"); + await dev.write("styles.css", ` `, { dedent: false }); + await client.style("body").notFound(); + }, +}); +devTest("css file with initial syntax error gets recovered", { + files: { + "index.html": emptyHtmlFile({ + styles: ["styles.css"], + body: `hello world`, + }), + "styles.css": ` + body { + color: red; + }} + `, + }, + async test(dev) { + await using client = await dev.client("/", { + errors: ["styles.css:3:3: error: Unexpected end of input"], + }); + // hard reload to dismiss the error overlay + await client.expectReload(async () => { + await dev.write( + "styles.css", + ` + body { + color: red; + } + `, + ); + }); + await client.style("body").color.expect.toBe("red"); + await dev.write( + "styles.css", + ` + body { + color: blue; + } + `, + ); + await client.style("body").color.expect.toBe("#00f"); + await dev.write( + "styles.css", + ` + body { + color: blue; + }} + `, + { + errors: ["styles.css:3:3: error: Unexpected end of input"], + }, + ); + }, +}); +devTest("add new css import later", { + files: { + ...reactRefreshStub, + "index.html": emptyHtmlFile({ + scripts: ["index.ts", "react-refresh/runtime"], + body: `hello world`, + }), + "index.ts": ` + // import "./styles.css"; + export default function () { + return "hello world"; + } + `, + "styles.css": ` + body { + color: red; + } + `, + }, + async test(dev) { + await using client = await dev.client("/"); + await client.style("body").notFound(); + await dev.patch("index.ts", { find: "// import", replace: "import" }); + await client.style("body").color.expect.toBe("red"); + await dev.patch("index.ts", { find: "import", replace: "// import" }); + await client.style("body").notFound(); }, }); -// devTest("css file with initial syntax error gets recovered", { -// framework: minimalFramework, -// files: { -// "routes/styles.css": ` -// body { -// color: red; -// `, -// "routes/index.ts": ` -// import { expect } from 'bun:test'; -// import './styles.css'; -// export default function (req, meta) { -// const input = req.json(); -// expect(meta.styles).toHaveLength(input.len); -// return new Response('' + meta.styles[0]); -// } -// `, -// }, -// async test(dev) { -// await dev.fetchJSON("/", { len: 1 }).equals("undefined"); -// }, -// }); // TODO: revive these tests for server components. they fail because some assertion. // devTest("css file with syntax error does not kill old styles", { @@ -137,3 +193,23 @@ devTest("css file with syntax error does not kill old styles", { // await dev.fetchJSON("/", { len: 1 }).equals("undefined"); // }, // }); + +devTest("fuzz case 1", { + files: { + "styles.css": ` + body { + background-image: url + } + `, + "index.html": emptyHtmlFile({ + styles: ["styles.css"], + body: `hello world`, + }), + }, + async test(dev) { + expect((await dev.fetch("/")).status).toBe(200); + // previously: panic(main thread): Asset double unref: 0000000000000000 + await dev.patch("styles.css", { find: "url\n", replace: "url(\n" }); + expect((await dev.fetch("/")).status).toBe(500); + }, +}); diff --git a/test/bake/dev/ecosystem.test.ts b/test/bake/dev/ecosystem.test.ts index 2f0a58d5ed..72fcb0dc80 100644 --- a/test/bake/dev/ecosystem.test.ts +++ b/test/bake/dev/ecosystem.test.ts @@ -21,7 +21,7 @@ devTest("svelte component islands example", { expect(html).toContain(`

This is my svelte server component (non-interactive)

Bun v${Bun.version}

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

`); - const c = await dev.client("/"); + 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. diff --git a/test/bake/dev/react-spa.test.ts b/test/bake/dev/react-spa.test.ts index c3f014babb..3d2a500161 100644 --- a/test/bake/dev/react-spa.test.ts +++ b/test/bake/dev/react-spa.test.ts @@ -6,7 +6,7 @@ import { devTest } from "../dev-server-harness"; devTest("react in html", { fixture: "react-spa-simple", async test(dev) { - const c = await dev.client(); + await using c = await dev.client(); expect(await c.elemText("h1")).toBe("Hello World"); diff --git a/test/bake/dev/sourcemap.test.ts b/test/bake/dev/sourcemap.test.ts new file mode 100644 index 0000000000..3a1a81800a --- /dev/null +++ b/test/bake/dev/sourcemap.test.ts @@ -0,0 +1,133 @@ +// Source maps are non-trivial to test because the tests shouldn't rely on any +// hardcodings of the generated line/column numbers. Hardcoding wouldn't even +// work because hmr-runtime is minified in release builds, which would affect +// the generated line/column numbers across different build configurations. +import { expect } from "bun:test"; +import { Dev, devTest, emptyHtmlFile, reactRefreshStub } from "../dev-server-harness"; +import { BasicSourceMapConsumer, IndexedSourceMapConsumer, SourceMapConsumer } from "source-map"; + +devTest("source map emitted for primary chunk", { + files: { + "index.html": emptyHtmlFile({ + scripts: ["index.ts"], + }), + "index.ts": ` + import other from "./❤️.js"; + console.log("Hello, " + other + "!"); + `, + "❤️.ts": ` + // hello + export default "♠️"; + `, + }, + async test(dev) { + const html = await dev.fetch("/").text(); + using sourceMap = await extractSourceMapHtml(dev, html); + expect(sourceMap.sources.map(Bun.fileURLToPath)) // + .toEqual([dev.join("index.ts"), dev.join("❤️.ts")]); + + const generated = indexOfLineColumn(sourceMap.script, "♠️"); + const original = sourceMap.originalPositionFor(generated); + expect(original).toEqual({ + source: sourceMap.sources[1], + name: null, + line: 2, + column: "export default ".length, + }); + }, +}); +devTest("source map emitted for hmr chunk", { + files: { + ...reactRefreshStub, + "index.html": emptyHtmlFile({ + scripts: ["index.ts"], + }), + "index.ts": ` + import "react-refresh/runtime"; + import other from "./App"; + console.log("Hello, " + other + "!"); + `, + "App.tsx": ` + console.log("some text here"); + export default "world"; + `, + }, + async test(dev) { + await using c = await dev.client("/", { storeHotChunks: true }); + await dev.write("App.tsx", "// yay\nconsole.log('magic');"); + const chunk = await c.getMostRecentHmrChunk(); + using sourceMap = await extractSourceMap(dev, chunk); + expect(sourceMap.sources.map(Bun.fileURLToPath)) // + .toEqual([dev.join("App.tsx")]); + const generated = indexOfLineColumn(sourceMap.script, "magic"); + const original = sourceMap.originalPositionFor(generated); + expect(original).toEqual({ + source: sourceMap.sources[0], + name: null, + line: 2, + column: "console.log(".length, + }); + await c.expectMessage("some text here", "Hello, world!", "magic"); + }, +}); + +type SourceMap = (BasicSourceMapConsumer | IndexedSourceMapConsumer) & { + /** Original script generated */ + script: string; + [Symbol.dispose](): void; +}; + +async function extractSourceMapHtml(dev: Dev, html: string) { + const scriptUrls = [...html.matchAll(/src="([^"]+.js)"/g)]; + if (scriptUrls.length !== 1) { + throw new Error("Expected 1 source file, got " + scriptUrls.length); + } + const scriptUrl = scriptUrls[0][1]; + const scriptSource = await dev.fetch(scriptUrl).text(); + return extractSourceMap(dev, scriptSource); +} + +async function extractSourceMap(dev: Dev, scriptSource: string) { + const sourceMapUrl = scriptSource.match(/\n\/\/# sourceMappingURL=([^"]+)/); + if (!sourceMapUrl) { + throw new Error("Source map URL not found in " + scriptSource); + } + const sourceMap = await dev.fetch(sourceMapUrl[1]).text(); + return new Promise((resolve, reject) => { + try { + SourceMapConsumer.with(sourceMap, null, async (consumer: any) => { + const { promise, resolve: release } = Promise.withResolvers(); + consumer[Symbol.dispose] = () => release(); + consumer.script = scriptSource; + resolve(consumer as SourceMap); + await promise; + }); + } catch (error) { + reject(error); + } + }); +} + +function indexOfLineColumn(text: string, search: string) { + const index = text.indexOf(search); + if (index === -1) { + throw new Error("Search not found"); + } + return charOffsetToLineColumn(text, index); +} + +function charOffsetToLineColumn(text: string, offset: number) { + let line = 1; + let i = 0; + let prevI = 0; + while (i < offset) { + const nextIndex = text.indexOf("\n", i); + if (nextIndex === -1) { + break; + } + prevI = i; + i = nextIndex + 1; + line++; + } + return { line: 1 + line, column: offset - prevI }; +}