Files
bun.sh/src/bun.js/api/server/HTMLBundle.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;