mirror of
https://github.com/oven-sh/bun
synced 2026-02-25 19:17:20 +01:00
523 lines
21 KiB
Zig
523 lines
21 KiB
Zig
//! This object is a description of an HTML bundle. It is created by importing an
|
|
//! HTML file, and can be passed to the `static` option in `Bun.serve`. The build
|
|
//! is done lazily (state held in HTMLBundle.Route or DevServer.RouteBundle.HTML).
|
|
pub const HTMLBundle = @This();
|
|
pub const js = JSC.Codegen.JSHTMLBundle;
|
|
pub const toJS = js.toJS;
|
|
pub const fromJS = js.fromJS;
|
|
pub const fromJSDirect = js.fromJSDirect;
|
|
|
|
/// HTMLBundle can be owned by JavaScript as well as any number of Server instances.
|
|
const RefCount = bun.ptr.RefCount(@This(), "ref_count", deinit, .{});
|
|
pub const ref = RefCount.ref;
|
|
pub const deref = RefCount.deref;
|
|
|
|
ref_count: RefCount,
|
|
global: *JSGlobalObject,
|
|
path: []const u8,
|
|
|
|
/// Initialize an HTMLBundle given a path.
|
|
pub fn init(global: *JSGlobalObject, path: []const u8) !*HTMLBundle {
|
|
return bun.new(HTMLBundle, .{
|
|
.ref_count = .init(),
|
|
.global = global,
|
|
.path = try bun.default_allocator.dupe(u8, path),
|
|
});
|
|
}
|
|
|
|
pub fn finalize(this: *HTMLBundle) void {
|
|
this.deref();
|
|
}
|
|
|
|
fn deinit(this: *HTMLBundle) void {
|
|
bun.default_allocator.free(this.path);
|
|
bun.destroy(this);
|
|
}
|
|
|
|
pub fn getIndex(this: *HTMLBundle, globalObject: *JSGlobalObject) JSValue {
|
|
return bun.String.createUTF8ForJS(globalObject, this.path);
|
|
}
|
|
|
|
/// Deprecated: use Route instead.
|
|
pub const HTMLBundleRoute = Route;
|
|
|
|
/// An HTMLBundle can be used across multiple server instances, an
|
|
/// HTMLBundle.Route can only be used on one server, but is also
|
|
/// reference-counted because a server can have multiple instances of the same
|
|
/// html file on multiple endpoints.
|
|
pub const Route = struct {
|
|
/// One HTMLBundle.Route can be specified multiple times
|
|
const RefCount = bun.ptr.RefCount(@This(), "ref_count", Route.deinit, .{ .debug_name = "HTMLBundleRoute" });
|
|
pub const ref = Route.RefCount.ref;
|
|
pub const deref = Route.RefCount.deref;
|
|
|
|
bundle: RefPtr(HTMLBundle),
|
|
ref_count: Route.RefCount,
|
|
// TODO: attempt to remove the null case. null is only present during server
|
|
// initialization as only a ServerConfig object is present.
|
|
server: ?AnyServer = null,
|
|
/// When using DevServer, this value is never read or written to.
|
|
state: State,
|
|
/// Written and read by DevServer to identify if this route has been
|
|
/// registered with the bundler.
|
|
dev_server_id: bun.bake.DevServer.RouteBundle.Index.Optional = .none,
|
|
/// When state == .pending, incomplete responses are stored here.
|
|
pending_responses: std.ArrayListUnmanaged(*PendingResponse) = .{},
|
|
|
|
method: union(enum) {
|
|
any: void,
|
|
method: bun.http.Method.Set,
|
|
} = .any,
|
|
|
|
pub fn memoryCost(this: *const Route) usize {
|
|
var cost: usize = 0;
|
|
cost += @sizeOf(Route);
|
|
cost += this.pending_responses.items.len * @sizeOf(PendingResponse);
|
|
cost += this.state.memoryCost();
|
|
return cost;
|
|
}
|
|
|
|
pub fn init(html_bundle: *HTMLBundle) RefPtr(Route) {
|
|
return .new(.{
|
|
.bundle = .initRef(html_bundle),
|
|
.pending_responses = .{},
|
|
.ref_count = .init(),
|
|
.server = null,
|
|
.state = .pending,
|
|
});
|
|
}
|
|
|
|
fn deinit(this: *Route) void {
|
|
bun.assert(this.pending_responses.items.len == 0); // pending responses keep a ref to the route
|
|
this.pending_responses.deinit(bun.default_allocator);
|
|
this.bundle.deref();
|
|
this.state.deinit();
|
|
bun.destroy(this);
|
|
}
|
|
|
|
pub const State = union(enum) {
|
|
pending,
|
|
building: ?*bun.BundleV2.JSBundleCompletionTask,
|
|
err: bun.logger.Log,
|
|
html: *StaticRoute,
|
|
|
|
pub fn deinit(this: *State) void {
|
|
switch (this.*) {
|
|
.err => |*log| {
|
|
log.deinit();
|
|
},
|
|
.building => |completion| if (completion) |c| {
|
|
c.cancelled = true;
|
|
c.deref();
|
|
},
|
|
.html => {
|
|
this.html.deref();
|
|
},
|
|
.pending => {},
|
|
}
|
|
}
|
|
|
|
pub fn memoryCost(this: *const State) usize {
|
|
return switch (this.*) {
|
|
.pending => 0,
|
|
.building => 0,
|
|
.err => |log| log.memoryCost(),
|
|
.html => |html| html.memoryCost(),
|
|
};
|
|
}
|
|
};
|
|
|
|
pub fn onRequest(this: *Route, req: *uws.Request, resp: HTTPResponse) void {
|
|
this.onAnyRequest(req, resp, false);
|
|
}
|
|
|
|
pub fn onHEADRequest(this: *Route, req: *uws.Request, resp: HTTPResponse) void {
|
|
this.onAnyRequest(req, resp, true);
|
|
}
|
|
|
|
fn onAnyRequest(this: *Route, req: *uws.Request, resp: HTTPResponse, is_head: bool) void {
|
|
this.ref();
|
|
defer this.deref();
|
|
const server: AnyServer = this.server orelse {
|
|
resp.endWithoutBody(true);
|
|
return;
|
|
};
|
|
|
|
if (server.config().isDevelopment()) {
|
|
if (server.devServer()) |dev| {
|
|
dev.respondForHTMLBundle(this, req, resp) catch bun.outOfMemory();
|
|
return;
|
|
}
|
|
|
|
// Simpler development workflow which rebundles on every request.
|
|
if (this.state == .html) {
|
|
this.state.html.deref();
|
|
this.state = .pending;
|
|
} else if (this.state == .err) {
|
|
this.state.err.deinit();
|
|
this.state = .pending;
|
|
}
|
|
}
|
|
|
|
state: switch (this.state) {
|
|
.pending => {
|
|
if (bun.Environment.enable_logs)
|
|
debug("onRequest: {s} - pending", .{req.url()});
|
|
this.scheduleBundle(server) catch bun.outOfMemory();
|
|
continue :state this.state;
|
|
},
|
|
.building => {
|
|
if (bun.Environment.enable_logs)
|
|
debug("onRequest: {s} - building", .{req.url()});
|
|
|
|
// create the PendingResponse, add it to the list
|
|
const pending = bun.new(PendingResponse, .{
|
|
.method = bun.http.Method.which(req.method()) orelse {
|
|
resp.writeStatus("405 Method Not Allowed");
|
|
resp.endWithoutBody(true);
|
|
return;
|
|
},
|
|
.resp = resp,
|
|
.server = this.server,
|
|
.route = this,
|
|
});
|
|
|
|
this.pending_responses.append(bun.default_allocator, pending) catch bun.outOfMemory();
|
|
|
|
this.ref();
|
|
resp.onAborted(*PendingResponse, PendingResponse.onAborted, pending);
|
|
req.setYield(false);
|
|
},
|
|
.err => |log| {
|
|
if (bun.Environment.enable_logs)
|
|
debug("onRequest: {s} - err", .{req.url()});
|
|
_ = log; // TODO: use the code from DevServer.zig to render the error
|
|
resp.endWithoutBody(true);
|
|
},
|
|
.html => |html| {
|
|
if (bun.Environment.enable_logs)
|
|
debug("onRequest: {s} - html", .{req.url()});
|
|
if (is_head) {
|
|
html.onHEADRequest(req, resp);
|
|
} else {
|
|
html.onRequest(req, resp);
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Schedule a bundle to be built.
|
|
/// If success, bumps the ref count and returns true;
|
|
fn scheduleBundle(this: *Route, server: AnyServer) !void {
|
|
switch (server.getOrLoadPlugins(.{ .html_bundle_route = this })) {
|
|
.err => this.state = .{ .err = bun.logger.Log.init(bun.default_allocator) },
|
|
.ready => |plugins| try onPluginsResolved(this, plugins),
|
|
.pending => this.state = .{ .building = null },
|
|
}
|
|
}
|
|
|
|
pub fn onPluginsResolved(this: *Route, plugins: ?*JSC.API.JSBundler.Plugin) !void {
|
|
const global = this.bundle.data.global;
|
|
const server = this.server.?;
|
|
const development = server.config().development;
|
|
const vm = global.bunVM();
|
|
|
|
var config: JSBundler.Config = .{};
|
|
errdefer config.deinit(bun.default_allocator);
|
|
try config.entry_points.insert(this.bundle.data.path);
|
|
if (vm.transpiler.options.transform_options.serve_public_path) |public_path| {
|
|
if (public_path.len > 0) {
|
|
try config.public_path.appendSlice(public_path);
|
|
} else {
|
|
try config.public_path.appendChar('/');
|
|
}
|
|
} else {
|
|
try config.public_path.appendChar('/');
|
|
}
|
|
|
|
if (vm.transpiler.options.transform_options.serve_env_behavior != ._none) {
|
|
config.env_behavior = vm.transpiler.options.transform_options.serve_env_behavior;
|
|
|
|
if (config.env_behavior == .prefix) {
|
|
try config.env_prefix.appendSlice(vm.transpiler.options.transform_options.serve_env_prefix orelse "");
|
|
}
|
|
}
|
|
|
|
if (vm.transpiler.options.transform_options.serve_splitting) {
|
|
config.code_splitting = vm.transpiler.options.transform_options.serve_splitting;
|
|
}
|
|
|
|
config.target = .browser;
|
|
const is_development = development.isDevelopment();
|
|
|
|
if (bun.CLI.Command.get().args.serve_minify_identifiers) |minify_identifiers| {
|
|
config.minify.identifiers = minify_identifiers;
|
|
} else if (!is_development) {
|
|
config.minify.identifiers = true;
|
|
}
|
|
|
|
if (bun.CLI.Command.get().args.serve_minify_whitespace) |minify_whitespace| {
|
|
config.minify.whitespace = minify_whitespace;
|
|
} else if (!is_development) {
|
|
config.minify.whitespace = true;
|
|
}
|
|
|
|
if (bun.CLI.Command.get().args.serve_minify_syntax) |minify_syntax| {
|
|
config.minify.syntax = minify_syntax;
|
|
} else if (!is_development) {
|
|
config.minify.syntax = true;
|
|
}
|
|
|
|
if (bun.CLI.Command.get().args.serve_define) |define| {
|
|
bun.assert(define.keys.len == define.values.len);
|
|
try config.define.map.ensureUnusedCapacity(define.keys.len);
|
|
config.define.map.unmanaged.entries.len = define.keys.len;
|
|
@memcpy(config.define.map.keys(), define.keys);
|
|
for (config.define.map.values(), define.values) |*to, from| {
|
|
to.* = config.define.map.allocator.dupe(u8, from) catch bun.outOfMemory();
|
|
}
|
|
try config.define.map.reIndex();
|
|
}
|
|
|
|
if (!is_development) {
|
|
config.define.put("process.env.NODE_ENV", "\"production\"") catch bun.outOfMemory();
|
|
config.jsx.development = false;
|
|
} else {
|
|
config.force_node_env = .development;
|
|
config.jsx.development = true;
|
|
}
|
|
config.source_map = .linked;
|
|
|
|
const completion_task = try bun.BundleV2.createAndScheduleCompletionTask(
|
|
config,
|
|
plugins,
|
|
global,
|
|
vm.eventLoop(),
|
|
bun.default_allocator,
|
|
);
|
|
completion_task.started_at_ns = bun.getRoughTickCount().ns();
|
|
completion_task.html_build_task = this;
|
|
this.state = .{ .building = completion_task };
|
|
|
|
// While we're building, ensure this doesn't get freed.
|
|
this.ref();
|
|
}
|
|
|
|
pub fn onPluginsRejected(this: *Route) !void {
|
|
debug("HTMLBundleRoute(0x{x}) plugins rejected", .{@intFromPtr(this)});
|
|
this.state = .{ .err = bun.logger.Log.init(bun.default_allocator) };
|
|
this.resumePendingResponses();
|
|
}
|
|
|
|
pub fn onComplete(this: *Route, completion_task: *bun.BundleV2.JSBundleCompletionTask) void {
|
|
// For the build task.
|
|
defer this.deref();
|
|
|
|
switch (completion_task.result) {
|
|
.err => |err| {
|
|
if (bun.Environment.enable_logs)
|
|
debug("onComplete: err - {s}", .{@errorName(err)});
|
|
this.state = .{ .err = bun.logger.Log.init(bun.default_allocator) };
|
|
completion_task.log.cloneToWithRecycled(&this.state.err, true) catch bun.outOfMemory();
|
|
|
|
if (this.server) |server| {
|
|
if (server.config().isDevelopment()) {
|
|
switch (bun.Output.enable_ansi_colors_stderr) {
|
|
inline else => |enable_ansi_colors| {
|
|
var writer = bun.Output.errorWriterBuffered();
|
|
this.state.err.printWithEnableAnsiColors(&writer, enable_ansi_colors) catch {};
|
|
writer.context.flush() catch {};
|
|
},
|
|
}
|
|
}
|
|
}
|
|
},
|
|
.value => |bundle| {
|
|
if (bun.Environment.enable_logs)
|
|
debug("onComplete: success", .{});
|
|
// Find the HTML entry point and create static routes
|
|
const server: AnyServer = this.server orelse return;
|
|
const globalThis = server.globalThis();
|
|
const output_files = bundle.output_files.items;
|
|
|
|
if (server.config().isDevelopment()) {
|
|
const now = bun.getRoughTickCount().ns();
|
|
const duration = now - completion_task.started_at_ns;
|
|
var duration_f64: f64 = @floatFromInt(duration);
|
|
duration_f64 /= std.time.ns_per_s;
|
|
|
|
bun.Output.printElapsed(duration_f64);
|
|
var byte_length: u64 = 0;
|
|
for (output_files) |*output_file| {
|
|
byte_length += output_file.size_without_sourcemap;
|
|
}
|
|
|
|
bun.Output.prettyErrorln(" <green>bundle<r> {s} <d>{d:.2} KB<r>", .{ std.fs.path.basename(this.bundle.data.path), @as(f64, @floatFromInt(byte_length)) / 1000.0 });
|
|
bun.Output.flush();
|
|
}
|
|
|
|
var this_html_route: ?*StaticRoute = null;
|
|
|
|
// Create static routes for each output file
|
|
for (output_files) |*output_file| {
|
|
const blob = JSC.WebCore.Blob.Any{ .Blob = output_file.toBlob(bun.default_allocator, globalThis) catch bun.outOfMemory() };
|
|
var headers = bun.http.Headers{ .allocator = bun.default_allocator };
|
|
const content_type = blob.Blob.contentTypeOrMimeType() orelse brk: {
|
|
bun.debugAssert(false); // should be populated by `output_file.toBlob`
|
|
break :brk output_file.loader.toMimeType(&.{}).value;
|
|
};
|
|
headers.append("Content-Type", content_type) catch bun.outOfMemory();
|
|
// Do not apply etags to html.
|
|
if (output_file.loader != .html and output_file.value == .buffer) {
|
|
var hashbuf: [64]u8 = undefined;
|
|
const etag_str = std.fmt.bufPrint(&hashbuf, "{}", .{bun.fmt.hexIntLower(output_file.hash)}) catch bun.outOfMemory();
|
|
headers.append("ETag", etag_str) catch bun.outOfMemory();
|
|
if (!server.config().isDevelopment() and (output_file.output_kind == .chunk))
|
|
headers.append("Cache-Control", "public, max-age=31536000") catch bun.outOfMemory();
|
|
}
|
|
|
|
// Add a SourceMap header if we have a source map index
|
|
// and it's in development mode.
|
|
if (server.config().isDevelopment()) {
|
|
if (output_file.source_map_index != std.math.maxInt(u32)) {
|
|
var route_path = output_files[output_file.source_map_index].dest_path;
|
|
if (strings.hasPrefixComptime(route_path, "./") or strings.hasPrefixComptime(route_path, ".\\")) {
|
|
route_path = route_path[1..];
|
|
}
|
|
headers.append("SourceMap", route_path) catch bun.outOfMemory();
|
|
}
|
|
}
|
|
|
|
const static_route = bun.new(StaticRoute, .{
|
|
.ref_count = .init(),
|
|
.blob = blob,
|
|
.server = server,
|
|
.status_code = 200,
|
|
.headers = headers,
|
|
.cached_blob_size = blob.size(),
|
|
});
|
|
|
|
if (this_html_route == null and output_file.output_kind == .@"entry-point") {
|
|
if (output_file.loader == .html) {
|
|
this_html_route = static_route;
|
|
}
|
|
}
|
|
|
|
var route_path = output_file.dest_path;
|
|
|
|
// The route path gets cloned inside of appendStaticRoute.
|
|
if (strings.hasPrefixComptime(route_path, "./") or strings.hasPrefixComptime(route_path, ".\\")) {
|
|
route_path = route_path[1..];
|
|
}
|
|
|
|
server.appendStaticRoute(route_path, .{ .static = static_route }, .any) catch bun.outOfMemory();
|
|
}
|
|
|
|
const html_route: *StaticRoute = this_html_route orelse @panic("Internal assertion failure: HTML entry point not found in HTMLBundle.");
|
|
const html_route_clone = html_route.clone(globalThis) catch bun.outOfMemory();
|
|
this.state = .{ .html = html_route_clone };
|
|
|
|
if (!(server.reloadStaticRoutes() catch bun.outOfMemory())) {
|
|
// Server has shutdown, so it won't receive any new requests
|
|
// TODO: handle this case
|
|
}
|
|
},
|
|
.pending => unreachable,
|
|
}
|
|
|
|
// Handle pending responses
|
|
this.resumePendingResponses();
|
|
}
|
|
|
|
pub fn resumePendingResponses(this: *Route) void {
|
|
var pending = this.pending_responses;
|
|
defer pending.deinit(bun.default_allocator);
|
|
this.pending_responses = .{};
|
|
for (pending.items) |pending_response| {
|
|
defer pending_response.deinit();
|
|
|
|
const resp = pending_response.resp;
|
|
const method = pending_response.method;
|
|
if (!pending_response.is_response_pending) {
|
|
// Aborted
|
|
continue;
|
|
}
|
|
pending_response.is_response_pending = false;
|
|
resp.clearAborted();
|
|
|
|
switch (this.state) {
|
|
.html => |html| {
|
|
if (method == .HEAD) {
|
|
html.onHEAD(resp);
|
|
} else {
|
|
html.on(resp);
|
|
}
|
|
},
|
|
.err => |log| {
|
|
if (this.server.?.config().isDevelopment()) {
|
|
_ = log; // TODO: use the code from DevServer.zig to render the error
|
|
} else {
|
|
// To protect privacy, do not show errors to end users in production.
|
|
// TODO: Show a generic error page.
|
|
}
|
|
resp.writeStatus("500 Build Failed");
|
|
resp.endWithoutBody(false);
|
|
},
|
|
else => {
|
|
resp.endWithoutBody(false);
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Represents an in-flight response before the bundle has finished building.
|
|
pub const PendingResponse = struct {
|
|
method: bun.http.Method,
|
|
resp: HTTPResponse,
|
|
is_response_pending: bool = true,
|
|
server: ?AnyServer = null,
|
|
route: *Route,
|
|
|
|
pub fn deinit(this: *PendingResponse) void {
|
|
if (this.is_response_pending) {
|
|
this.resp.clearAborted();
|
|
this.resp.clearOnWritable();
|
|
this.resp.endWithoutBody(true);
|
|
}
|
|
this.route.deref();
|
|
bun.destroy(this);
|
|
}
|
|
|
|
pub fn onAborted(this: *PendingResponse, _: HTTPResponse) void {
|
|
bun.debugAssert(this.is_response_pending == true);
|
|
this.is_response_pending = false;
|
|
|
|
// Technically, this could be the final ref count, but we don't want to risk it
|
|
this.route.ref();
|
|
defer this.route.deref();
|
|
|
|
while (std.mem.indexOfScalar(*PendingResponse, this.route.pending_responses.items, this)) |index| {
|
|
_ = this.route.pending_responses.orderedRemove(index);
|
|
this.route.deref();
|
|
}
|
|
}
|
|
};
|
|
};
|
|
|
|
const bun = @import("bun");
|
|
const std = @import("std");
|
|
const JSC = bun.JSC;
|
|
const JSValue = JSC.JSValue;
|
|
const JSGlobalObject = JSC.JSGlobalObject;
|
|
const JSString = JSC.JSString;
|
|
const JSValueRef = JSC.JSValueRef;
|
|
const JSBundler = JSC.API.JSBundler;
|
|
const HTTPResponse = bun.uws.AnyResponse;
|
|
const uws = bun.uws;
|
|
const AnyServer = JSC.API.AnyServer;
|
|
const StaticRoute = @import("./StaticRoute.zig");
|
|
const RefPtr = bun.ptr.RefPtr;
|
|
|
|
const debug = bun.Output.scoped(.HTMLBundle, true);
|
|
const strings = bun.strings;
|