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 };
+}