diff --git a/cmake/sources/ZigSources.txt b/cmake/sources/ZigSources.txt index 87798dd5e0a3..6700c9b7f6e8 100644 --- a/cmake/sources/ZigSources.txt +++ b/cmake/sources/ZigSources.txt @@ -304,12 +304,14 @@ src/bundler/linker_context/generateCodeForLazyExport.zig src/bundler/linker_context/generateCompileResultForCssChunk.zig src/bundler/linker_context/generateCompileResultForHtmlChunk.zig src/bundler/linker_context/generateCompileResultForJSChunk.zig +src/bundler/linker_context/OutputFileListBuilder.zig src/bundler/linker_context/postProcessCSSChunk.zig src/bundler/linker_context/postProcessHTMLChunk.zig src/bundler/linker_context/postProcessJSChunk.zig src/bundler/linker_context/prepareCssAstsForChunk.zig src/bundler/linker_context/renameSymbolsInChunk.zig src/bundler/linker_context/scanImportsAndExports.zig +src/bundler/linker_context/StaticRouteVisitor.zig src/bundler/linker_context/writeOutputFilesToDisk.zig src/bundler/LinkerContext.zig src/bundler/LinkerGraph.zig diff --git a/src/OutputFile.zig b/src/OutputFile.zig index 1b58c6ef24a7..bb01b0936ba0 100644 --- a/src/OutputFile.zig +++ b/src/OutputFile.zig @@ -20,8 +20,24 @@ side: ?bun.bake.Side, /// This is only set for the JS bundle, and not files associated with an /// entrypoint like sourcemaps and bytecode entry_point_index: ?u32, -referenced_css_files: []const Index = &.{}, +referenced_css_chunks: []const Index = &.{}, source_index: Index.Optional = .none, +bake_extra: BakeExtra = .{}, + +pub const zero_value = OutputFile{ + .loader = .file, + .src_path = Fs.Path.init(""), + .value = .noop, + .output_kind = .chunk, + .side = null, + .entry_point_index = null, +}; + +pub const BakeExtra = struct { + is_route: bool = false, + fully_static: bool = false, + bake_is_runtime: bool = false, +}; pub const Index = bun.GenericIndex(u32, OutputFile); @@ -30,7 +46,7 @@ pub fn deinit(this: *OutputFile) void { bun.default_allocator.free(this.src_path.text); bun.default_allocator.free(this.dest_path); - bun.default_allocator.free(this.referenced_css_files); + bun.default_allocator.free(this.referenced_css_chunks); } // Depending on: @@ -99,6 +115,13 @@ pub const Value = union(Kind) { } } + pub fn asSlice(v: Value) []const u8 { + return switch (v) { + .buffer => |buf| buf.bytes, + else => "", + }; + } + pub fn toBunString(v: Value) bun.String { return switch (v) { .noop => bun.String.empty, @@ -206,7 +229,8 @@ pub const Options = struct { }, side: ?bun.bake.Side, entry_point_index: ?u32, - referenced_css_files: []const Index = &.{}, + referenced_css_chunks: []const Index = &.{}, + bake_extra: BakeExtra = .{}, }; pub fn init(options: Options) OutputFile { @@ -240,7 +264,8 @@ pub fn init(options: Options) OutputFile { }, .side = options.side, .entry_point_index = options.entry_point_index, - .referenced_css_files = options.referenced_css_files, + .referenced_css_chunks = options.referenced_css_chunks, + .bake_extra = options.bake_extra, }; } diff --git a/src/api/schema.zig b/src/api/schema.zig index 554fb34cb51c..5b90763aed23 100644 --- a/src/api/schema.zig +++ b/src/api/schema.zig @@ -1956,6 +1956,27 @@ pub const Api = struct { _, + pub fn fromJS(global: *bun.JSC.JSGlobalObject, value: bun.JSC.JSValue) bun.JSError!?SourceMapMode { + if (value.isString()) { + const str = try value.toSliceOrNull(global); + defer str.deinit(); + const utf8 = str.slice(); + if (bun.strings.eqlComptime(utf8, "none")) { + return .none; + } + if (bun.strings.eqlComptime(utf8, "inline")) { + return .@"inline"; + } + if (bun.strings.eqlComptime(utf8, "external")) { + return .external; + } + if (bun.strings.eqlComptime(utf8, "linked")) { + return .linked; + } + } + return null; + } + pub fn jsonStringify(self: @This(), writer: anytype) !void { return try writer.write(@tagName(self)); } diff --git a/src/bake/BakeGlobalObject.cpp b/src/bake/BakeGlobalObject.cpp index f7ce8b326e65..c48f2af84e97 100644 --- a/src/bake/BakeGlobalObject.cpp +++ b/src/bake/BakeGlobalObject.cpp @@ -9,6 +9,7 @@ #include "JavaScriptCore/JSSourceCode.h" extern "C" BunString BakeProdResolve(JSC::JSGlobalObject*, BunString a, BunString b); +extern "C" BunString BakeToWindowsPath(BunString a); namespace Bake { using namespace JSC; @@ -107,7 +108,18 @@ static JSC::JSInternalPromise* resolvedInternalPromise(JSC::JSGlobalObject* glob return promise; } -extern "C" BunString BakeProdLoad(ProductionPerThread* perThreadData, BunString a); +extern "C" BunString BakeProdLoad(void* perThreadData, BunString a); + +extern "C" bool BakeGlobalObject__isBakeGlobalObject(JSC::JSGlobalObject* global) +{ + return global->JSCell::inherits(Bake::GlobalObject::info()); +} + +extern "C" void* BakeGlobalObject__getPerThreadData(JSC::JSGlobalObject* global) +{ + Bake::GlobalObject* bake = jsCast(global); + return bake->m_perThreadData; +} JSC::JSInternalPromise* bakeModuleLoaderFetch(JSC::JSGlobalObject* globalObject, JSC::JSModuleLoader* loader, JSC::JSValue key, @@ -134,12 +146,33 @@ JSC::JSInternalPromise* bakeModuleLoaderFetch(JSC::JSGlobalObject* globalObject, JSC::SourceProviderSourceType::Module)); return resolvedInternalPromise(globalObject, JSC::JSSourceCode::create(vm, WTFMove(sourceCode))); } - return rejectedInternalPromise(globalObject, createTypeError(globalObject, makeString("Bundle does not have \""_s, moduleKey, "\". This is a bug in Bun's bundler."_s))); + + // We unconditionally prefix the key with "bake:" inside + // BakeProdResolve in production.zig. + // + // But if someone does: `await import(resolve(import.meta.dir, "nav.ts"))` + // we don't actually want to load it from the Bake production module + // map and instead make it go through the normal codepath. + auto bakePrefixRemoved = moduleKey.substringSharingImpl("bake:"_s.length()); + +#ifdef _WIN32 + // We normalize paths to contain forward slashes in bake so we don't + // have to worry about platform paths. Now we have to worry about + // it, because `moduleLoaderFetch(...)` may read the path from disk + // and so we need to give a Windows path to it. + auto temp = BakeToWindowsPath(Bun::toString(bakePrefixRemoved)); + bakePrefixRemoved = temp.toWTFString(); +#endif + JSString* bakePrefixRemovedString = jsNontrivialString(vm, bakePrefixRemoved); + JSValue bakePrefixRemovedJsvalue = bakePrefixRemovedString; + return Zig::GlobalObject::moduleLoaderFetch(globalObject, loader, bakePrefixRemovedJsvalue, parameters, script); } return rejectedInternalPromise(globalObject, createTypeError(globalObject, "BakeGlobalObject does not have per-thread data configured"_s)); } - return Zig::GlobalObject::moduleLoaderFetch(globalObject, loader, key, parameters, script); + auto result = Zig::GlobalObject::moduleLoaderFetch(globalObject, loader, key, parameters, script); + RETURN_IF_EXCEPTION(scope, rejectedInternalPromise(globalObject, scope.exception()->value())); + return result; } GlobalObject* GlobalObject::create(JSC::VM& vm, JSC::Structure* structure, @@ -205,7 +238,16 @@ const JSC::GlobalObjectMethodTable& GlobalObject::globalObjectMethodTable() // TODO: remove this entire method extern "C" GlobalObject* BakeCreateProdGlobal(void* console) { - JSC::VM& vm = JSC::VM::create(JSC::HeapType::Large).leakRef(); + RefPtr vmPtr = JSC::VM::tryCreate(JSC::HeapType::Large); + if (!vmPtr) [[unlikely]] { + BUN_PANIC("Failed to allocate JavaScriptCore Virtual Machine. Did your computer run out of memory? Or maybe you compiled Bun with a mismatching libc++ version or compiler?"); + } + // We need to unsafely ref this so it stays alive, later in + // `Zig__GlobalObject__destructOnExit` will call + // `vm.derefSuppressingSaferCPPChecking()` to free it. + vmPtr->refSuppressingSaferCPPChecking(); + JSC::VM& vm = *vmPtr; + vm.heap.acquireAccess(); JSC::JSLockHolder locker(vm); BunVirtualMachine* bunVM = Bun__getVM(); @@ -225,6 +267,11 @@ extern "C" GlobalObject* BakeCreateProdGlobal(void* console) global->setStackTraceLimit(10); // Node.js defaults to 10 global->isThreadLocalDefaultGlobalObject = true; + // if (shouldDisableStopIfNecessaryTimer) { + vm.heap.disableStopIfNecessaryTimer(); + // } + + // if you process.nextTick on a microtask we need thsi // TODO: it segfaults! process.nextTick is scoped out for now i guess! // vm.setOnComputeErrorInfo(computeErrorInfoWrapper); // vm.setOnEachMicrotaskTick([global](JSC::VM &vm) -> void { @@ -240,7 +287,7 @@ extern "C" GlobalObject* BakeCreateProdGlobal(void* console) return global; } -extern "C" void BakeGlobalObject__attachPerThreadData(GlobalObject* global, ProductionPerThread* perThreadData) +extern "C" void BakeGlobalObject__attachPerThreadData(GlobalObject* global, void* perThreadData) { global->m_perThreadData = perThreadData; } diff --git a/src/bake/BakeGlobalObject.h b/src/bake/BakeGlobalObject.h index a308fe64c60c..58e4d3f2753d 100644 --- a/src/bake/BakeGlobalObject.h +++ b/src/bake/BakeGlobalObject.h @@ -4,13 +4,11 @@ namespace Bake { -struct ProductionPerThread; - class GlobalObject : public Zig::GlobalObject { public: using Base = Zig::GlobalObject; - ProductionPerThread* m_perThreadData = nullptr; + void* m_perThreadData = nullptr; DECLARE_INFO; template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) @@ -39,4 +37,7 @@ class GlobalObject : public Zig::GlobalObject { } }; +extern "C" void* BakeGlobalObject__getPerThreadData(JSC::JSGlobalObject* global); +extern "C" void BakeGlobalObject__attachPerThreadData(GlobalObject* global, void* perThreadData); + }; // namespace Kit diff --git a/src/bake/BakeSourceProvider.cpp b/src/bake/BakeSourceProvider.cpp index 2a70ceae05fb..4534ddd3e82d 100644 --- a/src/bake/BakeSourceProvider.cpp +++ b/src/bake/BakeSourceProvider.cpp @@ -22,7 +22,7 @@ extern "C" BunString BakeSourceProvider__getSourceSlice(SourceProvider* provider return Bun::toStringView(provider->source()); } -extern "C" JSC::EncodedJSValue BakeLoadInitialServerCode(GlobalObject* global, BunString source, bool separateSSRGraph) { +extern "C" JSC::EncodedJSValue BakeLoadInitialServerCode(JSC::JSGlobalObject* global, BunString source, bool separateSSRGraph) { auto& vm = JSC::getVM(global); auto scope = DECLARE_THROW_SCOPE(vm); diff --git a/src/bake/FrameworkRouter.zig b/src/bake/FrameworkRouter.zig index 097b8b729fd7..e183b91983f5 100644 --- a/src/bake/FrameworkRouter.zig +++ b/src/bake/FrameworkRouter.zig @@ -287,6 +287,11 @@ pub const EncodedPattern = struct { }, .param => |name| { const end = strings.indexOfCharPos(path, '/', i) orelse path.len; + // Check if we're about to exceed the maximum number of parameters + if (param_num >= MatchedParams.max_count) { + // TODO: ideally we should throw a nice user message + bun.Output.panic("Route pattern matched more than {d} parameters. Path: {s}", .{ MatchedParams.max_count, path }); + } params.params.len = @intCast(param_num + 1); params.params.buffer[param_num] = .{ .key = name, @@ -295,8 +300,30 @@ pub const EncodedPattern = struct { param_num += 1; i = if (end == path.len) end else end + 1; }, - .catch_all_optional => return true, - .catch_all => break, + .catch_all_optional, .catch_all => |name| { + // Capture remaining path segments as individual parameters + if (i < path.len) { + var segment_start = i; + while (segment_start < path.len) { + const segment_end = strings.indexOfCharPos(path, '/', segment_start) orelse path.len; + if (segment_start < segment_end) { + // Check if we're about to exceed the maximum number of parameters + if (param_num >= MatchedParams.max_count) { + // TODO: ideally we should throw a nice user message + bun.Output.panic("Route pattern matched more than {d} parameters. Path: {s}", .{ MatchedParams.max_count, path }); + } + params.params.len = @intCast(param_num + 1); + params.params.buffer[param_num] = .{ + .key = name, + .value = path[segment_start..segment_end], + }; + param_num += 1; + } + segment_start = if (segment_end == path.len) segment_end else segment_end + 1; + } + } + return true; + }, .group => continue, } } diff --git a/src/bake/bake.d.ts b/src/bake/bake.d.ts index af40b0cd7fb6..06189b6bd3da 100644 --- a/src/bake/bake.d.ts +++ b/src/bake/bake.d.ts @@ -418,9 +418,9 @@ declare module "bun" { } type GetParamIterator = - | AsyncIterable, GetParamsFinalOpts> - | Iterable, GetParamsFinalOpts> - | ({ pages: Array> } & GetParamsFinalOpts); + | AsyncIterable, GetParamsFinalOpts> + | Iterable, GetParamsFinalOpts> + | ({ pages: Array> } & GetParamsFinalOpts); type GetParamsFinalOpts = void | null | { /** @@ -478,7 +478,7 @@ declare module "bun" { */ readonly layouts: ReadonlyArray; /** Received route params. `null` if the route does not take params */ - readonly params: null | Record; + readonly params: null | Record; /** * A list of js files that the route will need to be interactive. */ diff --git a/src/bake/bake.zig b/src/bake/bake.zig index 4e82d397ecde..eedf981572ff 100644 --- a/src/bake/bake.zig +++ b/src/bake/bake.zig @@ -38,6 +38,18 @@ pub const UserOptions = struct { errdefer allocations.free(); var bundler_options = SplitBundlerOptions.empty; + if (try config.getOptional(global, "bundlerOptions", JSValue)) |js_options| { + if (try js_options.getOptional(global, "server", JSValue)) |server_options| { + bundler_options.server = try BuildConfigSubset.fromJS(global, server_options); + } + if (try js_options.getOptional(global, "client", JSValue)) |client_options| { + bundler_options.client = try BuildConfigSubset.fromJS(global, client_options); + } + if (try js_options.getOptional(global, "ssr", JSValue)) |ssr_options| { + bundler_options.ssr = try BuildConfigSubset.fromJS(global, ssr_options); + } + } + const framework = try Framework.fromJS( try config.get(global, "framework") orelse { return global.throwInvalidArguments("'" ++ api_name ++ "' is missing 'framework'", .{}); @@ -155,6 +167,45 @@ const BuildConfigSubset = struct { env: bun.Schema.Api.DotEnvBehavior = ._none, env_prefix: ?[]const u8 = null, define: bun.Schema.Api.StringMap = .{ .keys = &.{}, .values = &.{} }, + source_map: bun.Schema.Api.SourceMapMode = .external, + + minify_syntax: ?bool = null, + minify_identifiers: ?bool = null, + minify_whitespace: ?bool = null, + + pub fn fromJS(global: *JSC.JSGlobalObject, js_options: JSValue) bun.JSError!BuildConfigSubset { + var options = BuildConfigSubset{}; + + if (try js_options.getOptional(global, "sourcemap", JSValue)) |val| brk: { + if (try bun.Schema.Api.SourceMapMode.fromJS(global, val)) |sourcemap| { + options.source_map = sourcemap; + break :brk; + } + + return bun.JSC.Node.validators.throwErrInvalidArgType(global, "sourcemap", .{}, "\"inline\" | \"external\" | \"linked\"", val); + } + + if (try js_options.getOptional(global, "minify", JSValue)) |minify_options| brk: { + if (minify_options.isBoolean() and minify_options.asBoolean()) { + options.minify_syntax = minify_options.asBoolean(); + options.minify_identifiers = minify_options.asBoolean(); + options.minify_whitespace = minify_options.asBoolean(); + break :brk; + } + + if (try minify_options.getBooleanLoose(global, "whitespace")) |value| { + options.minify_whitespace = value; + } + if (try minify_options.getBooleanLoose(global, "syntax")) |value| { + options.minify_syntax = value; + } + if (try minify_options.getBooleanLoose(global, "identifiers")) |value| { + options.minify_identifiers = value; + } + } + + return options; + } }; /// A "Framework" in our eyes is simply set of bundler options that a framework @@ -577,11 +628,6 @@ pub const Framework = struct { try bundler_options.parsePluginArray(plugin_array, global); } - if (try opts.getOptional(global, "bundlerOptions", JSValue)) |js_options| { - _ = js_options; // TODO: - // try bundler_options.parseInto(global, js_options, .root); - } - return framework; } @@ -603,7 +649,7 @@ pub const Framework = struct { else => .none, }; - return initTranspilerWithSourceMap( + return initTranspilerWithOptions( framework, arena, log, @@ -612,10 +658,13 @@ pub const Framework = struct { out, bundler_options, source_map, + null, + null, + null, ); } - pub fn initTranspilerWithSourceMap( + pub fn initTranspilerWithOptions( framework: *Framework, arena: std.mem.Allocator, log: *bun.logger.Log, @@ -624,6 +673,9 @@ pub const Framework = struct { out: *bun.transpiler.Transpiler, bundler_options: *const BuildConfigSubset, source_map: bun.options.SourceMapOption, + minify_whitespace: ?bool, + minify_syntax: ?bool, + minify_identifiers: ?bool, ) !void { const JSAst = bun.JSAst; @@ -688,9 +740,9 @@ pub const Framework = struct { out.options.production = mode != .development; out.options.tree_shaking = mode != .development; - out.options.minify_syntax = mode != .development; - out.options.minify_identifiers = mode != .development; - out.options.minify_whitespace = mode != .development; + out.options.minify_syntax = minify_syntax orelse (mode != .development); + out.options.minify_identifiers = minify_identifiers orelse (mode != .development); + out.options.minify_whitespace = minify_whitespace orelse (mode != .development); out.options.css_chunking = true; out.options.framework = framework; out.options.inline_entrypoint_import_meta_main = true; diff --git a/src/bake/production.zig b/src/bake/production.zig index e8ee00620f48..f4f029d92124 100644 --- a/src/bake/production.zig +++ b/src/bake/production.zig @@ -38,9 +38,7 @@ pub fn buildCommand(ctx: bun.CLI.Command.Context) !void { defer vm.deinit(); // A special global object is used to allow registering virtual modules // that bypass Bun's normal module resolver and plugin system. - vm.global = BakeCreateProdGlobal(vm.console); vm.regular_event_loop.global = vm.global; - vm.jsc = vm.global.vm(); vm.event_loop.ensureWaker(); const b = &vm.transpiler; vm.preload = ctx.preloads; @@ -82,7 +80,21 @@ pub fn buildCommand(ctx: bun.CLI.Command.Context) !void { const api_lock = vm.jsc.getAPILock(); defer api_lock.release(); - buildWithVm(ctx, cwd, vm) catch |err| switch (err) { + + var pt: PerThread = .{ + .input_files = &.{}, + .bundled_outputs = &.{}, + .output_indexes = &.{}, + .module_keys = &.{}, + .module_map = .{}, + .source_maps = .{}, + + .vm = vm, + .loaded_files = bun.bit_set.AutoBitSet.initEmpty(vm.allocator, 0) catch unreachable, + .all_server_files = JSValue.null, + }; + + buildWithVm(ctx, cwd, vm, &pt) catch |err| switch (err) { error.JSError => |e| { bun.handleErrorReturnTrace(err, @errorReturnTrace()); const err_value = vm.global.takeException(e); @@ -96,7 +108,34 @@ pub fn buildCommand(ctx: bun.CLI.Command.Context) !void { }; } -pub fn buildWithVm(ctx: bun.CLI.Command.Context, cwd: []const u8, vm: *VirtualMachine) !void { +pub fn writeSourcemapToDisk( + allocator: std.mem.Allocator, + file: *const OutputFile, + bundled_outputs: []const OutputFile, + source_maps: *bun.StringArrayHashMapUnmanaged(OutputFile.Index), +) !void { + // don't call this if the file does not have sourcemaps! + bun.assert(file.source_map_index != std.math.maxInt(u32)); + + // TODO: should we just write the sourcemaps to disk? + const source_map_index = file.source_map_index; + const source_map_file: *const OutputFile = &bundled_outputs[source_map_index]; + bun.assert(source_map_file.output_kind == .sourcemap); + + const without_prefix = if (bun.strings.hasPrefixComptime(file.dest_path, "./") or + (Environment.isWindows and bun.strings.hasPrefixComptime(file.dest_path, ".\\"))) + file.dest_path[2..] + else + file.dest_path; + + try source_maps.put( + allocator, + try std.fmt.allocPrint(allocator, "bake:/{s}", .{without_prefix}), + OutputFile.Index.init(@intCast(source_map_index)), + ); +} + +pub fn buildWithVm(ctx: bun.CLI.Command.Context, cwd: []const u8, vm: *VirtualMachine, pt: *PerThread) !void { // Load and evaluate the configuration module const global = vm.global; const b = &vm.transpiler; @@ -174,10 +213,10 @@ pub fn buildWithVm(ctx: bun.CLI.Command.Context, cwd: []const u8, vm: *VirtualMa var client_transpiler: bun.transpiler.Transpiler = undefined; var server_transpiler: bun.transpiler.Transpiler = undefined; var ssr_transpiler: bun.transpiler.Transpiler = undefined; - try framework.initTranspilerWithSourceMap(allocator, vm.log, .production_static, .server, &server_transpiler, &options.bundler_options.server, .@"inline"); - try framework.initTranspilerWithSourceMap(allocator, vm.log, .production_static, .client, &client_transpiler, &options.bundler_options.client, .@"inline"); + try framework.initTranspilerWithOptions(allocator, vm.log, .production_static, .server, &server_transpiler, &options.bundler_options.server, bun.options.SourceMapOption.fromApi(options.bundler_options.server.source_map), options.bundler_options.server.minify_whitespace, options.bundler_options.server.minify_syntax, options.bundler_options.server.minify_identifiers); + try framework.initTranspilerWithOptions(allocator, vm.log, .production_static, .client, &client_transpiler, &options.bundler_options.client, bun.options.SourceMapOption.fromApi(options.bundler_options.client.source_map), options.bundler_options.client.minify_whitespace, options.bundler_options.client.minify_syntax, options.bundler_options.client.minify_identifiers); if (separate_ssr_graph) { - try framework.initTranspilerWithSourceMap(allocator, vm.log, .production_static, .ssr, &ssr_transpiler, &options.bundler_options.ssr, .@"inline"); + try framework.initTranspilerWithOptions(allocator, vm.log, .production_static, .ssr, &ssr_transpiler, &options.bundler_options.ssr, bun.options.SourceMapOption.fromApi(options.bundler_options.ssr.source_map), options.bundler_options.ssr.minify_whitespace, options.bundler_options.ssr.minify_syntax, options.bundler_options.ssr.minify_identifiers); } if (ctx.bundler_options.bake_debug_disable_minify) { @@ -261,6 +300,11 @@ pub fn buildWithVm(ctx: bun.CLI.Command.Context, cwd: []const u8, vm: *VirtualMa .{ .js = vm.event_loop }, ); const bundled_outputs = bundled_outputs_list.items; + if (bundled_outputs.len == 0) { + Output.prettyln("done", .{}); + Output.flush(); + return; + } Output.prettyErrorln("Rendering routes", .{}); Output.flush(); @@ -268,6 +312,8 @@ pub fn buildWithVm(ctx: bun.CLI.Command.Context, cwd: []const u8, vm: *VirtualMa var root_dir = try std.fs.cwd().makeOpenPath("dist", .{}); defer root_dir.close(); + var maybe_runtime_file_index: ?u32 = null; + var css_chunks_count: usize = 0; var css_chunks_first: usize = 0; @@ -278,16 +324,22 @@ pub fn buildWithVm(ctx: bun.CLI.Command.Context, cwd: []const u8, vm: *VirtualMa const module_keys = try vm.allocator.alloc(bun.String, entry_points.files.count()); const output_indexes = entry_points.files.values(); var output_module_map: bun.StringArrayHashMapUnmanaged(OutputFile.Index) = .{}; + var source_maps: bun.StringArrayHashMapUnmanaged(OutputFile.Index) = .{}; @memset(module_keys, bun.String.dead); for (bundled_outputs, 0..) |file, i| { - log("{s} - {s} : {s} - {?d}\n", .{ + log("src_index={any} side={s} src={s} dest={s} - {?d}\n", .{ + file.source_index.unwrap(), if (file.side) |s| @tagName(s) else "null", file.src_path.text, file.dest_path, file.entry_point_index, }); if (file.loader.isCSS()) { - if (css_chunks_count == 0) css_chunks_first = i; + if (css_chunks_count == 0) { + css_chunks_first = i; + } else { + css_chunks_first = @min(css_chunks_first, i); + } css_chunks_count += 1; } @@ -297,6 +349,17 @@ pub fn buildWithVm(ctx: bun.CLI.Command.Context, cwd: []const u8, vm: *VirtualMa } } + // The output file which contains the runtime (Index.runtime, contains + // wrapper functions like `__esm`) is marked as server side, but it is + // also used by client + if (file.bake_extra.bake_is_runtime) { + if (comptime bun.Environment.allow_assert) { + bun.assertf(maybe_runtime_file_index == null, "Runtime file should only be in one chunk.", .{}); + } + maybe_runtime_file_index = @intCast(i); + } + + // TODO: Maybe not do all the disk-writing in 1 thread? switch (file.side orelse continue) { .client => { // Client-side resources will be written to disk for usage in on the client side @@ -306,7 +369,6 @@ pub fn buildWithVm(ctx: bun.CLI.Command.Context, cwd: []const u8, vm: *VirtualMa }; }, .server => { - // For Debugging if (ctx.bundler_options.bake_debug_dump_server) { _ = file.writeToDisk(root_dir, ".") catch |err| { bun.handleErrorReturnTrace(err, @errorReturnTrace()); @@ -314,6 +376,13 @@ pub fn buildWithVm(ctx: bun.CLI.Command.Context, cwd: []const u8, vm: *VirtualMa }; } + // If the file has a sourcemap, store it so we can put it on + // `PerThread` so we can provide sourcemapped stacktraces for + // server components. + if (file.source_map_index != std.math.maxInt(u32)) { + try writeSourcemapToDisk(allocator, &file, bundled_outputs, &source_maps); + } + switch (file.output_kind) { .@"entry-point", .chunk => { const without_prefix = if (bun.strings.hasPrefixComptime(file.dest_path, "./") or @@ -330,6 +399,8 @@ pub fn buildWithVm(ctx: bun.CLI.Command.Context, cwd: []const u8, vm: *VirtualMa } } + log(" adding module map entry: output_module_map(bake:/{s}) = {d}\n", .{ without_prefix, i }); + try output_module_map.put( allocator, try std.fmt.allocPrint(allocator, "bake:/{s}", .{without_prefix}), @@ -342,6 +413,34 @@ pub fn buildWithVm(ctx: bun.CLI.Command.Context, cwd: []const u8, vm: *VirtualMa } }, } + + // TODO: should we just write the sourcemaps to disk? + if (file.source_map_index != std.math.maxInt(u32)) { + try writeSourcemapToDisk(allocator, &file, bundled_outputs, &source_maps); + } + } + // Write the runtime file to disk if there are any client chunks + { + const runtime_file_index = maybe_runtime_file_index orelse { + bun.Output.panic("Runtime file not found. This is an unexpected bug in Bun. Please file a bug report on GitHub.", .{}); + }; + const any_client_chunks = any_client_chunks: { + for (bundled_outputs) |file| { + if (file.side) |s| { + if (s == .client and !bun.strings.eqlComptime(file.src_path.text, "bun-framework-react/client.tsx")) { + break :any_client_chunks true; + } + } + } + break :any_client_chunks false; + }; + if (any_client_chunks) { + const runtime_file: *const OutputFile = &bundled_outputs[runtime_file_index]; + _ = runtime_file.writeToDisk(root_dir, ".") catch |err| { + bun.handleErrorReturnTrace(err, @errorReturnTrace()); + Output.err(err, "Failed to write {} to output directory", .{bun.fmt.quote(runtime_file.dest_path)}); + }; + } } const per_thread_options: PerThread.Options = .{ @@ -350,9 +449,10 @@ pub fn buildWithVm(ctx: bun.CLI.Command.Context, cwd: []const u8, vm: *VirtualMa .output_indexes = output_indexes, .module_keys = module_keys, .module_map = output_module_map, + .source_maps = source_maps, }; - var pt = try PerThread.init(vm, per_thread_options); + pt.* = try PerThread.init(vm, per_thread_options); pt.attach(); // Static site generator @@ -417,15 +517,33 @@ pub fn buildWithVm(ctx: bun.CLI.Command.Context, cwd: []const u8, vm: *VirtualMa const css_chunk_js_strings = try allocator.alloc(JSValue, css_chunks_count); for (bundled_outputs[css_chunks_first..][0..css_chunks_count], css_chunk_js_strings) |output_file, *str| { bun.assert(output_file.dest_path[0] != '.'); + // CSS chunks must be in contiguous order!! bun.assert(output_file.loader.isCSS()); str.* = (try bun.String.createFormat("{s}{s}", .{ public_path, output_file.dest_path })).toJS(global); } + // Route URL patterns with parameter placeholders. + // Examples: "/", "/about", "/blog/:slug", "/products/:category/:id" const route_patterns = try JSValue.createEmptyArray(global, navigatable_routes.items.len); + + // File indices for each route's components (page, layouts). + // Example: [2, 5, 0] = page at index 2, layout at 5, root layout at 0 const route_nested_files = try JSValue.createEmptyArray(global, navigatable_routes.items.len); + + // Router type index (lower 8 bits) and flags (upper 24 bits). + // Example: 0x00000001 = router type 1, no flags const route_type_and_flags = try JSValue.createEmptyArray(global, navigatable_routes.items.len); + + // Source file paths relative to project root. + // Examples: "pages/index.tsx", "pages/blog/[slug].tsx" const route_source_files = try JSValue.createEmptyArray(global, navigatable_routes.items.len); + + // Parameter names for dynamic routes (reversed order), null for static routes. + // Examples: ["slug"] for /blog/[slug], ["id", "category"] for /products/[category]/[id] const route_param_info = try JSValue.createEmptyArray(global, navigatable_routes.items.len); + + // CSS chunk URLs for each route. + // Example: ["/assets/main.css", "/assets/blog.css"] const route_style_references = try JSValue.createEmptyArray(global, navigatable_routes.items.len); var params_buf: std.ArrayListUnmanaged([]const u8) = .{}; @@ -444,15 +562,18 @@ pub fn buildWithVm(ctx: bun.CLI.Command.Context, cwd: []const u8, vm: *VirtualMa .param => { params_buf.append(ctx.allocator, route.part.param) catch unreachable; }, - .catch_all, .catch_all_optional => { + .catch_all => { + params_buf.append(ctx.allocator, route.part.catch_all) catch unreachable; + }, + .catch_all_optional => { return global.throw("catch-all routes are not supported in static site generation", .{}); }, else => {}, } var file_count: u32 = 1; - var css_file_count: u32 = @intCast(main_file.referenced_css_files.len); + var css_file_count: u32 = @intCast(main_file.referenced_css_chunks.len); if (route.file_layout.unwrap()) |file| { - css_file_count += @intCast(pt.outputFile(file).referenced_css_files.len); + css_file_count += @intCast(pt.outputFile(file).referenced_css_chunks.len); file_count += 1; } var next: ?FrameworkRouter.Route.Index = route.parent.unwrap(); @@ -463,13 +584,16 @@ pub fn buildWithVm(ctx: bun.CLI.Command.Context, cwd: []const u8, vm: *VirtualMa .param => { params_buf.append(ctx.allocator, parent.part.param) catch unreachable; }, - .catch_all, .catch_all_optional => { + .catch_all => { + params_buf.append(ctx.allocator, parent.part.catch_all) catch unreachable; + }, + .catch_all_optional => { return global.throw("catch-all routes are not supported in static site generation", .{}); }, else => {}, } if (parent.file_layout.unwrap()) |file| { - css_file_count += @intCast(pt.outputFile(file).referenced_css_files.len); + css_file_count += @intCast(pt.outputFile(file).referenced_css_chunks.len); file_count += 1; } next = parent.parent.unwrap(); @@ -483,13 +607,13 @@ pub fn buildWithVm(ctx: bun.CLI.Command.Context, cwd: []const u8, vm: *VirtualMa file_count = 1; css_file_count = 0; try file_list.putIndex(global, 0, try pt.preloadBundledModule(main_file_route_index)); - for (main_file.referenced_css_files) |ref| { + for (main_file.referenced_css_chunks) |ref| { try styles.putIndex(global, css_file_count, css_chunk_js_strings[ref.get() - css_chunks_first]); css_file_count += 1; } if (route.file_layout.unwrap()) |file| { try file_list.putIndex(global, file_count, try pt.preloadBundledModule(file)); - for (pt.outputFile(file).referenced_css_files) |ref| { + for (pt.outputFile(file).referenced_css_chunks) |ref| { try styles.putIndex(global, css_file_count, css_chunk_js_strings[ref.get() - css_chunks_first]); css_file_count += 1; } @@ -500,7 +624,7 @@ pub fn buildWithVm(ctx: bun.CLI.Command.Context, cwd: []const u8, vm: *VirtualMa const parent = router.routePtr(parent_index); if (parent.file_layout.unwrap()) |file| { try file_list.putIndex(global, file_count, try pt.preloadBundledModule(file)); - for (pt.outputFile(file).referenced_css_files) |ref| { + for (pt.outputFile(file).referenced_css_chunks) |ref| { try styles.putIndex(global, css_file_count, css_chunk_js_strings[ref.get() - css_chunks_first]); css_file_count += 1; } @@ -520,6 +644,7 @@ pub fn buildWithVm(ctx: bun.CLI.Command.Context, cwd: []const u8, vm: *VirtualMa try route_nested_files.putIndex(global, @intCast(nav_index), file_list); try route_type_and_flags.putIndex(global, @intCast(nav_index), JSValue.jsNumberFromInt32(@bitCast(TypeAndFlags{ .type = route.type.get(), + .no_client = main_file.bake_extra.fully_static, }))); if (params_buf.items.len > 0) { @@ -570,6 +695,12 @@ fn loadModule(vm: *VirtualMachine, global: *JSC.JSGlobalObject, key: JSValue) !J const promise = BakeLoadModuleByKey(global, key).asAnyPromise().?.internal; promise.setHandled(vm.jsc); vm.waitForPromise(.{ .internal = promise }); + // TODO: Specially draining microtasks here because `waitForPromise` has a + // bug which forgets to do it, but I don't want to fix it right now as it + // could affect a lot of the codebase. This should be removed. + vm.eventLoop().drainMicrotasks() catch { + bun.Global.crash(); + }; switch (promise.unwrap(vm.jsc, .mark_handled)) { .pending => unreachable, .fulfilled => |val| { @@ -598,23 +729,33 @@ fn BakeGetOnModuleNamespace(global: *JSC.JSGlobalObject, module: JSValue, proper return result; } +/// Renders all routes for static site generation by calling the JavaScript implementation. extern fn BakeRenderRoutesForProdStatic( *JSC.JSGlobalObject, + /// Output directory path (e.g., "./dist") out_base: bun.String, + /// Server module paths (e.g., ["bake://page.js", "bake://layout.js"]) all_server_files: JSValue, + /// Framework prerender functions by router type render_static: JSValue, + /// Framework getParams functions by router type get_params: JSValue, + /// Client entry URLs by router type (e.g., ["/client.js", null]) client_entry_urls: JSValue, + /// Route patterns (e.g., ["/", "/about", "/blog/:slug"]) patterns: JSValue, + /// File indices per route (e.g., [[0], [1], [2, 0]]) files: JSValue, + /// Packed router type and flags (e.g., [0x00000000, 0x00000001]) type_and_flags: JSValue, + /// Source paths (e.g., ["pages/index.tsx", "pages/blog/[slug].tsx"]) src_route_files: JSValue, + /// Dynamic route params (e.g., [null, null, ["slug"]]) param_information: JSValue, + /// CSS URLs per route (e.g., [["/main.css"], ["/main.css", "/blog.css"]]) styles: JSValue, ) *JSC.JSPromise; -extern fn BakeCreateProdGlobal(console_ptr: *anyopaque) *JSC.JSGlobalObject; - /// The result of this function is a JSValue that wont be garbage collected, as /// it will always have at least one reference by the module loader. fn BakeRegisterProductionChunk(global: *JSC.JSGlobalObject, key: bun.String, source_code: bun.String) bun.JSError!JSValue { @@ -627,6 +768,21 @@ fn BakeRegisterProductionChunk(global: *JSC.JSGlobalObject, key: bun.String, sou return result; } +pub export fn BakeToWindowsPath(input: bun.String) callconv(.C) bun.String { + if (comptime bun.Environment.isPosix) { + @panic("This code should not be called on POSIX systems."); + } + var sfa = std.heap.stackFallback(1024, bun.default_allocator); + const alloc = sfa.get(); + const input_utf8 = input.toUTF8(alloc); + defer input_utf8.deinit(); + const input_slice = input_utf8.slice(); + const output = bun.w_path_buffer_pool.get(); + defer bun.w_path_buffer_pool.put(output); + const output_slice = bun.strings.toWPathNormalizeAutoExtend(output.*[0..], input_slice); + return bun.String.cloneUTF16(output_slice); +} + pub export fn BakeProdResolve(global: *JSC.JSGlobalObject, a_str: bun.String, specifier_str: bun.String) callconv(.C) bun.String { var sfa = std.heap.stackFallback(@sizeOf(bun.PathBuffer) * 2, bun.default_allocator); const alloc = sfa.get(); @@ -653,7 +809,7 @@ pub export fn BakeProdResolve(global: *JSC.JSGlobalObject, a_str: bun.String, sp return bun.String.createFormat("bake:{s}", .{bun.path.joinAbs( bun.Dirname.dirname(u8, referrer.slice()[5..]) orelse referrer.slice()[5..], - .auto, + .posix, // force posix paths in bake specifier.slice(), )}) catch return bun.String.dead; } @@ -743,6 +899,7 @@ pub const PerThread = struct { module_keys: []const bun.String, /// Unordered module_map: bun.StringArrayHashMapUnmanaged(OutputFile.Index), + source_maps: bun.StringArrayHashMapUnmanaged(OutputFile.Index), // Thread-local vm: *JSC.VirtualMachine, @@ -761,6 +918,7 @@ pub const PerThread = struct { module_keys: []const bun.String, /// Unordered module_map: bun.StringArrayHashMapUnmanaged(OutputFile.Index), + source_maps: bun.StringArrayHashMapUnmanaged(OutputFile.Index), }; extern fn BakeGlobalObject__attachPerThreadData(global: *JSC.JSGlobalObject, pt: ?*PerThread) void; @@ -782,6 +940,7 @@ pub const PerThread = struct { .vm = vm, .loaded_files = loaded_files, .all_server_files = all_server_files, + .source_maps = opts.source_maps, }; } @@ -807,7 +966,7 @@ pub const PerThread = struct { } // Must be run at the top of the event loop - pub fn loadBundledModule(pt: *PerThread, id: OpaqueFileId) bun.JSError!JSValue { + pub fn loadBundledModule(pt: *PerThread, id: OpaqueFileId) !JSValue { return try loadModule( pt.vm, pt.vm.global, @@ -842,7 +1001,20 @@ pub export fn BakeProdLoad(pt: *PerThread, key: bun.String) bun.String { const allocator = sfa.get(); const utf8 = key.toUTF8(allocator); defer utf8.deinit(); + log("BakeProdLoad: {s}\n", .{utf8.slice()}); if (pt.module_map.get(utf8.slice())) |value| { + log(" found in module_map: {s}\n", .{utf8.slice()}); + return pt.bundled_outputs[value.get()].value.toBunString(); + } + return bun.String.dead; +} + +pub export fn BakeProdSourceMap(pt: *PerThread, key: bun.String) bun.String { + var sfa = std.heap.stackFallback(4096, bun.default_allocator); + const allocator = sfa.get(); + const utf8 = key.toUTF8(allocator); + defer utf8.deinit(); + if (pt.source_maps.get(utf8.slice())) |value| { return pt.bundled_outputs[value.get()].value.toBunString(); } return bun.String.dead; @@ -850,7 +1022,12 @@ pub export fn BakeProdLoad(pt: *PerThread, key: bun.String) bun.String { const TypeAndFlags = packed struct(i32) { type: u8, - unused: u24 = 0, + /// Don't inclue the runtime client code (e.g. + /// bun-framework-react/client.tsx). This is used if we know a server + /// component does not include any downstream usages of "use client" and so + /// we can omit the client code entirely. + no_client: bool = false, + unused: u23 = 0, }; const std = @import("std"); diff --git a/src/bun.js/VirtualMachine.zig b/src/bun.js/VirtualMachine.zig index 8021c023e427..7fad6ae7069c 100644 --- a/src/bun.js/VirtualMachine.zig +++ b/src/bun.js/VirtualMachine.zig @@ -1307,6 +1307,8 @@ pub fn initWorker( return vm; } +extern fn BakeCreateProdGlobal(console_ptr: *anyopaque) *JSC.JSGlobalObject; + pub fn initBake(opts: Options) anyerror!*VirtualMachine { JSC.markBinding(@src()); const allocator = opts.allocator; @@ -1359,7 +1361,16 @@ pub fn initBake(opts: Options) anyerror!*VirtualMachine { vm.regular_event_loop.tasks.ensureUnusedCapacity(64) catch unreachable; vm.regular_event_loop.concurrent_tasks = .{}; vm.event_loop = &vm.regular_event_loop; - vm.eventLoop().ensureWaker(); + if (comptime bun.Environment.isWindows) { + vm.eventLoop().ensureWaker(); + vm.global = BakeCreateProdGlobal(vm.console); + vm.jsc = vm.global.vm(); + uws.Loop.get().internal_loop_data.jsc_vm = vm.jsc; + } else { + vm.global = BakeCreateProdGlobal(vm.console); + vm.jsc = vm.global.vm(); + vm.eventLoop().ensureWaker(); + } vm.transpiler.macro_context = null; vm.transpiler.resolver.store_fd = opts.store_fd; diff --git a/src/bun.js/bindings/BunJSCEventLoop.cpp b/src/bun.js/bindings/BunJSCEventLoop.cpp index 6ca417034c3f..9fafa9f6c288 100644 --- a/src/bun.js/bindings/BunJSCEventLoop.cpp +++ b/src/bun.js/bindings/BunJSCEventLoop.cpp @@ -17,6 +17,9 @@ extern "C" void Bun__JSC_onBeforeWait(JSC::VM* vm) ASSERT(!drop_all_locks.has_value()); if (vm) { bool previouslyHadAccess = vm->heap.hasHeapAccess(); + // sanity check for debug builds to ensure we're not doing a + // use-after-free here + ASSERT(vm->refCount() > 0); drop_all_locks.emplace(*vm); if (previouslyHadAccess) { vm->heap.releaseAccess(); diff --git a/src/bun.js/bindings/ZigGlobalObject.cpp b/src/bun.js/bindings/ZigGlobalObject.cpp index d87299a706f8..4a0c86d94702 100644 --- a/src/bun.js/bindings/ZigGlobalObject.cpp +++ b/src/bun.js/bindings/ZigGlobalObject.cpp @@ -1013,6 +1013,7 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__create(void* console_client, vm.setOnComputeErrorInfo(computeErrorInfoWrapperToString); vm.setOnComputeErrorInfoJSValue(computeErrorInfoWrapperToJSValue); vm.setOnEachMicrotaskTick([](JSC::VM& vm) -> void { + // if you process.nextTick on a microtask we need this auto* globalObject = defaultGlobalObject(); if (auto queue = globalObject->m_nextTickQueue.get()) { globalObject->resetOnEachMicrotaskTick(); diff --git a/src/bundler/LinkerContext.zig b/src/bundler/LinkerContext.zig index bd60c7753057..200fd9c1c987 100644 --- a/src/bundler/LinkerContext.zig +++ b/src/bundler/LinkerContext.zig @@ -2,6 +2,9 @@ pub const LinkerContext = struct { pub const debug = Output.scoped(.LinkerCtx, false); pub const CompileResult = bundler.CompileResult; + pub const OutputFileListBuilder = @import("./linker_context/OutputFileListBuilder.zig"); + pub const StaticRouteVisitor = @import("./linker_context/StaticRouteVisitor.zig"); + parse_graph: *Graph = undefined, graph: LinkerGraph = undefined, allocator: std.mem.Allocator = undefined, diff --git a/src/bundler/ServerComponentParseTask.zig b/src/bundler/ServerComponentParseTask.zig index 9bfecfdd8472..eaa12569fe41 100644 --- a/src/bundler/ServerComponentParseTask.zig +++ b/src/bundler/ServerComponentParseTask.zig @@ -185,8 +185,9 @@ fn generateClientReferenceProxy(task: *ServerComponentParseTask, data: Data.Refe }); if (is_default) { + const ref = try b.newSymbol(.other, "default"); // export default registerClientReference(...); - try b.appendStmt(S.ExportDefault{ .value = .{ .expr = value }, .default_name = .{} }); + try b.appendStmt(S.ExportDefault{ .value = .{ .expr = value }, .default_name = .{ .ref = ref } }); } else { // export const Component = registerClientReference(...); const export_ref = try b.newSymbol(.other, key); diff --git a/src/bundler/bundle_v2.zig b/src/bundler/bundle_v2.zig index cd5d34eeade9..b299832fcab5 100644 --- a/src/bundler/bundle_v2.zig +++ b/src/bundler/bundle_v2.zig @@ -1480,6 +1480,10 @@ pub const BundleV2 = struct { reachable_files, ); + if (chunks.len == 0) { + return std.ArrayList(options.OutputFile).init(bun.default_allocator); + } + return try this.linker.generateChunksInParallel(chunks, false); } diff --git a/src/bundler/linker_context/OutputFileListBuilder.zig b/src/bundler/linker_context/OutputFileListBuilder.zig new file mode 100644 index 000000000000..8c675a62a17e --- /dev/null +++ b/src/bundler/linker_context/OutputFileListBuilder.zig @@ -0,0 +1,150 @@ +//! Q: What does this struct do? +//! A: This struct segments the `OutputFile` list into 3 separate spaces so +//! chunk indexing remains the same: +//! +//! 1. chunks +//! 2. sourcemaps and bytecode +//! 3. additional output files +//! +//! We can calculate the space ahead of time and avoid having to do something +//! more complicated or which requires extra work. +//! +//! Q: Why does it need to do that? +//! A: We would like it so if we have a chunk index, we can also index its +//! corresponding output file in the output file list. +//! +//! The DevServer uses the `referenced_css_chunks` (a list of chunk indices) +//! field on `OutputFile` to know which CSS files to hand to the rendering +//! function. For React this just adds tags that point to each output CSS +//! file. +//! +//! However, we previously were pushing sourcemaps and bytecode output files +//! to the output file list directly after their corresponding chunk, meaning +//! the index of the chunk in the chunk list and its corresponding +//! `OutputFile` in the output file list got scrambled. +//! +//! If we maintain the property that `outputIndexForChunk(chunk[i]) == i` +//! then we don't need to do any allocations or extra work to get the output +//! file for a chunk. +pub const OutputFileList = @This(); + +output_files: std.ArrayList(options.OutputFile), +index_for_chunk: u32, +index_for_sourcemaps_and_bytecode: ?u32, +additional_output_files_start: u32, + +total_insertions: u32, + +pub fn init( + allocator: std.mem.Allocator, + c: *const bun.bundle_v2.LinkerContext, + chunks: []const bun.bundle_v2.Chunk, + _: usize, +) !@This() { + const length, const source_map_and_bytecode_count = OutputFileList.calculateOutputFileListCapacity(c, chunks); + var output_files = try std.ArrayList(options.OutputFile).initCapacity( + allocator, + length, + ); + output_files.appendNTimesAssumeCapacity(OutputFile.zero_value, length); + + return .{ + .output_files = output_files, + .index_for_chunk = 0, + .index_for_sourcemaps_and_bytecode = if (source_map_and_bytecode_count == 0) null else @as(u32, @truncate(chunks.len)), + .additional_output_files_start = @as(u32, @intCast(chunks.len)) + source_map_and_bytecode_count, + .total_insertions = 0, + }; +} + +pub fn take(this: *@This()) std.ArrayList(options.OutputFile) { + // TODO: should this return an error + bun.assertf(this.total_insertions == this.output_files.items.len, "total_insertions ({d}) != output_files.items.len ({d})", .{ this.total_insertions, this.output_files.items.len }); + const list = this.output_files; + this.output_files = std.ArrayList(options.OutputFile).init(bun.default_allocator); + return list; +} + +pub fn calculateOutputFileListCapacity(c: *const bun.bundle_v2.LinkerContext, chunks: []const bun.bundle_v2.Chunk) struct { u32, u32 } { + const source_map_count = if (c.options.source_maps.hasExternalFiles()) brk: { + var count: usize = 0; + for (chunks) |*chunk| { + if (chunk.content.sourcemap(c.options.source_maps).hasExternalFiles()) { + count += 1; + } + } + break :brk count; + } else 0; + const bytecode_count = if (c.options.generate_bytecode_cache) bytecode_count: { + var bytecode_count: usize = 0; + for (chunks) |*chunk| { + // TODO: this was the original logic, but it seems like it is + // incorrect / does unnecessary work? Leaving it here just in-case, + // as it moved from a different file and is not git blame-able. + // + // const loader: Loader = if (chunk.entry_point.is_entry_point) + // c.parse_graph.input_files.items(.loader)[ + // chunk.entry_point.source_index + // ] + // else + // .js; + // if (loader.isJavaScriptLike()) { + // bytecode_count += 1; + // } + + if (chunk.content == .javascript) { + bytecode_count += 1; + } + } + break :bytecode_count bytecode_count; + } else 0; + + return .{ @intCast(chunks.len + source_map_count + bytecode_count + c.parse_graph.additional_output_files.items.len), @intCast(source_map_count + bytecode_count) }; +} + +pub fn insertForChunk(this: *OutputFileList, output_file: options.OutputFile) u32 { + const index = this.indexForChunk(); + bun.assertf(index < this.index_for_sourcemaps_and_bytecode orelse std.math.maxInt(u32), "index ({d}) \\< index_for_sourcemaps_and_bytecode ({d})", .{ index, this.index_for_sourcemaps_and_bytecode orelse std.math.maxInt(u32) }); + this.output_files.items[index] = output_file; + this.total_insertions += 1; + return index; +} + +pub fn insertForSourcemapOrBytecode(this: *OutputFileList, output_file: options.OutputFile) !u32 { + const index = this.indexForSourcemapOrBytecode() orelse return error.NoSourceMapsOrBytecode; + bun.assertf(index < this.additional_output_files_start, "index ({d}) \\< additional_output_files_start ({d})", .{ index, this.additional_output_files_start }); + this.output_files.items[index] = output_file; + this.total_insertions += 1; + return index; +} + +pub fn insertAdditionalOutputFiles(this: *OutputFileList, additional_output_files: []const options.OutputFile) void { + bun.assertf(this.index_for_sourcemaps_and_bytecode orelse 0 <= this.additional_output_files_start, "index_for_sourcemaps_and_bytecode ({d}) \\< additional_output_files_start ({d})", .{ this.index_for_sourcemaps_and_bytecode orelse 0, this.additional_output_files_start }); + bun.copy( + options.OutputFile, + this.getMutableAdditionalOutputFiles(), + additional_output_files, + ); + this.total_insertions += @as(u32, @intCast(additional_output_files.len)); +} + +pub fn getMutableAdditionalOutputFiles(this: *OutputFileList) []options.OutputFile { + return this.output_files.items[this.additional_output_files_start..]; +} + +fn indexForChunk(this: *@This()) u32 { + const result = this.index_for_chunk; + this.index_for_chunk += 1; + return result; +} + +fn indexForSourcemapOrBytecode(this: *@This()) ?u32 { + const result = this.index_for_sourcemaps_and_bytecode orelse return null; + this.index_for_sourcemaps_and_bytecode.? += 1; + return result; +} + +const std = @import("std"); +const bun = @import("bun"); +const options = bun.options; +const OutputFile = options.OutputFile; diff --git a/src/bundler/linker_context/StaticRouteVisitor.zig b/src/bundler/linker_context/StaticRouteVisitor.zig new file mode 100644 index 000000000000..758ec32f9e72 --- /dev/null +++ b/src/bundler/linker_context/StaticRouteVisitor.zig @@ -0,0 +1,92 @@ +//! The `isFullyStatic(source_index)` function returns whether or not +//! `source_index` imports a file with `"use client"`. +//! +//! TODO: Could we move this into the ReachableFileVisitor inside `bundle_v2.zig`? +const StaticRouteVisitor = @This(); + +c: *LinkerContext, +cache: std.AutoArrayHashMapUnmanaged(Index.Int, bool) = .{}, +visited: bun.bit_set.AutoBitSet, + +pub fn deinit(this: *StaticRouteVisitor) void { + this.cache.deinit(bun.default_allocator); + this.visited.deinit(bun.default_allocator); +} + +/// This the quickest, simplest, dumbest way I can think of doing this. +/// Investigate performance. It can have false negatives (it doesn't properly +/// handle cycles), but that's okay as it's just used an optimization +pub fn hasTransitiveUseClient(this: *StaticRouteVisitor, entry_point_source_index: u32) bool { + if (bun.Environment.isDebug and bun.getenvZ("BUN_SSG_DISABLE_STATIC_ROUTE_VISITOR") != null) { + return false; + } + + const all_import_records: []const ImportRecord.List = this.c.parse_graph.ast.items(.import_records); + const referenced_source_indices: []const u32 = this.c.parse_graph.server_component_boundaries.list.items(.reference_source_index); + const use_directives: []const UseDirective = this.c.parse_graph.server_component_boundaries.list.items(.use_directive); + + return this.hasTransitiveUseClientImpl( + all_import_records, + referenced_source_indices, + use_directives, + Index.init(entry_point_source_index), + ); +} + +/// 1. Get AST for `source_index` +/// 2. Recursively traverse its imports in import records +/// 3. If any of the imports match any item in +/// `referenced_source_indices` which has `use_directive == +/// .client`, then we know `source_index` is NOT fully +/// static. +fn hasTransitiveUseClientImpl( + this: *StaticRouteVisitor, + all_import_records: []const ImportRecord.List, + referenced_source_indices: []const u32, + use_directives: []const UseDirective, + source_index: Index, +) bool { + if (this.cache.get(source_index.get())) |result| { + return result; + } + if (this.visited.isSet(source_index.get())) { + return false; + } + this.visited.set(source_index.get()); + + const import_records = all_import_records[source_index.get()]; + + const result = result: { + for (import_records.sliceConst()) |*import_record| { + if (!import_record.source_index.isValid()) continue; + + // check if this import is a client boundary + for (referenced_source_indices, use_directives) |referenced_source_index, use_directive| { + if (use_directive != .client) continue; + // it's a client boundary + if (referenced_source_index == import_record.source_index.get()) break :result true; + } + + // otherwise check its children + if (this.hasTransitiveUseClientImpl( + all_import_records, + referenced_source_indices, + use_directives, + import_record.source_index, + )) break :result true; + } + break :result false; + }; + + this.cache.put(bun.default_allocator, source_index.get(), result) catch unreachable; + + return result; +} + +const std = @import("std"); +const bun = @import("bun"); +const LinkerContext = bun.bundle_v2.LinkerContext; +const Index = bun.bundle_v2.Index; + +const ImportRecord = bun.bundle_v2.ImportRecord; +const UseDirective = bun.bundle_v2.UseDirective; diff --git a/src/bundler/linker_context/computeChunks.zig b/src/bundler/linker_context/computeChunks.zig index d2f5abeef0c9..4cc71fbd25f3 100644 --- a/src/bundler/linker_context/computeChunks.zig +++ b/src/bundler/linker_context/computeChunks.zig @@ -310,6 +310,12 @@ pub noinline fn computeChunks( // Determine the order of JS files (and parts) within the chunk ahead of time try this.findAllImportedPartsInJSOrder(temp_allocator, chunks); + // Handle empty chunks case + if (chunks.len == 0) { + this.unique_key_buf = ""; + return chunks; + } + const unique_key_item_len = std.fmt.count("{any}C{d:0>8}", .{ bun.fmt.hexIntLower(unique_key), chunks.len }); var unique_key_builder = try bun.StringBuilder.initCapacity(this.allocator, unique_key_item_len * chunks.len); this.unique_key_buf = unique_key_builder.allocatedSlice(); diff --git a/src/bundler/linker_context/generateChunksInParallel.zig b/src/bundler/linker_context/generateChunksInParallel.zig index 762c8bfd15d5..e6ab6ceaf09a 100644 --- a/src/bundler/linker_context/generateChunksInParallel.zig +++ b/src/bundler/linker_context/generateChunksInParallel.zig @@ -1,4 +1,8 @@ -pub fn generateChunksInParallel(c: *LinkerContext, chunks: []Chunk, comptime is_dev_server: bool) !if (is_dev_server) void else std.ArrayList(options.OutputFile) { +pub fn generateChunksInParallel( + c: *LinkerContext, + chunks: []Chunk, + comptime is_dev_server: bool, +) !if (is_dev_server) void else std.ArrayList(options.OutputFile) { const trace = bun.perf.trace("Bundler.generateChunksInParallel"); defer trace.end(); @@ -300,11 +304,7 @@ pub fn generateChunksInParallel(c: *LinkerContext, chunks: []Chunk, comptime is_ } } - var output_files = std.ArrayList(options.OutputFile).initCapacity( - bun.default_allocator, - (if (c.options.source_maps.hasExternalFiles()) chunks.len * 2 else chunks.len) + - @as(usize, c.parse_graph.additional_output_files.items.len), - ) catch unreachable; + var output_files = try OutputFileListBuilder.init(bun.default_allocator, c, chunks, c.parse_graph.additional_output_files.items.len); const root_path = c.resolver.opts.output_dir; const more_than_one_output = c.parse_graph.additional_output_files.items.len > 0 or c.options.generate_bytecode_cache or (has_css_chunk and has_js_chunk) or (has_html_chunk and (has_js_chunk or has_css_chunk)); @@ -315,12 +315,14 @@ pub fn generateChunksInParallel(c: *LinkerContext, chunks: []Chunk, comptime is_ } const bundler = @as(*bun.bundle_v2.BundleV2, @fieldParentPtr("linker", c)); + var static_route_visitor = StaticRouteVisitor{ .c = c, .visited = bun.bit_set.AutoBitSet.initEmpty(bun.default_allocator, c.graph.files.len) catch unreachable }; + defer static_route_visitor.deinit(); if (root_path.len > 0) { try c.writeOutputFilesToDisk(root_path, chunks, &output_files); } else { // In-memory build - for (chunks) |*chunk| { + for (chunks, 0..) |*chunk, chunk_index_in_chunks_list| { var display_size: usize = 0; const public_path = if (chunk.is_browser_chunk_from_server_build) @@ -469,14 +471,12 @@ pub fn generateChunksInParallel(c: *LinkerContext, chunks: []Chunk, comptime is_ }; const source_map_index: ?u32 = if (sourcemap_output_file != null) - @as(u32, @truncate(output_files.items.len + 1)) + try output_files.insertForSourcemapOrBytecode(sourcemap_output_file.?) else null; - const bytecode_index: ?u32 = if (bytecode_output_file != null and source_map_index != null) - @as(u32, @truncate(output_files.items.len + 2)) - else if (bytecode_output_file != null) - @as(u32, @truncate(output_files.items.len + 1)) + const bytecode_index: ?u32 = if (bytecode_output_file != null) + try output_files.insertForSourcemapOrBytecode(bytecode_output_file.?) else null; @@ -486,7 +486,14 @@ pub fn generateChunksInParallel(c: *LinkerContext, chunks: []Chunk, comptime is_ c.graph.files.items(.entry_point_kind)[chunk.entry_point.source_index].outputKind() else .chunk; - try output_files.append(options.OutputFile.init(.{ + + const side: bun.bake.Side = if (chunk.content == .css or chunk.is_browser_chunk_from_server_build) + .client + else switch (c.graph.ast.items(.target)[chunk.entry_point.source_index]) { + .browser => .client, + else => .server, + }; + const chunk_index = output_files.insertForChunk(options.OutputFile.init(.{ .data = .{ .buffer = .{ .data = code_result.buffer, @@ -503,34 +510,39 @@ pub fn generateChunksInParallel(c: *LinkerContext, chunks: []Chunk, comptime is_ .is_executable = chunk.is_executable, .source_map_index = source_map_index, .bytecode_index = bytecode_index, - .side = if (chunk.content == .css or chunk.is_browser_chunk_from_server_build) - .client - else switch (c.graph.ast.items(.target)[chunk.entry_point.source_index]) { - .browser => .client, - else => .server, - }, + .side = side, .entry_point_index = if (output_kind == .@"entry-point") chunk.entry_point.source_index - @as(u32, (if (c.framework) |fw| if (fw.server_components != null) 3 else 1 else 1)) else null, - .referenced_css_files = switch (chunk.content) { + .referenced_css_chunks = switch (chunk.content) { .javascript => |js| @ptrCast(try bun.default_allocator.dupe(u32, js.css_chunks)), .css => &.{}, .html => &.{}, }, + .bake_extra = brk: { + if (c.framework == null or is_dev_server) break :brk .{}; + if (!c.framework.?.is_built_in_react) break :brk .{}; + + var extra: OutputFile.BakeExtra = .{}; + extra.bake_is_runtime = chunk.files_with_parts_in_chunk.contains(Index.runtime.get()); + if (output_kind == .@"entry-point" and side == .server) { + extra.is_route = true; + extra.fully_static = !static_route_visitor.hasTransitiveUseClient(chunk.entry_point.source_index); + } + + break :brk extra; + }, })); - if (sourcemap_output_file) |sourcemap_file| { - try output_files.append(sourcemap_file); - } - if (bytecode_output_file) |bytecode_file| { - try output_files.append(bytecode_file); - } + + // We want the chunk index to remain the same in `output_files` so the indices in `OutputFile.referenced_css_chunks` work + bun.assertf(chunk_index == chunk_index_in_chunks_list, "chunk_index ({d}) != chunk_index_in_chunks_list ({d})", .{ chunk_index, chunk_index_in_chunks_list }); } - try output_files.appendSlice(c.parse_graph.additional_output_files.items); + output_files.insertAdditionalOutputFiles(c.parse_graph.additional_output_files.items); } - return output_files; + return output_files.take(); } const bun = @import("bun"); @@ -555,6 +567,7 @@ const CompileResult = LinkerContext.CompileResult; const PendingPartRange = LinkerContext.PendingPartRange; const Output = bun.Output; +const OutputFile = bun.options.OutputFile; const debugPartRanges = Output.scoped(.PartRanges, true); const generateCompileResultForJSChunk = LinkerContext.generateCompileResultForJSChunk; @@ -562,6 +575,8 @@ const generateCompileResultForCssChunk = LinkerContext.generateCompileResultForC const generateCompileResultForHtmlChunk = LinkerContext.generateCompileResultForHtmlChunk; const generateChunk = LinkerContext.generateChunk; +const Index = bun.bundle_v2.Index; + const AutoBitSet = bun.bit_set.AutoBitSet; const ContentHasher = bun.bundle_v2.ContentHasher; @@ -572,4 +587,6 @@ const base64 = bun.base64; const JSC = bun.JSC; +const OutputFileListBuilder = bun.bundle_v2.LinkerContext.OutputFileListBuilder; +const StaticRouteVisitor = bun.bundle_v2.LinkerContext.StaticRouteVisitor; const ThreadPoolLib = bun.ThreadPool; diff --git a/src/bundler/linker_context/writeOutputFilesToDisk.zig b/src/bundler/linker_context/writeOutputFilesToDisk.zig index d3f5622f5c54..a973142ebd2e 100644 --- a/src/bundler/linker_context/writeOutputFilesToDisk.zig +++ b/src/bundler/linker_context/writeOutputFilesToDisk.zig @@ -2,7 +2,7 @@ pub fn writeOutputFilesToDisk( c: *LinkerContext, root_path: string, chunks: []Chunk, - output_files: *std.ArrayList(options.OutputFile), + output_files: *OutputFileListBuilder, ) !void { const trace = bun.perf.trace("Bundler.writeOutputFilesToDisk"); defer trace.end(); @@ -41,7 +41,7 @@ pub fn writeOutputFilesToDisk( var pathbuf: bun.PathBuffer = undefined; const bv2: *bundler.BundleV2 = @fieldParentPtr("linker", c); - for (chunks) |*chunk| { + for (chunks, 0..) |*chunk, chunk_index_in_chunks_list| { const trace2 = bun.perf.trace("Bundler.writeChunkToDisk"); defer trace2.end(); defer max_heap_allocator.reset(); @@ -292,14 +292,12 @@ pub fn writeOutputFilesToDisk( } const source_map_index: ?u32 = if (source_map_output_file != null) - @as(u32, @truncate(output_files.items.len + 1)) + try output_files.insertForSourcemapOrBytecode(source_map_output_file.?) else null; - const bytecode_index: ?u32 = if (bytecode_output_file != null and source_map_index != null) - @as(u32, @truncate(output_files.items.len + 2)) - else if (bytecode_output_file != null) - @as(u32, @truncate(output_files.items.len + 1)) + const bytecode_index: ?u32 = if (bytecode_output_file != null) + try output_files.insertForSourcemapOrBytecode(bytecode_output_file.?) else null; @@ -309,7 +307,8 @@ pub fn writeOutputFilesToDisk( c.graph.files.items(.entry_point_kind)[chunk.entry_point.source_index].outputKind() else .chunk; - try output_files.append(options.OutputFile.init(.{ + + const chunk_index = output_files.insertForChunk(options.OutputFile.init(.{ .output_path = bun.default_allocator.dupe(u8, chunk.final_rel_path) catch unreachable, .input_path = input_path, .input_loader = if (chunk.entry_point.is_entry_point) @@ -337,27 +336,21 @@ pub fn writeOutputFilesToDisk( chunk.entry_point.source_index - @as(u32, (if (c.framework) |fw| if (fw.server_components != null) 3 else 1 else 1)) else null, - .referenced_css_files = switch (chunk.content) { + .referenced_css_chunks = switch (chunk.content) { .javascript => |js| @ptrCast(try bun.default_allocator.dupe(u32, js.css_chunks)), .css => &.{}, .html => &.{}, }, })); - if (source_map_output_file) |sourcemap_file| { - try output_files.append(sourcemap_file); - } - - if (bytecode_output_file) |bytecode_file| { - try output_files.append(bytecode_file); - } + // We want the chunk index to remain the same in `output_files` so the indices in `OutputFile.referenced_css_chunks` work + bun.assertf(chunk_index == chunk_index_in_chunks_list, "chunk_index ({d}) != chunk_index_in_chunks_list ({d})", .{ chunk_index, chunk_index_in_chunks_list }); } { - const offset = output_files.items.len; - output_files.items.len += c.parse_graph.additional_output_files.items.len; - - for (c.parse_graph.additional_output_files.items, output_files.items[offset..][0..c.parse_graph.additional_output_files.items.len]) |*src, *dest| { + const additional_output_files = output_files.getMutableAdditionalOutputFiles(); + output_files.total_insertions += @intCast(additional_output_files.len); + for (c.parse_graph.additional_output_files.items, additional_output_files) |*src, *dest| { const bytes = src.value.buffer.bytes; src.value.buffer.bytes.len = 0; @@ -442,3 +435,4 @@ pub const ParseTask = bun.bundle_v2.ParseTask; const Chunk = bundler.Chunk; const cheapPrefixNormalizer = bundler.cheapPrefixNormalizer; const debug = LinkerContext.debug; +const OutputFileListBuilder = bun.bundle_v2.LinkerContext.OutputFileListBuilder; diff --git a/src/js/builtins/Bake.ts b/src/js/builtins/Bake.ts index bfe3cdfcb448..8b48b607070e 100644 --- a/src/js/builtins/Bake.ts +++ b/src/js/builtins/Bake.ts @@ -11,7 +11,7 @@ type FileIndex = number; * This layer is implemented in JavaScript to reduce Native <-> JS context switches, * as well as use the async primitives provided by the language. */ -export function renderRoutesForProdStatic( +export async function renderRoutesForProdStatic( outBase: string, allServerFiles: string[], // Indexed by router type index @@ -44,17 +44,18 @@ export function renderRoutesForProdStatic( async function doGenerateRoute( type: number, + noClient: boolean, i: number, layouts: any[], pageModule: any, - params: Record | null, + params: Record | null, ) { // Call the framework's rendering function const callback = renderStatic[type]; $assert(callback != null && $isCallable(callback)); let client = clientEntryUrl[type]; const results = await callback({ - modules: client ? [client] : [], + modules: client && !noClient ? [client] : [], modulepreload: [], styles: styles[i], layouts, @@ -73,13 +74,14 @@ export function renderRoutesForProdStatic( if (files == null) { throw new Error(`Route ${JSON.stringify(sourceRouteFiles[i])} cannot be pre-rendered to a static page.`); } + await Promise.all( Object.entries(files).map(([key, value]) => { if (params != null) { $assert(patterns[i].includes(`:`)); - // replace the :paramName part of patterns[i] with the value of params[paramName] - // use a regex in replace with a callback - const newKey = patterns[i].replace(/:(\w+)/g, (_, p1) => params[p1]); + const newKey = patterns[i].replace(/:(\w+)/g, (_, p1) => + typeof params[p1] === "string" ? params[p1] : params[p1].join("/"), + ); return Bun.write(pathJoin(outBase, newKey + key), value); } return Bun.write(pathJoin(outBase, patterns[i] + key), value); @@ -89,37 +91,42 @@ export function renderRoutesForProdStatic( function callRouteGenerator( type: number, + noClient: boolean, i: number, layouts: any[], pageModule: any, - params: Record, + params: Record, ) { for (const param of paramInformation[i]!) { - if (!params[param]) { + if (params[param] === undefined) { throw new Error(`Missing param ${param} for route ${JSON.stringify(sourceRouteFiles[i])}`); } } - return doGenerateRoute(type, i, layouts, pageModule, params); + return doGenerateRoute(type, noClient, i, layouts, pageModule, params); + } + + let modulesForFiles = []; + for (const fileList of files) { + $assert(fileList.length > 0); + if (fileList.length > 1) { + let anyPromise = false; + let loaded = fileList.map( + x => loadedModules[x] ?? ((anyPromise = true), import(allServerFiles[x]).then(x => (loadedModules[x] = x))), + ); + modulesForFiles.push(anyPromise ? await Promise.all(loaded) : loaded); + } else { + const id = fileList[0]; + modulesForFiles.push([loadedModules[id] ?? (loadedModules[id] = await import(allServerFiles[id]))]); + } } return Promise.all( - files.map(async (fileList, i) => { + modulesForFiles.map(async (modules, i) => { const typeAndFlag = typeAndFlags[i]; const type = typeAndFlag & 0xff; + const noClient = (typeAndFlag & 0b100000000) !== 0; - var pageModule: any, layouts: any[]; - $assert(fileList.length > 0); - if (fileList.length > 1) { - let anyPromise = false; - let loaded = fileList.map( - x => loadedModules[x] ?? ((anyPromise = true), import(allServerFiles[x]).then(x => (loadedModules[x] = x))), - ); - [pageModule, ...layouts] = anyPromise ? await Promise.all(loaded) : loaded; - } else { - const id = fileList[0]; - pageModule = loadedModules[id] ?? (loadedModules[id] = await import(allServerFiles[id])); - layouts = []; - } + let [pageModule, ...layouts] = modules; if (paramInformation[i] != null) { const getParam = getParams[type]; @@ -131,14 +138,14 @@ export function renderRoutesForProdStatic( let result; if (paramGetter[Symbol.asyncIterator] != undefined) { for await (const params of paramGetter) { - result = callRouteGenerator(type, i, layouts, pageModule, params); + result = callRouteGenerator(type, noClient, i, layouts, pageModule, params); if ($isPromise(result) && $isPromisePending(result)) { await result; } } } else if (paramGetter[Symbol.iterator] != undefined) { for (const params of paramGetter) { - result = callRouteGenerator(type, i, layouts, pageModule, params); + result = callRouteGenerator(type, noClient, i, layouts, pageModule, params); if ($isPromise(result) && $isPromisePending(result)) { await result; } @@ -146,12 +153,12 @@ export function renderRoutesForProdStatic( } else { await Promise.all( paramGetter.pages.map(params => { - callRouteGenerator(type, i, layouts, pageModule, params); + callRouteGenerator(type, noClient, i, layouts, pageModule, params); }), ); } } else { - await doGenerateRoute(type, i, layouts, pageModule, null); + await doGenerateRoute(type, noClient, i, layouts, pageModule, null); } }), ); diff --git a/src/js_ast.zig b/src/js_ast.zig index 11cbcc9ba614..1a006e29605a 100644 --- a/src/js_ast.zig +++ b/src/js_ast.zig @@ -116,8 +116,16 @@ pub const Flags = struct { }; pub const ClauseItem = struct { + /// The local alias used for the imported/exported symbol in the current module. + /// For imports: `import { foo as bar }` - "bar" is the alias + /// For exports: `export { foo as bar }` - "bar" is the alias + /// For re-exports: `export { foo as bar } from 'path'` - "bar" is the alias alias: string, alias_loc: logger.Loc = logger.Loc.Empty, + /// Reference to the actual symbol being imported/exported. + /// For imports: `import { foo as bar }` - ref to the symbol representing "foo" from the source module + /// For exports: `export { foo as bar }` - ref to the local symbol "foo" + /// For re-exports: `export { foo as bar } from 'path'` - ref to an intermediate symbol name: LocRef, /// This is the original name of the symbol stored in "Name". It's needed for @@ -550,6 +558,13 @@ pub const NamedImport = struct { // Parts within this file that use this import local_parts_with_uses: BabyList(u32) = BabyList(u32){}, + // The original export name from the source module being imported. + // Examples: + // - `import { foo } from 'module'` → alias = "foo" + // - `import { foo as bar } from 'module'` → alias = "foo" (original export name) + // - `import * as ns from 'module'` → alias_is_star = true, alias = "" + // This field is used by the bundler to match imports with their corresponding + // exports and for error reporting when imports can't be resolved. alias: ?string, alias_loc: ?logger.Loc = null, namespace_ref: ?Ref, diff --git a/src/js_parser.zig b/src/js_parser.zig index bd5079b829f9..897338f187be 100644 --- a/src/js_parser.zig +++ b/src/js_parser.zig @@ -9049,7 +9049,9 @@ fn NewParser_( }) catch unreachable; } - item_refs.putAssumeCapacity(name, name_loc.*); + // No need to add the `default_name` to `item_refs` because + // `.scanImportsAndExports(...)` special cases and handles + // `default_name` separately } var end: usize = 0; @@ -17519,7 +17521,7 @@ fn NewParser_( // // When we see a hook call, we need to hash it, and then mark a flag so that if // it is assigned to a variable, that variable also get's hashed. - if (p.options.features.react_fast_refresh) try_record_hook: { + if (p.options.features.react_fast_refresh or p.options.features.server_components.isServerSide()) try_record_hook: { const original_name = switch (e_.target.data) { inline .e_identifier, .e_import_identifier, @@ -17529,7 +17531,33 @@ fn NewParser_( else => break :try_record_hook, }; if (!ReactRefresh.isHookName(original_name)) break :try_record_hook; - p.handleReactRefreshHookCall(e_, original_name); + if (p.options.features.react_fast_refresh) { + p.handleReactRefreshHookCall(e_, original_name); + } else if ( + // If we're here it means we're in server component. + // Error if the user is using the `useState` hook as it + // is disallowed in server components. + // + // We're also specifically checking that the target is + // `.e_import_identifier`. + // + // Why? Because we *don't* want to check for uses of + // `useState` _inside_ React, and we know React uses + // commonjs so it will never be `.e_import_identifier`. + e_.target.data == .e_import_identifier) { + bun.assert(p.options.features.server_components.isServerSide()); + if (bun.strings.eqlComptime(original_name, "useState")) { + p.log.addError( + p.source, + expr.loc, + std.fmt.allocPrint( + p.allocator, + "\"useState\" is not available in a server component. If you need interactivity, consider converting part of this to a Client Component (by adding `\"use client\";` to the top of the file).", + .{}, + ) catch bun.outOfMemory(), + ) catch bun.outOfMemory(); + } + } } // Implement constant folding for 'string'.charCodeAt(n) @@ -18796,6 +18824,26 @@ fn NewParser_( }, .loc = loc }; } + // Inline import.meta properties for Bake + if (p.options.framework != null) { + if (strings.eqlComptime(name, "dir") or strings.eqlComptime(name, "dirname")) { + // Inline import.meta.dir + return p.newExpr(E.String.init(p.source.path.name.dir), name_loc); + } else if (strings.eqlComptime(name, "file")) { + // Inline import.meta.file (filename only) + return p.newExpr(E.String.init(p.source.path.name.filename), name_loc); + } else if (strings.eqlComptime(name, "path")) { + // Inline import.meta.path (full path) + return p.newExpr(E.String.init(p.source.path.text), name_loc); + } else if (strings.eqlComptime(name, "url")) { + // Inline import.meta.url as file:// URL + const bunstr = bun.String.fromBytes(p.source.path.text); + defer bunstr.deref(); + const url = std.fmt.allocPrint(p.allocator, "{s}", .{JSC.URL.fileURLFromString(bunstr)}) catch unreachable; + return p.newExpr(E.String.init(url), name_loc); + } + } + // Make all property accesses on `import.meta.url` side effect free. return p.newExpr( E.Dot{ diff --git a/src/runtime.zig b/src/runtime.zig index 7854881b1c15..666fa8c1833d 100644 --- a/src/runtime.zig +++ b/src/runtime.zig @@ -283,6 +283,15 @@ pub const Runtime = struct { /// - Ban "use server" functions since it is on the client-side client_side, + pub fn isServerSide(mode: ServerComponentsMode) bool { + return switch (mode) { + .wrap_exports_for_server_reference, + .wrap_anon_server_functions, + => true, + else => false, + }; + } + pub fn wrapsExports(mode: ServerComponentsMode) bool { return switch (mode) { .wrap_exports_for_client_reference, diff --git a/src/sourcemap/sourcemap.zig b/src/sourcemap/sourcemap.zig index d6d512e092a5..a71ee0569d42 100644 --- a/src/sourcemap/sourcemap.zig +++ b/src/sourcemap/sourcemap.zig @@ -1100,6 +1100,40 @@ pub fn getSourceMapImpl( // try to load a .map file if (load_hint != .is_inline_map) try_external: { + if (comptime SourceProviderKind == BakeSourceProvider) fallback_to_normal: { + const global = bun.JSC.VirtualMachine.get().global; + // If we're using bake's production build the global object will + // be Bake::GlobalObject and we can fetch the sourcemap from it, + // if not fallback to the normal way + if (!BakeGlobalObject__isBakeGlobalObject(global)) { + break :fallback_to_normal; + } + const data = BakeSourceProvider.getExternal( + provider, + global, + source_filename, + ); + break :parsed .{ + .is_external_map, + parseJSON( + bun.default_allocator, + allocator, + data, + result, + ) catch |err| { + // Print warning even if this came from non-visible code like + // calling `error.stack`. This message is only printed if + // the sourcemap has been found but is invalid, such as being + // invalid JSON text or corrupt mappings. + bun.Output.warn("Could not decode sourcemap in '{s}': {s}", .{ + source_filename, + @errorName(err), + }); // Disable the "try using --sourcemap=external" hint + bun.JSC.SavedSourceMap.MissingSourceMapNoteInfo.seen_invalid = true; + return null; + }, + }; + } var load_path_buf: *bun.PathBuffer = bun.path_buffer_pool.get(); defer bun.path_buffer_pool.put(load_path_buf); if (source_filename.len + 4 > load_path_buf.len) @@ -1182,6 +1216,10 @@ pub const SourceProviderMap = opaque { } }; +extern "c" fn BakeGlobalObject__isBakeGlobalObject(global: *bun.JSC.JSGlobalObject) bool; + +extern "c" fn BakeGlobalObject__getPerThreadData(global: *bun.JSC.JSGlobalObject) *bun.bake.production.PerThread; + pub const BakeSourceProvider = opaque { extern fn BakeSourceProvider__getSourceSlice(*BakeSourceProvider) bun.String; pub const getSourceSlice = BakeSourceProvider__getSourceSlice; @@ -1189,6 +1227,15 @@ pub const BakeSourceProvider = opaque { return ParsedSourceMap.SourceContentPtr.fromBakeProvider(this); } + pub fn getExternal(_: *BakeSourceProvider, global: *bun.JSC.JSGlobalObject, source_filename: []const u8) []const u8 { + bun.assert(BakeGlobalObject__isBakeGlobalObject(global)); + const pt = BakeGlobalObject__getPerThreadData(global); + if (pt.source_maps.get(source_filename)) |value| { + return pt.bundled_outputs[value.get()].value.asSlice(); + } + return ""; + } + /// The last two arguments to this specify loading hints pub fn getSourceMap( provider: *BakeSourceProvider, diff --git a/test/bake/bake-harness.ts b/test/bake/bake-harness.ts index b44f64d09029..8e91c328c523 100644 --- a/test/bake/bake-harness.ts +++ b/test/bake/bake-harness.ts @@ -18,7 +18,7 @@ import { Matchers } from "bun:test"; import { EventEmitter } from "node:events"; // @ts-ignore import { dedent } from "../bundler/expectBundled.ts"; -import { bunEnv, bunExe, isCI, isWindows, mergeWindowEnvs } from "harness"; +import { bunEnv, bunExe, isCI, isWindows, mergeWindowEnvs, tempDirWithFiles } from "harness"; import { expect } from "bun:test"; import { exitCodeMapStrings } from "./exit-code-map.mjs"; @@ -318,7 +318,7 @@ export class Dev extends EventEmitter { if (wantsHmrEvent && interactive) { await seenFiles.promise; } else if (wantsHmrEvent) { - await Promise.race([seenFiles.promise, Bun.sleep(1000)]); + await Promise.race([seenFiles.promise]); } if (!fastBatches) { // Wait an extra delay to avoid double-triggering events. @@ -1415,6 +1415,90 @@ async function installReactWithCache(root: string) { } } +// Global React cache management +let reactCachePromise: Promise | null = null; + +/** + * Ensures the React cache is populated. This is a global operation that + * only happens once per test run. + */ +export async function ensureReactCache(): Promise { + if (!reactCachePromise) { + reactCachePromise = (async () => { + const cacheFiles = ["node_modules", "package.json", "bun.lock"]; + const cacheValid = cacheFiles.every(file => fs.existsSync(path.join(reactCacheDir, file))); + + if (!cacheValid) { + // Create a temporary directory for installation + const tempInstallDir = fs.mkdtempSync(path.join(tempDir, "react-install-")); + + // Create a minimal package.json + fs.writeFileSync( + path.join(tempInstallDir, "package.json"), + JSON.stringify({ + name: "react-cache-install", + version: "1.0.0", + private: true, + }), + ); + + try { + // Install React packages + await Bun.$`${bunExe()} i react@experimental react-dom@experimental react-server-dom-bun react-refresh@experimental && ${bunExe()} install` + .cwd(tempInstallDir) + .env({ ...bunEnv }) + .throws(true); + + // Copy to cache + for (const file of cacheFiles) { + const src = path.join(tempInstallDir, file); + const dest = path.join(reactCacheDir, file); + if (fs.existsSync(src)) { + if (fs.statSync(src).isDirectory()) { + fs.cpSync(src, dest, { recursive: true, force: true }); + } else { + fs.copyFileSync(src, dest); + } + } + } + } finally { + // Clean up temp directory + fs.rmSync(tempInstallDir, { recursive: true, force: true }); + } + } + })(); + } + + return reactCachePromise; +} + +/** + * Copies cached React dependencies to the specified directory. + * This ensures React is available without running install. + */ +export async function copyCachedReactDeps(root: string): Promise { + // Ensure cache is populated + await ensureReactCache(); + + // Copy node_modules from cache to target directory + const src = path.join(reactCacheDir, "node_modules"); + const dest = path.join(root, "node_modules"); + + if (fs.existsSync(src)) { + fs.cpSync(src, dest, { recursive: true, force: true }); + } +} + +/** + * Creates a temporary directory with files and React dependencies pre-installed. + * This is a convenience wrapper that combines tempDirWithFiles with copyCachedReactDeps. + */ +export async function tempDirWithBakeDeps(name: string, files: Record): Promise { + const dir = tempDirWithFiles(name, files); + await copyCachedReactDeps(dir); + return dir; +} + const devTestRoot = path.join(import.meta.dir, "dev").replaceAll("\\", "/"); const prodTestRoot = path.join(import.meta.dir, "dev").replaceAll("\\", "/"); const counts: Record = {}; @@ -1632,6 +1716,7 @@ function testImpl( await writeAll(root, options.files); const runInstall = options.framework === "react"; if (runInstall) { + // await copyCachedReactDeps(root); await installReactWithCache(root); } if (options.files["bun.app.ts"] == undefined && htmlFiles.length === 0) { diff --git a/test/bake/dev/import-meta-inline-negative.test.ts b/test/bake/dev/import-meta-inline-negative.test.ts new file mode 100644 index 000000000000..599b9bd17686 --- /dev/null +++ b/test/bake/dev/import-meta-inline-negative.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDirWithFiles } from "harness"; + +test("import.meta properties are NOT inlined without bake framework", async () => { + const dir = tempDirWithFiles("import-meta-no-inline", { + "index.ts": ` + console.log("dir:", import.meta.dir); + console.log("dirname:", import.meta.dirname); + console.log("file:", import.meta.file); + console.log("path:", import.meta.path); + console.log("url:", import.meta.url); + `, + }); + + // Run without bundling - should show actual values + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: bunEnv, + cwd: dir, + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + + // When not bundled, these properties should resolve to actual values + expect(stdout).toContain("dir:"); + expect(stdout).toContain("dirname:"); + expect(stdout).toContain("file:"); + expect(stdout).toContain("path:"); + expect(stdout).toContain("url:"); + + // The values should NOT be inlined - they should be the actual runtime values + expect(stdout).not.toContain("undefined"); +}); diff --git a/test/bake/dev/import-meta-inline.test.ts b/test/bake/dev/import-meta-inline.test.ts new file mode 100644 index 000000000000..9f0bbdc0c985 --- /dev/null +++ b/test/bake/dev/import-meta-inline.test.ts @@ -0,0 +1,307 @@ +// import.meta properties are inlined at parse time in Bake +import { expect } from "bun:test"; +import { devTest, emptyHtmlFile, minimalFramework } from "../bake-harness"; + +const platformPath = (path: string) => { + if (process.platform === "win32") { + return path.replace(/\//g, "\\"); + } + return path; +}; + +devTest("import.meta properties are inlined in bake", { + framework: minimalFramework, + files: { + "routes/index.ts": ` +export default function (req, meta) { + return Response.json({ + dir: import.meta.dir, + dirname: import.meta.dirname, + file: import.meta.file, + path: import.meta.path, + url: import.meta.url, + }); +} +`, + }, + async test(dev) { + const response = await dev.fetch("/"); + const json = await response.json(); + + // Check that all properties are strings, not undefined + expect(typeof json.dir).toBe("string"); + expect(typeof json.dirname).toBe("string"); + expect(typeof json.file).toBe("string"); + expect(typeof json.path).toBe("string"); + expect(typeof json.url).toBe("string"); + + // Check that dir and dirname are the same + expect(json.dir).toBe(json.dirname); + + // Check that file is just the filename + expect(json.file).toBe("index.ts"); + + // Check that path contains the full path including filename + expect(json.path).toContain(platformPath("routes/index.ts")); + expect(json.path).toEndWith("index.ts"); + + // Check that url is a file:// URL + expect(json.url).toStartWith("file://"); + expect(json.url).toContain("routes/index.ts"); + }, +}); + +devTest("import.meta properties work with dynamic updates", { + framework: minimalFramework, + files: { + "routes/test.ts": ` +export default function (req, meta) { + const values = [ + "dir: " + import.meta.dir, + "file: " + import.meta.file, + "path: " + import.meta.path, + ]; + return new Response(values.join("\\n")); +} +`, + }, + async test(dev) { + const response = await dev.fetch("/test"); + const text = await response.text(); + + // Verify the values are inlined strings + expect(text).toContain("dir: "); + expect(text).toContain("file: test.ts"); + expect(text).toContain("path: "); + expect(text).toContain(platformPath("routes/test.ts")); + + // Update the file with a meaningful change + await dev.patch("routes/test.ts", { + find: '"dir: "', + replace: '"directory: "', + }); + + const response2 = await dev.fetch("/test"); + const text2 = await response2.text(); + + // After the patch, the first line should say "directory:" instead of "dir:" + expect(text2).toContain("directory: "); + expect(text2).toContain("file: test.ts"); + expect(text2).toContain("path: "); + expect(text2).toContain(platformPath("routes/test.ts")); + }, +}); + +devTest("import.meta properties with nested directories", { + framework: minimalFramework, + files: { + "routes/api/v1/handler.ts": ` +export default function (req, meta) { + return Response.json({ + dir: import.meta.dir, + file: import.meta.file, + path: import.meta.path, + url: import.meta.url, + }); +} +`, + }, + async test(dev) { + const response = await dev.fetch("/api/v1/handler"); + const json = await response.json(); + + expect(json.file).toBe("handler.ts"); + expect(json.path).toContain(platformPath("routes/api/v1/handler.ts")); + expect(json.dir).toContain(platformPath("routes/api/v1")); + expect(json.url).toMatch(/^file:\/\/.*routes\/api\/v1\/handler\.ts$/); + }, +}); + +devTest("import.meta properties in client-side code show runtime values", { + framework: minimalFramework, + files: { + "test_import_meta_inline.js": ` +// Test file for import.meta inlining +console.log("import.meta.dir:", import.meta.dir); +console.log("import.meta.dirname:", import.meta.dirname); +console.log("import.meta.file:", import.meta.file); +console.log("import.meta.path:", import.meta.path); +console.log("import.meta.url:", import.meta.url); +`, + "index.html": emptyHtmlFile({ + scripts: ["test_import_meta_inline.js"], + }), + }, + async test(dev) { + await using c = await dev.client("/"); + + // In client-side code, import.meta properties show runtime values + // They are NOT inlined because this is not server-side code + const messages = [ + await c.getStringMessage(), + await c.getStringMessage(), + await c.getStringMessage(), + await c.getStringMessage(), + await c.getStringMessage(), + ]; + + // Verify all properties are logged + expect(messages.some(m => m.startsWith("import.meta.dir:"))).toBe(true); + expect(messages.some(m => m.startsWith("import.meta.dirname:"))).toBe(true); + expect(messages.some(m => m.startsWith("import.meta.file:"))).toBe(true); + expect(messages.some(m => m.startsWith("import.meta.path:"))).toBe(true); + expect(messages.some(m => m.startsWith("import.meta.url:"))).toBe(true); + }, +}); + +devTest("import.meta properties in catch-all routes", { + framework: minimalFramework, + files: { + "routes/blog/[...slug].ts": ` +export default function BlogPost(req, meta) { + const url = new URL(req.url); + const slug = url.pathname.replace('/blog/', '').split('/').filter(Boolean); + + const metaInfo = { + file: import.meta.file, + dir: import.meta.dir, + path: import.meta.path, + url: import.meta.url, + dirname: import.meta.dirname, + }; + + return Response.json({ + slug: slug, + title: slug.map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' '), + meta: metaInfo, + content: "This is a blog post at: " + slug.join('/'), + }); +} +`, + }, + async test(dev) { + // Test single segment + const post1 = await dev.fetch("/blog/hello"); + const json1 = await post1.json(); + + expect(json1.slug).toEqual(["hello"]); + expect(json1.title).toBe("Hello"); + expect(json1.content).toBe("This is a blog post at: hello"); + + // Verify import.meta properties are inlined + expect(json1.meta.file).toBe("[...slug].ts"); + expect(json1.meta.dir).toContain(platformPath("routes/blog")); + expect(json1.meta.dirname).toBe(json1.meta.dir); + expect(json1.meta.path).toContain(platformPath("routes/blog/[...slug].ts")); + // url encoded! + expect(json1.meta.url).toMatch( + /^file:\/\/.*routes\/blog\/%5B\.\.\.slug%5D\.ts$/, + ); + + // Test multiple segments + const post2 = await dev.fetch("/blog/2024/tech/bun-framework"); + const json2 = await post2.json(); + + expect(json2.slug).toEqual(["2024", "tech", "bun-framework"]); + expect(json2.title).toBe("2024 Tech Bun-framework"); + expect(json2.content).toBe("This is a blog post at: 2024/tech/bun-framework"); + + // Meta properties should be the same regardless of the route + expect(json2.meta.file).toBe("[...slug].ts"); + expect(json2.meta.path).toContain(platformPath("routes/blog/[...slug].ts")); + + // Test empty slug (just /blog/) + const post3 = await dev.fetch("/blog/"); + const json3 = await post3.json(); + + expect(json3.slug).toEqual([]); + expect(json3.title).toBe(""); + expect(json3.content).toBe("This is a blog post at: "); + }, +}); + +devTest("import.meta properties in nested catch-all routes with static siblings", { + framework: minimalFramework, + files: { + "routes/docs/[...path].ts": ` +export default function DocsPage(req, meta) { + const url = new URL(req.url); + const path = url.pathname.replace('/docs/', '').split('/').filter(Boolean); + + return Response.json({ + type: "catch-all", + path: path, + file: import.meta.file, + dir: import.meta.dir, + fullPath: import.meta.path, + }); +} +`, + "routes/docs/api.ts": ` +export default function ApiDocs(req, meta) { + return Response.json({ + type: "static", + page: "API Documentation", + file: import.meta.file, + dir: import.meta.dir, + fullPath: import.meta.path, + }); +} +`, + "routes/docs/getting-started.ts": ` +export default function GettingStarted(req, meta) { + return Response.json({ + type: "static", + page: "Getting Started", + file: import.meta.file, + dir: import.meta.dir, + fullPath: import.meta.path, + }); +} +`, + }, + async test(dev) { + // Test static route - should match api.ts, not catch-all + const apiResponse = await dev.fetch("/docs/api"); + const apiJson = await apiResponse.json(); + + expect(apiJson.type).toBe("static"); + expect(apiJson.page).toBe("API Documentation"); + expect(apiJson.file).toBe("api.ts"); + expect(apiJson.dir).toContain(platformPath("routes/docs")); + expect(apiJson.fullPath).toContain(platformPath("routes/docs/api.ts")); + + // Test another static route + const startResponse = await dev.fetch("/docs/getting-started"); + const startJson = await startResponse.json(); + + expect(startJson.type).toBe("static"); + expect(startJson.page).toBe("Getting Started"); + expect(startJson.file).toBe("getting-started.ts"); + expect(startJson.fullPath).toContain(platformPath("routes/docs/getting-started.ts")); + + // Test catch-all route - should match for non-static paths + const guideResponse = await dev.fetch("/docs/guides/advanced/optimization"); + expect(guideResponse.status).toBe(200); + const guideJson = await guideResponse.json(); + + expect(guideJson.type).toBe("catch-all"); + expect(guideJson.path).toEqual(["guides", "advanced", "optimization"]); + expect(guideJson.file).toBe("[...path].ts"); + expect(guideJson.dir).toContain(platformPath("routes/docs")); + expect(guideJson.fullPath).toContain(platformPath("routes/docs/[...path].ts")); + + // Update catch-all route and verify import.meta values remain inlined + await dev.patch("routes/docs/[...path].ts", { + find: '"catch-all"', + replace: '"dynamic-catch-all"', + }); + + const updatedResponse = await dev.fetch("/docs/tutorials/intro"); + const updatedJson = await updatedResponse.json(); + + expect(updatedJson.type).toBe("dynamic-catch-all"); + expect(updatedJson.file).toBe("[...path].ts"); + expect(updatedJson.fullPath).toContain(platformPath("routes/docs/[...path].ts")); + }, +}); diff --git a/test/bake/dev/production.test.ts b/test/bake/dev/production.test.ts new file mode 100644 index 000000000000..d064214977ea --- /dev/null +++ b/test/bake/dev/production.test.ts @@ -0,0 +1,543 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, readdirSync } from "fs"; +import { bunEnv, bunExe } from "harness"; +import path from "path"; +import { tempDirWithBakeDeps } from "../bake-harness"; + +const normalizePath = (path: string) => (process.platform === "win32" ? path.replaceAll("\\", "/") : path); +const platformPath = (path: string) => (process.platform === "win32" ? path.replaceAll("/", "\\") : path); + +/** + * Production build tests + */ +describe("production", () => { + test("works with sourcemaps - error thrown in React component", async () => { + const dir = await tempDirWithBakeDeps("bake-production-sourcemap", { + "src/index.tsx": `export default { app: { framework: "react" } };`, + "pages/index.tsx": `export default function IndexPage() { + throw new Error("oh no!"); + return
Hello World
; +}`, + "package.json": JSON.stringify({ + "name": "test-app", + "version": "1.0.0", + "devDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0", + }, + }), + }); + + // Run the build command + const { + exitCode: buildExitCode, + stdout: buildStdout, + stderr: buildStderr, + } = await Bun.$`${bunExe()} build --app ./src/index.tsx`.cwd(dir).throws(false); + + // The build should fail due to the runtime error during SSG + expect(buildExitCode).toBe(1); + + // Check that the error message shows the proper source location + expect(buildStderr.toString()).toContain("throw new Error"); + expect(buildStderr.toString()).toContain("oh no!"); + }); + + test("import.meta properties are inlined in production build", async () => { + const dir = await tempDirWithBakeDeps("bake-production-import-meta", { + "src/index.tsx": `export default { + app: { + framework: "react", + } + };`, + "pages/index.tsx": ` +export default function IndexPage() { + const metaInfo = { + dir: import.meta.dir, + dirname: import.meta.dirname, + file: import.meta.file, + path: import.meta.path, + url: import.meta.url, + }; + + return ( +
+

Import Meta Test

+
{JSON.stringify(metaInfo, null, 2)}
+
{JSON.stringify(metaInfo)}
+
+ ); +} +`, + "pages/api/test.tsx": ` +export default function TestPage() { + const values = [ + "dir=" + import.meta.dir, + "dirname=" + import.meta.dirname, + "file=" + import.meta.file, + "path=" + import.meta.path, + "url=" + import.meta.url, + ]; + + return ( +
+

API Test

+
{values.join("\\n")}
+
{values.join("|")}
+
+ ); +} +`, + }); + + // Run the build command + const buildProc = await Bun.$`${bunExe()} build --app ./src/index.tsx --outdir ./dist` + .cwd(dir) + .env(bunEnv) + .throws(false); + + expect(buildProc.exitCode).toBe(0); + + // Check that the build output contains the generated files + const distFiles = await Bun.$`ls -la dist/`.cwd(dir).text(); + expect(distFiles).toContain("index.html"); + expect(distFiles).toContain("_bun"); + + // In production SSG, the import.meta values are inlined during build time + // and rendered into the static HTML. The values should appear in the HTML output. + + // Check the generated static HTML files + const indexHtml = await Bun.file(path.join(dir, "dist", "index.html")).text(); + const apiTestHtml = await Bun.file(path.join(dir, "dist", "api", "test", "index.html")).text(); + + // The HTML output should contain the rendered import.meta values + // Check for the presence of the expected values in the HTML + + // For the index page, check that it contains the expected file paths + expect(indexHtml).toContain("index.tsx"); + expect(indexHtml).toContain("pages"); + + // Check if the HTML contains evidence of import.meta values being used + // The exact format might be HTML-escaped, so we check for key patterns + const hasIndexPath = + indexHtml.includes("pages/index.tsx") || + indexHtml.includes("pages/index.tsx") || + indexHtml.includes("pages\\index.tsx"); + expect(hasIndexPath).toBe(true); + + // For the API test page + expect(apiTestHtml).toContain("test.tsx"); + expect(apiTestHtml).toContain("pages"); + + const hasApiPath = + apiTestHtml.includes("pages/api/test.tsx") || + apiTestHtml.includes("pages/api/test.tsx") || + apiTestHtml.includes("pages\\api\\test.tsx"); + expect(hasApiPath).toBe(true); + }); + + test("import.meta properties are inlined in catch-all routes during production build", async () => { + const dir = await tempDirWithBakeDeps("bake-production-catch-all", { + "src/index.tsx": `export default { + app: { + framework: "react", + } + };`, + "pages/blog/[...slug].tsx": ` +export default function BlogPost({ params }) { + const slug = params.slug || []; + + const metaInfo = { + file: import.meta.file, + dir: import.meta.dir, + path: import.meta.path, + url: import.meta.url, + dirname: import.meta.dirname, + }; + + return ( +
+

Blog Post: {slug.join(' / ')}

+

You are reading: {slug.length === 0 ? 'the blog index' : slug.join('/')}

+
+
{JSON.stringify(metaInfo, null, 2)}
+
+
+ ); +} + +export async function getStaticPaths() { + return { + paths: [ + { params: { slug: ['2024', 'hello-world'] } }, + { params: { slug: ['2024', 'tech', 'bun-framework'] } }, + { params: { slug: ['tutorials', 'getting-started'] } }, + ], + fallback: false, + }; +} +`, + "pages/docs/[...path].tsx": ` +export default function DocsPage({ params }) { + const path = params.path || []; + + return ( +
+

Documentation

+ +
+

Reading docs at: /{path.join('/')}

+
+ + + + +
+
+
+ ); +} + +export async function getStaticPaths() { + return { + paths: [ + { params: { path: ['api', 'reference'] } }, + { params: { path: ['guides', 'advanced', 'optimization'] } }, + { params: { path: [] } }, // docs index + ], + fallback: false, + }; +} +`, + "pages/docs/getting-started.tsx": ` +export default function GettingStarted() { + return ( +
+

Getting Started

+

This is a static page, not a catch-all route.

+
+ + +
+
+ ); +} +`, + }); + + console.error("DIR", dir); + + // Run the build command + const buildProc = await Bun.$`${bunExe()} build --app ./src/index.tsx --outdir ./dist` + .cwd(dir) + .env(bunEnv) + .throws(false); + + expect(buildProc.exitCode).toBe(0); + + // Check that the build output contains the generated files + const htmlFiles = Array.from(new Bun.Glob("dist/**/*.html").scanSync(dir)) + .sort() + .map(p => normalizePath(p)); + + // Should have generated all the static paths + // Note: React's routing may flatten the paths + expect(htmlFiles).toContain("dist/blog/2024/hello-world/index.html"); + expect(htmlFiles).toContain("dist/blog/2024/tech/bun-framework/index.html"); + expect(htmlFiles).toContain("dist/blog/tutorials/getting-started/index.html"); + expect(htmlFiles).toContain("dist/docs/api/reference/index.html"); + expect(htmlFiles).toContain("dist/docs/guides/advanced/optimization/index.html"); + expect(htmlFiles).toContain("dist/docs/index.html"); + expect(htmlFiles).toContain("dist/docs/getting-started/index.html"); + + // Check blog post with multiple segments + const blogPostHtml = await Bun.file( + path.join(dir, "dist", "blog", "2024", "tech", "bun-framework", "index.html"), + ).text(); + + // Verify the content is rendered (may include HTML comments) + expect(blogPostHtml).toContain("Blog Post:"); + expect(blogPostHtml).toContain("2024 / tech / bun-framework"); + expect(blogPostHtml).toContain("You are reading:"); + expect(blogPostHtml).toContain("2024/tech/bun-framework"); + + // Check that import.meta values are inlined in the HTML + expect(blogPostHtml).toContain('data-file="[...slug].tsx"'); + expect(blogPostHtml).toContain("data-dir="); + expect(blogPostHtml).toContain(platformPath('/pages/blog"')); // The full path will include the temp directory + expect(blogPostHtml).toContain("data-path="); + expect(blogPostHtml).toContain(platformPath('/pages/blog/[...slug].tsx"')); + + // Check docs catch-all route + const docsHtml = await Bun.file( + path.join(dir, "dist", "docs", "guides", "advanced", "optimization", "index.html"), + ).text(); + + expect(docsHtml).toContain("Reading docs at:"); + expect(docsHtml).toContain("guides/advanced/optimization"); + expect(docsHtml).toContain('data-file="[...path].tsx"'); + expect(docsHtml).toContain(platformPath('/pages/docs/[...path].tsx"')); + + // Check that the static getting-started page uses its own file name, not the catch-all + const staticHtml = await Bun.file(path.join(dir, "dist", "docs", "getting-started", "index.html")).text(); + + expect(staticHtml).toContain("Getting Started"); + expect(staticHtml).toContain("This is a static page"); + expect(staticHtml).toContain('data-file="getting-started.tsx"'); + expect(staticHtml).toContain(platformPath('/pages/docs/getting-started.tsx"')); + expect(staticHtml).not.toContain("[...path].tsx"); + + // Verify that import.meta values are consistent across all catch-all instances + const blogIndex = await Bun.file( + path.join(dir, "dist", "blog", "tutorials", "getting-started", "index.html"), + ).text(); + expect(blogIndex).toContain('data-file="[...slug].tsx"'); + expect(blogIndex).toContain(platformPath('/pages/blog/[...slug].tsx"')); + }); + + test("handles build with no pages directory without crashing", async () => { + const dir = await tempDirWithBakeDeps("bake-production-no-pages", { + "app.ts": `export default { app: { framework: "react" } };`, + "package.json": JSON.stringify({ + "name": "test-app", + "version": "1.0.0", + "devDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0", + }, + }), + }); + + // Run the build command - should not crash even with no pages + const { exitCode, stderr } = await Bun.$`${bunExe()} build --app ./app.ts`.cwd(dir).throws(false); + + // The build should complete successfully (or fail gracefully, not crash) + // We're testing that it doesn't crash with the StringBuilder assertion + expect(exitCode).toBeDefined(); + + // If it fails, it should be a graceful failure, not a crash + if (exitCode !== 0) { + expect(stderr.toString()).not.toContain("reached unreachable code"); + expect(stderr.toString()).not.toContain("assert(this.cap > 0)"); + } + }); + + test("client-side component with default import should work", async () => { + const dir = await tempDirWithBakeDeps("bake-production-client-import", { + "src/index.tsx": `export default { app: { framework: "react" } };`, + "pages/index.tsx": `import Client from "../components/Client"; + +export default function IndexPage() { + return ( +
+ LMAOHello World + +
+ ); +}`, + "components/Client.tsx": `"use client"; + +export default function Client() { + console.log("Client-side!"); + return
Hello World
; +}`, + "package.json": JSON.stringify({ + "name": "test-app", + "version": "1.0.0", + "devDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0", + }, + }), + }); + + // Run the build command + const { exitCode, stderr } = await Bun.$`${bunExe()} build --app ./src/index.tsx`.cwd(dir).throws(false); + + expect(exitCode).toBe(0); + + // Check the generated HTML file for pages/index.tsx + const htmlPage = path.join(dir, "dist", "index.html"); + expect(existsSync(htmlPage)).toBe(true); + + const htmlContent = await Bun.file(htmlPage).text(); + + // Verify the static content is rendered + expect(htmlContent).toContain("LMAO"); + expect(htmlContent).toContain("Hello World"); + }); + + test("importing useState server-side", async () => { + const dir = await tempDirWithBakeDeps("bake-production-react-import", { + "src/index.tsx": `export default { app: { framework: "react" } };`, + "pages/index.tsx": `import { useState } from 'react'; + +export default function IndexPage() { + const [count, setCount] = useState(0); + + return ( +
+ LMAOHello World + +
+ ); +}`, + "package.json": JSON.stringify({ + "name": "test-app", + "version": "1.0.0", + "devDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0", + }, + }), + }); + + // Run the build command + const { exitCode, stderr } = await Bun.$`${bunExe()} build --app ./src/index.tsx`.cwd(dir).throws(false); + + // The build should succeed - client components should support default imports + expect(stderr.toString()).toContain( + '"useState" is not available in a server component. If you need interactivity, consider converting part of this to a Client Component (by adding `"use client";` to the top of the file).', + ); + expect(exitCode).toBe(1); + }); + + test("importing useState from client component", async () => { + const dir = await tempDirWithBakeDeps("bake-production-client-useState", { + "src/index.tsx": ` + const bundlerOptions = { + sourcemap: "inline", + minify: { + whitespace: false, + identifiers: false, + syntax: false, + }, +}; +export default { app: { framework: "react", bundlerOptions: { server: bundlerOptions, client: bundlerOptions, ssr: bundlerOptions } } };`, + "pages/index.tsx": `import Counter from "../components/Counter"; + +export default function IndexPage() { + return ( +
+

Counter Example

+ +
+ ); +}`, + "components/Counter.tsx": `"use client"; +import { useState } from 'react'; + +export default function Counter() { + const [count, setCount] = useState(0); + + return ( +
+

Count: {count}

+ +
+ ); +}`, + "package.json": JSON.stringify({ + "name": "test-app", + "version": "1.0.0", + "devDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0", + }, + }), + }); + + // Run the build command + const { exitCode, stderr } = await Bun.$`${bunExe()} build --app ./src/index.tsx`.cwd(dir).throws(false); + + // The build should succeed - client components CAN use useState + expect(stderr.toString()).not.toContain("useState"); + expect(exitCode).toBe(0); + + // Check the generated HTML file + const htmlPage = path.join(dir, "dist", "index.html"); + expect(existsSync(htmlPage)).toBe(true); + + const htmlContent = await Bun.file(htmlPage).text(); + + // Verify the static content is rendered + expect(htmlContent).toContain("

Counter Example

"); + + // Verify client component script tags exist + expect(htmlContent).toContain(" { + const dir = await tempDirWithBakeDeps("bake-production-no-client-js", { + "src/index.tsx": `export default { app: { framework: "react" } };`, + "pages/index.tsx": ` +export default function IndexPage() { + return ( +
+ Hello World +
+ ); +}`, + "package.json": JSON.stringify({ + "name": "test-app", + "version": "1.0.0", + "devDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0", + }, + }), + }); + + // Run the build command + const { exitCode, stderr } = await Bun.$`${bunExe()} build --app ./src/index.tsx`.cwd(dir).throws(false); + + // The build should succeed + // expect(stderr.toString()).toBe(""); + expect(exitCode).toBe(0); + + // Check the generated HTML file + const htmlPage = path.join(dir, "dist", "index.html"); + expect(existsSync(htmlPage)).toBe(true); + + const htmlContent = await Bun.file(htmlPage).text(); + + // Verify the content is rendered + expect(htmlContent).toContain("Hello World"); + + // Verify NO JavaScript imports are included in the HTML + expect(htmlContent).not.toContain('