Compare commits

...

45 Commits

Author SHA1 Message Date
Zack Radisic
59f12d30b3 response redirect 2025-08-25 17:33:32 -07:00
Zack Radisic
f0d4fa8b63 support return Response.render(...) 2025-08-25 15:36:55 -07:00
Zack Radisic
3fb0a824cb wip 2025-08-21 18:07:19 -07:00
Zack Radisic
ab3566627d comment 2025-08-20 15:11:07 -07:00
Zack Radisic
3906407e5d stuff 2025-08-20 15:07:39 -07:00
Zack Radisic
33447ef2db that was way more complicated then need be 2025-08-20 15:00:46 -07:00
Zack Radisic
3760407908 stuff 2025-08-19 20:07:10 -07:00
Zack Radisic
c1f0ce277d comment that cursed shit 2025-08-19 19:54:31 -07:00
Zack Radisic
bfe3041179 cookie + request 2025-08-19 19:50:10 -07:00
Zack Radisic
5b6344cf3c make it work 2025-08-19 19:29:25 -07:00
Zack Radisic
b4fdf41ea5 WIP 2025-08-19 16:18:28 -07:00
Zack Radisic
b9da6b71f9 stupid hack 2025-08-19 00:36:34 -07:00
Zack Radisic
87487468f3 wip 2025-08-19 00:07:31 -07:00
Zack Radisic
cfdeb42023 WIP 2025-08-18 22:23:30 -07:00
Zack Radisic
20e4c094ac support `streaming = false | true= 2025-08-18 17:13:31 -07:00
Zack Radisic
17be416250 Merge branch 'main' into zack/ssg-3 2025-08-18 16:54:59 -07:00
Zack Radisic
9745f01041 Merge branch 'zack/dev-server-sourcemaps-server-side' into zack/ssg-3 2025-08-13 16:32:48 -07:00
Zack Radisic
16131f92e1 Merge branch 'main' into zack/dev-server-sourcemaps-server-side 2025-08-13 16:32:02 -07:00
Zack Radisic
59a4d0697b fix 2025-08-13 16:31:27 -07:00
Zack Radisic
78a2ae44aa move change back 2025-08-13 16:30:44 -07:00
Zack Radisic
7f295919a9 Merge branch 'main' into zack/ssg-3 2025-08-13 16:28:49 -07:00
Zack Radisic
1d0984b5c4 Merge branch 'main' into zack/dev-server-sourcemaps-server-side 2025-08-08 17:23:33 -07:00
Zack Radisic
dfa93a8ede small 2025-08-08 17:18:19 -07:00
Zack Radisic
c8773c5e30 error modal on ssr error 2025-08-07 20:04:58 -07:00
Zack Radisic
0f74fafc59 cleanup 2025-08-07 18:24:03 -07:00
Zack Radisic
47d6e161fe fix that 2025-08-07 18:10:05 -07:00
Zack Radisic
160625c37c remove debugging stuff 2025-08-06 18:17:46 -07:00
Zack Radisic
1b9b686772 fix compile errors from merge 2025-08-05 20:22:09 -07:00
Zack Radisic
6f3e098bac Merge branch 'main' into zack/dev-server-sourcemaps-server-side 2025-08-05 17:14:04 -07:00
Zack Radisic
4c6b296a7c okie dokie 2025-08-05 17:04:54 -07:00
Zack Radisic
2ab962bf6b small stuff 2025-07-31 15:32:34 -07:00
Zack Radisic
f556fc987c test 2025-07-30 21:56:09 -07:00
Zack Radisic
3a1b12ee61 no need to percent encode or add "file://" to server-side sourcemaps 2025-07-30 17:55:52 -07:00
Zack Radisic
a952b4200e fix that 2025-07-30 15:58:50 -07:00
Zack Radisic
24485fb432 WIP 2025-07-29 17:24:53 -07:00
Zack Radisic
b10fda0487 allow app configuration form Bun.serve(...) 2025-07-28 15:21:39 -07:00
Zack Radisic
740cdaba3d - fix catch-all routes not working in dev server
- fix crash
- fix require bug
2025-07-27 18:31:01 -07:00
Zack Radisic
68be15361a fix use after free 2025-07-27 01:19:53 -07:00
Zack Radisic
c57be8dcdb extra option 2025-07-27 00:57:29 -07:00
Zack Radisic
5115a88126 Merge branch 'main' into zack/ssg-3 2025-07-23 13:49:18 -07:00
Zack Radisic
e992b804c8 fix 2025-07-23 13:47:55 -07:00
Zack Radisic
b92555e099 better error message 2025-07-23 11:35:23 -07:00
Zack Radisic
381848cd69 WIP less noisy errors 2025-07-22 16:40:46 -07:00
Zack Radisic
61f9845f80 Merge branch 'main' into zack/ssg-3 2025-07-21 23:46:54 -07:00
Zack Radisic
abc52da7bb add check for React.useState 2025-07-21 13:31:51 -07:00
42 changed files with 2323 additions and 180 deletions

View File

@@ -3,6 +3,7 @@ packages/bun-usockets/src/crypto/sni_tree.cpp
src/bake/BakeGlobalObject.cpp
src/bake/BakeProduction.cpp
src/bake/BakeSourceProvider.cpp
src/bake/DevServerSourceProvider.cpp
src/bun.js/bindings/ActiveDOMCallback.cpp
src/bun.js/bindings/AsymmetricKeyValue.cpp
src/bun.js/bindings/AsyncContextFrame.cpp
@@ -391,6 +392,7 @@ src/bun.js/bindings/webcore/ReadableStreamDefaultController.cpp
src/bun.js/bindings/webcore/ReadableStreamSink.cpp
src/bun.js/bindings/webcore/ReadableStreamSource.cpp
src/bun.js/bindings/webcore/ResourceTiming.cpp
src/bun.js/bindings/webcore/ResponseHelpers.cpp
src/bun.js/bindings/webcore/RFC7230.cpp
src/bun.js/bindings/webcore/SerializedScriptValue.cpp
src/bun.js/bindings/webcore/ServerTiming.cpp

View File

@@ -1,5 +1,6 @@
src/js/builtins.d.ts
src/js/builtins/Bake.ts
src/js/builtins/BakeSSRResponse.ts
src/js/builtins/BundlerPlugin.ts
src/js/builtins/ByteLengthQueuingStrategy.ts
src/js/builtins/CommonJS.ts

View File

@@ -252,6 +252,12 @@ pub inline fn downcast(a: Allocator) ?*AllocationScope {
null;
}
pub fn leakSlice(scope: *AllocationScope, memory: anytype) void {
if (comptime !enabled) return;
_ = @typeInfo(@TypeOf(memory)).pointer;
bun.assert(!scope.trackExternalFree(memory, null));
}
const std = @import("std");
const Allocator = std.mem.Allocator;

View File

@@ -1439,9 +1439,20 @@ pub fn VisitExpr(
// 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) {
check_for_usestate: {
if (e_.target.data == .e_import_identifier) break :check_for_usestate true;
// Also check for `React.useState(...)`
if (e_.target.data == .e_dot and e_.target.data.e_dot.target.data == .e_import_identifier) {
const id = e_.target.data.e_dot.target.data.e_import_identifier;
const name = p.symbols.items[id.ref.innerIndex()].original_name;
break :check_for_usestate bun.strings.eqlComptime(name, "React");
}
break :check_for_usestate false;
}) {
bun.assert(p.options.features.server_components.isServerSide());
if (bun.strings.eqlComptime(original_name, "useState")) {
if (!bun.strings.startsWith(p.source.path.pretty, "node_modules") and
bun.strings.eqlComptime(original_name, "useState"))
{
p.log.addError(
p.source,
expr.loc,

View File

@@ -27,9 +27,6 @@ pub const UserOptions = struct {
/// Currently, this function must run at the top of the event loop.
pub fn fromJS(config: JSValue, global: *jsc.JSGlobalObject) !UserOptions {
if (!config.isObject()) {
return global.throwInvalidArguments("'" ++ api_name ++ "' is not an object", .{});
}
var arena = std.heap.ArenaAllocator.init(bun.default_allocator);
errdefer arena.deinit();
const alloc = arena.allocator();
@@ -38,6 +35,38 @@ pub const UserOptions = struct {
errdefer allocations.free();
var bundler_options = SplitBundlerOptions.empty;
if (!config.isObject()) {
// Allow users to do `export default { app: 'react' }` for convenience
if (config.isString()) {
const bunstr = try config.toBunString(global);
defer bunstr.deref();
const utf8_string = bunstr.toUTF8(bun.default_allocator);
defer utf8_string.deinit();
if (bun.strings.eql(utf8_string.byteSlice(), "react")) {
const root = bun.getcwdAlloc(alloc) catch |err| switch (err) {
error.OutOfMemory => {
return global.throwOutOfMemory();
},
else => {
return global.throwError(err, "while querying current working directory");
},
};
const framework = try Framework.react(alloc);
return UserOptions{
.arena = arena,
.allocations = allocations,
.root = try alloc.dupeZ(u8, root),
.framework = framework,
.bundler_options = bundler_options,
};
}
}
return global.throwInvalidArguments("'" ++ api_name ++ "' is not an object", .{});
}
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);

View File

@@ -1,5 +1,6 @@
// clang-format off
#include "BakeSourceProvider.h"
#include "DevServerSourceProvider.h"
#include "BakeGlobalObject.h"
#include "JavaScriptCore/CallData.h"
#include "JavaScriptCore/Completion.h"
@@ -78,6 +79,34 @@ extern "C" JSC::EncodedJSValue BakeLoadServerHmrPatch(GlobalObject* global, BunS
return JSC::JSValue::encode(result);
}
extern "C" JSC::EncodedJSValue BakeLoadServerHmrPatchWithSourceMap(GlobalObject* global, BunString source, const char* sourceMapJSONPtr, size_t sourceMapJSONLength) {
JSC::VM&vm = global->vm();
auto scope = DECLARE_THROW_SCOPE(vm);
String string = "bake://server.patch.js"_s;
JSC::SourceOrigin origin = JSC::SourceOrigin(WTF::URL(string));
// Use DevServerSourceProvider with the source map JSON
auto provider = DevServerSourceProvider::create(
global,
source.toWTFString(),
sourceMapJSONPtr,
sourceMapJSONLength,
origin,
WTFMove(string),
WTF::TextPosition(),
JSC::SourceProviderSourceType::Program
);
JSC::SourceCode sourceCode = JSC::SourceCode(provider);
JSC::JSValue result = vm.interpreter.executeProgram(sourceCode, global, global);
RETURN_IF_EXCEPTION(scope, {});
RELEASE_ASSERT(result);
return JSC::JSValue::encode(result);
}
extern "C" JSC::EncodedJSValue BakeGetModuleNamespace(
JSC::JSGlobalObject* global,
JSC::JSValue keyValue

View File

@@ -839,6 +839,7 @@ fn onJsRequest(dev: *DevServer, req: *Request, resp: AnyResponse) void {
arena.allocator(),
source_id.kind,
dev.allocator,
.client,
) catch bun.outOfMemory();
const response = StaticRoute.initFromAnyBlob(&.fromOwnedSlice(dev.allocator, json_bytes), .{
.server = dev.server,
@@ -950,7 +951,7 @@ fn ensureRouteIsBundled(
dev: *DevServer,
route_bundle_index: RouteBundle.Index,
kind: DeferredRequest.Handler.Kind,
req: *Request,
req: ReqOrSaved,
resp: AnyResponse,
) bun.JSError!void {
assert(dev.magic == .valid);
@@ -1065,35 +1066,60 @@ fn ensureRouteIsBundled(
);
},
.loaded => switch (kind) {
.server_handler => try dev.onFrameworkRequestWithBundle(route_bundle_index, .{ .stack = req }, resp),
.bundled_html_page => dev.onHtmlRequestWithBundle(route_bundle_index, resp, bun.http.Method.which(req.method()) orelse .POST),
.server_handler => try dev.onFrameworkRequestWithBundle(route_bundle_index, if (req == .req) .{ .stack = req.req } else .{ .saved = req.saved }, resp),
.bundled_html_page => dev.onHtmlRequestWithBundle(route_bundle_index, resp, req.method()),
},
}
}
const ReqOrSaved = union(enum) {
req: *Request,
saved: bun.jsc.API.SavedRequest,
pub fn method(this: *const @This()) bun.http.Method {
return switch (this.*) {
.req => |req| bun.http.Method.which(req.method()) orelse .POST,
.saved => |saved| saved.request.method,
};
}
};
fn deferRequest(
dev: *DevServer,
requests_array: *DeferredRequest.List,
route_bundle_index: RouteBundle.Index,
kind: DeferredRequest.Handler.Kind,
req: *Request,
req: ReqOrSaved,
resp: AnyResponse,
) !void {
const deferred = dev.deferred_request_pool.get();
const method = bun.http.Method.which(req.method()) orelse .POST;
debug.log("DeferredRequest(0x{x}).init", .{@intFromPtr(&deferred.data)});
const method = req.method();
deferred.data = .{
.route_bundle_index = route_bundle_index,
.dev = dev,
.ref_count = .init(),
.handler = switch (kind) {
.bundled_html_page => .{ .bundled_html_page = .{ .response = resp, .method = method } },
.server_handler => .{
.server_handler = dev.server.?.prepareAndSaveJsRequestContext(req, resp, dev.vm.global, method) orelse return,
.bundled_html_page => brk: {
resp.onAborted(*DeferredRequest, DeferredRequest.onAbort, &deferred.data);
break :brk .{ .bundled_html_page = .{ .response = resp, .method = method } };
},
.server_handler => brk: {
const server_handler = switch (req) {
.req => |r| dev.server.?.prepareAndSaveJsRequestContext(r, resp, dev.vm.global, method) orelse {
dev.deferred_request_pool.put(deferred);
return;
},
.saved => |saved| saved,
};
server_handler.ctx.setAbortCallback(DeferredRequest.onAbortWrapper, &deferred.data);
break :brk .{
.server_handler = server_handler,
};
},
},
};
deferred.data.ref();
resp.onAborted(*DeferredRequest, DeferredRequest.onAbort, &deferred.data);
requests_array.prepend(deferred);
}
@@ -1181,7 +1207,7 @@ fn onFrameworkRequestWithBundle(
const route_bundle = dev.routeBundlePtr(route_bundle_index);
assert(route_bundle.data == .framework);
const bundle = &route_bundle.data.framework;
const framework_bundle = &route_bundle.data.framework;
// Extract route params by re-matching the URL
var params: FrameworkRouter.MatchedParams = undefined;
@@ -1230,7 +1256,7 @@ fn onFrameworkRequestWithBundle(
const value_str = bun.String.cloneUTF8(param.value);
defer value_str.deref();
obj.put(global, key_str, value_str.toJS(global));
_ = try obj.putBunStringOneOrArray(global, &key_str, value_str.toJS(global));
}
break :blk obj;
} else JSValue.null;
@@ -1238,13 +1264,31 @@ fn onFrameworkRequestWithBundle(
const server_request_callback = dev.server_fetch_function_callback.get() orelse
unreachable; // did not initialize server code
const router_type = dev.router.typePtr(dev.router.routePtr(bundle.route_index).type);
const router_type = dev.router.typePtr(dev.router.routePtr(framework_bundle.route_index).type);
dev.server.?.onRequestFromSaved(
// FIXME: We should not create these on every single request
// Wrapper functions for AsyncLocalStorage that match JSHostFnZig signature
const SetAsyncLocalStorageWrapper = struct {
pub fn call(global: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
return VirtualMachine.VirtualMachine__setDevServerAsyncLocalStorage(global, callframe);
}
};
const GetAsyncLocalStorageWrapper = struct {
pub fn call(global: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
return VirtualMachine.VirtualMachine__getDevServerAsyncLocalStorage(global, callframe);
}
};
// Create the setter and getter functions for AsyncLocalStorage
const setAsyncLocalStorage = jsc.JSFunction.create(dev.vm.global, "setDevServerAsyncLocalStorage", SetAsyncLocalStorageWrapper.call, 1, .{});
const getAsyncLocalStorage = jsc.JSFunction.create(dev.vm.global, "getDevServerAsyncLocalStorage", GetAsyncLocalStorageWrapper.call, 0, .{});
dev.server.?.onSavedRequest(
req,
resp,
server_request_callback,
5,
7,
.{
// routerTypeMain
router_type.server_file_string.get() orelse str: {
@@ -1256,17 +1300,17 @@ fn onFrameworkRequestWithBundle(
break :str str;
},
// routeModules
bundle.cached_module_list.get() orelse arr: {
framework_bundle.cached_module_list.get() orelse arr: {
const global = dev.vm.global;
const keys = dev.server_graph.bundled_files.keys();
var n: usize = 1;
var route = dev.router.routePtr(bundle.route_index);
var route = dev.router.routePtr(framework_bundle.route_index);
while (true) {
if (route.file_layout != .none) n += 1;
route = dev.router.routePtr(route.parent.unwrap() orelse break);
}
const arr = try JSValue.createEmptyArray(global, n);
route = dev.router.routePtr(bundle.route_index);
route = dev.router.routePtr(framework_bundle.route_index);
{
const relative_path_buf = bun.path_buffer_pool.get();
defer bun.path_buffer_pool.put(relative_path_buf);
@@ -1287,11 +1331,11 @@ fn onFrameworkRequestWithBundle(
}
route = dev.router.routePtr(route.parent.unwrap() orelse break);
}
bundle.cached_module_list = .create(arr, global);
framework_bundle.cached_module_list = .create(arr, global);
break :arr arr;
},
// clientId
bundle.cached_client_bundle_url.get() orelse str: {
framework_bundle.cached_client_bundle_url.get() orelse str: {
const bundle_index: u32 = route_bundle_index.get();
const generation: u32 = route_bundle.client_script_generation;
const str = bun.String.createFormat(client_prefix ++ "/route-{}{}.js", .{
@@ -1300,17 +1344,21 @@ fn onFrameworkRequestWithBundle(
}) catch bun.outOfMemory();
defer str.deref();
const js = str.toJS(dev.vm.global);
bundle.cached_client_bundle_url = .create(js, dev.vm.global);
framework_bundle.cached_client_bundle_url = .create(js, dev.vm.global);
break :str js;
},
// styles
bundle.cached_css_file_array.get() orelse arr: {
framework_bundle.cached_css_file_array.get() orelse arr: {
const js = dev.generateCssJSArray(route_bundle) catch bun.outOfMemory();
bundle.cached_css_file_array = .create(js, dev.vm.global);
framework_bundle.cached_css_file_array = .create(js, dev.vm.global);
break :arr js;
},
// params
params_js_value,
// setDevServerAsyncLocalStorage function
setAsyncLocalStorage,
// getDevServerAsyncLocalStorage function
getAsyncLocalStorage,
},
);
}
@@ -1476,7 +1524,7 @@ fn generateJavaScriptCodeForHTMLFile(
pub fn onJsRequestWithBundle(dev: *DevServer, bundle_index: RouteBundle.Index, resp: AnyResponse, method: bun.http.Method) void {
const route_bundle = dev.routeBundlePtr(bundle_index);
const blob = route_bundle.client_bundle orelse generate: {
const client_bundle = route_bundle.client_bundle orelse generate: {
const payload = dev.generateClientBundle(route_bundle) catch bun.outOfMemory();
errdefer dev.allocator.free(payload);
route_bundle.client_bundle = StaticRoute.initFromAnyBlob(
@@ -1489,7 +1537,7 @@ pub fn onJsRequestWithBundle(dev: *DevServer, bundle_index: RouteBundle.Index, r
break :generate route_bundle.client_bundle.?;
};
dev.source_maps.addWeakRef(route_bundle.sourceMapId());
blob.onWithMethod(method, resp);
client_bundle.onWithMethod(method, resp);
}
pub fn onSrcRequest(dev: *DevServer, req: *uws.Request, resp: anytype) void {
@@ -1539,7 +1587,11 @@ pub const DeferredRequest = struct {
pub const List = std.SinglyLinkedList(DeferredRequest);
pub const Node = List.Node;
const RefCount = bun.ptr.RefCount(@This(), "ref_count", deinitImpl, .{});
const RefCount = bun.ptr.RefCount(@This(), "ref_count", deinitImpl, .{
.debug_name = "DeferredRequest",
});
const debugLog = bun.Output.Scoped("DlogeferredRequest", .hidden).log;
route_bundle_index: RouteBundle.Index,
handler: Handler,
@@ -1572,8 +1624,18 @@ pub const DeferredRequest = struct {
};
};
fn onAbort(this: *DeferredRequest, resp: AnyResponse) void {
_ = resp;
fn onAbortWrapper(this: *anyopaque) void {
const self: *DeferredRequest = @alignCast(@ptrCast(this));
self.onAbortImpl();
}
fn onAbort(this: *DeferredRequest, _: AnyResponse) void {
this.onAbortImpl();
}
fn onAbortImpl(this: *DeferredRequest) void {
debugLog("DeferredRequest(0x{x}) onAbort", .{@intFromPtr(this)});
this.abort();
assert(this.handler == .aborted);
}
@@ -1584,6 +1646,7 @@ pub const DeferredRequest = struct {
/// such as for bundling failures or aborting the server.
/// Does not free the underlying `DeferredRequest.Node`
fn deinitImpl(this: *DeferredRequest) void {
debugLog("DeferredRequest(0x{x}) deinitImpl", .{@intFromPtr(this)});
this.ref_count.assertNoRefs();
defer this.dev.deferred_request_pool.put(@fieldParentPtr("data", this));
@@ -1595,11 +1658,11 @@ pub const DeferredRequest = struct {
/// Deinitializes state by aborting the connection.
fn abort(this: *DeferredRequest) void {
debugLog("DeferredRequest(0x{x}) abort", .{@intFromPtr(this)});
var handler = this.handler;
this.handler = .aborted;
switch (handler) {
.server_handler => |*saved| {
saved.ctx.onAbort(saved.response);
saved.js_request.deinit();
},
.bundled_html_page => |r| {
@@ -2266,14 +2329,70 @@ pub fn finalizeBundle(
// Load all new chunks into the server runtime.
if (!dev.frontend_only and dev.server_graph.current_chunk_len > 0) {
const server_bundle = try dev.server_graph.takeJSBundle(&.{ .kind = .hmr_chunk });
// Generate a script_id for server bundles
// Use high bit set to distinguish from client bundles, and include generation
const server_script_id = SourceMapStore.Key.init((1 << 63) | @as(u64, dev.generation));
// Get the source map if available and render to JSON
var source_map_json = if (dev.server_graph.current_chunk_source_maps.items.len > 0) json: {
// Create a temporary source map entry to render
var source_map_entry = SourceMapStore.Entry{
.ref_count = 1,
.paths = &.{},
.files = .empty,
.overlapping_memory_cost = 0,
};
// Fill the source map entry
var arena = std.heap.ArenaAllocator.init(dev.allocator);
defer arena.deinit();
try dev.server_graph.takeSourceMap(arena.allocator(), dev.allocator, &source_map_entry);
defer {
source_map_entry.ref_count = 0;
source_map_entry.deinit(dev);
}
const json_data = try source_map_entry.renderJSON(
dev,
arena.allocator(),
.hmr_chunk,
dev.allocator,
.server,
);
break :json json_data;
} else null;
defer if (source_map_json) |json| bun.default_allocator.free(json);
const server_bundle = try dev.server_graph.takeJSBundle(&.{
.kind = .hmr_chunk,
.script_id = server_script_id,
});
defer dev.allocator.free(server_bundle);
const server_modules = c.BakeLoadServerHmrPatch(@ptrCast(dev.vm.global), bun.String.cloneLatin1(server_bundle)) catch |err| {
// No user code has been evaluated yet, since everything is to
// be wrapped in a function clousure. This means that the likely
// error is going to be a syntax error, or other mistake in the
// bundler.
// TODO: is this the best place to set this? Would it be better to
// transpile the server modules to replace `new Response(...)` with `new
// ResponseBake(...)`??
dev.vm.setAllowJSXInResponseConstructor(true);
const server_modules = if (bun.take(&source_map_json)) |json| blk: {
// This memory will be owned by the `DevServerSourceProvider` in C++
// from here on out
dev.allocation_scope.leakSlice(json);
break :blk c.BakeLoadServerHmrPatchWithSourceMap(
@ptrCast(dev.vm.global),
bun.String.cloneUTF8(server_bundle),
json.ptr,
json.len,
) catch |err| {
// No user code has been evaluated yet, since everything is to
// be wrapped in a function clousure. This means that the likely
// error is going to be a syntax error, or other mistake in the
// bundler.
dev.vm.printErrorLikeObjectToConsole(dev.vm.global.takeException(err));
@panic("Error thrown while evaluating server code. This is always a bug in the bundler.");
};
} else c.BakeLoadServerHmrPatch(@ptrCast(dev.vm.global), bun.String.cloneLatin1(server_bundle)) catch |err| {
dev.vm.printErrorLikeObjectToConsole(dev.vm.global.takeException(err));
@panic("Error thrown while evaluating server code. This is always a bug in the bundler.");
};
@@ -2819,7 +2938,7 @@ fn onRequest(dev: *DevServer, req: *Request, resp: anytype) void {
dev.ensureRouteIsBundled(
dev.getOrPutRouteBundle(.{ .framework = route_index }) catch bun.outOfMemory(),
.server_handler,
req,
.{ .req = req },
AnyResponse.init(resp),
) catch bun.outOfMemory();
return;
@@ -2834,7 +2953,31 @@ fn onRequest(dev: *DevServer, req: *Request, resp: anytype) void {
}
pub fn respondForHTMLBundle(dev: *DevServer, html: *HTMLBundle.HTMLBundleRoute, req: *uws.Request, resp: AnyResponse) !void {
try dev.ensureRouteIsBundled(try dev.getOrPutRouteBundle(.{ .html = html }), .bundled_html_page, req, resp);
try dev.ensureRouteIsBundled(try dev.getOrPutRouteBundle(.{ .html = html }), .bundled_html_page, .{ .req = req }, resp);
}
// TODO: path params
pub fn handleRenderRedirect(
dev: *DevServer,
saved_request: bun.jsc.API.SavedRequest,
render_path: []const u8,
resp: AnyResponse,
) !void {
// Match the render path against the router
var params: FrameworkRouter.MatchedParams = undefined;
if (dev.router.matchSlow(render_path, &params)) |route_index| {
// Found a matching route, bundle it and handle the request
dev.ensureRouteIsBundled(
dev.getOrPutRouteBundle(.{ .framework = route_index }) catch bun.outOfMemory(),
.server_handler,
.{ .saved = saved_request },
resp,
) catch bun.outOfMemory();
return;
}
// No matching route found - render 404
sendBuiltInNotFound(resp);
}
fn getOrPutRouteBundle(dev: *DevServer, route: RouteBundle.UnresolvedIndex) !RouteBundle.Index {
@@ -3591,6 +3734,11 @@ const c = struct {
return bun.jsc.fromJSHostCall(global, @src(), f, .{ global, code });
}
fn BakeLoadServerHmrPatchWithSourceMap(global: *jsc.JSGlobalObject, code: bun.String, source_map_json_ptr: [*]const u8, source_map_json_len: usize) bun.JSError!JSValue {
const f = @extern(*const fn (*jsc.JSGlobalObject, bun.String, [*]const u8, usize) callconv(.c) JSValue, .{ .name = "BakeLoadServerHmrPatchWithSourceMap" }).*;
return bun.jsc.fromJSHostCall(global, @src(), f, .{ global, code, source_map_json_ptr, source_map_json_len });
}
fn BakeLoadInitialServerCode(global: *jsc.JSGlobalObject, code: bun.String, separate_ssr_graph: bool) bun.JSError!JSValue {
const f = @extern(*const fn (*jsc.JSGlobalObject, bun.String, bool) callconv(.c) JSValue, .{ .name = "BakeLoadInitialServerCode" }).*;
return bun.jsc.fromJSHostCall(global, @src(), f, .{ global, code, separate_ssr_graph });

View File

@@ -76,6 +76,12 @@ pub fn IncrementalGraph(side: bake.Side) type {
.server => void,
},
/// Source maps for server chunks
current_chunk_source_maps: if (side == .server) ArrayListUnmanaged(PackedMap.RefOrEmpty) else void = if (side == .server) .empty,
/// File indices for server chunks to track which file each chunk comes from
current_chunk_file_indices: if (side == .server) ArrayListUnmanaged(FileIndex) else void = if (side == .server) .empty,
pub const empty: @This() = .{
.bundled_files = .empty,
.stale_files = .empty,
@@ -89,6 +95,8 @@ pub fn IncrementalGraph(side: bake.Side) type {
.current_chunk_parts = .empty,
.current_css_files = if (side == .client) .empty,
.current_chunk_source_maps = if (side == .server) .empty else {},
.current_chunk_file_indices = if (side == .server) .empty else {},
};
pub const File = switch (side) {
@@ -324,6 +332,13 @@ pub fn IncrementalGraph(side: bake.Side) type {
.current_chunk_len = {},
.current_chunk_parts = g.current_chunk_parts.deinit(allocator),
.current_css_files = if (side == .client) g.current_css_files.deinit(allocator),
.current_chunk_source_maps = if (side == .server) {
for (g.current_chunk_source_maps.items) |source_map| {
source_map.deref(&g.owner().*);
}
g.current_chunk_source_maps.deinit(allocator);
},
.current_chunk_file_indices = if (side == .server) g.current_chunk_file_indices.deinit(allocator),
};
}
@@ -356,6 +371,14 @@ pub fn IncrementalGraph(side: bake.Side) type {
.empty => {},
}
}
} else if (side == .server) {
graph += DevServer.memoryCostArrayList(g.current_chunk_source_maps);
graph += DevServer.memoryCostArrayList(g.current_chunk_file_indices);
for (g.current_chunk_source_maps.items) |source_map| {
if (source_map == .ref) {
source_maps += source_map.ref.data.memoryCostWithDedupe(new_dedupe_bits);
}
}
}
return .{
.graph = graph,
@@ -488,7 +511,7 @@ pub fn IncrementalGraph(side: bake.Side) type {
.js => |js| {
// Insert new source map or patch existing empty source map.
const source_map: PackedMap.RefOrEmpty = brk: {
if (js.source_map) |source_map| {
if (js.source_map) |*source_map| {
bun.debugAssert(!flags.is_html_route); // suspect behind #17956
if (source_map.chunk.buffer.len() > 0) {
flags.source_map_state = .ref;
@@ -585,12 +608,40 @@ pub fn IncrementalGraph(side: bake.Side) type {
if (content == .js) {
try g.current_chunk_parts.append(dev.allocator, content.js.code);
g.current_chunk_len += content.js.code.len;
if (content.js.source_map) |source_map| {
var take = source_map.chunk.buffer;
take.deinit();
if (source_map.escaped_source) |escaped_source| {
bun.default_allocator.free(escaped_source);
// Track the file index for this chunk
try g.current_chunk_file_indices.append(dev.allocator, file_index);
// TODO: we probably want to store SSR chunks but not
// server chunks, but not 100% sure
const should_immediately_free_sourcemap = false;
if (should_immediately_free_sourcemap) {
if (content.js.source_map) |source_map| {
var take = source_map.chunk.buffer;
take.deinit();
if (source_map.escaped_source) |escaped_source| {
bun.default_allocator.free(escaped_source);
}
}
} else {
if (content.js.source_map) |source_map| append_empty: {
const packed_map = PackedMap.newNonEmpty(source_map.chunk, source_map.escaped_source orelse break :append_empty);
try g.current_chunk_source_maps.append(dev.allocator, .{
.ref = packed_map,
});
return;
}
// Must precompute this. Otherwise, source maps won't have
// the info needed to concatenate VLQ mappings.
const count: u32 = @intCast(bun.strings.countChar(content.js.code, '\n'));
try g.current_chunk_source_maps.append(dev.allocator, PackedMap.RefOrEmpty{
.empty = .{
.line_count = .init(count),
// TODO: not sure if this is correct
.html_bundle_route_index = .none,
},
});
}
}
},
@@ -1577,7 +1628,12 @@ pub fn IncrementalGraph(side: bake.Side) type {
g.owner().graph_safety_lock.assertLocked();
g.current_chunk_len = 0;
g.current_chunk_parts.clearRetainingCapacity();
if (side == .client) g.current_css_files.clearRetainingCapacity();
if (side == .client) {
g.current_css_files.clearRetainingCapacity();
} else if (side == .server) {
g.current_chunk_source_maps.clearRetainingCapacity();
g.current_chunk_file_indices.clearRetainingCapacity();
}
}
const TakeJSBundleOptions = switch (side) {
@@ -1590,6 +1646,7 @@ pub fn IncrementalGraph(side: bake.Side) type {
},
.server => struct {
kind: ChunkKind,
script_id: SourceMapStore.Key,
},
};
@@ -1732,44 +1789,70 @@ pub fn IncrementalGraph(side: bake.Side) type {
};
/// Uses `arena` as a temporary allocator, fills in all fields of `out` except ref_count
pub fn takeSourceMap(g: *@This(), arena: std.mem.Allocator, gpa: Allocator, out: *SourceMapStore.Entry) bun.OOM!void {
if (side == .server) @compileError("not implemented");
pub fn takeSourceMap(g: *@This(), _: std.mem.Allocator, gpa: Allocator, out: *SourceMapStore.Entry) bun.OOM!void {
const paths = g.bundled_files.keys();
const files = g.bundled_files.values();
// This buffer is temporary, holding the quoted source paths, joined with commas.
var source_map_strings = std.ArrayList(u8).init(arena);
defer source_map_strings.deinit();
switch (side) {
.client => {
const files = g.bundled_files.values();
const buf = bun.path_buffer_pool.get();
defer bun.path_buffer_pool.put(buf);
const buf = bun.path_buffer_pool.get();
defer bun.path_buffer_pool.put(buf);
var file_paths = try ArrayListUnmanaged([]const u8).initCapacity(gpa, g.current_chunk_parts.items.len);
errdefer file_paths.deinit(gpa);
var contained_maps: bun.MultiArrayList(PackedMap.RefOrEmpty) = .empty;
try contained_maps.ensureTotalCapacity(gpa, g.current_chunk_parts.items.len);
errdefer contained_maps.deinit(gpa);
var file_paths = try ArrayListUnmanaged([]const u8).initCapacity(gpa, g.current_chunk_parts.items.len);
errdefer file_paths.deinit(gpa);
var contained_maps: bun.MultiArrayList(PackedMap.RefOrEmpty) = .empty;
try contained_maps.ensureTotalCapacity(gpa, g.current_chunk_parts.items.len);
errdefer contained_maps.deinit(gpa);
var overlapping_memory_cost: u32 = 0;
var overlapping_memory_cost: u32 = 0;
for (g.current_chunk_parts.items) |file_index| {
file_paths.appendAssumeCapacity(paths[file_index.get()]);
const source_map = files[file_index.get()].sourceMap();
contained_maps.appendAssumeCapacity(source_map.dupeRef());
if (source_map == .ref) {
overlapping_memory_cost += @intCast(source_map.ref.data.memoryCost());
}
for (g.current_chunk_parts.items) |file_index| {
file_paths.appendAssumeCapacity(paths[file_index.get()]);
const source_map = files[file_index.get()].sourceMap();
contained_maps.appendAssumeCapacity(source_map.dupeRef());
if (source_map == .ref) {
overlapping_memory_cost += @intCast(source_map.ref.data.memoryCost());
}
}
overlapping_memory_cost += @intCast(contained_maps.memoryCost() + DevServer.memoryCostSlice(file_paths.items));
out.* = .{
.ref_count = out.ref_count,
.paths = file_paths.items,
.files = contained_maps,
.overlapping_memory_cost = overlapping_memory_cost,
};
},
.server => {
var file_paths = try ArrayListUnmanaged([]const u8).initCapacity(gpa, g.current_chunk_parts.items.len);
errdefer file_paths.deinit(gpa);
var contained_maps: bun.MultiArrayList(PackedMap.RefOrEmpty) = .empty;
try contained_maps.ensureTotalCapacity(gpa, g.current_chunk_parts.items.len);
errdefer contained_maps.deinit(gpa);
var overlapping_memory_cost: u32 = 0;
// For server, we use the tracked file indices to get the correct paths
for (g.current_chunk_file_indices.items, g.current_chunk_source_maps.items) |file_index, source_map| {
file_paths.appendAssumeCapacity(paths[file_index.get()]);
contained_maps.appendAssumeCapacity(source_map.dupeRef());
if (source_map == .ref) {
overlapping_memory_cost += @intCast(source_map.ref.data.memoryCost());
}
}
overlapping_memory_cost += @intCast(contained_maps.memoryCost() + DevServer.memoryCostSlice(file_paths.items));
out.* = .{
.ref_count = out.ref_count,
.paths = file_paths.items,
.files = contained_maps,
.overlapping_memory_cost = overlapping_memory_cost,
};
},
}
overlapping_memory_cost += @intCast(contained_maps.memoryCost() + DevServer.memoryCostSlice(file_paths.items));
out.* = .{
.ref_count = out.ref_count,
.paths = file_paths.items,
.files = contained_maps,
.overlapping_memory_cost = overlapping_memory_cost,
};
}
fn disconnectAndDeleteFile(g: *@This(), file_index: FileIndex) void {

View File

@@ -75,11 +75,11 @@ pub const Entry = struct {
pub fn renderMappings(map: Entry, kind: ChunkKind, arena: Allocator, gpa: Allocator) ![]u8 {
var j: StringJoiner = .{ .allocator = arena };
j.pushStatic("AAAA");
try joinVLQ(&map, kind, &j, arena);
try joinVLQ(&map, kind, &j, arena, .client);
return j.done(gpa);
}
pub fn renderJSON(map: *const Entry, dev: *DevServer, arena: Allocator, kind: ChunkKind, gpa: Allocator) ![]u8 {
pub fn renderJSON(map: *const Entry, dev: *DevServer, arena: Allocator, kind: ChunkKind, gpa: Allocator, side: bake.Side) ![]u8 {
const map_files = map.files.slice();
const paths = map.paths;
@@ -105,13 +105,22 @@ pub const Entry = struct {
if (std.fs.path.isAbsolute(path)) {
const is_windows_drive_path = Environment.isWindows and path[0] != '/';
try source_map_strings.appendSlice(if (is_windows_drive_path)
"\"file:///"
else
"\"file://");
// On the client we prefix the sourcemap path with "file://" and
// percent encode it
if (side == .client) {
try source_map_strings.appendSlice(if (is_windows_drive_path)
"\"file:///"
else
"\"file://");
} else {
try source_map_strings.append('"');
}
if (Environment.isWindows and !is_windows_drive_path) {
// UNC namespace -> file://server/share/path.ext
bun.strings.percentEncodeWrite(
encodeSourceMapPath(
side,
if (path.len > 2 and path[0] == '/' and path[1] == '/')
path[2..]
else
@@ -126,7 +135,7 @@ pub const Entry = struct {
// -> file:///path/to/file.js
// windows drive letter paths have the extra slash added
// -> file:///C:/path/to/file.js
bun.strings.percentEncodeWrite(path, &source_map_strings) catch |err| switch (err) {
encodeSourceMapPath(side, path, &source_map_strings) catch |err| switch (err) {
error.IncompleteUTF8 => @panic("Unexpected: asset with incomplete UTF-8 as file path"),
error.OutOfMemory => |e| return e,
};
@@ -174,14 +183,14 @@ pub const Entry = struct {
j.pushStatic(
\\],"names":[],"mappings":"AAAA
);
try joinVLQ(map, kind, &j, arena);
try joinVLQ(map, kind, &j, arena, side);
const json_bytes = try j.doneWithEnd(gpa, "\"}");
errdefer @compileError("last try should be the final alloc");
if (bun.FeatureFlags.bake_debugging_features) if (dev.dump_dir) |dump_dir| {
const rel_path_escaped = "latest_chunk.js.map";
dumpBundle(dump_dir, .client, rel_path_escaped, json_bytes, false) catch |err| {
const rel_path_escaped = if (side == .client) "latest_chunk.js.map" else "latest_hmr.js.map";
dumpBundle(dump_dir, if (side == .client) .client else .server, rel_path_escaped, json_bytes, false) catch |err| {
bun.handleErrorReturnTrace(err, @errorReturnTrace());
Output.warn("Could not dump bundle: {}", .{err});
};
@@ -190,13 +199,22 @@ pub const Entry = struct {
return json_bytes;
}
fn joinVLQ(map: *const Entry, kind: ChunkKind, j: *StringJoiner, arena: Allocator) !void {
const map_files = map.files.slice();
fn encodeSourceMapPath(
side: bake.Side,
utf8_input: []const u8,
writer: *std.ArrayList(u8),
) error{ OutOfMemory, IncompleteUTF8 }!void {
// On the client, percent encode everything so it works in the browser
if (side == .client) {
return bun.strings.percentEncodeWrite(utf8_input, writer);
}
const runtime: bake.HmrRuntime = switch (kind) {
.initial_response => bun.bake.getHmrRuntime(.client),
.hmr_chunk => comptime .init("self[Symbol.for(\"bun:hmr\")]({\n"),
};
// On the server, we don't need to do anything
try writer.appendSlice(utf8_input);
}
fn joinVLQ(map: *const Entry, kind: ChunkKind, j: *StringJoiner, arena: Allocator, side: bake.Side) !void {
const map_files = map.files.slice();
var prev_end_state: SourceMap.SourceMapState = .{
.generated_line = 0,
@@ -206,8 +224,20 @@ pub const Entry = struct {
.original_column = 0,
};
// +2 because the magic fairy in my dreams said it would align the source maps.
var lines_between: u32 = runtime.line_count + 2;
var lines_between: u32 = lines_between: {
if (side == .client) {
const runtime: bake.HmrRuntime = switch (kind) {
.initial_response => bun.bake.getHmrRuntime(.client),
.hmr_chunk => comptime .init("self[Symbol.for(\"bun:hmr\")]({\n"),
};
// +2 because the magic fairy in my dreams said it would align the source maps.
// TODO: why the fuck is this 2?
const lines_between: u32 = runtime.line_count + 2;
break :lines_between lines_between;
}
break :lines_between 0;
};
// Join all of the mappings together.
for (map_files.items(.tags), map_files.items(.data), 1..) |tag, chunk, source_index| switch (tag) {
@@ -223,7 +253,7 @@ pub const Entry = struct {
continue;
},
.ref => {
const content = chunk.ref.data;
const content: *PackedMap = chunk.ref.data;
const start_state: SourceMap.SourceMapState = .{
.source_index = @intCast(source_index),
.generated_line = @intCast(lines_between),

View File

@@ -0,0 +1,17 @@
#include "DevServerSourceProvider.h"
#include "BunBuiltinNames.h"
#include "BunString.h"
// The Zig implementation will be provided to handle registration
extern "C" void Bun__addDevServerSourceProvider(void* bun_vm, Bake::DevServerSourceProvider* opaque_source_provider, BunString* specifier);
// Export functions for Zig to access DevServerSourceProvider
extern "C" BunString DevServerSourceProvider__getSourceSlice(Bake::DevServerSourceProvider* provider)
{
return Bun::toStringView(provider->source());
}
extern "C" Bake::SourceMapData DevServerSourceProvider__getSourceMapJSON(Bake::DevServerSourceProvider* provider)
{
return provider->sourceMapJSON();
}

View File

@@ -0,0 +1,118 @@
#pragma once
#include "root.h"
#include "headers-handwritten.h"
#include "JavaScriptCore/SourceOrigin.h"
#include "ZigGlobalObject.h"
#include <mimalloc.h>
namespace Bake {
class DevServerSourceProvider;
class SourceMapJSONString {
public:
SourceMapJSONString(const char* ptr, size_t length)
: m_ptr(ptr)
, m_length(length)
{
}
~SourceMapJSONString()
{
if (m_ptr) {
mi_free(const_cast<char*>(m_ptr));
}
}
// Delete copy constructor and assignment operator to prevent double free
SourceMapJSONString(const SourceMapJSONString&) = delete;
SourceMapJSONString& operator=(const SourceMapJSONString&) = delete;
// Move constructor and assignment
SourceMapJSONString(SourceMapJSONString&& other) noexcept
: m_ptr(other.m_ptr)
, m_length(other.m_length)
{
other.m_ptr = nullptr;
other.m_length = 0;
}
SourceMapJSONString& operator=(SourceMapJSONString&& other) noexcept
{
if (this != &other) {
if (m_ptr) {
mi_free(const_cast<char*>(m_ptr));
}
m_ptr = other.m_ptr;
m_length = other.m_length;
other.m_ptr = nullptr;
other.m_length = 0;
}
return *this;
}
const char* ptr() const { return m_ptr; }
size_t length() const { return m_length; }
private:
const char* m_ptr;
size_t m_length;
};
// Struct to return source map data to Zig
struct SourceMapData {
const char* ptr;
size_t length;
};
// Function to be implemented in Zig to register the source provider
extern "C" void Bun__addDevServerSourceProvider(void* bun_vm, DevServerSourceProvider* opaque_source_provider, BunString* specifier);
class DevServerSourceProvider final : public JSC::StringSourceProvider {
public:
static Ref<DevServerSourceProvider> create(
JSC::JSGlobalObject* globalObject,
const String& source,
const char* sourceMapJSONPtr,
size_t sourceMapJSONLength,
const JSC::SourceOrigin& sourceOrigin,
String&& sourceURL,
const TextPosition& startPosition,
JSC::SourceProviderSourceType sourceType)
{
auto provider = adoptRef(*new DevServerSourceProvider(source, sourceMapJSONPtr, sourceMapJSONLength, sourceOrigin, WTFMove(sourceURL), startPosition, sourceType));
auto* zigGlobalObject = jsCast<::Zig::GlobalObject*>(globalObject);
auto specifier = Bun::toString(provider->sourceURL());
Bun__addDevServerSourceProvider(zigGlobalObject->bunVM(), provider.ptr(), &specifier);
return provider;
}
SourceMapData sourceMapJSON() const
{
return SourceMapData { m_sourceMapJSON.ptr(), m_sourceMapJSON.length() };
}
private:
DevServerSourceProvider(
const String& source,
const char* sourceMapJSONPtr,
size_t sourceMapJSONLength,
const JSC::SourceOrigin& sourceOrigin,
String&& sourceURL,
const TextPosition& startPosition,
JSC::SourceProviderSourceType sourceType)
: StringSourceProvider(
source,
sourceOrigin,
JSC::SourceTaintedOrigin::Untainted,
WTFMove(sourceURL),
startPosition,
sourceType)
, m_sourceMapJSON(sourceMapJSONPtr, sourceMapJSONLength)
{
}
SourceMapJSONString m_sourceMapJSON;
};
} // namespace Bake

View File

@@ -3,6 +3,8 @@ import { renderToHtml, renderToStaticHtml } from "bun-framework-react/ssr.tsx" w
import { serverManifest } from "bun:bake/server";
import { PassThrough } from "node:stream";
import { renderToPipeableStream } from "react-server-dom-bun/server.node.unbundled.js";
import type { AsyncLocalStorage } from "node:async_hooks";
import type { RequestContext } from "../hmr-runtime-server";
function assertReactComponent(Component: any) {
if (typeof Component !== "function") {
@@ -12,8 +14,8 @@ function assertReactComponent(Component: any) {
}
// This function converts the route information into a React component tree.
function getPage(meta: Bake.RouteMetadata, styles: readonly string[]) {
let route = component(meta.pageModule, meta.params);
function getPage(meta: Bake.RouteMetadata & { request?: Request }, styles: readonly string[]) {
let route = component(meta.pageModule, meta.params, meta.request);
for (const layout of meta.layouts) {
const Layout = layout.default;
if (import.meta.env.DEV) assertReactComponent(Layout);
@@ -35,7 +37,7 @@ function getPage(meta: Bake.RouteMetadata, styles: readonly string[]) {
);
}
function component(mod: any, params: Record<string, string> | null) {
function component(mod: any, params: Record<string, string> | null, request?: Request) {
const Page = mod.default;
let props = {};
if (import.meta.env.DEV) assertReactComponent(Page);
@@ -49,12 +51,21 @@ function component(mod: any, params: Record<string, string> | null) {
props = method();
}
// Pass request prop if mode is 'ssr'
if (mod.mode === "ssr" && request) {
props.request = request;
}
return <Page params={params} {...props} />;
}
// `server.tsx` exports a function to be used for handling user routes. It takes
// in the Request object, the route's module, and extra route metadata.
export async function render(request: Request, meta: Bake.RouteMetadata): Promise<Response> {
// in the Request object, the route's module, extra route metadata, and the AsyncLocalStorage instance.
export async function render(
request: Request,
meta: Bake.RouteMetadata,
als?: AsyncLocalStorage<RequestContext>,
): Promise<Response> {
// The framework generally has two rendering modes.
// - Standard browser navigation
// - Client-side navigation
@@ -64,6 +75,9 @@ export async function render(request: Request, meta: Bake.RouteMetadata): Promis
// This is signaled by `client.tsx` via the `Accept` header.
const skipSSR = request.headers.get("Accept")?.includes("text/x-component");
// Check if the page module has a streaming export, default to false
const streaming = meta.pageModule.streaming ?? false;
// Do not render <link> tags if the request is skipping SSR.
const page = getPage(meta, skipSSR ? [] : meta.styles);
@@ -83,34 +97,119 @@ export async function render(request: Request, meta: Bake.RouteMetadata): Promis
// This renders Server Components to a ReadableStream "RSC Payload"
let pipe;
const signal: MiniAbortSignal = { aborted: false, abort: null! };
const signal: MiniAbortSignal = { aborted: undefined, abort: null! };
({ pipe, abort: signal.abort } = renderToPipeableStream(page, serverManifest, {
onError: err => {
// console.error("onError renderToPipeableStream", !!signal.aborted);
if (signal.aborted) return;
console.error(err);
// Mark as aborted and call the abort function
signal.aborted = err;
// @ts-expect-error
signal.abort(err);
rscPayload.destroy(err);
},
filterStackFrame: () => false,
}));
pipe(rscPayload);
rscPayload.on("error", err => {
if (signal.aborted) return;
console.error(err);
});
if (skipSSR) {
const responseOptions = als?.getStore()?.responseOptions || {};
return new Response(rscPayload as any, {
status: 200,
headers: { "Content-Type": "text/x-component" },
...responseOptions,
});
}
// The RSC payload is rendered into HTML
return new Response(await renderToHtml(rscPayload, meta.modules, signal), {
headers: {
"Content-Type": "text/html; charset=utf8",
},
});
if (streaming) {
const responseOptions = als?.getStore()?.responseOptions || {};
if (als) {
const state = als.getStore();
if (state) state.streamingStarted = true;
}
// Stream the response as before
return new Response(renderToHtml(rscPayload, meta.modules, signal), {
headers: {
"Content-Type": "text/html; charset=utf8",
},
...responseOptions,
});
} else {
// FIXME: this is bad and could be done way better
// FIXME: why are we even doing stream stuff is `streaming=false`, is there a way to do RSC without stream
// Set up the render abort handler for non-streaming mode
if (als) {
const store = als.getStore();
if (store) {
store.renderAbort = (path: string, params: Record<string, any> | null) => {
// Create the abort error
const abortError = new (globalThis as any).RenderAbortError(path, params);
// Abort the current render
signal.aborted = abortError;
signal.abort(abortError);
rscPayload.destroy(abortError);
throw abortError;
};
}
}
// Buffer the entire response and return it all at once
const htmlStream = renderToHtml(rscPayload, meta.modules, signal);
const chunks: Uint8Array[] = [];
const reader = htmlStream.getReader();
try {
let keepGoing = true;
do {
const { done, value } = await reader.read();
// Check if the render was aborted with an error
if (signal.aborted) {
// If it's a RenderAbortError, re-throw it to be handled upstream
if (signal.aborted instanceof (globalThis as any).RenderAbortError) {
throw signal.aborted;
}
// For some reason in react-server-dom the `stream.on("error")`
// handler creates a new Error???
if (signal.aborted.message !== "Connection closed.") {
// For other errors, we can handle them here or re-throw
throw signal.aborted;
}
}
keepGoing = !done;
if (!done) {
chunks.push(value);
}
} while (keepGoing);
} finally {
reader.releaseLock();
}
// Combine all chunks into a single response
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
const opts = als?.getStore()?.responseOptions ?? { headers: {} };
const { headers, ...response_options } = opts;
return new Response(result, {
headers: {
"Content-Type": "text/html; charset=utf8",
"Set-Cookie": request.cookies.toSetCookieHeaders(),
...headers,
},
...response_options,
});
}
}
// When a production build is performed, pre-rendering is invoked here. If this
@@ -128,7 +227,13 @@ export async function prerender(meta: Bake.RouteMetadata) {
let rscChunks: Array<BlobPart> = [int.buffer as ArrayBuffer, meta.styles.join("\n")];
rscPayload.on("data", chunk => rscChunks.push(chunk));
const html = await renderToStaticHtml(rscPayload, meta.modules);
let html;
try {
html = await renderToStaticHtml(rscPayload, meta.modules);
} catch (err) {
//console.error("ah fuck");
return undefined;
}
const rsc = new Blob(rscChunks, { type: "text/x-component" });
return {
@@ -184,7 +289,7 @@ export const contentTypeToStaticFile = {
/** Instead of using AbortController, this is used */
export interface MiniAbortSignal {
aborted: boolean;
aborted: Error | undefined;
/** Caller must set `aborted` to true before calling. */
abort: () => void;
}

View File

@@ -56,6 +56,12 @@ export function renderToHtml(
// with `use`, and then returning the parsed React component for the UI.
const Root: any = () => React.use(promise);
// If the signal is already aborted, we should not proceed
if (signal.aborted) {
controller.close(signal.aborted);
return Promise.reject(signal.aborted);
}
// `renderToPipeableStream` is what actually generates HTML.
// Here is where React is told what script tags to inject.
let pipe: (stream: any) => void;
@@ -63,7 +69,13 @@ export function renderToHtml(
bootstrapModules,
onError(error) {
if (!signal.aborted) {
console.error(error);
// Abort the rendering and close the stream
signal.aborted = error;
abort();
if (signal.abort) signal.abort();
if (stream) {
stream.controller.close();
}
}
},
}));
@@ -74,10 +86,12 @@ export function renderToHtml(
// Promise resolved after all data is combined.
return stream.finished;
},
cancel() {
signal.aborted = true;
signal.abort();
abort?.();
cancel(err) {
if (!signal.aborted) {
signal.aborted = err;
signal.abort(err);
}
abort?.(err);
},
} as Bun.DirectUnderlyingSource as any);
}
@@ -133,19 +147,28 @@ class RscInjectionStream extends EventEmitter {
/** Resolved when all data is written */
finished: Promise<void>;
finalize: () => void;
reject: (err: any) => void;
constructor(rscPayload: Readable, controller: ReadableStreamDirectController) {
super();
this.controller = controller;
const { resolve, promise } = Promise.withResolvers<void>();
const { resolve, promise, reject } = Promise.withResolvers<void>();
this.finished = promise;
this.finalize = resolve;
this.reject = reject;
rscPayload.on("data", this.writeRscData.bind(this));
rscPayload.on("end", () => {
this.rscHasEnded = true;
});
rscPayload.on("error", err => {
this.rscHasEnded = true;
// Close the controller
controller.close();
// Reject the promise instead of resolving it
this.reject(err);
});
}
write(data: Uint8Array) {
@@ -284,7 +307,8 @@ class StaticRscInjectionStream extends EventEmitter {
}
destroy(error) {
console.error(error);
// We don't need to console.error here as react does it itself
// console.error(error);
this.reject(error);
}
}

View File

@@ -115,6 +115,11 @@ export class HMRModule {
: null;
}
// Module Ids are pre-resolved by the bundler
requireResolve(id: Id): Id {
return id;
}
require(id: Id) {
try {
const mod = loadModuleSync(id, true, this);

View File

@@ -3,11 +3,48 @@
import type { Bake } from "bun";
import "./debug";
import { loadExports, replaceModules, serverManifest, ssrManifest } from "./hmr-module";
// import { AsyncLocalStorage } from "node:async_hooks";
const { AsyncLocalStorage } = require("node:async_hooks");
if (typeof IS_BUN_DEVELOPMENT !== "boolean") {
throw new Error("DCE is configured incorrectly");
}
export type RequestContext = {
responseOptions: ResponseInit;
streamingStarted?: boolean;
renderAbort?: (path: string, params: Record<string, any> | null) => never;
};
// Create the AsyncLocalStorage instance for propagating response options
const responseOptionsALS = new AsyncLocalStorage();
/// Created when the user does `return Response.render(...)`
class RenderAbortError extends Error {
constructor(
public path: string,
public params: Record<string, any> | null,
public response: Response,
) {
super("Response.render() called");
this.name = "RenderAbortError";
}
}
/// Created when the user does `return Response.redirect(...)`
class RedirectAbortError extends Error {
constructor(
public response: Response,
) {
super("Response.redirect() called");
this.name = "RedirectAbortError";
}
}
// Make RenderAbortError and RedirectAbortError globally available for other modules
(globalThis as any).RenderAbortError = RenderAbortError;
(globalThis as any).RedirectAbortError = RedirectAbortError;
interface Exports {
handleRequest: (
req: Request,
@@ -16,6 +53,8 @@ interface Exports {
clientEntryUrl: string,
styles: string[],
params: Record<string, string> | null,
setAsyncLocalStorage: Function,
getAsyncLocalStorage: Function,
) => any;
registerUpdate: (
modules: any,
@@ -26,7 +65,20 @@ interface Exports {
declare let server_exports: Exports;
server_exports = {
async handleRequest(req, routerTypeMain, routeModules, clientEntryUrl, styles, params) {
async handleRequest(
req,
routerTypeMain,
routeModules,
clientEntryUrl,
styles,
params,
setAsyncLocalStorage,
getAsyncLocalStorage,
) {
// FIXME: We should only have to do this once
// Set the AsyncLocalStorage instance in the VM
setAsyncLocalStorage(responseOptionsALS);
if (IS_BUN_DEVELOPMENT && process.env.BUN_DEBUG_BAKE_JS) {
console.log("handleRequest", {
routeModules,
@@ -48,20 +100,67 @@ server_exports = {
}
const [pageModule, ...layouts] = await Promise.all(routeModules.map(loadExports));
const response = await serverRenderer(req, {
styles: styles,
modules: [clientEntryUrl],
layouts,
pageModule,
modulepreload: [],
params,
});
if (!(response instanceof Response)) {
throw new Error(`Server-side request handler was expected to return a Response object.`);
// Add cookies to request when mode is 'ssr'
let requestWithCookies = req;
if (pageModule.mode === "ssr") {
requestWithCookies.cookies = req.cookies || new Bun.CookieMap(req.headers.get("Cookie") || "");
}
return response;
let storeValue: RequestContext = {
responseOptions: {},
};
try {
// Run the renderer inside the AsyncLocalStorage context
// This allows Response constructors to access the stored options
const response = await responseOptionsALS.run(storeValue, async () => {
return await serverRenderer(
requestWithCookies,
{
styles: styles,
modules: [clientEntryUrl],
layouts,
pageModule,
modulepreload: [],
params,
// Pass request in metadata when mode is 'ssr'
request: pageModule.mode === "ssr" ? requestWithCookies : undefined,
},
responseOptionsALS,
);
});
if (!(response instanceof Response)) {
throw new Error(`Server-side request handler was expected to return a Response object.`);
}
return response;
} catch (error) {
// Handle Response.render() aborts
if (error instanceof RenderAbortError) {
// TODO: Implement route resolution to get the new route modules
// For now, we'll need to get this information from the native side
// The native code will need to resolve the route and call handleRequest again
// Store the render error info so native code can access it
(globalThis as any).__lastRenderAbort = {
path: error.path,
params: error.params,
};
// return it so the Zig code can handle it and re-render new route
return error.response;
}
// Handle Response.redirect() aborts
if (error instanceof RedirectAbortError) {
// Return the redirect response directly
return error.response;
}
throw error;
}
},
async registerUpdate(modules, componentManifestAdd, componentManifestDelete) {
replaceModules(modules);

View File

@@ -184,11 +184,33 @@ pub fn buildWithVm(ctx: bun.cli.Command.Context, cwd: []const u8, vm: *VirtualMa
const default = BakeGetDefaultExportFromModule(vm.global, config_entry_point_string.toJS(vm.global));
if (!default.isObject()) {
Output.panic("TODO: print this error better, default export is not an object", .{});
return global.throwInvalidArguments(
\\Your config file's default export must be an object.
\\
\\Example:
\\ export default {
\\ app: {
\\ framework: "react",
\\ }
\\ }
\\
\\Learn more at https://bun.com/docs/ssg
, .{});
}
const app = try default.get(vm.global, "app") orelse {
Output.panic("TODO: print this error better, default export needs an 'app' object", .{});
return global.throwInvalidArguments(
\\Your config file's default export must contain an "app" property.
\\
\\Example:
\\ export default {
\\ app: {
\\ framework: "react",
\\ }
\\ }
\\
\\Learn more at https://bun.com/docs/ssg
, .{});
};
break :config try bake.UserOptions.fromJS(app, vm.global);
@@ -409,7 +431,7 @@ pub fn buildWithVm(ctx: bun.cli.Command.Context, cwd: []const u8, vm: *VirtualMa
},
.asset => {},
.bytecode => {},
.sourcemap => @panic("TODO: register source map"),
.sourcemap => {},
}
},
}

View File

@@ -88,6 +88,7 @@ pub const Value = bun.TaggedPointerUnion(.{
SavedMappings,
SourceProviderMap,
BakeSourceProvider,
DevServerSourceProvider,
});
pub const MissingSourceMapNoteInfo = struct {
@@ -108,6 +109,10 @@ pub fn putBakeSourceProvider(this: *SavedSourceMap, opaque_source_provider: *Bak
this.putValue(path, Value.init(opaque_source_provider)) catch bun.outOfMemory();
}
pub fn putDevServerSourceProvider(this: *SavedSourceMap, opaque_source_provider: *DevServerSourceProvider, path: []const u8) void {
this.putValue(path, Value.init(opaque_source_provider)) catch bun.outOfMemory();
}
pub fn putZigSourceProvider(this: *SavedSourceMap, opaque_source_provider: *anyopaque, path: []const u8) void {
const source_provider: *SourceProviderMap = @ptrCast(opaque_source_provider);
this.putValue(path, Value.init(source_provider)) catch bun.outOfMemory();
@@ -279,6 +284,33 @@ fn getWithContent(
MissingSourceMapNoteInfo.path = storage;
return .{};
},
@field(Value.Tag, @typeName(DevServerSourceProvider)) => {
// TODO: This is a copy-paste of above branch
const ptr: *DevServerSourceProvider = Value.from(mapping.value_ptr.*).as(DevServerSourceProvider);
this.unlock();
// Do not lock the mutex while we're parsing JSON!
if (ptr.getSourceMap(path, .none, hint)) |parse| {
if (parse.map) |map| {
map.ref();
// The mutex is not locked. We have to check the hash table again.
this.putValue(path, Value.init(map)) catch bun.outOfMemory();
return parse;
}
}
this.lock();
defer this.unlock();
// does not have a valid source map. let's not try again
_ = this.map.remove(hash);
// Store path for a user note.
const storage = MissingSourceMapNoteInfo.storage[0..path.len];
@memcpy(storage, path);
MissingSourceMapNoteInfo.path = storage;
return .{};
},
else => {
if (Environment.allow_assert) {
@panic("Corrupt pointer tag");
@@ -333,5 +365,6 @@ const logger = bun.logger;
const SourceMap = bun.sourcemap;
const BakeSourceProvider = bun.sourcemap.BakeSourceProvider;
const DevServerSourceProvider = bun.sourcemap.DevServerSourceProvider;
const ParsedSourceMap = SourceMap.ParsedSourceMap;
const SourceProviderMap = SourceMap.SourceProviderMap;

View File

@@ -196,6 +196,58 @@ has_mutated_built_in_extensions: u32 = 0,
initial_script_execution_context_identifier: i32,
dev_server_async_local_storage: jsc.Strong.Optional = .empty,
pub fn setAllowJSXInResponseConstructor(this: *VirtualMachine, value: bool) void {
// When enabled, we create an AsyncLocalStorage instance
// When disabled, we clear it
if (value) {
// We'll set this from JavaScript when we create the AsyncLocalStorage instance
// For now, just keep track of the flag internally
} else {
this.dev_server_async_local_storage.deinit();
}
}
pub fn allowJSXInResponseConstructor(this: *VirtualMachine) bool {
// Check if the AsyncLocalStorage instance exists
return this.dev_server_async_local_storage.has();
}
pub fn getDevServerAsyncLocalStorage(this: *VirtualMachine) ?jsc.JSValue {
return this.dev_server_async_local_storage.get();
}
pub fn setDevServerAsyncLocalStorage(this: *VirtualMachine, global: *jsc.JSGlobalObject, value: jsc.JSValue) void {
if (value == .zero) {
this.dev_server_async_local_storage.deinit();
} else if (this.dev_server_async_local_storage.has()) {
this.dev_server_async_local_storage.set(global, value);
} else {
this.dev_server_async_local_storage = jsc.Strong.Optional.create(value, global);
}
}
// JavaScript binding to set the AsyncLocalStorage instance
pub export fn VirtualMachine__setDevServerAsyncLocalStorage(global: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) jsc.JSValue {
const arguments = callframe.arguments_old(1).slice();
const vm = global.bunVM();
if (arguments.len < 1) {
_ = global.throwInvalidArguments("setDevServerAsyncLocalStorage expects 1 argument", .{}) catch {};
return .zero;
}
vm.setDevServerAsyncLocalStorage(global, arguments[0]);
return .js_undefined;
}
// JavaScript binding to get the AsyncLocalStorage instance
pub export fn VirtualMachine__getDevServerAsyncLocalStorage(global: *jsc.JSGlobalObject, _: *jsc.CallFrame) jsc.JSValue {
const vm = global.bunVM();
return vm.getDevServerAsyncLocalStorage() orelse .js_undefined;
}
pub const ProcessAutoKiller = @import("./ProcessAutoKiller.zig");
pub const OnUnhandledRejection = fn (*VirtualMachine, globalObject: *JSGlobalObject, JSValue) void;

View File

@@ -1040,7 +1040,7 @@ pub fn serve(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.J
&config,
&args,
.{
.allow_bake_config = bun.FeatureFlags.bake() and callframe.isFromBunMain(globalObject.vm()),
.allow_bake_config = bun.FeatureFlags.bake(),
.is_fetch_required = true,
.has_user_routes = false,
},

View File

@@ -297,6 +297,10 @@ pub const Stdio = union(enum) {
},
.Blob, .WTFStringImpl, .InternalBlob => unreachable, // handled above.
.Render => {
// Render bodies should not be used as stdio
return globalThis.throwInvalidArguments("Response.render() result cannot be used as stdio", .{});
},
.Locked => {
if (is_sync) {
return globalThis.throwInvalidArguments("ReadableStream cannot be used in sync mode", .{});

View File

@@ -2086,7 +2086,7 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
this.handleRequest(&should_deinit_context, prepared, req, response_value);
}
pub fn onRequestFromSaved(
pub fn onSavedRequest(
this: *ThisServer,
req: SavedRequest.Union,
resp: *App.Response,
@@ -2874,7 +2874,17 @@ pub const SavedRequest = struct {
}
pub const Union = union(enum) {
/// Direct pointer to a µWebSockets request that is still on the stack.
/// Used for synchronous request handling where the request can be processed
/// immediately within the current call frame. This avoids heap allocation
/// and is more efficient for simple, fast operations.
stack: *uws.Request,
/// A heap-allocated copy of the request data that persists beyond the
/// initial request handler. Used when request processing needs to be
/// deferred (e.g., async operations, waiting for framework initialization).
/// Contains strong references to JavaScript objects and all context needed
/// to complete the request later.
saved: bun.jsc.API.SavedRequest,
};
};
@@ -3147,7 +3157,7 @@ pub const AnyServer = struct {
};
}
pub fn onRequestFromSaved(
pub fn onSavedRequest(
this: AnyServer,
req: SavedRequest.Union,
resp: uws.AnyResponse,
@@ -3156,10 +3166,10 @@ pub const AnyServer = struct {
extra_args: [extra_arg_count]JSValue,
) void {
return switch (this.ptr.tag()) {
Ptr.case(HTTPServer) => this.ptr.as(HTTPServer).onRequestFromSaved(req, resp.TCP, callback, extra_arg_count, extra_args),
Ptr.case(HTTPSServer) => this.ptr.as(HTTPSServer).onRequestFromSaved(req, resp.SSL, callback, extra_arg_count, extra_args),
Ptr.case(DebugHTTPServer) => this.ptr.as(DebugHTTPServer).onRequestFromSaved(req, resp.TCP, callback, extra_arg_count, extra_args),
Ptr.case(DebugHTTPSServer) => this.ptr.as(DebugHTTPSServer).onRequestFromSaved(req, resp.SSL, callback, extra_arg_count, extra_args),
Ptr.case(HTTPServer) => this.ptr.as(HTTPServer).onSavedRequest(req, resp.TCP, callback, extra_arg_count, extra_args),
Ptr.case(HTTPSServer) => this.ptr.as(HTTPSServer).onSavedRequest(req, resp.SSL, callback, extra_arg_count, extra_args),
Ptr.case(DebugHTTPServer) => this.ptr.as(DebugHTTPServer).onSavedRequest(req, resp.TCP, callback, extra_arg_count, extra_args),
Ptr.case(DebugHTTPSServer) => this.ptr.as(DebugHTTPSServer).onSavedRequest(req, resp.SSL, callback, extra_arg_count, extra_args),
else => bun.unreachablePanic("Invalid pointer tag", .{}),
};
}

View File

@@ -18,6 +18,32 @@ pub fn init(request_ctx: anytype) AnyRequestContext {
return .{ .tagged_pointer = Pointer.init(request_ctx) };
}
pub fn setAbortCallback(self: AnyRequestContext, cb: *const fn (this: *anyopaque) void, data: *anyopaque) void {
if (self.tagged_pointer.isNull()) {
return;
}
switch (self.tagged_pointer.tag()) {
@field(Pointer.Tag, bun.meta.typeBaseName(@typeName(HTTPServer.RequestContext))) => {
self.tagged_pointer.as(HTTPServer.RequestContext).onAbortCb = cb;
self.tagged_pointer.as(HTTPServer.RequestContext).onAbortData = data;
},
@field(Pointer.Tag, bun.meta.typeBaseName(@typeName(HTTPSServer.RequestContext))) => {
self.tagged_pointer.as(HTTPSServer.RequestContext).onAbortCb = cb;
self.tagged_pointer.as(HTTPSServer.RequestContext).onAbortData = data;
},
@field(Pointer.Tag, bun.meta.typeBaseName(@typeName(DebugHTTPServer.RequestContext))) => {
self.tagged_pointer.as(DebugHTTPServer.RequestContext).onAbortCb = cb;
self.tagged_pointer.as(DebugHTTPServer.RequestContext).onAbortData = data;
},
@field(Pointer.Tag, bun.meta.typeBaseName(@typeName(DebugHTTPSServer.RequestContext))) => {
self.tagged_pointer.as(DebugHTTPSServer.RequestContext).onAbortCb = cb;
self.tagged_pointer.as(DebugHTTPSServer.RequestContext).onAbortData = data;
},
else => @panic("Unexpected AnyRequestContext tag"),
}
}
pub fn memoryCost(self: AnyRequestContext) usize {
if (self.tagged_pointer.isNull()) {
return 0;

View File

@@ -55,6 +55,9 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
/// Defer finalization until after the request handler task is completed?
defer_deinit_until_callback_completes: ?*bool = null,
onAbortCb: ?*const fn (this: *anyopaque) void = null,
onAbortData: ?*anyopaque = null,
// TODO: support builtin compression
const can_sendfile = !ssl_enabled and !Environment.isWindows;
@@ -183,7 +186,23 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
// 5 - is the only reference of the context
// 6 - is not waiting for request body
// 7 - did not call sendfile
return this.resp != null and !this.flags.aborted and !this.flags.has_marked_complete and !this.flags.has_marked_pending and this.ref_count == 1 and !this.flags.is_waiting_for_request_body and !this.flags.has_sendfile_ctx;
ctxLog("RequestContext(0x{x}).shouldRenderMissing {s} {s} {s} {s} {s} {s} {s}", .{
@intFromPtr(this),
if (this.resp != null) "has response" else "no response",
if (this.flags.aborted) "aborted" else "not aborted",
if (this.flags.has_marked_complete) "marked complete" else "not marked complete",
if (this.flags.has_marked_pending) "marked pending" else "not marked pending",
if (this.ref_count == 1) "only reference" else "not only reference",
if (this.flags.is_waiting_for_request_body) "waiting for request body" else "not waiting for request body",
if (this.flags.has_sendfile_ctx) "has sendfile context" else "no sendfile context",
});
return this.resp != null and
!this.flags.aborted and
!this.flags.has_marked_complete and
!this.flags.has_marked_pending and
this.ref_count == 1 and
!this.flags.is_waiting_for_request_body and
!this.flags.has_sendfile_ctx;
}
pub fn isDeadRequest(this: *RequestContext) bool {
@@ -201,6 +220,7 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
/// destroy RequestContext, should be only called by deref or if defer_deinit_until_callback_completes is ref is set to true
pub fn deinit(this: *RequestContext) void {
ctxLog("deinit", .{});
this.detachResponse();
this.endRequestStreamingAndDrain();
// TODO: has_marked_complete is doing something?
@@ -337,6 +357,7 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
pub fn renderDefaultError(
this: *RequestContext,
arena_allocator: std.mem.Allocator,
log: *logger.Log,
err: anyerror,
exceptions: []Api.JsException,
@@ -351,12 +372,10 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
}
}
const allocator = this.allocator;
const fallback_container = allocator.create(Api.FallbackMessageContainer) catch unreachable;
defer allocator.destroy(fallback_container);
const fallback_container = arena_allocator.create(Api.FallbackMessageContainer) catch unreachable;
defer arena_allocator.destroy(fallback_container);
fallback_container.* = Api.FallbackMessageContainer{
.message = std.fmt.allocPrint(allocator, comptime Output.prettyFmt(fmt, false), args) catch unreachable,
.message = std.fmt.allocPrint(arena_allocator, comptime Output.prettyFmt(fmt, false), args) catch unreachable,
.router = null,
.reason = .fetch_event_handler,
.cwd = VirtualMachine.get().transpiler.fs.top_level_dir,
@@ -364,18 +383,19 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
.code = @as(u16, @truncate(@intFromError(err))),
.name = @errorName(err),
.exceptions = exceptions,
.build = log.toAPI(allocator) catch unreachable,
.build = log.toAPI(arena_allocator) catch unreachable,
},
};
if (comptime fmt.len > 0) Output.prettyErrorln(fmt, args);
Output.flush();
var bb = std.ArrayList(u8).init(allocator);
// Explicitly use `this.allocator` and *not* the arena
var bb = std.ArrayList(u8).init(this.allocator);
const bb_writer = bb.writer();
Fallback.renderBackend(
allocator,
arena_allocator,
fallback_container,
@TypeOf(bb_writer),
bb_writer,
@@ -436,6 +456,7 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
}
pub fn end(this: *RequestContext, data: []const u8, closeConnection: bool) void {
ctxLog("end", .{});
if (this.resp) |resp| {
defer this.deref();
@@ -461,6 +482,7 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
}
pub fn endWithoutBody(this: *RequestContext, closeConnection: bool) void {
ctxLog("endWithoutBody", .{});
if (this.resp) |resp| {
defer this.deref();
@@ -556,11 +578,15 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
}
pub fn onAbort(this: *RequestContext, resp: *App.Response) void {
ctxLog("onAbort", .{});
assert(this.resp == resp);
assert(!this.flags.aborted);
assert(this.server != null);
// mark request as aborted
this.flags.aborted = true;
if (this.onAbortData != null and this.onAbortCb != null) {
this.onAbortCb.?(this.onAbortData.?);
}
this.detachResponse();
var any_js_calls = false;
@@ -1439,6 +1465,12 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
resp.writeHeaderInt("content-length", 0);
this.endWithoutBody(this.shouldCloseConnection());
},
.Render => {
// Render should have been handled elsewhere, this is unexpected
this.renderMetadata();
resp.writeHeaderInt("content-length", 0);
this.endWithoutBody(this.shouldCloseConnection());
},
}
}
@@ -1675,6 +1707,55 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
var exception_list: std.ArrayList(Api.JsException) = std.ArrayList(Api.JsException).init(req.allocator);
defer exception_list.deinit();
server.vm.runErrorHandler(err, &exception_list);
// Render the error fallback HTML page like renderDefaultError does
if (!req.flags.has_written_status) {
req.flags.has_written_status = true;
if (req.resp) |resp| {
resp.writeStatus("500 Internal Server Error");
resp.writeHeader("content-type", MimeType.html.value);
}
}
const allocator = req.allocator;
const fallback_container = allocator.create(Api.FallbackMessageContainer) catch unreachable;
defer allocator.destroy(fallback_container);
// Create error message for the stream rejection
const error_message = "Stream error during server-side rendering";
fallback_container.* = Api.FallbackMessageContainer{
.message = allocator.dupe(u8, error_message) catch unreachable,
.router = null,
.reason = .fetch_event_handler,
.cwd = server.vm.transpiler.fs.top_level_dir,
.problems = Api.Problems{
.code = 500,
.name = "StreamError",
.exceptions = exception_list.items,
.build = .{
.msgs = &.{},
},
},
};
var bb = std.ArrayList(u8).init(allocator);
defer bb.clearAndFree();
const bb_writer = bb.writer();
Fallback.renderBackend(
allocator,
fallback_container,
@TypeOf(bb_writer),
bb_writer,
) catch unreachable;
if (req.resp) |resp| {
_ = resp.write(bb.items);
}
req.endStream(req.shouldCloseConnection());
return;
}
}
}
@@ -1803,6 +1884,74 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
return;
},
.Render => |render_body| {
// Handle Response.render() case - need to call DevServer with new path
defer bun.default_allocator.free(render_body.path);
if (this.server) |server| {
if (@hasField(@TypeOf(server.*), "dev_server")) {
if (server.dev_server) |dev_server_| {
const dev_server: *bun.bake.DevServer = dev_server_;
// Use the current response from the RequestContext
const response = if (this.resp) |resp|
bun.uws.AnyResponse.init(resp)
else {
this.renderMissing();
return;
};
const signal = if (this.signal) |signal| signal.ref() else null;
const resp = this.resp;
this.detachResponse();
const new_request_ctx = server.request_pool_allocator.tryGet() catch bun.outOfMemory();
new_request_ctx.* = .{
.allocator = server.allocator,
.resp = resp,
.req = this.req,
.method = this.method,
.server = server,
.defer_deinit_until_callback_completes = null,
.signal = signal,
};
const url = bun.String.cloneUTF8(render_body.path);
const body: jsc.WebCore.Body.Value = .{ .Null = {} };
const new_request = Request.new(.{
.method = .GET, // TODO: use the correct one
.request_context = AnyRequestContext.init(new_request_ctx),
.https = ssl_enabled,
.signal = if (signal) |s| s.ref() else null,
.body = server.vm.initRequestBodyValue(body) catch bun.outOfMemory(),
.url = url,
});
new_request_ctx.request_weakref = .initRef(new_request);
server.onPendingRequest();
// Call DevServer with the render path
dev_server.handleRenderRedirect(bun.jsc.API.SavedRequest{
.js_request = .create(new_request.toJS(server.globalThis), server.globalThis),
.ctx = AnyRequestContext.init(new_request_ctx),
.request = new_request,
.response = bun.uws.AnyResponse.init(resp.?),
}, render_body.path, response) catch {
// On error, render missing
this.renderMissing();
return;
};
return;
}
}
}
// Fallback to rendering missing
this.renderMissing();
return;
},
else => {},
}
@@ -1945,7 +2094,10 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
var vm: *jsc.VirtualMachine = this.server.?.vm;
const globalThis = this.server.?.globalThis;
if (comptime debug_mode) {
var exception_list: std.ArrayList(Api.JsException) = std.ArrayList(Api.JsException).init(this.allocator);
var arena = std.heap.ArenaAllocator.init(this.allocator);
defer arena.deinit();
const allocator = arena.allocator();
var exception_list: std.ArrayList(Api.JsException) = std.ArrayList(Api.JsException).init(allocator);
defer exception_list.deinit();
const prev_exception_list = vm.onUnhandledRejectionExceptionList;
vm.onUnhandledRejectionExceptionList = &exception_list;
@@ -1953,9 +2105,10 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
vm.onUnhandledRejectionExceptionList = prev_exception_list;
this.renderDefaultError(
allocator,
vm.log,
error.ExceptionOcurred,
exception_list.toOwnedSlice() catch @panic("TODO"),
exception_list.items,
"<r><red>{s}<r> - <b>{}<r> failed",
.{ @as(string, @tagName(this.method)), this.ensurePathname() },
);

View File

@@ -749,6 +749,43 @@ extern "C" [[ZIG_EXPORT(nothrow)]] void Bun__WTFStringImpl__ensureHash(WTF::Stri
str->hash();
}
extern "C" JSC::EncodedJSValue JSC__JSValue__upsertBunStringArray(
JSC::EncodedJSValue encodedTarget,
JSC::JSGlobalObject* global,
const BunString* key,
JSC::EncodedJSValue encodedValue)
{
auto scope = DECLARE_THROW_SCOPE(global->vm());
JSC::JSObject* target = JSC::JSValue::decode(encodedTarget).getObject();
RETURN_IF_EXCEPTION(scope, {});
JSC::JSValue newValue = JSC::JSValue::decode(encodedValue);
auto& vm = global->vm();
WTF::String str = key->tag == BunStringTag::Empty ? WTF::emptyString() : key->toWTFString();
Identifier id = Identifier::fromString(vm, str);
auto existingValue = target->getIfPropertyExists(global, id);
RETURN_IF_EXCEPTION(scope, {});
if (!existingValue.isEmpty()) {
// If existing value is already an array, push to it
if (existingValue.isObject() && existingValue.getObject()->inherits<JSC::JSArray>()) {
JSC::JSArray* array = jsCast<JSC::JSArray*>(existingValue.getObject());
array->push(global, newValue);
} else {
// Create new array with both values
JSC::JSArray* array = JSC::constructEmptyArray(global, nullptr, 2);
array->putDirectIndex(global, 0, existingValue);
array->putDirectIndex(global, 1, newValue);
target->putDirect(vm, id, array, 0);
}
} else {
// No existing value, just put the new value directly
target->putDirect(vm, id, newValue, 0);
}
RETURN_IF_EXCEPTION(scope, {});
return JSC::JSValue::encode(JSC::jsUndefined());
}
extern "C" void JSC__JSValue__putBunString(
JSC::EncodedJSValue encodedTarget,
JSC::JSGlobalObject* global,

View File

@@ -12,6 +12,10 @@ pub const JSGlobalObject = opaque {
return error.JSError;
}
pub fn allowJSXInResponseConstructor(this: *JSGlobalObject) bool {
return this.bunVM().allowJSXInResponseConstructor();
}
extern fn JSGlobalObject__createOutOfMemoryError(this: *JSGlobalObject) JSValue;
pub fn createOutOfMemoryError(this: *JSGlobalObject) JSValue {
return JSGlobalObject__createOutOfMemoryError(this);

View File

@@ -45,6 +45,26 @@ pub const JSValue = enum(i64) {
return jsc.JSObject.getIndex(this, globalThis, i);
}
extern fn JSC__JSValue__isJSXElement(JSValue, *JSGlobalObject) bool;
pub fn isJSXElement(this: JSValue, globalThis: *jsc.JSGlobalObject) JSError!bool {
return bun.jsc.fromJSHostCallGeneric(
globalThis,
@src(),
JSC__JSValue__isJSXElement,
.{ this, globalThis },
);
}
extern fn JSC__JSValue__transformToReactElement(responseValue: JSValue, componentValue: JSValue, globalObject: *JSGlobalObject) void;
pub fn transformToReactElement(responseValue: JSValue, componentValue: JSValue, globalThis: *jsc.JSGlobalObject) void {
JSC__JSValue__transformToReactElement(responseValue, componentValue, globalThis);
}
extern fn JSC__JSValue__transformToReactElementWithOptions(responseValue: JSValue, componentValue: JSValue, responseOptions: JSValue, globalObject: *JSGlobalObject) void;
pub fn transformToReactElementWithOptions(responseValue: JSValue, componentValue: JSValue, responseOptions: JSValue, globalThis: *jsc.JSGlobalObject) void {
JSC__JSValue__transformToReactElementWithOptions(responseValue, componentValue, responseOptions, globalThis);
}
extern fn JSC__JSValue__getDirectIndex(JSValue, *JSGlobalObject, u32) JSValue;
pub fn getDirectIndex(this: JSValue, globalThis: *JSGlobalObject, i: u32) JSValue {
return JSC__JSValue__getDirectIndex(this, globalThis, i);
@@ -319,6 +339,13 @@ pub const JSValue = enum(i64) {
JSC__JSValue__putBunString(value, global, key, result);
}
extern "c" fn JSC__JSValue__upsertBunStringArray(value: JSValue, global: *JSGlobalObject, key: *const bun.String, result: jsc.JSValue) JSValue;
/// Put key/val pair into `obj`. If `key` is already present on the object, create an array for the values.
pub fn putBunStringOneOrArray(obj: JSValue, global: *JSGlobalObject, key: *const bun.String, value: jsc.JSValue) bun.JSError!JSValue {
return fromJSHostCall(global, @src(), JSC__JSValue__upsertBunStringArray, .{ obj, global, key, value });
}
pub fn put(value: JSValue, global: *JSGlobalObject, key: anytype, result: jsc.JSValue) void {
const Key = @TypeOf(key);
if (comptime @typeInfo(Key) == .pointer) {

View File

@@ -20,6 +20,7 @@
#include "BunClientData.h"
#include "GCDefferalContext.h"
#include "WebCoreJSBuiltins.h"
#include "JavaScriptCore/AggregateError.h"
#include "JavaScriptCore/BytecodeIndex.h"
@@ -48,6 +49,7 @@
#include "JavaScriptCore/JSONObject.h"
#include "JavaScriptCore/JSObject.h"
#include "JavaScriptCore/JSSet.h"
#include "JavaScriptCore/Strong.h"
#include "JavaScriptCore/JSSetIterator.h"
#include "JavaScriptCore/JSString.h"
#include "JavaScriptCore/ProxyObject.h"
@@ -5884,12 +5886,307 @@ restart:
JSC__JSValue__forEachPropertyImpl<false>(JSValue0, globalObject, arg2, iter);
}
extern "C" bool JSC__JSValue__isJSXElement(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* globalObject)
{
auto& vm = JSC::getVM(globalObject);
// React does this:
// export const REACT_LEGACY_ELEMENT_TYPE: symbol = Symbol.for('react.element');
// export const REACT_ELEMENT_TYPE: symbol = renameElementSymbol
// ? Symbol.for('react.transitional.element')
// : REACT_LEGACY_ELEMENT_TYPE;
// TODO: cache these, i cri everytim
auto react_legacy_element_symbol = JSC::Symbol::create(vm, vm.symbolRegistry().symbolForKey("react.element"_s));
auto react_element_symbol = JSC::Symbol::create(vm, vm.symbolRegistry().symbolForKey("react.transitional.element"_s));
JSC::JSValue value = JSC::JSValue::decode(JSValue0);
// TODO: primitive values (strings, numbers, booleans, null, undefined) are also valid
if (value.isObject()) {
auto scope = DECLARE_THROW_SCOPE(vm);
JSC::JSObject* object = value.getObject();
auto typeofProperty = JSC::Identifier::fromString(vm, "$$typeof"_s);
JSC::JSValue typeofValue = object->get(globalObject, typeofProperty);
RETURN_IF_EXCEPTION(scope, false);
if (typeofValue.isSymbol() && (typeofValue == react_legacy_element_symbol || typeofValue == react_element_symbol)) {
return true;
}
}
return false;
}
// Forward declaration
extern "C" void JSC__JSValue__transformToReactElementWithOptions(JSC::EncodedJSValue responseValue, JSC::EncodedJSValue componentValue, JSC::EncodedJSValue responseOptionsValue, JSC::JSGlobalObject* globalObject);
extern "C" void JSC__JSValue__transformToReactElement(JSC::EncodedJSValue responseValue, JSC::EncodedJSValue componentValue, JSC::JSGlobalObject* globalObject)
{
// Call the version with options, passing undefined for options
JSC__JSValue__transformToReactElementWithOptions(responseValue, componentValue, JSC::JSValue::encode(JSC::jsUndefined()), globalObject);
}
/// TODO: This could just be a builtin function and be 10x less lines of code why is it in C++
/// TODO: this should actually just be a special Response sub-class and the transpiler rewrites the code to use this
///
/// What this function does is make a Response object pretend to be a React
/// component. To do this we have to put a couple properties on it:
///
/// ```ts
/// response["$$typeof"] = REACT_ELEMENT_TYPE;
/// response.type = Component;
/// response.key = null;
/// response.props = {};
/// // Add the _store object that React expects in dev mode
/// response._store = {};
/// Object.defineProperty(response._store, 'validated', {
/// configurable: false,
/// enumerable: false,
/// writable: true,
/// value: 0 // or 1 to mark it as already validated
/// });
/// // Add debug properties expected in dev mode
/// response._owner = null;
/// response._debugInfo = null;
/// response._debugStack = null; // or new Error() if you want a stack trace
/// response._debugTask = null;
/// ```
extern "C" void JSC__JSValue__transformToReactElementWithOptions(JSC::EncodedJSValue responseValue, JSC::EncodedJSValue componentValue, JSC::EncodedJSValue responseOptionsValue, JSC::JSGlobalObject* globalObject)
{
auto& vm = JSC::getVM(globalObject);
auto scope = DECLARE_CATCH_SCOPE(vm);
JSC::JSValue response = JSC::JSValue::decode(responseValue);
JSC::JSValue component = JSC::JSValue::decode(componentValue);
JSC::JSValue responseOptions = JSC::JSValue::decode(responseOptionsValue);
if (!response.isObject()) {
return;
}
JSC::JSObject* responseObject = response.getObject();
// Get the React element symbol - same as in isJSXElement
// For now, use the transitional element symbol (React 19+)
// TODO: check for legacy symbol as fallback
auto react_element_symbol = JSC::Symbol::create(vm, vm.symbolRegistry().symbolForKey("react.transitional.element"_s));
// If we have response options, we need to wrap the component
// For now, we'll store the response options directly on the response object
// The actual wrapping with AsyncLocalStorage update will happen when rendered
if (!responseOptions.isUndefinedOrNull() && responseOptions.isObject()) {
// Store the response options on the response object for later use
// This will be accessed when the component is rendered
auto responseOptionsIdentifier = JSC::Identifier::fromString(vm, "__responseOptions"_s);
responseObject->putDirect(vm, responseOptionsIdentifier, responseOptions, JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete | 0);
}
// Transform the Response object itself into a React element
// The component parameter is what will be stored in the 'type' field
// Set $$typeof property to mark this as a React element
auto typeofIdentifier = JSC::Identifier::fromString(vm, "$$typeof"_s);
responseObject->putDirect(vm, typeofIdentifier, react_element_symbol, JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete | 0);
// Set type property
auto typeIdentifier = JSC::Identifier::fromString(vm, "type"_s);
// TODO: this is fucking awful
// Check if this is a render redirect (Response.render() case) or regular redirect (Response.redirect() case)
// We detect this by checking if component is the special marker string "__bun_render_redirect__" or "__bun_redirect__"
bool isRenderRedirect = false;
bool isRedirect = false;
if (component.isString()) {
JSC::JSString* componentString = component.toString(globalObject);
if (componentString) {
String componentStr = componentString->value(globalObject);
if (componentStr == "__bun_render_redirect__"_s) {
isRenderRedirect = true;
} else if (componentStr == "__bun_redirect__"_s) {
isRedirect = true;
}
}
}
// Handle Response.render() case - use BakeSSRResponse builtin to create wrapper
if (isRenderRedirect) {
// Get the render path and params from the response object
auto renderPathIdentifier = JSC::Identifier::fromString(vm, "__renderPath"_s);
auto renderParamsIdentifier = JSC::Identifier::fromString(vm, "__renderParams"_s);
JSC::JSValue renderPath = responseObject->get(globalObject, renderPathIdentifier);
JSC::JSValue renderParams = responseObject->get(globalObject, renderParamsIdentifier);
// Use the BakeSSRResponse builtin's wrapComponent function
JSC::JSFunction* wrapComponentFn = JSC::JSFunction::create(vm, globalObject, bakeSSRResponseWrapComponentCodeGenerator(vm), globalObject);
// Call wrapComponent(path, params, true, responseObject) where true indicates this is a render redirect
JSC::MarkedArgumentBuffer args;
args.append(renderPath);
args.append(renderParams);
args.append(JSC::jsBoolean(true)); // This is a render redirect
args.append(response); // Pass the Response object
// Call the wrapComponent function
auto callData = JSC::getCallData(wrapComponentFn);
JSC::JSValue wrappedComponent = JSC::call(globalObject, wrapComponentFn, callData, JSC::jsUndefined(), args);
if (!scope.exception() && !wrappedComponent.isUndefinedOrNull()) {
responseObject->putDirect(vm, typeIdentifier, wrappedComponent, JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete | 0);
} else {
// If there was an error, clear it and set type to null
scope.clearException();
responseObject->putDirect(vm, typeIdentifier, JSC::jsNull(), JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete | 0);
}
}
// Handle Response.redirect() case - use BakeSSRResponse builtin to create wrapper
else if (isRedirect) {
// Use the BakeSSRResponse builtin's wrapComponent function
JSC::JSFunction* wrapComponentFn = JSC::JSFunction::create(vm, globalObject, bakeSSRResponseWrapComponentCodeGenerator(vm), globalObject);
// Call wrapComponent(undefined, undefined, "redirect", responseObject)
// For redirect, we pass "redirect" as the third parameter and the Response object as the fourth
JSC::MarkedArgumentBuffer args;
args.append(JSC::jsUndefined()); // No component for redirect
args.append(JSC::jsUndefined()); // No response options for redirect
// Pass "redirect" string as the third parameter to indicate this is a redirect
// The responseOptions parameter from transformToReactElementWithOptions contains "redirect"
args.append(responseOptions); // This should be the "redirect" string
args.append(response); // Pass the Response object
// Call the wrapComponent function
auto callData = JSC::getCallData(wrapComponentFn);
JSC::JSValue wrappedComponent = JSC::call(globalObject, wrapComponentFn, callData, JSC::jsUndefined(), args);
if (!scope.exception() && !wrappedComponent.isUndefinedOrNull()) {
responseObject->putDirect(vm, typeIdentifier, wrappedComponent, JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete | 0);
} else {
// If there was an error, clear it and set type to null
scope.clearException();
responseObject->putDirect(vm, typeIdentifier, JSC::jsNull(), JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete | 0);
}
}
// TODO: this is stupid
// Check if the component is a JSX element (has $$typeof property)
// If it is, we need to wrap it in a function for React to work properly
// because `<Component />` is already the result of calling Component()
else if (component.isObject()) {
JSC::JSObject* componentObject = component.getObject();
auto typeofProperty = JSC::Identifier::fromString(vm, "$$typeof"_s);
JSC::JSValue typeofValue = componentObject->get(globalObject, typeofProperty);
if (!scope.exception() && typeofValue.isSymbol()) {
// It's a JSX element - wrap it in a function that returns it
// If we have response options, use the BakeSSRResponse builtin to wrap the component
// so it can update AsyncLocalStorage when rendered
if (!responseOptions.isUndefinedOrNull() && responseOptions.isObject()) {
// Use the BakeSSRResponse builtin's wrapComponent function
// This will create a wrapper that updates AsyncLocalStorage before returning the component
// Create the wrapComponent function from the BakeSSRResponse builtin
// The pattern is: <filename><functionName>CodeGenerator
// So for BakeSSRResponse.ts with export function wrapComponent:
JSC::JSFunction* wrapComponentFn = JSC::JSFunction::create(vm, globalObject, bakeSSRResponseWrapComponentCodeGenerator(vm), globalObject);
// Call wrapComponent(component, responseOptions, false, undefined)
JSC::MarkedArgumentBuffer args;
args.append(component);
args.append(responseOptions);
args.append(JSC::jsBoolean(false)); // Not a render redirect
args.append(JSC::jsUndefined()); // No response object for regular case
// Call the wrapComponent function
auto callData = JSC::getCallData(wrapComponentFn);
JSC::JSValue wrappedComponent = JSC::call(globalObject, wrapComponentFn, callData, JSC::jsUndefined(), args);
if (!scope.exception() && !wrappedComponent.isUndefinedOrNull()) {
responseObject->putDirect(vm, typeIdentifier, wrappedComponent, JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete | 0);
} else {
// Fall back to simple wrapper if there was an exception or undefined result
scope.clearException();
JSC::Strong<JSC::Unknown> strongComponent(vm, component);
auto* wrapperFunction = JSC::JSNativeStdFunction::create(
vm,
globalObject,
0, // arity
String(), // name
[strongComponent](JSC::JSGlobalObject* execGlobalObject, JSC::CallFrame* callFrame) -> JSC::EncodedJSValue {
return JSC::JSValue::encode(strongComponent.get());
});
responseObject->putDirect(vm, typeIdentifier, wrapperFunction, JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete | 0);
}
} else {
// No response options - create a simple wrapper
JSC::Strong<JSC::Unknown> strongComponent(vm, component);
auto* wrapperFunction = JSC::JSNativeStdFunction::create(
vm,
globalObject,
0, // arity
String(), // name
[strongComponent](JSC::JSGlobalObject* execGlobalObject, JSC::CallFrame* callFrame) -> JSC::EncodedJSValue {
return JSC::JSValue::encode(strongComponent.get());
});
responseObject->putDirect(vm, typeIdentifier, wrapperFunction, JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete | 0);
}
} else {
// It's not a JSX element, use it directly
responseObject->putDirect(vm, typeIdentifier, component, JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete | 0);
}
} else {
// It's not an object (could be a function or primitive), use it directly
responseObject->putDirect(vm, typeIdentifier, component, JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete | 0);
}
// Set key property to null
auto keyIdentifier = JSC::Identifier::fromString(vm, "key"_s);
responseObject->putDirect(vm, keyIdentifier, JSC::jsNull(), JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete | 0);
// Set props property to empty object
auto propsIdentifier = JSC::Identifier::fromString(vm, "props"_s);
responseObject->putDirect(vm, propsIdentifier, JSC::constructEmptyObject(globalObject), JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete | 0);
// Add _store object for dev mode
auto storeIdentifier = JSC::Identifier::fromString(vm, "_store"_s);
JSC::JSObject* storeObject = JSC::constructEmptyObject(globalObject);
// Add validated property to _store
auto validatedIdentifier = JSC::Identifier::fromString(vm, "validated"_s);
storeObject->putDirect(vm, validatedIdentifier, JSC::jsNumber(0), 0);
responseObject->putDirect(vm, storeIdentifier, storeObject, JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete | 0);
// Add debug properties (all set to null)
auto ownerIdentifier = JSC::Identifier::fromString(vm, "_owner"_s);
responseObject->putDirect(vm, ownerIdentifier, JSC::jsNull(), JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete | 0);
auto debugInfoIdentifier = JSC::Identifier::fromString(vm, "_debugInfo"_s);
responseObject->putDirect(vm, debugInfoIdentifier, JSC::jsNull(), JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete | 0);
auto debugStackIdentifier = JSC::Identifier::fromString(vm, "_debugStack"_s);
// TODO: we should put an error here if we want a stack trace apparently
responseObject->putDirect(vm, debugStackIdentifier, JSC::jsNull(), JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete | 0);
auto debugTaskIdentifier = JSC::Identifier::fromString(vm, "_debugTask"_s);
responseObject->putDirect(vm, debugTaskIdentifier, JSC::jsNull(), JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete | 0);
}
extern "C" void JSC__JSValue__forEachPropertyNonIndexed(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* globalObject, void* arg2, void (*iter)(JSC::JSGlobalObject* arg0, void* ctx, ZigString* arg2, JSC::EncodedJSValue JSValue3, bool isSymbol, bool isPrivateSymbol))
{
JSC__JSValue__forEachPropertyImpl<true>(JSValue0, globalObject, arg2, iter);
}
[[ZIG_EXPORT(check_slow)]] void JSC__JSValue__forEachPropertyOrdered(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* globalObject, void* arg2, void (*iter)([[ZIG_NONNULL]] JSC::JSGlobalObject* arg0, void* ctx, [[ZIG_NONNULL]] ZigString* arg2, JSC::EncodedJSValue JSValue3, bool isSymbol, bool isPrivateSymbol))
{
JSC::JSValue value = JSC::JSValue::decode(JSValue0);
JSC::JSObject* object = value.getObject();

View File

@@ -0,0 +1,90 @@
#include "config.h"
#include "JavaScriptCore/JSObject.h"
#include "JavaScriptCore/ObjectConstructor.h"
#include "JavaScriptCore/JSGlobalObject.h"
#include "ZigGlobalObject.h"
namespace Bun {
using namespace JSC;
// Helper function to merge AsyncLocalStorage context into Response init options
// This modifies initOptions in place by adding properties from alsStore that don't exist in initOptions
extern "C" void Response__mergeAsyncLocalStorageOptions(
JSC::JSGlobalObject* globalObject,
JSC::EncodedJSValue alsStoreValue,
JSC::EncodedJSValue initOptionsValue)
{
VM& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);
JSValue alsStore = JSValue::decode(alsStoreValue);
JSValue initOptions = JSValue::decode(initOptionsValue);
// Both must be objects
if (!alsStore.isObject() || !initOptions.isObject()) {
return;
}
JSObject* alsStoreObject = asObject(alsStore);
JSObject* initOptionsObject = asObject(initOptions);
// Get properties from alsStore
PropertyNameArray properties(vm, PropertyNameMode::Strings, PrivateSymbolMode::Exclude);
alsStoreObject->getOwnPropertyNames(alsStoreObject, globalObject, properties, DontEnumPropertiesMode::Exclude);
RETURN_IF_EXCEPTION(scope, );
// Copy properties from alsStore to initOptions (only if they don't already exist)
for (auto& propertyName : properties) {
// Check if initOptions already has this property
PropertySlot checkSlot(initOptionsObject, PropertySlot::InternalMethodType::Get);
if (!initOptionsObject->getOwnPropertySlot(initOptionsObject, globalObject, propertyName, checkSlot)) {
// Property doesn't exist in initOptions, copy it from alsStore
PropertySlot slot(alsStoreObject, PropertySlot::InternalMethodType::Get);
if (alsStoreObject->getOwnPropertySlot(alsStoreObject, globalObject, propertyName, slot)) {
JSValue value = slot.getValue(globalObject, propertyName);
RETURN_IF_EXCEPTION(scope, );
initOptionsObject->putDirect(vm, propertyName, value);
}
}
}
}
// Helper function to get the store from AsyncLocalStorage instance
extern "C" JSC::EncodedJSValue Response__getAsyncLocalStorageStore(
JSC::JSGlobalObject* globalObject,
JSC::EncodedJSValue alsValue)
{
VM& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);
JSValue als = JSValue::decode(alsValue);
if (!als.isObject()) {
return JSValue::encode(jsUndefined());
}
JSObject* alsObject = asObject(als);
// Call the getStore() method
Identifier getStoreId = Identifier::fromString(vm, "getStore"_s);
PropertySlot slot(alsObject, PropertySlot::InternalMethodType::Get);
if (!alsObject->getPropertySlot(globalObject, getStoreId, slot)) {
return JSValue::encode(jsUndefined());
}
JSValue getStoreFunction = slot.getValue(globalObject, getStoreId);
RETURN_IF_EXCEPTION(scope, {});
if (!getStoreFunction.isCallable()) {
return JSValue::encode(jsUndefined());
}
CallData callData = getCallData(getStoreFunction);
MarkedArgumentBuffer args;
JSValue result = call(globalObject, getStoreFunction, callData, alsObject, args);
RETURN_IF_EXCEPTION(scope, {});
return JSValue::encode(result);
}
} // namespace Bun

View File

@@ -173,6 +173,13 @@ export fn Bun__addBakeSourceProviderSourceMap(vm: *VirtualMachine, opaque_source
vm.source_mappings.putBakeSourceProvider(@as(*BakeSourceProvider, @ptrCast(opaque_source_provider)), slice.slice());
}
export fn Bun__addDevServerSourceProvider(vm: *VirtualMachine, opaque_source_provider: *anyopaque, specifier: *bun.String) void {
var sfb = std.heap.stackFallback(4096, bun.default_allocator);
const slice = specifier.toUTF8(sfb.get());
defer slice.deinit();
vm.source_mappings.putDevServerSourceProvider(@as(*DevServerSourceProvider, @ptrCast(opaque_source_provider)), slice.slice());
}
export fn Bun__addSourceProviderSourceMap(vm: *VirtualMachine, opaque_source_provider: *anyopaque, specifier: *bun.String) void {
var sfb = std.heap.stackFallback(4096, bun.default_allocator);
const slice = specifier.toUTF8(sfb.get());
@@ -209,6 +216,7 @@ const std = @import("std");
const bun = @import("bun");
const BakeSourceProvider = bun.sourcemap.BakeSourceProvider;
const DevServerSourceProvider = bun.sourcemap.DevServerSourceProvider;
const PluginRunner = bun.transpiler.PluginRunner;
const jsc = bun.jsc;

View File

@@ -1322,6 +1322,7 @@ pub fn writeFileInternal(globalThis: *jsc.JSGlobalObject, path_or_blob_: *PathOr
.Empty,
.Blob,
.Null,
.Render,
=> {
break :brk response.body.use();
},
@@ -1383,6 +1384,7 @@ pub fn writeFileInternal(globalThis: *jsc.JSGlobalObject, path_or_blob_: *PathOr
.Empty,
.Blob,
.Null,
.Render,
=> {
break :brk request.body.value.use();
},

View File

@@ -268,6 +268,9 @@ pub const Value = union(Tag) {
Empty,
Error: ValueError,
Null,
Render: struct {
path: []const u8,
},
// We may not have all the data yet
// So we can't know for sure if it's empty or not
@@ -280,6 +283,7 @@ pub const Value = union(Tag) {
.Blob => this.Blob.size == 0,
.WTFStringImpl => this.WTFStringImpl.length() == 0,
.Error, .Locked => false,
.Render => false,
};
}
@@ -440,6 +444,7 @@ pub const Value = union(Tag) {
Empty,
Error,
Null,
Render,
};
// pub const empty = Value{ .Empty = {} };
@@ -457,6 +462,10 @@ pub const Value = union(Tag) {
.Null => {
return JSValue.null;
},
.Render => {
// Render variant cannot be converted to a stream
return JSValue.null;
},
.InternalBlob, .Blob, .WTFStringImpl => {
var blob = this.use();
defer blob.detach();
@@ -751,6 +760,7 @@ pub const Value = union(Tag) {
.InternalBlob => this.InternalBlob.sliceConst(),
.WTFStringImpl => if (this.WTFStringImpl.canUseAsUTF8()) this.WTFStringImpl.latin1Slice() else "",
// .InlineBlob => this.InlineBlob.sliceConst(),
.Render => "",
else => "",
};
}
@@ -1396,6 +1406,7 @@ pub const ValueBufferer = struct {
.WTFStringImpl,
.InternalBlob,
.Blob,
.Render,
=> {
// toBlobIfPossible checks for WTFString needing a conversion.
var input = value.useAsAnyBlobAllowNonUTF8String();

View File

@@ -1,5 +1,21 @@
const Response = @This();
// C++ helper functions for AsyncLocalStorage integration
extern fn Response__getAsyncLocalStorageStore(global: *JSGlobalObject, als: JSValue) JSValue;
extern fn Response__mergeAsyncLocalStorageOptions(global: *JSGlobalObject, alsStore: JSValue, initOptions: JSValue) void;
// Zig function to update AsyncLocalStorage with response options
pub fn bakeGetAsyncLocalStorage(global: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const vm = global.bunVM();
// Get the AsyncLocalStorage instance from the VM
if (vm.getDevServerAsyncLocalStorage()) |als| {
return als;
}
return .js_undefined;
}
const ResponseMixin = BodyMixin(@This());
pub const js = jsc.Codegen.JSResponse;
// NOTE: toJS is overridden
@@ -14,6 +30,8 @@ redirected: bool = false,
/// In the server we use a flag response_protected to protect/unprotect the response
ref_count: u32 = 1,
is_jsx: bool = false,
// We must report a consistent value for this
reported_estimated_size: usize = 0,
@@ -104,7 +122,7 @@ pub export fn jsFunctionGetCompleteRequestOrResponseBodyValueAsArrayBuffer(globa
var any_blob = body.useAsAnyBlob();
return any_blob.toArrayBufferTransfer(globalObject) catch return .zero;
},
.Error, .Locked => return .js_undefined,
.Error, .Locked, .Render => return .js_undefined,
}
}
@@ -303,6 +321,7 @@ pub fn cloneValue(
}
pub fn clone(this: *Response, globalThis: *JSGlobalObject) bun.JSError!*Response {
// TODO: handle clone for jsxElement for bake?
return bun.new(Response, try this.cloneValue(globalThis));
}
@@ -497,9 +516,93 @@ pub fn constructRedirect(
var headers_ref = response.init.headers.?;
try headers_ref.put(.Location, url_string_slice.slice(), globalThis);
const ptr = bun.new(Response, response);
const response_js = ptr.toJS(globalThis);
// Check if dev_server_async_local_storage is set (indicating we're in Bun dev server)
const vm = globalThis.bunVM();
if (vm.dev_server_async_local_storage.has()) {
// Mark this as a redirect Response that should be handled specially
// when used in a React component
const redirect_marker = ZigString.init("__bun_redirect__").toJS(globalThis);
// Transform the Response to act as a React element with special redirect handling
// Pass "redirect" as the third parameter to indicate this is a redirect
const redirect_flag = ZigString.init("redirect").toJS(globalThis);
JSValue.transformToReactElementWithOptions(response_js, redirect_marker, redirect_flag, globalThis);
}
return ptr.toJS(globalThis);
return response_js;
}
pub fn constructRender(
globalThis: *jsc.JSGlobalObject,
callframe: *jsc.CallFrame,
) bun.JSError!JSValue {
const arguments = callframe.arguments_old(2);
const vm = globalThis.bunVM();
// Check if dev_server_async_local_storage is set
if (!vm.dev_server_async_local_storage.has()) {
return globalThis.throwInvalidArguments("Response.render() is only available in the Bun dev server", .{});
}
// Validate arguments
if (arguments.len < 1) {
return globalThis.throwInvalidArguments("Response.render() requires at least a path argument", .{});
}
const path_arg = arguments.ptr[0];
if (!path_arg.isString()) {
return globalThis.throwInvalidArguments("Response.render() path must be a string", .{});
}
// Get the path string
const path_str = try path_arg.toSlice(globalThis, bun.default_allocator);
// Duplicate the path string so it persists
const path_copy = bun.default_allocator.dupe(u8, path_str.slice()) catch {
path_str.deinit();
return globalThis.throwOutOfMemory();
};
path_str.deinit();
// Create a Response with Render body
var response = bun.new(Response, Response{
.body = Body{
.value = .{
.Render = .{
.path = path_copy,
},
},
},
.init = Response.Init{
.status_code = 200,
},
});
const response_js = response.toJS(globalThis);
response_js.ensureStillAlive();
// Store the render path and params on the response for later use
// When React tries to render this component, we'll check for these and throw RenderAbortError
response_js.put(globalThis, "__renderPath", path_arg);
const params_arg = if (arguments.len >= 2) arguments.ptr[1] else JSValue.jsNull();
response_js.put(globalThis, "__renderParams", params_arg);
// TODO: this is terrible
// Create a simple wrapper function that will be called by React
// This needs to be handled specially in transformToReactElementWithOptions
// We'll pass a special marker as the component to indicate this is a render redirect
const render_marker = ZigString.init("__bun_render_redirect__").toJS(globalThis);
// Transform the Response to act as a React element
// The C++ code will need to check for this special marker
JSValue.transformToReactElementWithOptions(response_js, render_marker, params_arg, globalThis);
return response_js;
}
pub fn constructError(
globalThis: *jsc.JSGlobalObject,
_: *jsc.CallFrame,
@@ -519,9 +622,10 @@ pub fn constructError(
return response.toJS(globalThis);
}
pub fn constructor(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!*Response {
const arguments = callframe.argumentsAsArray(2);
pub fn constructor(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame, this_value: jsc.JSValue) bun.JSError!*Response {
var arguments = callframe.argumentsAsArray(2);
var is_jsx = false;
if (!arguments[0].isUndefinedOrNull() and arguments[0].isObject()) {
if (arguments[0].as(Blob)) |blob| {
if (blob.isS3()) {
@@ -554,6 +658,20 @@ pub fn constructor(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) b
return bun.new(Response, response);
}
}
// Special case for bake: allow `return new Response(<jsx> ... </jsx>, { ... }`
// inside of a react component
if (globalThis.allowJSXInResponseConstructor()) {
const arg = arguments[0];
// Check if it's a JSX element (object with $$typeof)
if (try arg.isJSXElement(globalThis)) {
// Pass the response options (arguments[1]) to transformToReactElement
// so it can store them for later use when the component is rendered
const responseOptions = if (arguments[1].isObject()) arguments[1] else .js_undefined;
JSValue.transformToReactElementWithOptions(this_value, arg, responseOptions, globalThis);
is_jsx = true;
}
}
}
var init: Init = (brk: {
if (arguments[1].isUndefinedOrNull()) {
@@ -593,6 +711,7 @@ pub fn constructor(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) b
var response = bun.new(Response, Response{
.body = body,
.init = init,
.is_jsx = is_jsx,
});
if (response.body.value == .Blob and

View File

@@ -689,6 +689,7 @@ pub const WriteFileWaitFromLockedValueTask = struct {
.Null,
.Empty,
.Blob,
.Render,
=> {
var blob = value.use();
// TODO: this should be one promise not two!

View File

@@ -83,6 +83,9 @@ export default [
error: {
fn: "constructError",
},
render: {
fn: "constructRender",
},
},
proto: {
url: {
@@ -124,6 +127,7 @@ export default [
getter: "getBodyUsed",
},
},
constructNeedsThis: true,
}),
define({
name: "Blob",

View File

@@ -93,6 +93,7 @@ async function run() {
entrypoints: [generated_entrypoint],
minify: !debug,
drop: debug ? [] : ["DEBUG"],
target: side === "server" ? "bun" : "browser",
});
if (!result.success) throw new AggregateError(result.logs);
assert(result.outputs.length === 1, "must bundle to a single file");

View File

@@ -5879,7 +5879,8 @@ const Tokenizer = struct {
}
pub inline fn sliceFrom(this: *Tokenizer, start: usize) []const u8 {
return this.src[start..this.position];
const position = this.getPosition();
return this.src[start..position];
}
};

View File

@@ -62,6 +62,9 @@ export async function renderRoutesForProdStatic(
pageModule,
params,
} satisfies Bake.RouteMetadata);
if (results === undefined) {
return true;
}
if (results == null) {
throw new Error(`Route ${JSON.stringify(sourceRouteFiles[i])} cannot be pre-rendered to a static page.`);
}
@@ -151,6 +154,13 @@ export async function renderRoutesForProdStatic(
}
}
} else {
// for (const params of paramGetter.pages) {
// const failed = await callRouteGenerator(type, noClient, i, layouts, pageModule, params);
// if (failed) {
// return;
// }
// }
// TODO: error signal here?
await Promise.all(
paramGetter.pages.map(params => {
callRouteGenerator(type, noClient, i, layouts, pageModule, params);

View File

@@ -0,0 +1,51 @@
// Used to make a Response fake being a component
// When this is called, it will render the component and then update async local
// storage with the options of the Response
// For Response.render(), we pass the response as strongComponent and need a 4th parameter
// For Response.redirect(), isRenderRedirect will be "redirect" instead of true
export function wrapComponent(strongComponent, responseOptions, isRenderRedirect, responseObject) {
const bakeGetAsyncLocalStorage = $newZigFunction("bun.js/webcore/Response.zig", "bakeGetAsyncLocalStorage", 0);
return function() {
// For Response.redirect(), we need to throw a RedirectAbortError
if (isRenderRedirect === "redirect") {
// responseObject is the Response from Response.redirect()
const RedirectAbortError = globalThis.RedirectAbortError;
if (RedirectAbortError) {
throw new RedirectAbortError(responseObject);
}
// Fallback if RedirectAbortError is not available
const error = new Error("Response.redirect() called");
error.name = "RedirectAbortError";
error.response = responseObject;
throw error;
}
// For Response.render(), we need to throw a RenderAbortError
if (isRenderRedirect === true || isRenderRedirect === "render") {
// strongComponent is the path string, responseOptions is params, responseObject is the Response
// We need to get the RenderAbortError from the global
const RenderAbortError = globalThis.RenderAbortError;
if (RenderAbortError) {
throw new RenderAbortError(strongComponent, responseOptions, responseObject);
}
// Fallback if RenderAbortError is not available
const error = new Error("Response.render() called");
error.name = "RenderAbortError";
error.path = strongComponent;
error.params = responseOptions;
error.response = responseObject;
throw error;
}
// For new Response(<jsx />, {}), update AsyncLocalStorage
const async_local_storage = bakeGetAsyncLocalStorage();
if (async_local_storage) {
const store = async_local_storage.getStore();
if (store) {
store.responseOptions = responseOptions;
}
}
return strongComponent;
};
}

View File

@@ -2310,6 +2310,9 @@ fn NewPrinter(
if (p.options.require_ref) |require_ref| {
p.printSymbol(require_ref);
p.print(".resolve");
} else if (p.options.module_type == .internal_bake_dev) {
p.printSymbol(p.options.hmr_ref);
p.print(".requireResolve");
} else {
p.print("require.resolve");
}

View File

@@ -888,15 +888,17 @@ pub const ParsedSourceMap = struct {
is_standalone_module_graph: bool = false,
const SourceProviderKind = enum(u1) { zig, bake };
const SourceProviderKind = enum(u2) { zig, bake, dev_server };
const AnySourceProvider = union(enum) {
zig: *SourceProviderMap,
bake: *BakeSourceProvider,
dev_server: *DevServerSourceProvider,
pub fn ptr(this: AnySourceProvider) *anyopaque {
return switch (this) {
.zig => @ptrCast(this.zig),
.bake => @ptrCast(this.bake),
.dev_server => @ptrCast(this.dev_server),
};
}
@@ -909,6 +911,7 @@ pub const ParsedSourceMap = struct {
return switch (this) {
.zig => this.zig.getSourceMap(source_filename, load_hint, result),
.bake => this.bake.getSourceMap(source_filename, load_hint, result),
.dev_server => this.dev_server.getSourceMap(source_filename, load_hint, result),
};
}
};
@@ -916,7 +919,7 @@ pub const ParsedSourceMap = struct {
const SourceContentPtr = packed struct(u64) {
load_hint: SourceMapLoadHint,
kind: SourceProviderKind,
data: u61,
data: u60,
pub const none: SourceContentPtr = .{ .load_hint = .none, .kind = .zig, .data = 0 };
@@ -928,10 +931,15 @@ pub const ParsedSourceMap = struct {
return .{ .load_hint = .none, .data = @intCast(@intFromPtr(p)), .kind = .bake };
}
fn fromDevServerProvider(p: *DevServerSourceProvider) SourceContentPtr {
return .{ .load_hint = .none, .data = @intCast(@intFromPtr(p)), .kind = .dev_server };
}
pub fn provider(sc: SourceContentPtr) ?AnySourceProvider {
switch (sc.kind) {
.zig => return .{ .zig = @ptrFromInt(sc.data) },
.bake => return .{ .bake = @ptrFromInt(sc.data) },
.dev_server => return .{ .dev_server = @ptrFromInt(sc.data) },
}
}
};
@@ -1021,9 +1029,10 @@ pub const SourceMapLoadHint = enum(u2) {
is_external_map,
};
/// Always returns UTF-8
fn findSourceMappingURL(comptime T: type, source: []const T, alloc: std.mem.Allocator) ?bun.jsc.ZigString.Slice {
const needle = comptime bun.strings.literal(T, "\n//# sourceMappingURL=");
const found = bun.strings.indexOfT(T, source, needle) orelse return null;
const found = std.mem.lastIndexOf(T, source, needle) orelse return null;
const end = std.mem.indexOfScalarPos(T, source, found + needle.len, '\n') orelse source.len;
const url = std.mem.trimRight(T, source[found + needle.len .. end], &.{ ' ', '\r' });
return switch (T) {
@@ -1036,6 +1045,189 @@ fn findSourceMappingURL(comptime T: type, source: []const T, alloc: std.mem.Allo
};
}
fn findSourceMappingURLNah(comptime T: type, source: []const T, alloc: std.mem.Allocator) ?bun.jsc.ZigString.Slice {
// According to the spec, we need to find the LAST valid sourceMappingURL
// We need to handle both //# and //@ prefixes, and also /* */ comments
var last_url: ?bun.jsc.ZigString.Slice = null;
var i: usize = 0;
const solidus = comptime bun.strings.literal(T, "/")[0];
const asterisk = comptime bun.strings.literal(T, "*")[0];
const newline = comptime bun.strings.literal(T, "\n")[0];
const carriage_return = comptime bun.strings.literal(T, "\r")[0];
// Line terminators as per ECMAScript spec
// Note: For UTF-8, these would be multi-byte sequences, so we only check them in UTF-16
const line_separator: T = if (T == u16) 0x2028 else newline;
const paragraph_separator: T = if (T == u16) 0x2029 else newline;
while (i < source.len) {
// Skip to next potential comment
const slash_pos = std.mem.indexOfScalarPos(T, source, i, solidus) orelse break;
i = slash_pos + 1;
if (i >= source.len) break;
const next_char = source[i];
// Handle single-line comment //
if (next_char == solidus) {
i += 1;
const comment_start = i;
// Find end of line
var line_end = source.len;
var j = comment_start;
while (j < source.len) : (j += 1) {
const c = source[j];
if (c == newline or c == carriage_return or
(T == u16 and (c == line_separator or c == paragraph_separator)))
{
line_end = j;
break;
}
}
const comment = source[comment_start..line_end];
if (matchSourceMappingURL(T, comment, alloc)) |url| {
// Free previous URL if any
if (last_url) |prev| prev.deinit();
last_url = url;
}
i = line_end;
}
// Handle multi-line comment /* */
else if (next_char == asterisk) {
i += 1;
const comment_start = i;
// Find closing */
var found_end = false;
while (i + 1 < source.len) : (i += 1) {
if (source[i] == asterisk and source[i + 1] == solidus) {
const comment = source[comment_start..i];
if (matchSourceMappingURL(T, comment, alloc)) |url| {
// Free previous URL if any
if (last_url) |prev| prev.deinit();
last_url = url;
}
i += 2;
found_end = true;
break;
}
}
if (!found_end) {
// Unclosed comment - ignore rest of file
break;
}
}
// Not a comment - check if it's whitespace
else {
// Back up to check the character before the slash
const before_slash = slash_pos;
if (before_slash > 0) {
var j = before_slash - 1;
// Check backwards for non-whitespace on this line
while (j > 0) : (j -%= 1) {
const c = source[j];
if (c == newline or c == carriage_return or
(T == u16 and (c == line_separator or c == paragraph_separator)))
{
// Hit line boundary, this slash starts the line (after whitespace)
break;
}
if (!isWhitespace(T, c)) {
// Non-whitespace found - reset last_url per spec
if (last_url) |prev| {
prev.deinit();
last_url = null;
}
break;
}
if (j == 0) break;
}
}
}
}
return last_url;
}
// Helper function to match sourceMappingURL pattern in a comment
fn matchSourceMappingURL(comptime T: type, comment: []const T, alloc: std.mem.Allocator) ?bun.jsc.ZigString.Slice {
// Pattern: ^[@#]\s*sourceMappingURL=(\S*?)\s*$
var i: usize = 0;
// Skip leading whitespace
while (i < comment.len and isWhitespace(T, comment[i])) : (i += 1) {}
if (i >= comment.len) return null;
// Check for @ or # prefix
const at_sign = comptime bun.strings.literal(T, "@")[0];
const hash = comptime bun.strings.literal(T, "#")[0];
if (comment[i] != at_sign and comment[i] != hash) return null;
i += 1;
// Skip whitespace after prefix
while (i < comment.len and isWhitespace(T, comment[i])) : (i += 1) {}
// Check for "sourceMappingURL="
const mapping_text = comptime bun.strings.literal(T, "sourceMappingURL=");
if (i + mapping_text.len > comment.len) return null;
const text_part = comment[i .. i + mapping_text.len];
if (!std.mem.eql(T, text_part, mapping_text)) return null;
i += mapping_text.len;
// Find the URL (non-whitespace characters)
const url_start = i;
while (i < comment.len and !isWhitespace(T, comment[i])) : (i += 1) {}
if (url_start == i) return null; // Empty URL
const url = comment[url_start..i];
// Verify rest is only whitespace
while (i < comment.len) : (i += 1) {
if (!isWhitespace(T, comment[i])) return null;
}
// Return the URL as a ZigString.Slice
return switch (T) {
u8 => bun.jsc.ZigString.Slice.fromUTF8NeverFree(url),
u16 => bun.jsc.ZigString.Slice.init(
alloc,
bun.strings.toUTF8Alloc(alloc, url) catch bun.outOfMemory(),
),
else => @compileError("Not Supported"),
};
}
// Helper to check if a character is whitespace
fn isWhitespace(comptime T: type, char: T) bool {
return switch (char) {
'\t', '\n', '\r', ' ', 0x0B, 0x0C => true,
else => {
if (T == u16) {
return switch (char) {
0xA0, // non-breaking space
0xFEFF, // BOM
0x2028, // line separator
0x2029, // paragraph separator
=> true,
else => false,
};
}
return false;
},
};
}
/// The last two arguments to this specify loading hints
pub fn getSourceMapImpl(
comptime SourceProviderKind: type,
@@ -1066,29 +1258,61 @@ pub fn getSourceMapImpl(
defer source.deref();
bun.assert(source.tag == .ZigString);
const found_url = (if (source.is8Bit())
findSourceMappingURL(u8, source.latin1(), allocator)
else
findSourceMappingURL(u16, source.utf16(), allocator)) orelse
break :try_inline;
const maybe_found_url = found_url: {
if (source.is8Bit())
break :found_url findSourceMappingURL(u8, source.latin1(), allocator);
break :found_url findSourceMappingURL(u16, source.utf16(), allocator);
};
const found_url = maybe_found_url orelse break :try_inline;
defer found_url.deinit();
if (bun.strings.hasPrefixComptime(
found_url.slice(),
"bake://server.map",
)) {}
const parsed = parseUrl(
bun.default_allocator,
allocator,
found_url.slice(),
result,
) catch |err| {
inline_err = err;
break :try_inline;
};
break :parsed .{
.is_inline_map,
parseUrl(
bun.default_allocator,
allocator,
found_url.slice(),
result,
) catch |err| {
inline_err = err;
break :try_inline;
},
parsed,
};
}
// try to load a .map file
if (load_hint != .is_inline_map) try_external: {
if (comptime SourceProviderKind == DevServerSourceProvider) {
// For DevServerSourceProvider, get the source map JSON directly
const source_map_data = provider.getSourceMapJSON();
if (source_map_data.length == 0) {
break :try_external;
}
const json_slice = source_map_data.ptr[0..source_map_data.length];
// Parse the JSON source map
break :parsed .{
.is_external_map,
parseJSON(
bun.default_allocator,
allocator,
json_slice,
result,
) catch return null,
};
}
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
@@ -1242,6 +1466,39 @@ pub const BakeSourceProvider = opaque {
}
};
pub const DevServerSourceProvider = opaque {
pub const SourceMapData = extern struct {
ptr: [*]const u8,
length: usize,
};
extern fn DevServerSourceProvider__getSourceSlice(*DevServerSourceProvider) bun.String;
extern fn DevServerSourceProvider__getSourceMapJSON(*DevServerSourceProvider) SourceMapData;
pub const getSourceSlice = DevServerSourceProvider__getSourceSlice;
pub const getSourceMapJSON = DevServerSourceProvider__getSourceMapJSON;
pub fn toSourceContentPtr(this: *DevServerSourceProvider) ParsedSourceMap.SourceContentPtr {
return ParsedSourceMap.SourceContentPtr.fromDevServerProvider(this);
}
/// The last two arguments to this specify loading hints
pub fn getSourceMap(
provider: *DevServerSourceProvider,
source_filename: []const u8,
load_hint: SourceMap.SourceMapLoadHint,
result: SourceMap.ParseUrlResultHint,
) ?SourceMap.ParseUrl {
return getSourceMapImpl(
DevServerSourceProvider,
provider,
source_filename,
load_hint,
result,
);
}
};
/// The sourcemap spec says line and column offsets are zero-based
pub const LineColumnOffset = struct {
/// The zero-based line offset

View File

@@ -0,0 +1,150 @@
import { expect } from "bun:test";
import { devTest } from "../bake-harness";
devTest("server-side source maps show correct error lines", {
files: {
"pages/[...slug].tsx": `export default async function MyPage(params) {
myFunc();
return <h1>{JSON.stringify(params)}</h1>;
}
function myFunc() {
throw new Error("Test error for source maps!");
}
export async function getStaticPaths() {
return {
paths: [
{
params: {
slug: ["test-error"],
},
},
],
};
}`,
},
framework: "react",
async test(dev) {
// Make a request that will trigger the error
await dev.fetch("/test-error").catch(() => {});
// Give it a moment to process the error
await Bun.sleep(1000);
// The output we saw shows the stack trace with correct source mapping
// We need to check that the error shows the right file:line:column
const lines = dev.output.lines.join("\n");
// Check that we got the error
expect(lines).toContain("Test error for source maps!");
// Check that the stack trace shows correct file and line numbers
// The source maps are working if we see the correct patterns
// We need to check for the patterns because ANSI codes might be embedded
const hasCorrectThrowLine = lines.includes("myFunc") && lines.includes("7") && lines.includes("9");
const hasCorrectCallLine = lines.includes("MyPage") && lines.includes("2") && lines.includes("3");
const hasCorrectFileName = lines.includes("/pages/[...slug].tsx");
expect(hasCorrectThrowLine).toBe(true);
expect(hasCorrectCallLine).toBe(true);
expect(hasCorrectFileName).toBe(true);
},
timeoutMultiplier: 2, // Give more time for the test
});
devTest("server-side source maps work with HMR updates", {
files: {
"pages/error-page.tsx": `export default function ErrorPage() {
return <div>Initial content</div>;
}
export async function getStaticPaths() {
return {
paths: [{ params: {} }],
};
}`,
},
framework: "react",
async test(dev) {
// First fetch should work
const response1 = await dev.fetch("/error-page");
expect(response1.status).toBe(200);
expect(await response1.text()).toContain("Initial content");
// Update the file to throw an error
await dev.write("pages/error-page.tsx", `export default function ErrorPage() {
throwError();
return <div>Updated content</div>;
}
function throwError() {
throw new Error("HMR error test");
}
export async function getStaticPaths() {
return {
paths: [{ params: {} }],
};
}`);
// Wait for the rebuild
await dev.waitForHmr();
// Second fetch should error
await dev.fetch("/error-page").catch(() => {});
// Wait for error output
await dev.output.waitForLine(/HMR error test/);
// Check source map points to correct lines after HMR
const lines = dev.output.lines.join("\n");
const hasCorrectThrowLine = lines.includes("throwError") && lines.includes("7") && lines.includes("9");
const hasCorrectCallLine = lines.includes("ErrorPage") && lines.includes("2") && lines.includes("3");
expect(hasCorrectThrowLine).toBe(true);
expect(hasCorrectCallLine).toBe(true);
},
});
devTest("server-side source maps handle nested imports", {
files: {
"pages/nested.tsx": `import { doSomething } from "../lib/utils";
export default function NestedPage() {
const result = doSomething();
return <div>{result}</div>;
}
export async function getStaticPaths() {
return {
paths: [{ params: {} }],
};
}`,
"lib/utils.ts": `export function doSomething() {
return helperFunction();
}
function helperFunction() {
throw new Error("Nested error");
}`,
},
framework: "react",
async test(dev) {
// Make request that triggers error
await dev.fetch("/nested").catch(() => {});
// Wait for error output
await dev.output.waitForLine(/Nested error/);
// Check that stack trace shows both files with correct lines
const lines = dev.output.lines.join("\n");
const hasUtilsThrowLine = lines.includes("helperFunction") && lines.includes("6") && lines.includes("9");
const hasUtilsCallLine = lines.includes("doSomething") && lines.includes("2");
const hasPageCallLine = lines.includes("NestedPage") && lines.includes("4");
expect(hasUtilsThrowLine).toBe(true);
expect(hasUtilsCallLine).toBe(true);
expect(hasPageCallLine).toBe(true);
},
});

View File

@@ -324,3 +324,66 @@ export { Markdoc as default };`,
expect(await c1.elemText("h1")).toBe("Welcome to SSG");
},
});
devTest("SSG pages router - catch-all routes [...slug]", {
framework: "react",
files: {
"pages/[...slug].tsx": `
const CatchAllPage: Bun.SSGPage = ({ params }) => {
return (
<div>
<h1>Catch-all Route</h1>
<p id="params">{JSON.stringify(params)}</p>
<ul>
{params.slug && Array.isArray(params.slug) ? (
params.slug.map((segment, index) => (
<li key={index}>{segment}</li>
))
) : (
<li>No slug array</li>
)}
</ul>
</div>
);
};
export default CatchAllPage;
export const getStaticPaths: Bun.GetStaticPaths = async () => {
return {
paths: [
{ params: { slug: ["docs"] } },
{ params: { slug: ["docs", "getting-started"] } },
{ params: { slug: ["docs", "api", "reference"] } },
{ params: { slug: ["blog", "2024", "january", "new-features"] } },
],
};
};
`,
},
async test(dev) {
// Test single segment
await using c1 = await dev.client("/docs");
expect(await c1.elemText("h1")).toBe("Catch-all Route");
expect(await c1.elemText("#params")).toBe('{"slug":"docs"}');
expect(await c1.elemsText("li")).toEqual(["No slug array"]);
// Test two segments
await using c2 = await dev.client("/docs/getting-started");
expect(await c2.elemText("h1")).toBe("Catch-all Route");
expect(await c2.elemText("#params")).toBe('{"slug":["docs","getting-started"]}');
expect(await c2.elemsText("li")).toEqual(["docs", "getting-started"]);
// Test three segments
await using c3 = await dev.client("/docs/api/reference");
expect(await c3.elemText("h1")).toBe("Catch-all Route");
expect(await c3.elemText("#params")).toBe('{"slug":["docs","api","reference"]}');
expect(await c3.elemsText("li")).toEqual(["docs", "api", "reference"]);
// Test four segments
await using c4 = await dev.client("/blog/2024/january/new-features");
expect(await c4.elemText("h1")).toBe("Catch-all Route");
expect(await c4.elemText("#params")).toBe('{"slug":["blog","2024","january","new-features"]}');
expect(await c4.elemsText("li")).toEqual(["blog", "2024", "january", "new-features"]);
},
});