From 7bbdfc8bf8da5141f600adbe3b2060c536a59ffe Mon Sep 17 00:00:00 2001 From: dave caruso Date: Fri, 18 Oct 2024 22:08:34 -0700 Subject: [PATCH] feat(bake): css (#14622) --- .gitignore | 1 + src/allocators.zig | 2 +- src/bake/DevServer.zig | 824 ++++++++++++++++++------- src/bake/bake.d.ts | 22 +- src/bake/bake.private.d.ts | 9 +- src/bake/bun-framework-rsc/server.tsx | 15 +- src/bake/client/error-serialization.ts | 4 +- src/bake/client/overlay.css | 14 +- src/bake/client/overlay.ts | 75 ++- src/bake/client/reader.ts | 4 + src/bake/client/websocket.ts | 33 +- src/bake/enums.ts | 19 - src/bake/hmr-protocol.md | 75 --- src/bake/hmr-runtime-client.ts | 70 ++- src/bake/hmr-runtime-error.ts | 2 +- src/bake/hmr-runtime-server.ts | 10 +- src/bake/macros.ts | 2 +- src/bun.zig | 18 +- src/bundler/bundle_v2.zig | 427 ++++++++----- src/codegen/bake-codegen.ts | 264 ++++---- src/js_ast.zig | 16 +- 21 files changed, 1237 insertions(+), 669 deletions(-) delete mode 100644 src/bake/hmr-protocol.md diff --git a/.gitignore b/.gitignore index 126af7cebda0c..50f5d766bfcd3 100644 --- a/.gitignore +++ b/.gitignore @@ -141,6 +141,7 @@ test/node.js/upstream .zig-cache scripts/env.local *.generated.ts +src/bake/generated.ts # Dependencies /vendor diff --git a/src/allocators.zig b/src/allocators.zig index bd8217163e024..727146960f8f5 100644 --- a/src/allocators.zig +++ b/src/allocators.zig @@ -676,7 +676,7 @@ pub fn BSSMap(comptime ValueType: type, comptime count: anytype, comptime store_ } // There's two parts to this. - // 1. Storing the underyling string. + // 1. Storing the underlying string. // 2. Making the key accessible at the index. pub fn putKey(self: *Self, key: anytype, result: *Result) !void { self.map.mutex.lock(); diff --git a/src/bake/DevServer.zig b/src/bake/DevServer.zig index 6c19d2893f8eb..0c60d01fbb4ca 100644 --- a/src/bake/DevServer.zig +++ b/src/bake/DevServer.zig @@ -32,6 +32,10 @@ allocator: Allocator, /// Project root directory. For the HMR runtime, its /// module IDs are strings relative to this. cwd: []const u8, +/// Hex string generated by hashing the framework config and bun revision. +/// Emebedding in client bundles and sent when the HMR Socket is opened; +/// When the value mismatches the page is forcibly reloaded. +configuration_hash_key: [16]u8, // UWS App app: *App, @@ -45,11 +49,10 @@ listener: ?*App.ListenSocket, // Server Runtime server_global: *DevGlobalObject, vm: *VirtualMachine, -/// This is a handle to the server_fetch_function, which is shared -/// across all loaded modules. -/// (Request, Id, Meta) => Response + +// These values are handles to the functions in server_exports. +// For type definitions, see `./bake.private.d.ts` server_fetch_function_callback: JSC.Strong, -/// (modules: any, clientComponentsAdd: null|string[], clientComponentsRemove: null|string[]) => Promise server_register_update_callback: JSC.Strong, // Watching @@ -72,6 +75,11 @@ bundles_since_last_error: usize = 0, graph_safety_lock: bun.DebugThreadLock, client_graph: IncrementalGraph(.client), server_graph: IncrementalGraph(.server), +/// CSS files are accessible via `/_bun/css/.css` +/// Value is bundled code. +css_files: AutoArrayHashMapUnmanaged(u64, []const u8), +// /// Assets are accessible via `/_bun/asset/` +// assets: bun.StringArrayHashMapUnmanaged(u64, Asset), /// All bundling failures are stored until a file is saved and rebuilt. /// They are stored in the wire format the HMR runtime expects so that /// serialization only happens once. @@ -86,7 +94,7 @@ route_lookup: AutoArrayHashMapUnmanaged(IncrementalGraph(.server).FileIndex, Rou /// State populated during bundling. Often cleared incremental_result: IncrementalResult, framework: bake.Framework, -// Each logical graph gets it's own bundler configuration +// Each logical graph gets its own bundler configuration server_bundler: Bundler, client_bundler: Bundler, ssr_bundler: Bundler, @@ -99,6 +107,8 @@ emit_visualizer_events: u32, pub const internal_prefix = "/_bun"; pub const client_prefix = internal_prefix ++ "/client"; +pub const asset_prefix = internal_prefix ++ "/asset"; +pub const css_prefix = internal_prefix ++ "/css"; pub const Route = struct { pub const Index = bun.GenericIndex(u30, Route); @@ -122,6 +132,10 @@ pub const Route = struct { /// Cached to avoid re-creating the string every request module_name_string: JSC.Strong = .{}, + /// Cached to avoid re-creating the string every request + client_bundle_url_value: JSC.Strong = .{}, + /// Cached to avoid re-creating the array every request + css_file_array: JSC.Strong = .{}, /// Assigned in DevServer.init dev: *DevServer = undefined, @@ -136,7 +150,7 @@ pub const Route = struct { /// imports this one. unqueued, /// This route was flagged for bundling failures. There are edge cases - /// where a route can be disconnected from it's failures, so the route + /// where a route can be disconnected from its failures, so the route /// imports has to be traced to discover if possible failures still /// exist. possible_bundling_failures, @@ -146,13 +160,17 @@ pub const Route = struct { /// at fault of bundling. loaded, }; +}; - pub fn clientPublicPath(route: *const Route) []const u8 { - return route.client_bundled_url[0 .. route.client_bundled_url.len - "/client.js".len]; - } +const Asset = union(enum) { + /// File contents are allocated with `dev.allocator` + /// The slice is mirrored in `dev.client_graph.bundled_files`, so freeing this slice is not required. + css: []const u8, + /// A file path relative to cwd, owned by `dev.allocator` + file_path: []const u8, }; -/// DevServer is stored on the heap, storing it's allocator. +/// DevServer is stored on the heap, storing its allocator. pub fn init(options: Options) !*DevServer { const allocator = options.allocator orelse bun.default_allocator; bun.analytics.Features.kit_dev +|= 1; @@ -194,6 +212,8 @@ pub fn init(options: Options) !*DevServer { .watch_state = .{ .raw = 0 }, .watch_current = 0, .emit_visualizer_events = 0, + .css_files = .{}, + // .assets = .{}, .client_graph = IncrementalGraph(.client).empty, .server_graph = IncrementalGraph(.server).empty, @@ -209,6 +229,8 @@ pub fn init(options: Options) !*DevServer { .bun_watcher = undefined, .watch_events = undefined, + + .configuration_hash_key = undefined, }); errdefer allocator.destroy(dev); @@ -254,6 +276,47 @@ pub fn init(options: Options) !*DevServer { dev.vm.jsc = dev.vm.global.vm(); dev.vm.event_loop.ensureWaker(); + dev.configuration_hash_key = hash_key: { + var hash = std.hash.Wyhash.init(128); + + if (bun.Environment.isDebug) { + const stat = try bun.sys.stat(try bun.selfExePath()).unwrap(); + bun.writeAnyToHasher(&hash, stat.mtime()); + hash.update(bake.getHmrRuntime(.client)); + hash.update(bake.getHmrRuntime(.server)); + } else { + hash.update(bun.Environment.git_sha_short); + } + + hash.update(dev.framework.entry_client); + hash.update(dev.framework.entry_server); + if (dev.framework.server_components) |sc| { + bun.writeAnyToHasher(&hash, true); + bun.writeAnyToHasher(&hash, sc.separate_ssr_graph); + hash.update(sc.client_register_server_reference); + hash.update(&.{0}); + hash.update(sc.server_register_client_reference); + hash.update(&.{0}); + hash.update(sc.server_register_server_reference); + hash.update(&.{0}); + hash.update(sc.server_runtime_import); + hash.update(&.{0}); + } else { + bun.writeAnyToHasher(&hash, false); + } + + if (dev.framework.react_fast_refresh) |rfr| { + bun.writeAnyToHasher(&hash, true); + hash.update(rfr.import_source); + } else { + bun.writeAnyToHasher(&hash, false); + } + + // TODO: dev.framework.built_in_modules + + break :hash_key std.fmt.bytesToHex(std.mem.asBytes(&hash.final()), .lower); + }; + var has_fallback = false; for (options.routes, 0..) |*route, i| { @@ -262,7 +325,7 @@ pub fn init(options: Options) !*DevServer { route.dev = dev; route.client_bundled_url = std.fmt.allocPrint( allocator, - client_prefix ++ "/{d}/client.js", + client_prefix ++ "/{d}.js", .{i}, ) catch bun.outOfMemory(); @@ -270,13 +333,16 @@ pub fn init(options: Options) !*DevServer { has_fallback = true; } - app.get(client_prefix ++ "/:route/:asset", *DevServer, dev, onAssetRequest); + app.get(client_prefix ++ "/:route", *DevServer, dev, onJsRequest); + app.get(asset_prefix ++ "/:asset", *DevServer, dev, onAssetRequest); + app.get(css_prefix ++ "/:asset", *DevServer, dev, onCssRequest); + app.get(internal_prefix ++ "/src/*", *DevServer, dev, onSrcRequest); app.ws( internal_prefix ++ "/hmr", dev, 0, - uws.WebSocketBehavior.Wrap(DevServer, DevWebSocket, false).apply(.{}), + uws.WebSocketBehavior.Wrap(DevServer, HmrSocket, false).apply(.{}), ); app.get(internal_prefix ++ "/incremental_visualizer", *DevServer, dev, onIncrementalVisualizer); @@ -284,8 +350,6 @@ pub fn init(options: Options) !*DevServer { if (!has_fallback) app.any("/*", void, {}, onFallbackRoute); - app.listenWithConfig(*DevServer, dev, onListen, options.listen_config); - // Some indices at the start of the graph are reserved for framework files. { dev.graph_safety_lock.lock(); @@ -320,6 +384,8 @@ pub fn init(options: Options) !*DevServer { }; } + app.listenWithConfig(*DevServer, dev, onListen, options.listen_config); + return dev; } @@ -414,10 +480,12 @@ fn onListen(ctx: *DevServer, maybe_listen: ?*App.ListenSocket) void { Output.flush(); } -fn onAssetRequest(dev: *DevServer, req: *Request, resp: *Response) void { +fn onJsRequest(dev: *DevServer, req: *Request, resp: *Response) void { const route = route: { const route_id = req.parameter(0); - const i = std.fmt.parseInt(u16, route_id, 10) catch + if (!bun.strings.hasSuffixComptime(route_id, ".js")) + return req.setYield(true); + const i = std.fmt.parseInt(u16, route_id[0 .. route_id.len - 3], 10) catch return req.setYield(true); if (i >= dev.routes.len) return req.setYield(true); @@ -463,7 +531,38 @@ fn onAssetRequest(dev: *DevServer, req: *Request, resp: *Response) void { route.client_bundle = out; break :code out; }; - sendJavaScriptSource(js_source, resp); + sendTextFile(js_source, MimeType.javascript.value, resp); +} + +fn onAssetRequest(dev: *DevServer, req: *Request, resp: *Response) void { + _ = dev; + _ = req; + _ = resp; + bun.todoPanic(@src(), "serve asset file", .{}); + // const route_id = req.parameter(0); + // const asset = dev.assets.get(route_id) orelse + // return req.setYield(true); + // _ = asset; // autofix + +} + +fn onCssRequest(dev: *DevServer, req: *Request, resp: *Response) void { + const param = req.parameter(0); + if (!bun.strings.hasSuffixComptime(param, ".css")) + return req.setYield(true); + const hex = param[0 .. param.len - ".css".len]; + if (hex.len != @sizeOf(u64) * 2) + return req.setYield(true); + + var out: [@sizeOf(u64)]u8 = undefined; + assert((std.fmt.hexToBytes(&out, hex) catch + return req.setYield(true)).len == @sizeOf(u64)); + const hash: u64 = @bitCast(out); + + const css = dev.css_files.get(hash) orelse + return req.setYield(true); + + sendTextFile(css, MimeType.css.value, resp); } fn onIncrementalVisualizer(_: *DevServer, _: *Request, resp: *Response) void { @@ -557,20 +656,13 @@ fn onServerRequest(route: *Route, req: *Request, resp: *Response) void { const server_request_callback = dev.server_fetch_function_callback.get() orelse unreachable; // did not bundle - // TODO: use a custom class for this metadata type + revise the object structure too - const meta = JSValue.createEmptyObject(global, 1); - meta.put( - dev.server_global.js(), - bun.String.static("clientEntryPoint"), - bun.String.init(route.client_bundled_url).toJS(global), - ); - var result = server_request_callback.call( global, .undefined, &.{ + // req js_request, - meta, + // routeModuleId route.module_name_string.get() orelse str: { const js = bun.String.createUTF8( bun.path.relative(dev.cwd, route.entry_point), @@ -578,6 +670,18 @@ fn onServerRequest(route: *Route, req: *Request, resp: *Response) void { route.module_name_string = JSC.Strong.create(js, dev.server_global.js()); break :str js; }, + // clientId + route.client_bundle_url_value.get() orelse str: { + const js = bun.String.createUTF8(route.client_bundled_url).toJS(global); + route.client_bundle_url_value = JSC.Strong.create(js, dev.server_global.js()); + break :str js; + }, + // styles + route.css_file_array.get() orelse arr: { + const js = dev.generateCssList(route) catch bun.outOfMemory(); + route.css_file_array = JSC.Strong.create(js, dev.server_global.js()); + break :arr js; + }, }, ) catch |err| { const exception = global.takeException(err); @@ -614,10 +718,11 @@ fn onServerRequest(route: *Route, req: *Request, resp: *Response) void { // // This would allow us to support all of the nice things `new Response` allows + bun.assert(result.isString()); const bun_string = result.toBunString(dev.server_global.js()); defer bun_string.deref(); if (bun_string.tag == .Dead) { - bun.todoPanic(@src(), "Bake: support non-string return value", .{}); + bun.outOfMemory(); } const utf8 = bun_string.toUTF8(dev.allocator); @@ -628,6 +733,36 @@ fn onServerRequest(route: *Route, req: *Request, resp: *Response) void { resp.end(utf8.slice(), true); // TODO: You should never call res.end(huge buffer) } +pub fn onSrcRequest(dev: *DevServer, req: *uws.Request, resp: *App.Response) void { + if (req.header("open-in-editor") == null) { + resp.writeStatus("501 Not Implemented"); + resp.end("Viewing source without opening in editor is not implemented yet!", false); + return; + } + + const ctx = &dev.vm.rareData().editor_context; + ctx.autoDetectEditor(JSC.VirtualMachine.get().bundler.env); + const line: ?[]const u8 = req.header("editor-line"); + const column: ?[]const u8 = req.header("editor-column"); + + if (ctx.editor) |editor| { + var url = req.url()[internal_prefix.len + "/src/".len ..]; + if (bun.strings.indexOfChar(url, ':')) |colon| { + url = url[0..colon]; + } + editor.open(ctx.path, url, line, column, dev.allocator) catch { + resp.writeStatus("202 No Content"); + resp.end("", false); + return; + }; + resp.writeStatus("202 No Content"); + resp.end("", false); + } else { + resp.writeStatus("500 Internal Server Error"); + resp.end("Please set your editor in bunfig.toml", false); + } +} + const BundleError = error{ OutOfMemory, /// Graph entry points will be annotated with failures to display. @@ -689,37 +824,7 @@ fn bundle(dev: *DevServer, files: []const BakeEntryPoint) BundleError!void { dev.client_graph.reset(); dev.server_graph.reset(); - errdefer |e| brk: { - // Wait for wait groups to finish. There still may be ongoing work. - bv2.linker.source_maps.line_offset_wait_group.wait(); - bv2.linker.source_maps.quoted_contents_wait_group.wait(); - - if (e == error.OutOfMemory) break :brk; - - // Since a bundle failed, track all files as stale. This allows - // hot-reloading to remember the targets to rebuild for. - for (bv2.graph.input_files.items(.source), bv2.graph.ast.items(.target)) |file, target| { - const abs_path = file.path.text; - if (!std.fs.path.isAbsolute(abs_path)) continue; - - switch (target.bakeGraph()) { - .server => { - _ = dev.server_graph.insertStale(abs_path, false) catch bun.outOfMemory(); - }, - .ssr => { - _ = dev.server_graph.insertStale(abs_path, true) catch bun.outOfMemory(); - }, - .client => { - _ = dev.client_graph.insertStale(abs_path, false) catch bun.outOfMemory(); - }, - } - } - - dev.client_graph.ensureStaleBitCapacity(true) catch bun.outOfMemory(); - dev.server_graph.ensureStaleBitCapacity(true) catch bun.outOfMemory(); - } - - const chunk = bv2.runFromBakeDevServer(files) catch |err| { + const bundle_result = bv2.runFromBakeDevServer(files) catch |err| { bun.handleErrorReturnTrace(err, @errorReturnTrace()); bv2.bundler.log.printForLogLevel(Output.errorWriter()) catch {}; @@ -731,7 +836,7 @@ fn bundle(dev: *DevServer, files: []const BakeEntryPoint) BundleError!void { bv2.bundler.log.printForLogLevel(Output.errorWriter()) catch {}; - try dev.finalizeBundle(bv2, &chunk); + try dev.finalizeBundle(bv2, bundle_result); try dev.client_graph.ensureStaleBitCapacity(false); try dev.server_graph.ensureStaleBitCapacity(false); @@ -821,6 +926,37 @@ fn bundle(dev: *DevServer, files: []const BakeEntryPoint) BundleError!void { } } + const css_chunks = bundle_result.cssChunks(); + if ((dev.client_graph.current_chunk_len > 0 or + css_chunks.len > 0) and + dev.app.num_subscribers(HmrSocket.global_topic) > 0) + { + var sfb2 = std.heap.stackFallback(65536, bun.default_allocator); + var payload = std.ArrayList(u8).initCapacity(sfb2.get(), 65536) catch + unreachable; // enough space + defer payload.deinit(); + payload.appendAssumeCapacity(MessageId.hot_update.char()); + const w = payload.writer(); + + const css_values = dev.css_files.values(); + try w.writeInt(u32, @intCast(css_chunks.len), .little); + const sources = bv2.graph.input_files.items(.source); + for (css_chunks) |chunk| { + const abs_path = sources[chunk.entry_point.source_index].path.text; + + try w.writeAll(&std.fmt.bytesToHex(std.mem.asBytes(&bun.hash(abs_path)), .lower)); + + const css_data = css_values[chunk.entry_point.entry_point_id]; + try w.writeInt(u32, @intCast(css_data.len), .little); + try w.writeAll(css_data); + } + + if (dev.client_graph.current_chunk_len > 0) + try dev.client_graph.takeBundleToList(.hmr_chunk, &payload); + + _ = dev.app.publish(HmrSocket.global_topic, payload.items, .binary, true); + } + if (dev.incremental_result.failures_added.items.len > 0) { dev.bundles_since_last_error = 0; return error.BuildFailed; @@ -873,10 +1009,10 @@ fn indexFailures(dev: *DevServer) !void { route.server_state = .possible_bundling_failures; } - _ = dev.app.publish(DevWebSocket.global_channel, payload.items, .binary, false); + _ = dev.app.publish(HmrSocket.global_topic, payload.items, .binary, false); } else if (dev.incremental_result.failures_removed.items.len > 0) { if (dev.bundling_failures.count() == 0) { - _ = dev.app.publish(DevWebSocket.global_channel, &.{MessageId.errors_cleared.char()}, .binary, false); + _ = dev.app.publish(HmrSocket.global_topic, &.{MessageId.errors_cleared.char()}, .binary, false); for (dev.incremental_result.failures_removed.items) |removed| { removed.deinit(); } @@ -893,7 +1029,7 @@ fn indexFailures(dev: *DevServer) !void { removed.deinit(); } - _ = dev.app.publish(DevWebSocket.global_channel, payload.items, .binary, false); + _ = dev.app.publish(HmrSocket.global_topic, payload.items, .binary, false); } } @@ -911,8 +1047,9 @@ fn generateClientBundle(dev: *DevServer, route: *Route) bun.OOM![]const u8 { // Prepare bitsets var sfa_state = std.heap.stackFallback(65536, dev.allocator); - const sfa = sfa_state.get(); + // const gts = try dev.initGraphTraceState(sfa); + // defer gts.deinit(sfa); dev.server_graph.affected_by_trace = try DynamicBitSetUnmanaged.initEmpty(sfa, dev.server_graph.bundled_files.count()); defer dev.server_graph.affected_by_trace.deinit(sfa); @@ -923,22 +1060,69 @@ fn generateClientBundle(dev: *DevServer, route: *Route) bun.OOM![]const u8 { dev.client_graph.reset(); // Framework entry point is always needed. - try dev.client_graph.traceImports(IncrementalGraph(.client).framework_entry_point_index); + try dev.client_graph.traceImports( + IncrementalGraph(.client).framework_entry_point_index, + .{ .find_client_modules = true }, + ); // If react fast refresh is enabled, it will be imported by the runtime instantly. if (dev.framework.react_fast_refresh != null) { - try dev.client_graph.traceImports(IncrementalGraph(.client).react_refresh_index); + try dev.client_graph.traceImports(IncrementalGraph(.client).react_refresh_index, .{ .find_client_modules = true }); } // Trace the route to the client components try dev.server_graph.traceImports( route.server_file.unwrap() orelse Output.panic("File index for route not present", .{}), + .{ .find_client_modules = true }, ); return dev.client_graph.takeBundle(.initial_response); } +fn generateCssList(dev: *DevServer, route: *Route) bun.OOM!JSC.JSValue { + if (!Environment.allow_assert) assert(!route.css_file_array.has()); + assert(route.server_state == .loaded); // page is unfit to load + + dev.graph_safety_lock.lock(); + defer dev.graph_safety_lock.unlock(); + + // Prepare bitsets + var sfa_state = std.heap.stackFallback(65536, dev.allocator); + + const sfa = sfa_state.get(); + dev.server_graph.affected_by_trace = try DynamicBitSetUnmanaged.initEmpty(sfa, dev.server_graph.bundled_files.count()); + defer dev.server_graph.affected_by_trace.deinit(sfa); + + dev.client_graph.affected_by_trace = try DynamicBitSetUnmanaged.initEmpty(sfa, dev.client_graph.bundled_files.count()); + defer dev.client_graph.affected_by_trace.deinit(sfa); + + // Run tracing + dev.client_graph.reset(); + + // Framework entry point is allowed to include its own CSS + try dev.client_graph.traceImports( + IncrementalGraph(.client).framework_entry_point_index, + .{ .find_css = true }, + ); + + // Trace the route to the css files + try dev.server_graph.traceImports( + route.server_file.unwrap() orelse + Output.panic("File index for route not present", .{}), + .{ .find_css = true }, + ); + + const names = dev.client_graph.current_css_files.items; + const arr = JSC.JSArray.createEmpty(dev.server_global.js(), names.len); + for (names, 0..) |item, i| { + const str = bun.String.createUTF8(item); + defer str.deref(); + arr.putIndex(dev.server_global.js(), @intCast(i), str.toJS(dev.server_global.js())); + } + return arr; +} + fn makeArrayForServerComponentsPatch(dev: *DevServer, global: *JSC.JSGlobalObject, items: []const IncrementalGraph(.server).FileIndex) JSValue { if (items.len == 0) return .null; const arr = JSC.JSArray.createEmpty(global, items.len); @@ -994,8 +1178,9 @@ pub const HotUpdateContext = struct { pub fn finalizeBundle( dev: *DevServer, bv2: *bun.bundle_v2.BundleV2, - chunk: *const [2]bun.bundle_v2.Chunk, + result: bun.bundle_v2.BakeBundleOutput, ) !void { + const js_chunk = result.jsPseudoChunk(); const input_file_sources = bv2.graph.input_files.items(.source); const import_records = bv2.graph.ast.items(.import_records); const targets = bv2.graph.ast.items(.target); @@ -1026,20 +1211,50 @@ pub fn finalizeBundle( }; // Pass 1, update the graph's nodes, resolving every bundler source - // index into it's `IncrementalGraph(...).FileIndex` + // index into its `IncrementalGraph(...).FileIndex` for ( - chunk[0].content.javascript.parts_in_chunk_in_order, - chunk[0].compile_results_for_chunk, + js_chunk.content.javascript.parts_in_chunk_in_order, + js_chunk.compile_results_for_chunk, ) |part_range, compile_result| { - try dev.receiveChunk( - &ctx, - part_range.source_index, - targets[part_range.source_index.get()].bakeGraph(), - compile_result, + const index = part_range.source_index; + switch (targets[part_range.source_index.get()].bakeGraph()) { + .server => try dev.server_graph.receiveChunk(&ctx, index, compile_result.code(), .js, false), + .ssr => try dev.server_graph.receiveChunk(&ctx, index, compile_result.code(), .js, true), + .client => try dev.client_graph.receiveChunk(&ctx, index, compile_result.code(), .js, false), + } + } + for (result.cssChunks(), result.css_file_list.metas) |*chunk, metadata| { + const index = bun.JSAst.Index.init(chunk.entry_point.source_index); + + const code = try chunk.intermediate_output.code( + dev.allocator, + &bv2.graph, + "/_bun/TODO-import-prefix-where-is-this-used?", + chunk, + result.chunks, + null, + false, // TODO: sourcemaps true ); - } - _ = chunk[1].content.css; // TODO: Index CSS files + // Create an asset entry for this file. + const abs_path = ctx.sources[index.get()].path.text; + // 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 = try dev.insertOrUpdateCssAsset(abs_path, code.buffer); + + try dev.client_graph.receiveChunk(&ctx, index, "", .css, false); + + // If imported on server, there needs to be a server-side file entry + // so that edges can be attached. When a file is only imported on + // the server, this file is used to trace the CSS to the route. + if (metadata.imported_on_server) { + try dev.server_graph.insertCssFileOnServer( + &ctx, + index, + abs_path, + ); + } + } dev.client_graph.affected_by_trace = try DynamicBitSetUnmanaged.initEmpty(bv2.graph.allocator, dev.client_graph.bundled_files.count()); defer dev.client_graph.affected_by_trace = .{}; @@ -1051,19 +1266,33 @@ pub fn finalizeBundle( // 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. - for (chunk[0].content.javascript.parts_in_chunk_in_order) |part_range| { - try dev.processChunkDependencies( - &ctx, - part_range.source_index, - targets[part_range.source_index.get()].bakeGraph(), - bv2.graph.allocator, - ); + for (js_chunk.content.javascript.parts_in_chunk_in_order) |part_range| { + switch (targets[part_range.source_index.get()].bakeGraph()) { + .server, .ssr => try dev.server_graph.processChunkDependencies(&ctx, part_range.source_index, bv2.graph.allocator), + .client => try dev.client_graph.processChunkDependencies(&ctx, part_range.source_index, bv2.graph.allocator), + } + } + for (result.cssChunks(), result.css_file_list.metas) |*chunk, metadata| { + const index = bun.JSAst.Index.init(chunk.entry_point.source_index); + // TODO: index css deps + _ = index; // autofix + _ = metadata; // autofix } // Index all failed files now that the incremental graph has been updated. try dev.indexFailures(); } +fn insertOrUpdateCssAsset(dev: *DevServer, abs_path: []const u8, code: []const u8) !u31 { + const path_hash = bun.hash(abs_path); + const gop = try dev.css_files.getOrPut(dev.allocator, path_hash); + if (gop.found_existing) { + dev.allocator.free(gop.value_ptr.*); + } + gop.value_ptr.* = code; + return @intCast(gop.index); +} + pub fn handleParseTaskFailure( dev: *DevServer, graph: bake.Graph, @@ -1084,34 +1313,11 @@ pub fn handleParseTaskFailure( }; } -pub fn receiveChunk( - dev: *DevServer, - ctx: *HotUpdateContext, - index: bun.JSAst.Index, - side: bake.Graph, - chunk: bun.bundle_v2.CompileResult, -) !void { - return switch (side) { - .server => dev.server_graph.receiveChunk(ctx, index, chunk, false), - .ssr => dev.server_graph.receiveChunk(ctx, index, chunk, true), - .client => dev.client_graph.receiveChunk(ctx, index, chunk, false), - }; -} - -pub fn processChunkDependencies( - dev: *DevServer, - ctx: *HotUpdateContext, - index: bun.JSAst.Index, - side: bake.Graph, - temp_alloc: Allocator, -) !void { - return switch (side) { - .server, .ssr => dev.server_graph.processChunkDependencies(ctx, index, temp_alloc), - .client => dev.client_graph.processChunkDependencies(ctx, index, temp_alloc), - }; -} +const CacheEntry = struct { + kind: FileKind, +}; -pub fn isFileStale(dev: *DevServer, path: []const u8, side: bake.Graph) bool { +pub fn isFileCached(dev: *DevServer, path: []const u8, side: bake.Graph) ?CacheEntry { switch (side) { inline else => |side_comptime| { const g = switch (side_comptime) { @@ -1120,8 +1326,11 @@ pub fn isFileStale(dev: *DevServer, path: []const u8, side: bake.Graph) bool { .ssr => &dev.server_graph, }; const index = g.bundled_files.getIndex(path) orelse - return true; // non-existent files are considered stale - return g.stale_files.isSet(index); + return null; // non-existent files are considered stale + if (!g.stale_files.isSet(index)) { + return .{ .kind = g.bundled_files.values()[index].fileKind() }; + } + return null; }, } } @@ -1130,7 +1339,7 @@ fn onFallbackRoute(_: void, _: *Request, resp: *Response) void { sendBuiltInNotFound(resp); } -fn sendJavaScriptSource(code: []const u8, resp: *Response) void { +fn sendTextFile(code: []const u8, content_type: []const u8, resp: *Response) void { if (code.len == 0) { resp.writeStatus("202 No Content"); resp.writeHeaderInt("Content-Length", 0); @@ -1139,8 +1348,7 @@ fn sendJavaScriptSource(code: []const u8, resp: *Response) void { } resp.writeStatus("200 OK"); - // TODO: CSS, Sourcemap - resp.writeHeader("Content-Type", MimeType.javascript.value); + resp.writeHeader("Content-Type", content_type); resp.end(code, true); // TODO: You should never call res.end(huge buffer) } @@ -1229,6 +1437,15 @@ fn sendStubErrorMessage(dev: *DevServer, route: *Route, resp: *Response, err: JS resp.end(a.items, true); // TODO: "You should never call res.end(huge buffer)" } +const FileKind = enum { + /// Files that failed to bundle or do not exist on disk will appear in the + /// graph as "unknown". + unknown, + js, + css, + asset, +}; + /// The paradigm of Bake's incremental state is to store a separate list of files /// than the Graph in bundle_v2. When watch events happen, the bundler is run on /// the changed files, excluding non-stale files via `isFileStale`. @@ -1278,6 +1495,7 @@ pub fn IncrementalGraph(side: bake.Side) type { /// so garbage collection can run less often. edges_free_list: ArrayListUnmanaged(EdgeIndex), + // TODO: delete /// Used during an incremental update to determine what "HMR roots" /// are affected. Set for all `bundled_files` that have been visited /// by the dependency tracing logic. @@ -1296,6 +1514,11 @@ pub fn IncrementalGraph(side: bake.Side) type { .server => []const u8, }), + current_css_files: switch (side) { + .client => ArrayListUnmanaged([]const u8), + .server => void, + }, + const empty: @This() = .{ .bundled_files = .{}, .stale_files = .{}, @@ -1309,6 +1532,11 @@ pub fn IncrementalGraph(side: bake.Side) type { .current_chunk_len = 0, .current_chunk_parts = .{}, + + .current_css_files = switch (side) { + .client => .{}, + .server => {}, + }, }; pub const File = switch (side) { @@ -1330,11 +1558,15 @@ pub fn IncrementalGraph(side: bake.Side) type { /// If the file has an error, the failure can be looked up /// in the `.failures` map. failed: bool, + /// CSS and Asset files get special handling + kind: FileKind, - unused: enum(u2) { unused = 0 } = .unused, + fn stopsDependencyTrace(file: @This()) bool { + return file.is_client_component_boundary; + } - fn stopsDependencyTrace(flags: @This()) bool { - return flags.is_client_component_boundary; + fn fileKind(file: @This()) FileKind { + return file.kind; } }, .client => struct { @@ -1349,13 +1581,14 @@ pub fn IncrementalGraph(side: bake.Side) type { /// If the file has an error, the failure can be looked up /// in the `.failures` map. failed: bool, - /// If set, the client graph contains a matching file. - is_component_root: bool, + /// For JS files, this is a component root; the server contains a matching file. + /// For CSS files, this is also marked on the stylesheet that is imported from JS. + is_hmr_root: bool, /// This is a file is an entry point to the framework. /// Changing this will always cause a full page reload. is_special_framework_file: bool, - - kind: enum { js, css }, + /// CSS and Asset files get special handling + kind: FileKind, }; comptime { @@ -1378,6 +1611,10 @@ pub fn IncrementalGraph(side: bake.Side) type { inline fn stopsDependencyTrace(_: @This()) bool { return false; } + + fn fileKind(file: @This()) FileKind { + return file.flags.kind; + } }, }; @@ -1415,12 +1652,13 @@ pub fn IncrementalGraph(side: bake.Side) type { /// /// For server, the code is temporarily kept in the /// `current_chunk_parts` array, where it must live until - /// takeChunk is called. Then it can be freed. + /// takeBundle is called. Then it can be freed. pub fn receiveChunk( g: *@This(), ctx: *HotUpdateContext, index: bun.JSAst.Index, - chunk: bun.bundle_v2.CompileResult, + code: []const u8, + kind: FileKind, is_ssr_graph: bool, ) !void { const dev = g.owner(); @@ -1428,22 +1666,26 @@ pub fn IncrementalGraph(side: bake.Side) type { const abs_path = ctx.sources[index.get()].path.text; - const code = chunk.code(); if (Environment.allow_assert) { - if (bun.strings.isAllWhitespace(code)) { - // Should at least contain the function wrapper - bun.Output.panic("Empty chunk is impossible: {s} {s}", .{ - abs_path, - switch (side) { - .client => "client", - .server => if (is_ssr_graph) "ssr" else "server", - }, - }); + switch (kind) { + .css => bun.assert(code.len == 0), + .js => if (bun.strings.isAllWhitespace(code)) { + // Should at least contain the function wrapper + bun.Output.panic("Empty chunk is impossible: {s} {s}", .{ + abs_path, + switch (side) { + .client => "client", + .server => if (is_ssr_graph) "ssr" else "server", + }, + }); + }, + else => Output.panic("unexpected file kind: .{s}", .{@tagName(kind)}), } } g.current_chunk_len += code.len; + // Dump to filesystem if enabled if (dev.dump_dir) |dump_dir| { const cwd = dev.cwd; var a: bun.PathBuffer = undefined; @@ -1479,26 +1721,41 @@ pub fn IncrementalGraph(side: bake.Side) type { switch (side) { .client => { if (gop.found_existing) { - bun.default_allocator.free(gop.value_ptr.code()); + if (kind == .js) + bun.default_allocator.free(gop.value_ptr.code()); if (gop.value_ptr.flags.failed) { const kv = dev.bundling_failures.fetchSwapRemoveAdapted( SerializedFailure.Owner{ .client = file_index }, SerializedFailure.ArrayHashAdapter{}, ) orelse - Output.panic("Missing failure in IncrementalGraph", .{}); + Output.panic("Missing SerializedFailure in IncrementalGraph", .{}); try dev.incremental_result.failures_removed.append( dev.allocator, kv.key, ); } } - gop.value_ptr.* = File.init(code, .{ + const flags: File.Flags = .{ .failed = false, - .is_component_root = ctx.server_to_client_bitset.isSet(index.get()), + .is_hmr_root = ctx.server_to_client_bitset.isSet(index.get()), .is_special_framework_file = false, - .kind = .js, - }); + .kind = kind, + }; + if (kind == .css) { + if (!gop.found_existing or gop.value_ptr.code_len == 0) { + gop.value_ptr.* = File.init(try std.fmt.allocPrint( + dev.allocator, + css_prefix ++ "/{}.css", + .{std.fmt.fmtSliceHexLower(std.mem.asBytes(&bun.hash(abs_path)))}, + ), flags); + } else { + // The key is just the file-path + gop.value_ptr.flags = flags; + } + } else { + gop.value_ptr.* = File.init(code, flags); + } try g.current_chunk_parts.append(dev.allocator, file_index); }, .server => { @@ -1511,12 +1768,15 @@ pub fn IncrementalGraph(side: bake.Side) type { .is_route = false, .is_client_component_boundary = client_component_boundary, .failed = false, + .kind = kind, }; if (client_component_boundary) { try dev.incremental_result.client_components_added.append(dev.allocator, file_index); } } else { + gop.value_ptr.kind = kind; + if (is_ssr_graph) { gop.value_ptr.is_ssr = true; } else { @@ -1549,7 +1809,7 @@ pub fn IncrementalGraph(side: bake.Side) type { ); } } - try g.current_chunk_parts.append(dev.allocator, chunk.code()); + try g.current_chunk_parts.append(dev.allocator, code); }, } } @@ -1720,6 +1980,11 @@ pub fn IncrementalGraph(side: bake.Side) type { stop_at_boundary, no_stop, }; + const TraceImportGoal = struct { + // gts: *GraphTraceState, + find_css: bool = false, + find_client_modules: bool = false, + }; fn traceDependencies(g: *@This(), file_index: FileIndex, trace_kind: TraceDependencyKind) !void { g.owner().graph_safety_lock.assertLocked(); @@ -1753,7 +2018,7 @@ pub fn IncrementalGraph(side: bake.Side) type { } }, .client => { - if (file.flags.is_component_root) { + if (file.flags.is_hmr_root) { const dev = g.owner(); const key = g.bundled_files.keys()[file_index.get()]; const index = dev.server_graph.getFileIndex(key) orelse @@ -1782,7 +2047,7 @@ pub fn IncrementalGraph(side: bake.Side) type { } } - fn traceImports(g: *@This(), file_index: FileIndex) !void { + fn traceImports(g: *@This(), file_index: FileIndex, goal: TraceImportGoal) !void { g.owner().graph_safety_lock.assertLocked(); if (Environment.enable_logs) { @@ -1801,18 +2066,34 @@ pub fn IncrementalGraph(side: bake.Side) type { switch (side) { .server => { - if (file.is_client_component_boundary) { + if (file.is_client_component_boundary or file.kind == .css) { const dev = g.owner(); const key = g.bundled_files.keys()[file_index.get()]; const index = dev.client_graph.getFileIndex(key) orelse Output.panic("Client Incremental Graph is missing component for {}", .{bun.fmt.quote(key)}); - try dev.client_graph.traceImports(index); + try dev.client_graph.traceImports(index, goal); } }, .client => { assert(!g.stale_files.isSet(file_index.get())); // should not be left stale - try g.current_chunk_parts.append(g.owner().allocator, file_index); - g.current_chunk_len += file.code_len; + if (file.flags.kind == .css) { + if (goal.find_css) { + try g.current_css_files.append(g.owner().allocator, file.code()); + } + + // Do not count css files as a client module + // and also do not trace its dependencies. + // + // The server version of this code does not need to + // early return, since server css files never have + // imports. + return; + } + + if (goal.find_client_modules) { + try g.current_chunk_parts.append(g.owner().allocator, file_index); + g.current_chunk_len += file.code_len; + } }, } @@ -1821,7 +2102,7 @@ pub fn IncrementalGraph(side: bake.Side) type { while (it) |dep_index| { const edge = g.edges.items[dep_index.get()]; it = edge.next_import.unwrap(); - try g.traceImports(edge.imported); + try g.traceImports(edge.imported, goal); } } @@ -1870,9 +2151,9 @@ pub fn IncrementalGraph(side: bake.Side) type { .client => { gop.value_ptr.* = File.init("", .{ .failed = false, - .is_component_root = false, + .is_hmr_root = false, .is_special_framework_file = false, - .kind = .js, + .kind = .unknown, }); }, .server => { @@ -1883,6 +2164,7 @@ pub fn IncrementalGraph(side: bake.Side) type { .is_route = is_route, .is_client_component_boundary = false, .failed = false, + .kind = .unknown, }; } else if (is_ssr_graph) { gop.value_ptr.is_ssr = true; @@ -1895,6 +2177,38 @@ pub fn IncrementalGraph(side: bake.Side) type { return file_index; } + /// Server CSS files are just used to be targets for graph traversal. + /// Its content lives only on the client. + pub fn insertCssFileOnServer(g: *@This(), ctx: *HotUpdateContext, index: bun.JSAst.Index, abs_path: []const u8) bun.OOM!void { + g.owner().graph_safety_lock.assertLocked(); + + debug.log("Insert stale: {s}", .{abs_path}); + const gop = try g.bundled_files.getOrPut(g.owner().allocator, abs_path); + const file_index = FileIndex.init(@intCast(gop.index)); + + if (!gop.found_existing) { + gop.key_ptr.* = try bun.default_allocator.dupe(u8, abs_path); + try g.first_dep.append(g.owner().allocator, .none); + try g.first_import.append(g.owner().allocator, .none); + } + + switch (side) { + .client => @compileError("not implemented: use receiveChunk"), + .server => { + gop.value_ptr.* = .{ + .is_rsc = false, + .is_ssr = false, + .is_route = false, + .is_client_component_boundary = false, + .failed = false, + .kind = .css, + }; + }, + } + + ctx.getCachedIndex(.server, index).* = file_index; + } + pub fn insertFailure( g: *@This(), abs_path: []const u8, @@ -1921,9 +2235,9 @@ pub fn IncrementalGraph(side: bake.Side) type { .client => { gop.value_ptr.* = File.init("", .{ .failed = true, - .is_component_root = false, + .is_hmr_root = false, .is_special_framework_file = false, - .kind = .js, + .kind = .unknown, }); }, .server => { @@ -1934,6 +2248,7 @@ pub fn IncrementalGraph(side: bake.Side) type { .is_route = false, .is_client_component_boundary = false, .failed = true, + .kind = .unknown, }; } else { if (is_ssr_graph) { @@ -1995,7 +2310,9 @@ pub fn IncrementalGraph(side: bake.Side) type { // When re-bundling SCBs, only bundle the server. Otherwise // the bundler gets confused and bundles both sides without // knowledge of the boundary between them. - if (!data.flags.is_component_root) + if (data.flags.kind == .css) + try out_paths.append(BakeEntryPoint.initCss(path)) + else if (!data.flags.is_hmr_root) try out_paths.append(BakeEntryPoint.init(path, .client)); }, .server => { @@ -2011,9 +2328,17 @@ pub fn IncrementalGraph(side: bake.Side) type { fn reset(g: *@This()) void { g.current_chunk_len = 0; g.current_chunk_parts.clearRetainingCapacity(); + if (side == .client) g.current_css_files.clearRetainingCapacity(); } pub fn takeBundle(g: *@This(), kind: ChunkKind) ![]const u8 { + var chunk = std.ArrayList(u8).init(g.owner().allocator); + try g.takeBundleToList(kind, &chunk); + bun.assert(chunk.items.len == chunk.capacity); + return chunk.items; + } + + pub fn takeBundleToList(g: *@This(), kind: ChunkKind, list: *std.ArrayList(u8)) !void { g.owner().graph_safety_lock.assertLocked(); // initial bundle needs at least the entry point // hot updates shouldnt be emitted if there are no chunks @@ -2049,6 +2374,9 @@ pub fn IncrementalGraph(side: bake.Side) type { ); switch (side) { .client => { + try w.writeAll(",\n version: \""); + try w.writeAll(&g.owner().configuration_hash_key); + try w.writeAll("\""); if (fw.react_fast_refresh) |rfr| { try w.writeAll(",\n refresh: "); try bun.js_printer.writeJSONString( @@ -2078,42 +2406,39 @@ pub fn IncrementalGraph(side: bake.Side) type { const files = g.bundled_files.values(); - // This function performs one allocation, right here - var chunk = try ArrayListUnmanaged(u8).initCapacity( - g.owner().allocator, - g.current_chunk_len + runtime.len + end.len, - ); + const start = list.items.len; + if (start == 0) + try list.ensureTotalCapacityPrecise(g.current_chunk_len + runtime.len + end.len) + else + try list.ensureUnusedCapacity(g.current_chunk_len + runtime.len + end.len); - chunk.appendSliceAssumeCapacity(runtime); + list.appendSliceAssumeCapacity(runtime); for (g.current_chunk_parts.items) |entry| { - chunk.appendSliceAssumeCapacity(switch (side) { + list.appendSliceAssumeCapacity(switch (side) { // entry is an index into files .client => files[entry.get()].code(), // entry is the '[]const u8' itself .server => entry, }); } - chunk.appendSliceAssumeCapacity(end); + list.appendSliceAssumeCapacity(end); if (g.owner().dump_dir) |dump_dir| { const rel_path_escaped = "latest_chunk.js"; dumpBundle(dump_dir, switch (side) { .client => .client, .server => .server, - }, rel_path_escaped, chunk.items, false) catch |err| { + }, rel_path_escaped, list.items[start..], false) catch |err| { bun.handleErrorReturnTrace(err, @errorReturnTrace()); Output.warn("Could not dump bundle: {}", .{err}); }; } - - return chunk.items; } fn disconnectAndDeleteFile(g: *@This(), file_index: FileIndex) void { const last = FileIndex.init(@intCast(g.bundled_files.count() - 1)); bun.assert(g.bundled_files.count() > 1); // never remove all files - bun.assert(g.first_dep.items[file_index.get()] == .none); // must have no dependencies // Disconnect all imports @@ -2203,6 +2528,9 @@ const IncrementalResult = struct { /// When tracing a file's dependencies via `traceDependencies`, this is /// populated with the hit routes. Tracing is used for many purposes. routes_affected: ArrayListUnmanaged(Route.Index), + // /// When tracing a file's imports via `traceImports` this is populated + // /// with hit css files. + // css_files: ArrayListUnmanaged(IncrementalGraph(.client).FileIndex), // Following three fields are populated during `receiveChunk` @@ -2253,6 +2581,28 @@ const IncrementalResult = struct { } }; +const GraphTraceState = struct { + client_bits: DynamicBitSetUnmanaged, + server_bits: DynamicBitSetUnmanaged, + + fn deinit(gts: *GraphTraceState, alloc: Allocator) void { + gts.client_bits.deinit(alloc); + gts.server_bits.deinit(alloc); + } + + fn clear(gts: *GraphTraceState) void { + gts.server_bits.setAll(false); + gts.client_bits.setAll(false); + } +}; + +fn initGraphTraceState(dev: *const DevServer, sfa: Allocator) !GraphTraceState { + const server_bits = try DynamicBitSetUnmanaged.initEmpty(sfa, dev.server_graph.bundled_files.count()); + errdefer server_bits.deinit(sfa); + const client_bits = try DynamicBitSetUnmanaged.initEmpty(sfa, dev.client_graph.bundled_files.count()); + return .{ .server_bits = server_bits, .client_bits = client_bits }; +} + /// When a file fails to import a relative path, directory watchers are added so /// that when a matching file is created, the dependencies can be rebuilt. This /// handles HMR cases where a user writes an import before creating the file, @@ -2762,7 +3112,7 @@ fn emitVisualizerMessageIfNeeded(dev: *DevServer) !void { var sfb = std.heap.stackFallback(65536, bun.default_allocator); var payload = try std.ArrayList(u8).initCapacity(sfb.get(), 65536); defer payload.deinit(); - payload.appendAssumeCapacity('v'); + payload.appendAssumeCapacity(MessageId.visualizer.char()); const w = payload.writer(); inline for ( @@ -2788,7 +3138,7 @@ fn emitVisualizerMessageIfNeeded(dev: *DevServer) !void { try w.writeByte(@intFromBool(side == .client and v.flags.is_special_framework_file)); try w.writeByte(@intFromBool(switch (side) { .server => v.is_client_component_boundary, - .client => v.flags.is_component_root, + .client => v.flags.is_hmr_root, })); } } @@ -2805,7 +3155,7 @@ fn emitVisualizerMessageIfNeeded(dev: *DevServer) !void { } } - _ = dev.app.publish(DevWebSocket.visualizer_channel, payload.items, .binary, false); + _ = dev.app.publish(HmrSocket.visualizer_topic, payload.items, .binary, false); } pub fn onWebSocketUpgrade( @@ -2817,12 +3167,12 @@ pub fn onWebSocketUpgrade( ) void { assert(id == 0); - const dw = bun.create(dev.allocator, DevWebSocket, .{ + const dw = bun.create(dev.allocator, HmrSocket, .{ .dev = dev, .emit_visualizer_events = false, }); res.upgrade( - *DevWebSocket, + *HmrSocket, dw, req.header("sec-websocket-key") orelse "", req.header("sec-websocket-protocol") orelse "", @@ -2831,53 +3181,122 @@ pub fn onWebSocketUpgrade( ); } +/// Every message is to use `.binary`/`ArrayBuffer` transport mode. The first byte +/// indicates a Message ID; see comments on each type for how to interpret the rest. +/// +/// This format is only intended for communication for the browser build of +/// `hmr-runtime.ts` <-> `DevServer.zig`. Server-side HMR is implemented using a +/// different interface. This document is aimed for contributors to these two +/// components; Any other use-case is unsupported. +/// +/// All integers are sent in little-endian pub const MessageId = enum(u8) { - /// Version packet + /// Version payload. Sent on connection startup. The client should issue a + /// hard-reload when it mismatches with its `config.version`. version = 'V', - /// When visualization mode is enabled, this packet contains - /// the entire serialized IncrementalGraph state. - visualizer = 'v', - /// Sent on a successful bundle, containing client code. - hot_update = '(', - /// Sent on a successful bundle, containing a list of - /// routes that are updated. + /// Sent on a successful bundle, containing client code and changed CSS files. + /// + /// - u32: Number of CSS updates. For Each: + /// - [16]u8 ASCII: CSS identifier (hash of source path) + /// - u32: Length of CSS code + /// - [n]u8 UTF-8: CSS payload + /// - [n]u8 UTF-8: JS Payload. No length, rest of buffer is text. + /// + /// The JS payload will be code to hand to `eval` + // TODO: the above structure does not consider CSS attachments/detachments + hot_update = 'u', + /// Sent on a successful bundle, containing a list of routes that have + /// server changes. This is not sent when only client code changes. + /// + /// - `u32`: Number of updated routes. + /// - For each route: + /// - `u32`: Route ID + /// - `u32`: Length of route pattern + /// - `[n]u8` UTF-8: Route pattern + /// + /// HMR Runtime contains code that performs route matching at runtime + /// against `location.pathname`. The server is unaware of its routing + /// state. route_update = 'R', /// Sent when the list of errors changes. + /// + /// - `u32`: Removed errors. For Each: + /// - `u32`: Error owner + /// - Remainder are added errors. For Each: + /// - `SerializedFailure`: Error Data errors = 'E', - /// Sent when all errors are cleared. Semi-redundant + /// Sent when all errors are cleared. + // TODO: Remove this message ID errors_cleared = 'c', + /// Payload for `incremental_visualizer.html`. This can be accessed via + /// `/_bun/incremental_visualizer`. This contains both graphs. + /// + /// - `u32`: Number of files in `client_graph`. For Each: + /// - `u32`: Length of name. If zero then no other fields are provided. + /// - `[n]u8`: File path in UTF-8 encoded text + /// - `u8`: If file is stale, set 1 + /// - `u8`: If file is in server graph, set 1 + /// - `u8`: If file is in ssr graph, set 1 + /// - `u8`: If file is a server-side route root, set 1 + /// - `u8`: If file is a server-side component boundary file, set 1 + /// - `u32`: Number of files in the server graph. For Each: + /// - Repeat the same parser for the client graph + /// - `u32`: Number of client edges. For Each: + /// - `u32`: File index of the dependency file + /// - `u32`: File index of the imported file + /// - `u32`: Number of server edges. For Each: + /// - `u32`: File index of the dependency file + /// - `u32`: File index of the imported file + visualizer = 'v', pub fn char(id: MessageId) u8 { return @intFromEnum(id); } }; -const DevWebSocket = struct { +pub const IncomingMessageId = enum(u8) { + /// Subscribe to `.visualizer` events. No payload. + visualizer = 'v', + /// Invalid data + _, +}; + +const HmrSocket = struct { dev: *DevServer, emit_visualizer_events: bool, - pub const global_channel = "*"; - pub const visualizer_channel = "v"; + pub const global_topic = "*"; + pub const visualizer_topic = "v"; - pub fn onOpen(dw: *DevWebSocket, ws: AnyWebSocket) void { - _ = dw; - // TODO: append hash of the framework config - _ = ws.send(.{MessageId.version.char()} ++ bun.Global.package_json_version_with_revision, .binary, false, true); - _ = ws.subscribe(global_channel); + pub fn onOpen(dw: *HmrSocket, ws: AnyWebSocket) void { + _ = ws.send(&(.{MessageId.version.char()} ++ dw.dev.configuration_hash_key), .binary, false, true); + _ = ws.subscribe(global_topic); } - pub fn onMessage(dw: *DevWebSocket, ws: AnyWebSocket, msg: []const u8, opcode: uws.Opcode) void { + pub fn onMessage(dw: *HmrSocket, ws: AnyWebSocket, msg: []const u8, opcode: uws.Opcode) void { _ = opcode; - if (msg.len == 1 and msg[0] == MessageId.visualizer.char() and !dw.emit_visualizer_events) { - dw.emit_visualizer_events = true; - dw.dev.emit_visualizer_events += 1; - _ = ws.subscribe(visualizer_channel); - dw.dev.emitVisualizerMessageIfNeeded() catch bun.outOfMemory(); + if (msg.len == 0) { + ws.close(); + return; + } + + switch (@as(IncomingMessageId, @enumFromInt(msg[0]))) { + .visualizer => { + if (!dw.emit_visualizer_events) { + dw.emit_visualizer_events = true; + dw.dev.emit_visualizer_events += 1; + _ = ws.subscribe(visualizer_topic); + dw.dev.emitVisualizerMessageIfNeeded() catch bun.outOfMemory(); + } + }, + else => { + ws.close(); + }, } } - pub fn onClose(dw: *DevWebSocket, ws: AnyWebSocket, exit_code: i32, message: []const u8) void { + pub fn onClose(dw: *HmrSocket, ws: AnyWebSocket, exit_code: i32, message: []const u8) void { _ = ws; _ = exit_code; _ = message; @@ -3017,13 +3436,6 @@ pub fn reload(dev: *DevServer, reload_task: *HotReloadTask) bun.OOM!void { dev.graph_safety_lock.lock(); defer dev.graph_safety_lock.unlock(); - if (dev.client_graph.current_chunk_len > 0) { - const client = try dev.client_graph.takeBundle(.hmr_chunk); - defer dev.allocator.free(client); - assert(client[0] == '('); - _ = dev.app.publish(DevWebSocket.global_channel, client, .binary, true); - } - // This list of routes affected excludes client code. This means changing // a client component wont count as a route to trigger a reload on. if (dev.incremental_result.routes_affected.items.len > 0) { @@ -3031,18 +3443,18 @@ pub fn reload(dev: *DevServer, reload_task: *HotReloadTask) bun.OOM!void { var payload = std.ArrayList(u8).initCapacity(sfb2.get(), 65536) catch unreachable; // enough space defer payload.deinit(); - payload.appendAssumeCapacity('R'); + payload.appendAssumeCapacity(MessageId.route_update.char()); const w = payload.writer(); try w.writeInt(u32, @intCast(dev.incremental_result.routes_affected.items.len), .little); for (dev.incremental_result.routes_affected.items) |route| { try w.writeInt(u32, route.get(), .little); const pattern = dev.routes[route.get()].pattern; - try w.writeInt(u16, @intCast(pattern.len), .little); + try w.writeInt(u32, @intCast(pattern.len), .little); try w.writeAll(pattern); } - _ = dev.app.publish(DevWebSocket.global_channel, payload.items, .binary, true); + _ = dev.app.publish(HmrSocket.global_topic, payload.items, .binary, true); } // When client component roots get updated, the `client_components_affected` @@ -3073,7 +3485,7 @@ pub fn reload(dev: *DevServer, reload_task: *HotReloadTask) bun.OOM!void { // TODO: improve this visual feedback if (dev.bundling_failures.count() == 0) { - const clear_terminal = true; + const clear_terminal = !debug.isVisible(); if (clear_terminal) { Output.flush(); Output.disableBuffering(); diff --git a/src/bake/bake.d.ts b/src/bake/bake.d.ts index 9784678a152dc..30269ecb213f1 100644 --- a/src/bake/bake.d.ts +++ b/src/bake/bake.d.ts @@ -158,11 +158,25 @@ declare module "bun" { onServerSideReload?: () => void; } + /** + * This object and it's children may be re-used between invocations, so it + * is not safe to mutate it at all. + */ interface RouteMetadata { - /** A list of css files that the route will need to be styled */ - styles: string[]; - /** A list of js files that the route will need to be interactive */ - scripts: string[]; + /** + * A list of js files that the route will need to be interactive. + */ + readonly scripts: ReadonlyArray; + /** + * A list of css files that the route will need to be styled. + */ + readonly styles: ReadonlyArray; + /** + * Can be used by the framework to mention the route file. Only provided in + * development mode to prevent leaking these details into production + * builds. + */ + devRoutePath?: string; } } diff --git a/src/bake/bake.private.d.ts b/src/bake/bake.private.d.ts index 14e4038f437c4..c7c740ef39500 100644 --- a/src/bake/bake.private.d.ts +++ b/src/bake/bake.private.d.ts @@ -11,6 +11,8 @@ interface Config { separateSSRGraph?: true; // Client + /** Dev Server's `configuration_hash_key` */ + version: string; /** If available, this is the Id of `react-refresh/runtime` */ refresh?: Id; /** @@ -39,7 +41,7 @@ declare const side: "client" | "server"; * interface as opposed to a WebSocket connection. */ declare var server_exports: { - handleRequest: (req: Request, meta: HandleRequestMeta, id: Id) => any; + handleRequest: (req: Request, routeModuleId: Id, clientEntryUrl: string, styles: string[]) => any; registerUpdate: ( modules: any, componentManifestAdd: null | string[], @@ -47,11 +49,6 @@ declare var server_exports: { ) => void; }; -interface HandleRequestMeta { - // url for script tag - clientEntryPoint: string; -} - /* * If you are running a debug build of Bun. These debug builds should provide * helpful information to someone working on the bundler itself. diff --git a/src/bake/bun-framework-rsc/server.tsx b/src/bake/bun-framework-rsc/server.tsx index 03c418b3818af..1284929e645af 100644 --- a/src/bake/bun-framework-rsc/server.tsx +++ b/src/bake/bun-framework-rsc/server.tsx @@ -19,12 +19,25 @@ export default async function (request: Request, route: any, meta: Bake.RouteMet const skipSSR = request.headers.get("Accept")?.includes("text/x-component"); const Route = route.default; + + if (import.meta.env.DEV) { + if (typeof Route !== "function") { + throw new Error( + "Expected the default export of " + + JSON.stringify(meta.devRoutePath) + + " to be a React component, got " + + JSON.stringify(Route), + ); + } + } + + const { styles } = meta; const page = ( Bun + React Server Components - {meta.styles.map(url => ( + {styles.map(url => ( ))} diff --git a/src/bake/client/error-serialization.ts b/src/bake/client/error-serialization.ts index 391b9b2c8159c..ea361a9b62ed3 100644 --- a/src/bake/client/error-serialization.ts +++ b/src/bake/client/error-serialization.ts @@ -6,7 +6,7 @@ export interface DeserializedFailure { // If not specified, it is a client-side error. file: string | null; messages: BundlerMessage[]; -}; +} export interface BundlerMessage { kind: "bundler"; @@ -50,7 +50,7 @@ function readLogMsg(r: DataViewReader, level: BundlerMessageLevel) { notes[i] = readLogData(r); } return { - kind: 'bundler', + kind: "bundler", level, message, location, diff --git a/src/bake/client/overlay.css b/src/bake/client/overlay.css index 04945957a02c6..171d057505691 100644 --- a/src/bake/client/overlay.css +++ b/src/bake/client/overlay.css @@ -98,7 +98,7 @@ pre { cursor: pointer; } -/* .file-name:hover, +.file-name:hover, .file-name:focus-visible { background-color: var(--item-bg-hover); } @@ -108,20 +108,21 @@ pre { font-size: 70%; } -.file-name:hover::after { +.file-name:hover::after, +.file-name:focus-visible { content: " (click to open in editor)"; -} */ +} .message { margin: 1rem; margin-bottom: 0; } -button+.message { +button + .message { margin-top: 0.5rem; } -.message-text>span { +.message-text > span { color: var(--color); } @@ -168,9 +169,8 @@ button+.message { } @media (prefers-color-scheme: light) { - .log-warn, .log-note { font-weight: bold; } -} \ No newline at end of file +} diff --git a/src/bake/client/overlay.ts b/src/bake/client/overlay.ts index 1d0ee9e31071a..11d68e430554a 100644 --- a/src/bake/client/overlay.ts +++ b/src/bake/client/overlay.ts @@ -11,7 +11,13 @@ // added or previous ones are solved. import { BundlerMessageLevel } from "../enums"; import { css } from "../macros" with { type: "macro" }; -import { BundlerMessage, BundlerMessageLocation, BundlerNote, decodeSerializedError, type DeserializedFailure } from "./error-serialization"; +import { + BundlerMessage, + BundlerMessageLocation, + BundlerNote, + decodeSerializedError, + type DeserializedFailure, +} from "./error-serialization"; import { DataViewReader } from "./reader"; if (side !== "client") throw new Error("Not client side!"); @@ -172,11 +178,22 @@ export function updateErrorOverlay() { // Create the element for the root if it does not yet exist. if (!dom) { let title; + let btn; const root = elem("div", { class: "message-group" }, [ - elem("button", { class: "file-name" }, [ - title = textNode() - ]), + (btn = elem("button", { class: "file-name" }, [(title = textNode())])), ]); + btn.addEventListener("click", () => { + const firstLocation = errors.get(owner)?.messages[0]?.location; + if (!firstLocation) return; + let fileName = title.textContent.replace(/^\//, ""); + fetch("/_bun/src/" + fileName, { + headers: { + "Open-In-Editor": "1", + "Editor-Line": firstLocation.line.toString(), + "Editor-Column": firstLocation.column.toString(), + }, + }); + }); dom = { root, title, messages: [] }; // TODO: sorted insert? domErrorList.appendChild(root); @@ -203,50 +220,48 @@ export function updateErrorOverlay() { setModalVisible(true); } -const bundleLogLevelToName = [ - "error", - "warn", - "note", - "debug", - "verbose", -]; +const bundleLogLevelToName = ["error", "warn", "note", "debug", "verbose"]; function renderBundlerMessage(msg: BundlerMessage) { - return elem('div', { class: 'message' }, [ - renderErrorMessageLine(msg.level, msg.message), - ...msg.location ? renderCodeLine(msg.location, msg.level) : [], - ...msg.notes.map(renderNote), - ].flat(1)); + return elem( + "div", + { class: "message" }, + [ + renderErrorMessageLine(msg.level, msg.message), + ...(msg.location ? renderCodeLine(msg.location, msg.level) : []), + ...msg.notes.map(renderNote), + ].flat(1), + ); } function renderErrorMessageLine(level: BundlerMessageLevel, text: string) { const levelName = bundleLogLevelToName[level]; - if(IS_BUN_DEVELOPMENT && !levelName) { + if (IS_BUN_DEVELOPMENT && !levelName) { throw new Error("Unknown log level: " + level); } - return elem('div', { class: 'message-text' } , [ - elemText('span', { class: 'log-' + levelName }, levelName), - elemText('span', { class: 'log-colon' }, ': '), - elemText('span', { class: 'log-text' }, text), + return elem("div", { class: "message-text" }, [ + elemText("span", { class: "log-" + levelName }, levelName), + elemText("span", { class: "log-colon" }, ": "), + elemText("span", { class: "log-text" }, text), ]); } function renderCodeLine(location: BundlerMessageLocation, level: BundlerMessageLevel) { return [ - elem('div', { class: 'code-line' }, [ - elemText('code', { class: 'line-num' }, `${location.line}`), - elemText('pre', { class: 'code-view' }, location.lineText), + elem("div", { class: "code-line" }, [ + elemText("code", { class: "line-num" }, `${location.line}`), + elemText("pre", { class: "code-view" }, location.lineText), + ]), + elem("div", { class: "highlight-wrap log-" + bundleLogLevelToName[level] }, [ + elemText("span", { class: "space" }, "_".repeat(`${location.line}`.length + location.column - 1)), + elemText("span", { class: "line" }, "_".repeat(location.length)), ]), - elem('div', { class: 'highlight-wrap log-' + bundleLogLevelToName[level] }, [ - elemText('span', { class: 'space' }, '_'.repeat(`${location.line}`.length + location.column - 1)), - elemText('span', { class: 'line' }, '_'.repeat(location.length)), - ]) ]; } function renderNote(note: BundlerNote) { return [ renderErrorMessageLine(BundlerMessageLevel.note, note.message), - ...note.location ? renderCodeLine(note.location, BundlerMessageLevel.note) : [], + ...(note.location ? renderCodeLine(note.location, BundlerMessageLevel.note) : []), ]; -} \ No newline at end of file +} diff --git a/src/bake/client/reader.ts b/src/bake/client/reader.ts index a8005bd3efdba..b6ea0f9323369 100644 --- a/src/bake/client/reader.ts +++ b/src/bake/client/reader.ts @@ -40,4 +40,8 @@ export class DataViewReader { hasMoreData() { return this.cursor < this.view.byteLength; } + + rest() { + return this.view.buffer.slice(this.cursor); + } } diff --git a/src/bake/client/websocket.ts b/src/bake/client/websocket.ts index 8ab85520cc180..fec4771d86f72 100644 --- a/src/bake/client/websocket.ts +++ b/src/bake/client/websocket.ts @@ -2,22 +2,35 @@ const isLocal = location.host === "localhost" || location.host === "127.0.0.1"; function wait() { return new Promise(done => { - let timer; + let timer: Timer | null = null; + + const onBlur = () => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }; const onTimeout = () => { if (timer !== null) clearTimeout(timer); - document.removeEventListener("focus", onTimeout); + window.removeEventListener("focus", onTimeout); + window.removeEventListener("blur", onBlur); done(); }; - document.addEventListener("focus", onTimeout); - timer = setTimeout( - () => { - timer = null; - onTimeout(); - }, - isLocal ? 2_500 : 30_000, - ); + window.addEventListener("focus", onTimeout); + + if (document.hasFocus()) { + timer = setTimeout( + () => { + timer = null; + onTimeout(); + }, + isLocal ? 2_500 : 2_500, + ); + + window.addEventListener("blur", onBlur); + } }); } diff --git a/src/bake/enums.ts b/src/bake/enums.ts index c3e9605de78a6..8bed72528862d 100644 --- a/src/bake/enums.ts +++ b/src/bake/enums.ts @@ -1,22 +1,3 @@ -// TODO: generate this using information in DevServer.zig - -export const enum MessageId { - /// Version packet - version = 86, - /// When visualization mode is enabled, this packet contains - /// the entire serialized IncrementalGraph state. - visualizer = 118, - /// Sent on a successful bundle, containing client code. - hot_update = 40, - /// Sent on a successful bundle, containing a list of - /// routes that are updated. - route_update = 82, - /// Sent when the list of errors changes. - errors = 69, - /// Sent when all errors are cleared. Semi-redundant - errors_cleared = 99, -} - export const enum BundlerMessageLevel { err = 0, warn = 1, diff --git a/src/bake/hmr-protocol.md b/src/bake/hmr-protocol.md deleted file mode 100644 index c0f69d91383e4..0000000000000 --- a/src/bake/hmr-protocol.md +++ /dev/null @@ -1,75 +0,0 @@ -# Kit's WebSocket Protocol - -This format is only intended for communication for the browser build of -`hmr-runtime.ts` <-> `DevServer.zig`. Server-side HMR is implemented using a -different interface. This document is aimed for contributors to these -two components; Any other use-case is unsupported. - -Every message is to use `.binary`/`ArrayBuffer` transport mode. The first byte -indicates a Message ID, with the length being inferred by the payload size. - -All integers are in little-endian - -## Client->Server messages - -### `v` - -Subscribe to visualizer packets (`v`) - -## Server->Client messages - -### `V` - -Version payload. Sent on connection startup. The client should issue a hard-reload -when it does not match the embedded version. - -Example: - -``` -V1.1.30-canary.37+117e1b388 -``` - -### `(` - -Hot-module-reloading patch. The entire payload is UTF-8 Encoded JavaScript Payload. - -### `R` - Route reload request - -Server-side code has reloaded. Client should either refetch the route or perform a hard reload. - -- `u32`: Number of updated routes -- For each route: - - `u32`: Route ID - - `u16`: Length of route name. - - `[n]u8`: Route name in UTF-8 encoded text. - -### `e` - Error status update - -- `u32`: Number of errors removed -- For each removed error: - - `u32` Error owner -- Remainder of payload is repeating each error object: - - `u32` Error owner - - Error Payload - -### `v` - -Payload for `incremental_visualizer.html`. This can be accessed via `/_bun/incremental_visualizer`. - -- `u32`: Number of files in client graph -- For each file in client graph - - `u32`: Length of name. If zero then no other fields are provided. - - `[n]u8`: File path in UTF-8 encoded text - - `u8`: If file is stale, set 1 - - `u8`: If file is in server graph, set 1 - - `u8`: If file is in ssr graph, set 1 - - `u8`: If file is a server-side route root, set 1 - - `u8`: If file is a server-side component boundary file, set 1 -- `u32`: Number of files in the server graph -- For each file in server graph, repeat the same parser for the clienr graph -- `u32`: Number of client edges. For each, - - `u32`: File index of the dependency file - - `u32`: File index of the imported file -- `u32`: Number of server edges. For each, - - `u32`: File index of the dependency file - - `u32`: File index of the imported file diff --git a/src/bake/hmr-runtime-client.ts b/src/bake/hmr-runtime-client.ts index ec833fb5e623b..cb29878cc114e 100644 --- a/src/bake/hmr-runtime-client.ts +++ b/src/bake/hmr-runtime-client.ts @@ -7,7 +7,7 @@ import { td } from "./shared"; import { DataViewReader } from "./client/reader"; import { routeMatch } from "./client/route"; import { initWebSocket } from "./client/websocket"; -import { MessageId } from "./enums"; +import { MessageId } from "./generated"; if (typeof IS_BUN_DEVELOPMENT !== "boolean") { throw new Error("DCE is configured incorrectly"); @@ -49,18 +49,49 @@ try { console.error(e); } +/** + * Map between CSS identifier and its style tag. + * If a file is not present in this map, it might exist as a link tag in the HTML. + */ +const cssStore = new Map(); + +let isFirstRun = true; initWebSocket({ [MessageId.version](view) { - // TODO: config.version and verify everything is sane - console.log("VERSION: ", td.decode(view.buffer.slice(1))); + if (td.decode(view.buffer.slice(1)) !== config.version) { + console.error("Version mismatch, hard-reloading"); + location.reload(); + } + + if (isFirstRun) { + isFirstRun = false; + return; + } + + // It would be possible to use `performRouteReload` to do a hot-reload, + // but the issue lies in possibly outdated client files. For correctness, + // all client files have to be HMR reloaded or proven unchanged. + // Configuration changes are already handled by the `config.version` data. + location.reload(); }, [MessageId.hot_update](view) { - const code = td.decode(view.buffer); - const modules = (0, eval)(code); - replaceModules(modules); + const reader = new DataViewReader(view, 1); + + const cssCount = reader.u32(); + if (cssCount > 0) { + for (let i = 0; i < cssCount; i++) { + const moduleId = reader.stringWithLength(16); + const content = reader.string32(); + reloadCss(moduleId, content); + } + } + + if (reader.hasMoreData()) { + const code = td.decode(reader.rest()); + const modules = (0, eval)(code); + replaceModules(modules); + } }, - [MessageId.errors]: onErrorMessage, - [MessageId.errors_cleared]: onErrorClearedMessage, [MessageId.route_update](view) { const reader = new DataViewReader(view, 1); let routeCount = reader.u32(); @@ -68,11 +99,32 @@ initWebSocket({ while (routeCount > 0) { routeCount -= 1; const routeId = reader.u32(); - const routePattern = reader.stringWithLength(reader.u16()); + const routePattern = reader.string32(); if (routeMatch(routeId, routePattern)) { performRouteReload(); break; } } }, + [MessageId.errors]: onErrorMessage, + [MessageId.errors_cleared]: onErrorClearedMessage, }); + +function reloadCss(id: string, newContent: string) { + console.log(`[Bun] Reloading CSS: ${id}`); + + // TODO: can any of the following operations throw? + let sheet = cssStore.get(id); + if (!sheet) { + sheet = new CSSStyleSheet(); + sheet.replace(newContent); + document.adoptedStyleSheets.push(sheet); + cssStore.set(id, sheet); + + // Delete the link tag if it exists + document.querySelector(`link[href="/_bun/css/${id}.css"]`)?.remove(); + return; + } + + sheet.replace(newContent); +} diff --git a/src/bake/hmr-runtime-error.ts b/src/bake/hmr-runtime-error.ts index a5694012e67ca..e59e97efe40f0 100644 --- a/src/bake/hmr-runtime-error.ts +++ b/src/bake/hmr-runtime-error.ts @@ -10,7 +10,7 @@ import { decodeAndAppendError, onErrorMessage, updateErrorOverlay } from "./clie import { DataViewReader } from "./client/reader"; import { routeMatch } from "./client/route"; import { initWebSocket } from "./client/websocket"; -import { MessageId } from "./enums"; +import { MessageId } from "./generated"; /** Injected by DevServer */ declare const error: Uint8Array; diff --git a/src/bake/hmr-runtime-server.ts b/src/bake/hmr-runtime-server.ts index 512c74581ba85..da5cd0935c966 100644 --- a/src/bake/hmr-runtime-server.ts +++ b/src/bake/hmr-runtime-server.ts @@ -8,9 +8,8 @@ if (typeof IS_BUN_DEVELOPMENT !== "boolean") { throw new Error("DCE is configured incorrectly"); } -// Server Side server_exports = { - async handleRequest(req, { clientEntryPoint }, requested_id) { + async handleRequest(req, routeModuleId, clientEntryUrl, styles) { const serverRenderer = loadModule(config.main, LoadModuleType.AssertPresent).exports.default; if (!serverRenderer) { @@ -20,9 +19,10 @@ server_exports = { throw new Error('Framework server entrypoint\'s "default" export is not a function.'); } - const response = await serverRenderer(req, loadModule(requested_id, LoadModuleType.AssertPresent).exports, { - styles: [], - scripts: [clientEntryPoint], + const response = await serverRenderer(req, loadModule(routeModuleId, LoadModuleType.AssertPresent).exports, { + styles: styles, + scripts: [clientEntryUrl], + devRoutePath: routeModuleId, }); if (!(response instanceof Response)) { diff --git a/src/bake/macros.ts b/src/bake/macros.ts index fd76ff8a4dd3c..0daa8c4e33ab7 100644 --- a/src/bake/macros.ts +++ b/src/bake/macros.ts @@ -12,5 +12,5 @@ export async function css(file: string, is_development: boolean): string { // if (!success) throw new Error(stderr.toString("utf-8")); // return stdout.toString("utf-8"); - return readFileSync(resolve(import.meta.dir, file)).toString('utf-8'); + return readFileSync(resolve(import.meta.dir, file)).toString("utf-8"); } diff --git a/src/bun.zig b/src/bun.zig index 6d77b9d8b7210..40d6dbd6388f2 100644 --- a/src/bun.zig +++ b/src/bun.zig @@ -3864,7 +3864,7 @@ pub const DebugThreadLock = if (Environment.allow_assert) if (impl.owning_thread) |thread| { Output.err("assertion failure", "Locked by thread {d} here:", .{thread}); crash_handler.dumpStackTrace(impl.locked_at.trace()); - @panic("Safety lock violated"); + Output.panic("Safety lock violated on thread {d}", .{std.Thread.getCurrentId()}); } impl.owning_thread = std.Thread.getCurrentId(); impl.locked_at = crash_handler.StoredTrace.capture(@returnAddress()); @@ -4037,3 +4037,19 @@ pub fn Once(comptime f: anytype) type { } }; } + +fn assertNoPointers(T: type) void { + switch (@typeInfo(T)) { + .Pointer => @compileError("no pointers!"), + inline .Struct, .Union => |s| for (s.fields) |field| { + assertNoPointers(field.type); + }, + .Array => |a| assertNoPointers(a.child), + else => {}, + } +} + +pub inline fn writeAnyToHasher(hasher: anytype, thing: anytype) void { + comptime assertNoPointers(@TypeOf(thing)); // catch silly mistakes + hasher.update(std.mem.asBytes(&thing)); +} diff --git a/src/bundler/bundle_v2.zig b/src/bundler/bundle_v2.zig index da7fd3d9a3af2..833a25bf00bfe 100644 --- a/src/bundler/bundle_v2.zig +++ b/src/bundler/bundle_v2.zig @@ -322,6 +322,7 @@ const Watcher = bun.JSC.NewHotReloader(BundleV2, EventLoop, true); pub const BakeEntryPoint = struct { path: []const u8, graph: bake.Graph, + css: bool = false, route_index: bake.DevServer.Route.Index.Optional = .none, pub fn init(path: []const u8, graph: bake.Graph) BakeEntryPoint { @@ -335,6 +336,10 @@ pub const BakeEntryPoint = struct { .route_index = index.toOptional(), }; } + + pub fn initCss(path: []const u8) BakeEntryPoint { + return .{ .path = path, .graph = .client, .css = true }; + } }; pub const BundleV2 = struct { @@ -748,7 +753,7 @@ pub const BundleV2 = struct { } } - pub fn enqueueItem( + pub fn enqueueEntryItem( this: *BundleV2, hash: ?u64, batch: *ThreadPoolLib.Batch, @@ -759,7 +764,7 @@ pub const BundleV2 = struct { var result = resolve; var path = result.path() orelse return null; - const entry = try this.graph.path_to_source_index_map.getOrPut(this.graph.allocator, hash orelse path.hashKey()); + const entry = try this.pathToSourceIndexMap(target).getOrPut(this.graph.allocator, hash orelse path.hashKey()); if (entry.found_existing) { return null; } @@ -914,8 +919,14 @@ pub const BundleV2 = struct { pub fn enqueueEntryPoints( this: *BundleV2, - user_entry_points: []const []const u8, - bake_entry_points: []const BakeEntryPoint, + comptime variant: enum { normal, dev_server }, + data: switch (variant) { + .normal => []const []const u8, + .dev_server => struct { + files: []const BakeEntryPoint, + css_data: *std.AutoArrayHashMapUnmanaged(Index, BakeBundleOutput.CssEntryPointMeta), + }, + }, ) !ThreadPoolLib.Batch { var batch = ThreadPoolLib.Batch{}; @@ -929,8 +940,8 @@ pub const BundleV2 = struct { }); // try this.graph.entry_points.append(allocator, Index.runtime); - this.graph.ast.append(bun.default_allocator, JSAst.empty) catch unreachable; - this.graph.path_to_source_index_map.put(this.graph.allocator, bun.hash("bun:wrap"), Index.runtime.get()) catch unreachable; + try this.graph.ast.append(bun.default_allocator, JSAst.empty); + try this.graph.path_to_source_index_map.put(this.graph.allocator, bun.hash("bun:wrap"), Index.runtime.get()); var runtime_parse_task = try this.graph.allocator.create(ParseTask); runtime_parse_task.* = rt.parse_task; runtime_parse_task.ctx = this; @@ -943,33 +954,52 @@ pub const BundleV2 = struct { // Bake reserves two source indexes at the start of the file list, but // gets its content set after the scan+parse phase, but before linking. - try this.reserveSourceIndexesForBake(); + // + // The dev server does not use these, as it is implement in the HMR runtime. + if (this.bundler.options.dev_server == null) { + try this.reserveSourceIndexesForBake(); + } { // Setup entry points - try this.graph.entry_points.ensureUnusedCapacity(this.graph.allocator, user_entry_points.len); - try this.graph.input_files.ensureUnusedCapacity(this.graph.allocator, user_entry_points.len); - try this.graph.path_to_source_index_map.ensureUnusedCapacity(this.graph.allocator, @as(u32, @truncate(user_entry_points.len))); + const entry_points = switch (variant) { + .normal => data, + .dev_server => data.files, + }; - for (user_entry_points) |entry_point| { - const resolved = this.bundler.resolveEntryPoint(entry_point) catch continue; - if (try this.enqueueItem(null, &batch, resolved, true, this.bundler.options.target)) |source_index| { - this.graph.entry_points.append(this.graph.allocator, Index.source(source_index)) catch unreachable; - } else {} - } + try this.graph.entry_points.ensureUnusedCapacity(this.graph.allocator, entry_points.len); + try this.graph.input_files.ensureUnusedCapacity(this.graph.allocator, entry_points.len); + try this.graph.path_to_source_index_map.ensureUnusedCapacity(this.graph.allocator, @as(u32, @truncate(entry_points.len))); - for (bake_entry_points) |entry_point| { - const resolved = this.bundler.resolveEntryPoint(entry_point.path) catch continue; - if (try this.enqueueItem(null, &batch, resolved, true, switch (entry_point.graph) { - .client => .browser, - .server => this.bundler.options.target, - .ssr => .kit_server_components_ssr, - })) |source_index| { - this.graph.entry_points.append(this.graph.allocator, Index.source(source_index)) catch unreachable; - } else {} + for (entry_points) |entry_point| { + switch (variant) { + .normal => { + const resolved = this.bundler.resolveEntryPoint(entry_point) catch + continue; + const source_index = try this.enqueueEntryItem(null, &batch, resolved, true, this.bundler.options.target) orelse + continue; + try this.graph.entry_points.append(this.graph.allocator, Index.source(source_index)); + }, + .dev_server => { + // Dev server provides target and some extra integration. + const resolved = this.bundler.resolveEntryPoint(entry_point.path) catch + continue; + const source_index = try this.enqueueEntryItem(null, &batch, resolved, true, switch (entry_point.graph) { + .client => .browser, + .server => this.bundler.options.target, + .ssr => .kit_server_components_ssr, + }) orelse continue; - if (entry_point.route_index.unwrap()) |route_index| { - _ = try this.bundler.options.dev_server.?.server_graph.insertStaleExtra(resolved.path_pair.primary.text, false, true, route_index); + try this.graph.entry_points.append(this.graph.allocator, Index.source(source_index)); + + if (entry_point.route_index.unwrap()) |route_index| { + _ = try this.bundler.options.dev_server.?.server_graph.insertStaleExtra(resolved.path_pair.primary.text, false, true, route_index); + } + + if (entry_point.css) { + try data.css_data.putNoClobber(this.graph.allocator, Index.init(source_index), .{ .imported_on_server = false }); + } + }, } } } @@ -1265,7 +1295,7 @@ pub const BundleV2 = struct { return error.BuildFailed; } - this.graph.pool.pool.schedule(try this.enqueueEntryPoints(this.bundler.options.entry_points, &.{})); + this.graph.pool.pool.schedule(try this.enqueueEntryPoints(.normal, this.bundler.options.entry_points)); if (this.bundler.log.hasErrors()) { return error.BuildFailed; @@ -1876,7 +1906,7 @@ pub const BundleV2 = struct { this.graph.heap.helpCatchMemoryIssues(); - this.graph.pool.pool.schedule(try this.enqueueEntryPoints(entry_points, &.{})); + this.graph.pool.pool.schedule(try this.enqueueEntryPoints(.normal, entry_points)); // We must wait for all the parse tasks to complete, even if there are errors. this.waitForParse(); @@ -1916,12 +1946,16 @@ pub const BundleV2 = struct { /// Dev Server uses this instead to run a subset of the bundler, where /// it indexes the chunks into IncrementalGraph on it's own. - pub fn runFromBakeDevServer(this: *BundleV2, bake_entry_points: []const BakeEntryPoint) ![2]Chunk { + pub fn runFromBakeDevServer(this: *BundleV2, bake_entry_points: []const BakeEntryPoint) !BakeBundleOutput { this.unique_key = std.crypto.random.int(u64); this.graph.heap.helpCatchMemoryIssues(); - this.graph.pool.pool.schedule(try this.enqueueEntryPoints(&.{}, bake_entry_points)); + var css_entry_points: std.AutoArrayHashMapUnmanaged(Index, BakeBundleOutput.CssEntryPointMeta) = .{}; + this.graph.pool.pool.schedule(try this.enqueueEntryPoints(.dev_server, .{ + .files = bake_entry_points, + .css_data = &css_entry_points, + })); this.waitForParse(); this.graph.heap.helpCatchMemoryIssues(); @@ -1933,30 +1967,52 @@ pub const BundleV2 = struct { this.dynamic_import_entry_points = std.AutoArrayHashMap(Index.Int, void).init(this.graph.allocator); // Separate non-failing files into two lists: JS and CSS - const js_reachable_files, const css_asts = reachable_files: { - var css_asts = try BabyList(bun.css.BundlerStyleSheet).initCapacity(this.graph.allocator, this.graph.css_file_count); + const js_reachable_files, const css_total_files = reachable_files: { + var css_total_files = try std.ArrayListUnmanaged(Index).initCapacity(this.graph.allocator, this.graph.css_file_count); + try css_entry_points.ensureUnusedCapacity(this.graph.allocator, this.graph.css_file_count); var js_files = try std.ArrayListUnmanaged(Index).initCapacity(this.graph.allocator, this.graph.ast.len - this.graph.css_file_count - 1); - for (this.graph.ast.items(.parts)[1..], this.graph.ast.items(.css)[1..], 1..) |part_list, maybe_css, index| { + const asts = this.graph.ast.slice(); + for ( + asts.items(.parts)[1..], + asts.items(.import_records)[1..], + asts.items(.css)[1..], + asts.items(.target)[1..], + 1.., + ) |part_list, import_records, maybe_css, target, index| { // Dev Server proceeds even with failed files. // These files are filtered out via the lack of any parts. // // Actual empty files will contain a part exporting an empty object. if (part_list.len != 0) { - if (maybe_css) |css| { - css_asts.appendAssumeCapacity(css.*); - } else { + if (maybe_css == null) { js_files.appendAssumeCapacity(Index.init(index)); + // Mark every part live. for (part_list.slice()) |*p| { p.is_live = true; } + + // Discover all CSS roots. + for (import_records.slice()) |record| { + if (record.tag != .css) continue; + if (!record.source_index.isValid()) continue; + + const gop = css_entry_points.getOrPutAssumeCapacity(record.source_index); + if (target != .browser) + gop.value_ptr.* = .{ .imported_on_server = true } + else if (!gop.found_existing) + gop.value_ptr.* = .{ .imported_on_server = false }; + } + } else { + css_total_files.appendAssumeCapacity(Index.init(index)); } } } - break :reachable_files .{ js_files.items, css_asts }; + break :reachable_files .{ js_files.items, css_total_files }; }; + _ = css_total_files; // autofix this.graph.heap.helpCatchMemoryIssues(); @@ -1992,50 +2048,55 @@ pub const BundleV2 = struct { }; } - _ = css_asts; // TODO: + const chunks = try this.graph.allocator.alloc(Chunk, css_entry_points.count() + 1); - var chunks = [_]Chunk{ - // One JS chunk - .{ - .entry_point = .{ - .entry_point_id = 0, - .source_index = 0, - .is_entry_point = true, - }, - .content = .{ - .javascript = .{ - // TODO(@paperdave): remove this ptrCast when Source Index is fixed - .files_in_chunk_order = @ptrCast(js_reachable_files), - .parts_in_chunk_in_order = js_part_ranges, - }, + chunks[0] = .{ + .entry_point = .{ + .entry_point_id = 0, + .source_index = 0, + .is_entry_point = true, + }, + .content = .{ + .javascript = .{ + // TODO(@paperdave): remove this ptrCast when Source Index is fixed + .files_in_chunk_order = @ptrCast(js_reachable_files), + .parts_in_chunk_in_order = js_part_ranges, }, - .output_source_map = sourcemap.SourceMapPieces.init(this.graph.allocator), }, - // One CSS chunk - .{ + .output_source_map = sourcemap.SourceMapPieces.init(this.graph.allocator), + }; + + for (chunks[1..], css_entry_points.keys()) |*chunk, entry_point| { + const order = this.linker.findImportedFilesInCSSOrder(this.graph.allocator, &.{entry_point}); + chunk.* = .{ .entry_point = .{ - .entry_point_id = 0, - .source_index = 0, - .is_entry_point = true, + .entry_point_id = @intCast(entry_point.get()), + .source_index = entry_point.get(), + .is_entry_point = false, }, .content = .{ .css = .{ - // TODO: - .imports_in_chunk_in_order = BabyList(Chunk.CssImportOrder).init(&.{}), - .asts = &.{}, + .imports_in_chunk_in_order = order, + .asts = try this.graph.allocator.alloc(bun.css.BundlerStyleSheet, order.len), }, }, .output_source_map = sourcemap.SourceMapPieces.init(this.graph.allocator), - }, - }; + }; + } this.graph.heap.helpCatchMemoryIssues(); - try this.linker.generateChunksInParallel(&chunks, true); + try this.linker.generateChunksInParallel(chunks, true); this.graph.heap.helpCatchMemoryIssues(); - return chunks; + return .{ + .chunks = chunks, + .css_file_list = .{ + .indexes = css_entry_points.keys(), + .metas = css_entry_points.values(), + }, + }; } pub fn enqueueOnResolvePluginIfNeeded( @@ -2420,11 +2481,14 @@ pub const BundleV2 = struct { import_record.source_index = Index.invalid; import_record.is_external_without_side_effects = true; - if (!dev_server.isFileStale(path.text, renderer)) { + if (dev_server.isFileCached(path.text, renderer)) |entry| { const rel = bun.path.relativePlatform(this.bundler.fs.top_level_dir, path.text, .loose, false); import_record.path.text = rel; import_record.path.pretty = rel; import_record.path = this.pathWithPrettyInitialized(path.*, target) catch bun.outOfMemory(); + if (entry.kind == .css) { + import_record.tag = .css; + } continue; } } @@ -2437,6 +2501,9 @@ pub const BundleV2 = struct { } else { import_record.source_index = Index.init(id); } + if (this.graph.input_files.items(.loader)[id] == .css) { + import_record.tag = .css; + } continue; } @@ -2476,6 +2543,10 @@ pub const BundleV2 = struct { resolve_task.tree_shaking = this.bundler.options.tree_shaking; } + if (resolve_task.loader == .css) { + import_record.tag = .css; + } + resolve_entry.value_ptr.* = resolve_task; } @@ -2646,11 +2717,17 @@ pub const BundleV2 = struct { var import_records = result.ast.import_records.clone(this.graph.allocator) catch unreachable; + const input_file_loaders = this.graph.input_files.items(.loader); + if (this.resolve_tasks_waiting_for_import_source_index.fetchSwapRemove(result.source.index.get())) |pending_entry| { for (pending_entry.value.slice()) |to_assign| { - if (this.bundler.options.dev_server == null) + if (this.bundler.options.dev_server == null or + input_file_loaders[to_assign.to_source_index.get()] == .css) + { import_records.slice()[to_assign.import_record_index].source_index = to_assign.to_source_index; + } } + var list = pending_entry.value.list(); list.deinit(this.graph.allocator); } @@ -2661,7 +2738,8 @@ pub const BundleV2 = struct { for (import_records.slice(), 0..) |*record, i| { if (path_to_source_index_map.get(record.path.hashKey())) |source_index| { - if (this.bundler.options.dev_server == null) + if (this.bundler.options.dev_server == null or + input_file_loaders[source_index] == .css) record.source_index.value = source_index; if (getRedirectId(result.ast.redirect_import_record_index)) |compare| { @@ -3787,8 +3865,6 @@ pub const ServerComponentParseTask = struct { const module_path = b.newExpr(E.String{ .data = data.other_source.path.pretty }); for (client_named_exports.keys()) |key| { - const export_ref = try b.newSymbol(.other, key); - const is_default = bun.strings.eqlComptime(key, "default"); // This error message is taken from @@ -3823,31 +3899,40 @@ pub const ServerComponentParseTask = struct { .close_parens_loc = Logger.Loc.Empty, }); - // export const Comp = registerClientReference( + // registerClientReference( // () => { throw new Error(...) }, // "src/filepath.tsx", // "Comp" // ); - try b.appendStmt(S.Local{ - .decls = try G.Decl.List.fromSlice(b.allocator, &.{.{ - .binding = Binding.alloc(b.allocator, B.Identifier{ .ref = export_ref }, Logger.Loc.Empty), - .value = b.newExpr(E.Call{ - .target = register_client_reference, - .args = try js_ast.ExprNodeList.fromSlice(b.allocator, &.{ - b.newExpr(E.Arrow{ .body = .{ - .stmts = try b.allocator.dupe(Stmt, &.{ - b.newStmt(S.Throw{ .value = err_msg }), - }), - .loc = Logger.Loc.Empty, - } }), - module_path, - b.newExpr(E.String{ .data = key }), + const value = b.newExpr(E.Call{ + .target = register_client_reference, + .args = try js_ast.ExprNodeList.fromSlice(b.allocator, &.{ + b.newExpr(E.Arrow{ .body = .{ + .stmts = try b.allocator.dupe(Stmt, &.{ + b.newStmt(S.Throw{ .value = err_msg }), }), - }), - }}), - .is_export = true, - .kind = .k_const, + .loc = Logger.Loc.Empty, + } }), + module_path, + b.newExpr(E.String{ .data = key }), + }), }); + + if (is_default) { + // export default registerClientReference(...); + try b.appendStmt(S.ExportDefault{ .value = .{ .expr = value }, .default_name = .{} }); + } else { + // export const Component = registerClientReference(...); + const export_ref = try b.newSymbol(.other, key); + try b.appendStmt(S.Local{ + .decls = try G.Decl.List.fromSlice(b.allocator, &.{.{ + .binding = Binding.alloc(b.allocator, B.Identifier{ .ref = export_ref }, Logger.Loc.Empty), + .value = value, + }}), + .is_export = true, + .kind = .k_const, + }); + } } } }; @@ -5567,7 +5652,6 @@ pub const LinkerContext = struct { wrapping_conditions: *BabyList(bun.css.ImportConditions), wrapping_import_records: *BabyList(*const ImportRecord), ) void { - // The CSS specification strangely does not describe what to do when there // is a cycle. So we are left with reverse-engineering the behavior from a // real browser. Here's what the WebKit code base has to say about this: @@ -5676,7 +5760,7 @@ pub const LinkerContext = struct { if (comptime bun.Environment.isDebug) { debug( - "Lookin' at file: {d}={s}", + "Looking at file: {d}={s}", .{ source_index.get(), visitor.parse_graph.input_files.items(.source)[source_index.get()].path.pretty }, ); for (visitor.visited.slice()) |idx| { @@ -5954,7 +6038,7 @@ pub const LinkerContext = struct { visits, o, record.source_index, - record.tag == .css or strings.hasSuffixComptime(record.path.text, ".css"), + record.tag == .css, ); } } @@ -8003,7 +8087,7 @@ pub const LinkerContext = struct { defer worker.unget(); switch (chunk.content) { .javascript => postProcessJSChunk(ctx, worker, chunk, chunk_index) catch |err| Output.panic("TODO: handle error: {s}", .{@errorName(err)}), - .css => postProcessCSSChunk(ctx, worker, chunk, chunk_index) catch |err| Output.panic("TODO: handle error: {s}", .{@errorName(err)}), + .css => postProcessCSSChunk(ctx, worker, chunk) catch |err| Output.panic("TODO: handle error: {s}", .{@errorName(err)}), } } @@ -8337,7 +8421,7 @@ pub const LinkerContext = struct { &buffer_writer, bun.css.PrinterOptions{ // TODO: make this more configurable - .minify = c.options.minify_whitespace or c.options.minify_syntax or c.options.minify_identifiers, + .minify = c.options.minify_whitespace, }, &import_records, ) catch { @@ -8597,8 +8681,7 @@ pub const LinkerContext = struct { } // This runs after we've already populated the compile results - fn postProcessCSSChunk(ctx: GenerateChunkCtx, worker: *ThreadPool.Worker, chunk: *Chunk, chunk_index: usize) !void { - _ = chunk_index; // autofix + fn postProcessCSSChunk(ctx: GenerateChunkCtx, worker: *ThreadPool.Worker, chunk: *Chunk) !void { const c = ctx.c; var j = StringJoiner{ .allocator = worker.allocator, @@ -8622,7 +8705,7 @@ pub const LinkerContext = struct { // TODO: (this is where we would put the imports) // Generate any prefix rules now // (THIS SHOULD BE SET WHEN GENERATING PREFIX RULES!) - newline_before_comment = true; + // newline_before_comment = true; // TODO: meta @@ -10657,47 +10740,53 @@ pub const LinkerContext = struct { // are not handled here because some of those generate // new local variables (it is too late to do that here). const record = ast.import_records.at(st.import_record_index); - const path = if (record.source_index.isValid()) - c.parse_graph.input_files.items(.source)[record.source_index.get()].path - else - record.path; - const is_builtin = record.tag == .builtin or record.tag == .bun_test or record.tag == .bun; const is_bare_import = st.star_name_loc == null and st.items.len == 0 and st.default_name == null; - const key_expr = Expr.init(E.String, .{ - .data = path.pretty, - }, stmt.loc); - // module.importSync('path', (module) => ns = module) - const call = Expr.init(E.Call, .{ - .target = Expr.init(E.Dot, .{ - .target = module_id, - .name = if (is_builtin) "importBuiltin" else "importSync", - .name_loc = stmt.loc, - }, stmt.loc), - .args = js_ast.ExprNodeList.init( - try allocator.dupe(Expr, if (is_bare_import or is_builtin) - &.{key_expr} - else - &.{ - key_expr, - Expr.init(E.Arrow, .{ - .args = receiver_args, - .body = .{ - .stmts = try allocator.dupe(Stmt, &.{Stmt.alloc(S.Return, .{ - .value = Expr.assign( - Expr.initIdentifier(st.namespace_ref, st.star_name_loc orelse stmt.loc), - module_id, - ), - }, stmt.loc)}), - .loc = stmt.loc, - }, - .prefer_expr = true, - }, stmt.loc), - }), - ), - }, stmt.loc); + const call = if (record.tag != .css) call: { + const path = if (record.source_index.isValid()) + c.parse_graph.input_files.items(.source)[record.source_index.get()].path + else + record.path; + + const is_builtin = record.tag == .builtin or record.tag == .bun_test or record.tag == .bun; + + const key_expr = Expr.init(E.String, .{ + .data = path.pretty, + }, stmt.loc); + + break :call Expr.init(E.Call, .{ + .target = Expr.init(E.Dot, .{ + .target = module_id, + .name = if (is_builtin) "importBuiltin" else "importSync", + .name_loc = stmt.loc, + }, stmt.loc), + .args = js_ast.ExprNodeList.init( + try allocator.dupe(Expr, if (is_bare_import or is_builtin) + &.{key_expr} + else + &.{ + key_expr, + Expr.init(E.Arrow, .{ + .args = receiver_args, + .body = .{ + .stmts = try allocator.dupe(Stmt, &.{Stmt.alloc(S.Return, .{ + .value = Expr.assign( + Expr.initIdentifier(st.namespace_ref, st.star_name_loc orelse stmt.loc), + module_id, + ), + }, stmt.loc)}), + .loc = stmt.loc, + }, + .prefer_expr = true, + }, stmt.loc), + }), + ), + }, stmt.loc); + } else ( + // CSS files just get an empty object + Expr.init(E.Object, .{}, stmt.loc)); if (is_bare_import) { // the import value is never read @@ -11612,27 +11701,36 @@ pub const LinkerContext = struct { c.source_maps.quoted_contents_tasks.len = 0; } - // When bake.DevServer is in use, we're going to take a different code path at the end. - // We want to extract the source code of each part instead of combining it into a single file. - // This is so that when hot-module updates happen, we can: - // - // - Reuse unchanged parts to assemble the full bundle if Cmd+R is used in the browser - // - Send only the newly changed code through a socket. - // - // When this isnt the initial bundle, concatenation as usual would produce a - // broken module. It is DevServer's job to create and send HMR patches. - if (is_dev_server) return; - - { - debug(" START {d} postprocess chunks", .{chunks.len}); - defer debug(" DONE {d} postprocess chunks", .{chunks.len}); + // For dev server, only post-process CSS chunks. + const chunks_to_do = if (is_dev_server) chunks[1..] else chunks; + if (!is_dev_server or chunks_to_do.len > 0) { + bun.assert(chunks_to_do.len > 0); + debug(" START {d} postprocess chunks", .{chunks_to_do.len}); + defer debug(" DONE {d} postprocess chunks", .{chunks_to_do.len}); wait_group.init(); - wait_group.counter = @as(u32, @truncate(chunks.len)); - - try c.parse_graph.pool.pool.doPtr(c.allocator, wait_group, chunk_contexts[0], generateChunk, chunks); + wait_group.counter = @as(u32, @truncate(chunks_to_do.len)); + + try c.parse_graph.pool.pool.doPtr( + c.allocator, + wait_group, + chunk_contexts[0], + generateChunk, + chunks_to_do, + ); } } + // When bake.DevServer is in use, we're going to take a different code path at the end. + // We want to extract the source code of each part instead of combining it into a single file. + // This is so that when hot-module updates happen, we can: + // + // - Reuse unchanged parts to assemble the full bundle if Cmd+R is used in the browser + // - Send only the newly changed code through a socket. + // + // When this isnt the initial bundle, concatenation as usual would produce a + // broken module. It is DevServer's job to create and send HMR patches. + if (is_dev_server) return; + // TODO: enforceNoCyclicChunkImports() { var path_names_map = bun.StringHashMap(void).init(c.allocator); @@ -14098,6 +14196,9 @@ pub const Chunk = struct { pub const CssChunk = struct { imports_in_chunk_in_order: BabyList(CssImportOrder), + /// When creating a chunk, this is to be an uninitialized slice with + /// length of `imports_in_chunk_in_order` + /// /// Multiple imports may refer to the same file/stylesheet, but may need to /// wrap them in conditions (e.g. a layer). /// @@ -14715,3 +14816,25 @@ pub const AstBuilder = struct { return p.newExpr(E.Dot{ .name = "exports", .name_loc = loc, .target = p.newExpr(E.Identifier{ .ref = p.module_ref }) }); } }; + +/// The lifetime of output pointers is tied to the bundler's arena +pub const BakeBundleOutput = struct { + chunks: []Chunk, + css_file_list: struct { + indexes: []const Index, + metas: []const CssEntryPointMeta, + }, + + pub const CssEntryPointMeta = struct { + /// When this is true, a stub file is added to the Server's IncrementalGraph + imported_on_server: bool, + }; + + pub fn jsPseudoChunk(out: BakeBundleOutput) *Chunk { + return &out.chunks[0]; + } + + pub fn cssChunks(out: BakeBundleOutput) []Chunk { + return out.chunks[1..]; + } +}; diff --git a/src/codegen/bake-codegen.ts b/src/codegen/bake-codegen.ts index 381d3813a000c..9d1fe13e008ff 100644 --- a/src/codegen/bake-codegen.ts +++ b/src/codegen/bake-codegen.ts @@ -1,5 +1,5 @@ import assert from "node:assert"; -import { existsSync, writeFileSync, rmSync } from "node:fs"; +import { existsSync, writeFileSync, rmSync, readFileSync } from "node:fs"; import { watch } from "node:fs/promises"; import { basename, join } from "node:path"; @@ -25,134 +25,150 @@ if (debug === "false" || debug === "0" || debug == "OFF") debug = false; const base_dir = join(import.meta.dirname, "../bake"); process.chdir(base_dir); // to make bun build predictable in development -async function run(){ - -const results = await Promise.allSettled( - ["client", "server", "error"].map(async file => { - const side = file === 'error' ? 'client' : file; - let result = await Bun.build({ - entrypoints: [join(base_dir, `hmr-runtime-${file}.ts`)], - define: { - side: JSON.stringify(side), - IS_BUN_DEVELOPMENT: String(!!debug), - }, - minify: { - syntax: true, - }, - }); - if (!result.success) throw new AggregateError(result.logs); - assert(result.outputs.length === 1, "must bundle to a single file"); - // @ts-ignore - let code = await result.outputs[0].text(); - - // A second pass is used to convert global variables into parameters, while - // allowing for renaming to properly function when minification is enabled. - const in_names = [ - file !== 'error' && 'input_graph', - file !== 'error' && 'config', - file === 'server' && 'server_exports' - ].filter(Boolean); - const combined_source = file === 'error' ? code : ` - __marker__; - ${in_names.length > 0 ? 'let' : ''} ${in_names.join(",")}; - __marker__(${in_names.join(",")}); - ${code}; - `; - const generated_entrypoint = join(base_dir, `.runtime-${file}.generated.ts`); - - writeFileSync(generated_entrypoint, combined_source); - - result = await Bun.build({ - entrypoints: [generated_entrypoint], - minify: { - syntax: true, - whitespace: !debug, - identifiers: !debug, - }, - }); - if (!result.success) throw new AggregateError(result.logs); - assert(result.outputs.length === 1, "must bundle to a single file"); - code = (await result.outputs[0].text()).replace(`// ${basename(generated_entrypoint)}`, "").trim(); - - rmSync(generated_entrypoint); - - if(file !== 'error') { - let names: string = ""; - code = code - .replace(/(\n?)\s*__marker__.*__marker__\((.+?)\);\s*/s, (_, n, captured) => { - names = captured; - return n; - }) - .trim(); - assert(names, "missing name"); - - if (debug) { - code = "\n " + code.replace(/\n/g, "\n ") + "\n"; - } - - if (code[code.length - 1] === ";") code = code.slice(0, -1); +function convertZigEnum(zig: string) { + const startTrigger = '\npub const MessageId = enum(u8) {'; + const start = zig.indexOf(startTrigger) + startTrigger.length; + const endTrigger = /\n pub fn |\n};/g; + const end = zig.slice(start).search(endTrigger) + start; + const enumText = zig.slice(start, end); + const values = enumText.replaceAll('\n ', '\n ').replace(/\n\s*(\w+)\s*=\s*'(.+?)',/g, (_, name, value) => { + return `\n ${name} = ${value.charCodeAt(0)},`; + }); + return `/** Generated from DevServer.zig */\nexport const enum MessageId {${values}}`; +} - if (side === "server") { - const server_fetch_function = names.split(",")[2].trim(); - code = debug ? `${code} return ${server_fetch_function};\n` : `${code};return ${server_fetch_function};`; +async function run() { + const devServerZig = readFileSync(join(base_dir, "DevServer.zig"), "utf-8"); + writeFileSync(join(base_dir, "generated.ts"), convertZigEnum(devServerZig)); + + const results = await Promise.allSettled( + ["client", "server", "error"].map(async file => { + const side = file === "error" ? "client" : file; + let result = await Bun.build({ + entrypoints: [join(base_dir, `hmr-runtime-${file}.ts`)], + define: { + side: JSON.stringify(side), + IS_BUN_DEVELOPMENT: String(!!debug), + }, + minify: { + syntax: true, + }, + }); + if (!result.success) throw new AggregateError(result.logs); + assert(result.outputs.length === 1, "must bundle to a single file"); + // @ts-ignore + let code = await result.outputs[0].text(); + + // A second pass is used to convert global variables into parameters, while + // allowing for renaming to properly function when minification is enabled. + const in_names = [ + file !== "error" && "input_graph", + file !== "error" && "config", + file === "server" && "server_exports", + ].filter(Boolean); + const combined_source = + file === "error" + ? code + : ` + __marker__; + ${in_names.length > 0 ? "let" : ""} ${in_names.join(",")}; + __marker__(${in_names.join(",")}); + ${code}; + `; + const generated_entrypoint = join(base_dir, `.runtime-${file}.generated.ts`); + + writeFileSync(generated_entrypoint, combined_source); + + result = await Bun.build({ + entrypoints: [generated_entrypoint], + minify: { + syntax: true, + whitespace: !debug, + identifiers: !debug, + }, + }); + if (!result.success) throw new AggregateError(result.logs); + assert(result.outputs.length === 1, "must bundle to a single file"); + code = (await result.outputs[0].text()).replace(`// ${basename(generated_entrypoint)}`, "").trim(); + + rmSync(generated_entrypoint); + + if (file !== "error") { + let names: string = ""; + code = code + .replace(/(\n?)\s*__marker__.*__marker__\((.+?)\);\s*/s, (_, n, captured) => { + names = captured; + return n; + }) + .trim(); + assert(names, "missing name"); + + if (debug) { + code = "\n " + code.replace(/\n/g, "\n ") + "\n"; + } + + if (code[code.length - 1] === ";") code = code.slice(0, -1); + + if (side === "server") { + const server_fetch_function = names.split(",")[2].trim(); + code = debug ? `${code} return ${server_fetch_function};\n` : `${code};return ${server_fetch_function};`; + } + + code = debug ? `((${names}) => {${code}})({\n` : `((${names})=>{${code}})({`; + + if (side === "server") { + code = `export default await ${code}`; + } } - code = debug ? `((${names}) => {${code}})({\n` : `((${names})=>{${code}})({`; + writeFileSync(join(codegen_root, `bake.${file}.js`), code); + }), + ); - if (side === "server") { - code = `export default await ${code}`; + // print failures in a de-duplicated fashion. + interface Err { + kind: ("client" | "server" | "error")[]; + err: any; + } + const failed = [ + { kind: ["client"], result: results[0] }, + { kind: ["server"], result: results[1] }, + { kind: ["error"], result: results[2] }, + ] + .filter(x => x.result.status === "rejected") + .map(x => ({ kind: x.kind, err: x.result.reason })) as Err[]; + if (failed.length > 0) { + const flattened_errors: Err[] = []; + for (const { kind, err } of failed) { + if (err instanceof AggregateError) { + flattened_errors.push(...err.errors.map(err => ({ kind, err }))); } + flattened_errors.push({ kind, err }); } - - writeFileSync(join(codegen_root, `bake.${file}.js`), code); - }), -); - -// print failures in a de-duplicated fashion. -interface Err { - kind: ("client" | "server" | "error")[]; - err: any; -} -const failed = [ - { kind: ["client"], result: results[0] }, - { kind: ["server"], result: results[1] }, - { kind: ["error"], result: results[2] }, -] - .filter(x => x.result.status === "rejected") - .map(x => ({ kind: x.kind, err: x.result.reason })) as Err[]; -if (failed.length > 0) { - const flattened_errors: Err[] = []; - for (const { kind, err } of failed) { - if (err instanceof AggregateError) { - flattened_errors.push(...err.errors.map(err => ({ kind, err }))); - } - flattened_errors.push({ kind, err }); - } - for (let i = 0; i < flattened_errors.length; i++) { - const x = flattened_errors[i]; - if (!x.err?.message) continue; - for (const other of flattened_errors.slice(0, i)) { - if (other.err?.message === x.err.message || other.err.stack === x.err.stack) { - other.kind = [...x.kind, ...other.kind]; - flattened_errors.splice(i, 1); - i -= 1; - continue; + for (let i = 0; i < flattened_errors.length; i++) { + const x = flattened_errors[i]; + if (!x.err?.message) continue; + for (const other of flattened_errors.slice(0, i)) { + if (other.err?.message === x.err.message || other.err.stack === x.err.stack) { + other.kind = [...x.kind, ...other.kind]; + flattened_errors.splice(i, 1); + i -= 1; + continue; + } } } - } - for (const { kind, err } of flattened_errors) { - const map = { error: "error runtime", client: "client runtime", server: "server runtime" }; - console.error(`Errors while bundling Bake ${kind.map(x=>map[x]).join(' and ')}:`); - console.error(err); - } - if(!live) - process.exit(1); -} else { - console.log("-> bake.client.js, bake.server.js, bake.error.js"); + for (const { kind, err } of flattened_errors) { + const map = { error: "error runtime", client: "client runtime", server: "server runtime" }; + console.error(`Errors while bundling Bake ${kind.map(x => map[x]).join(" and ")}:`); + console.error(err); + } + if (!live) process.exit(1); + } else { + console.log("-> bake.client.js, bake.server.js, bake.error.js"); - const empty_file = join(codegen_root, "bake_empty_file"); - if (!existsSync(empty_file)) writeFileSync(empty_file, "this is used to fulfill a cmake dependency"); -} + const empty_file = join(codegen_root, "bake_empty_file"); + if (!existsSync(empty_file)) writeFileSync(empty_file, "this is used to fulfill a cmake dependency"); + } } await run(); @@ -160,12 +176,12 @@ await run(); if (live) { const watcher = watch(base_dir, { recursive: true }) as any; for await (const event of watcher) { - if(event.filename.endsWith('.zig')) continue; - if(event.filename.startsWith('.')) continue; + if (event.filename.endsWith(".zig")) continue; + if (event.filename.startsWith(".")) continue; try { await run(); - }catch(e) { + } catch (e) { console.log(e); } } -} \ No newline at end of file +} diff --git a/src/js_ast.zig b/src/js_ast.zig index 2c325f6d1c3ee..cacd8e5112970 100644 --- a/src/js_ast.zig +++ b/src/js_ast.zig @@ -9028,18 +9028,4 @@ const ToJSError = error{ OutOfMemory, }; -fn assertNoPointers(T: type) void { - switch (@typeInfo(T)) { - .Pointer => @compileError("no pointers!"), - .Struct => |s| for (s.fields) |field| { - assertNoPointers(field.type); - }, - .Array => |a| assertNoPointers(a.child), - else => {}, - } -} - -inline fn writeAnyToHasher(hasher: anytype, thing: anytype) void { - comptime assertNoPointers(@TypeOf(thing)); // catch silly mistakes - hasher.update(std.mem.asBytes(&thing)); -} +const writeAnyToHasher = bun.writeAnyToHasher;