From 1ea14f483c50754ee5078bd4b8143650cad70a36 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 3 Feb 2025 04:06:12 -0800 Subject: [PATCH] Introduce `bun ./index.html` (#16993) --- docs/bundler/fullstack.md | 7 +- docs/bundler/html.md | 235 ++++++++- docs/nav.ts | 2 +- src/bun.js/api/server.zig | 38 +- src/bun.js/api/server/HTMLBundle.zig | 1 + src/bun.js/bindings/HTMLEntryPoint.cpp | 41 ++ src/bun.js/javascript.zig | 24 +- src/bun.zig | 13 +- src/bun_js.zig | 5 +- src/cli.zig | 2 +- src/cli/run_command.zig | 27 +- src/js/internal/html.ts | 350 +++++++++++++ test/js/bun/http/bun-serve-html-entry.test.ts | 477 ++++++++++++++++++ test/js/bun/http/bun-serve-html.test.ts | 136 ++++- 14 files changed, 1298 insertions(+), 60 deletions(-) create mode 100644 src/bun.js/bindings/HTMLEntryPoint.cpp create mode 100644 src/js/internal/html.ts create mode 100644 test/js/bun/http/bun-serve-html-entry.test.ts diff --git a/docs/bundler/fullstack.md b/docs/bundler/fullstack.md index c6c5ad3ad9f1b9..0496899cf09cb5 100644 --- a/docs/bundler/fullstack.md +++ b/docs/bundler/fullstack.md @@ -1,6 +1,6 @@ -As of Bun v1.1.44, we've added initial support for bundling frontend apps directly in Bun's HTTP server: `Bun.serve()`. Run your frontend and backend in the same app with no extra steps. +Using `Bun.serve()`'s `static` option, you can run your frontend and backend in the same app with no extra steps. -To get started, import your HTML files and pass them to the `static` option in `Bun.serve()`. +To get started, import HTML files and pass them to the `static` option in `Bun.serve()`. ```ts import dashboard from "./dashboard.html"; @@ -33,7 +33,7 @@ const server = Bun.serve({ }, }); -console.log(`Listening on ${server.url}`) +console.log(`Listening on ${server.url}`); ``` ```bash @@ -211,6 +211,7 @@ For example, enable TailwindCSS on your routes by installing and adding the `bun ```sh $ bun add bun-plugin-tailwind ``` + ```toml#bunfig.toml [serve.static] plugins = ["bun-plugin-tailwind"] diff --git a/docs/bundler/html.md b/docs/bundler/html.md index e295a7962873ae..b62b6f601b773f 100644 --- a/docs/bundler/html.md +++ b/docs/bundler/html.md @@ -1,4 +1,4 @@ -As of Bun v1.1.43, Bun's bundler now has first-class support for HTML. Build static sites, landing pages, and web applications with zero configuration. Just point Bun at your HTML file and it handles everything else. +Bun's bundler has first-class support for HTML. Build static sites, landing pages, and web applications with zero configuration. Just point Bun at your HTML file and it handles everything else. ```html#index.html @@ -13,45 +13,221 @@ As of Bun v1.1.43, Bun's bundler now has first-class support for HTML. Build sta ``` -One command is all you need (won't be experimental after Bun v1.2): +To get started, pass HTML files to `bun`. + +{% bunDevServerTerminal alt="bun ./index.html" path="./index.html" routes="" /%} + +Bun's development server provides powerful features with zero configuration: + +- **Automatic Bundling** - Bundles and serves your HTML, JavaScript, and CSS +- **Multi-Entry Support** - Handles multiple HTML entry points and glob entry points +- **Modern JavaScript** - TypeScript & JSX support out of the box +- **Smart Configuration** - Reads `tsconfig.json` for paths, JSX options, experimental decorators, and more +- **Plugins** - Plugins for TailwindCSS and more +- **ESM & CommonJS** - Use ESM and CommonJS in your JavaScript, TypeScript, and JSX files +- **CSS Bundling & Minification** - Bundles CSS from `` tags and `@import` statements +- **Asset Management** + - Automatic copying & hashing of images and assets + - Rewrites asset paths in JavaScript, CSS, and HTML + +## Single Page Apps (SPA) + +When you pass a single .html file to Bun, Bun will use it as a fallback route for all paths. This makes it perfect for single page apps that use client-side routing: + +{% bunDevServerTerminal alt="bun index.html" path="index.html" routes="" /%} + +Your React or other SPA will work out of the box — no configuration needed. All routes like `/about`, `/users/123`, etc. will serve the same HTML file, letting your client-side router handle the navigation. + +```html#index.html + + + + My SPA + + + +
+ + +``` + +## Multi-page apps (MPA) + +Some projects have several separate routes or HTML files as entry points. To support multiple entry points, pass them all to `bun` + +{% bunDevServerTerminal alt="bun ./index.html ./about.html" path="./index.html ./about.html" routes="[{\"path\": \"/\", \"file\": \"./index.html\"}, {\"path\": \"/about\", \"file\": \"./about.html\"}]" /%} + +This will serve: + +- `index.html` at `/` +- `about.html` at `/about` + +### Glob patterns + +To specify multiple files, you can use glob patterns that end in `.html`: + +{% bunDevServerTerminal alt="bun ./**/*.html" path="./**/*.html" routes="[{\"path\": \"/\", \"file\": \"./index.html\"}, {\"path\": \"/about\", \"file\": \"./about.html\"}]" /%} + +### Path normalization + +The base path is chosen from the longest common prefix among all the files. + +{% bunDevServerTerminal alt="bun ./index.html ./about/index.html ./about/foo/index.html" path="./index.html ./about/index.html ./about/foo/index.html" routes="[{\"path\": \"/\", \"file\": \"./index.html\"}, {\"path\": \"/about\", \"file\": \"./about/index.html\"}, {\"path\": \"/about/foo\", \"file\": \"./about/foo/index.html\"}]" /%} + +## JavaScript, TypeScript, and JSX + +Bun's transpiler natively implements JavaScript, TypeScript, and JSX support. [Learn more about loaders in Bun](/docs/bundler/loaders). + +Bun's transpiler is also used at runtime. + +### ES Modules & CommonJS + +You can use ESM and CJS in your JavaScript, TypeScript, and JSX files. Bun will handle the transpilation and bundling automatically. + +There is no pre-build or separate optimization step. It's all done at the same time. + +Learn more about [module resolution in Bun](/docs/runtime/modules). + +## CSS + +Bun's CSS parser is also natively implemented (clocking in around 58,000 lines of Zig). + +It's also a CSS bundler. You can use `@import` in your CSS files to import other CSS files. + +For example: + +```css#styles.css +@import "./abc.css"; + +.container { + background-color: blue; +} +``` + +```css#abc.css +body { + background-color: red; +} +``` + +This outputs: + +```css#styles.css +body { + background-color: red; +} + +.container { + background-color: blue; +} +``` + +### Referencing local assets in CSS + +You can reference local assets in your CSS files. + +```css#styles.css +body { + background-image: url("./logo.png"); +} +``` + +This will copy `./logo.png` to the output directory and rewrite the path in the CSS file to include a content hash. + +```css#styles.css +body { + background-image: url("./logo-[ABC123].png"); +} +``` + +### Importing CSS in JavaScript + +To associate a CSS file with a JavaScript file, you can import it in your JavaScript file. + +```ts#app.ts +import "./styles.css"; +import "./more-styles.css"; +``` + +This generates `./app.css` and `./app.js` in the output directory. All CSS files imported from JavaScript will be bundled into a single CSS file per entry point. If you import the same CSS file from multiple JavaScript files, it will only be included once in the output CSS file. + +## Plugins + +The dev server supports plugins. + +### Tailwind CSS + +To use TailwindCSS, add the plugin to your `bunfig.toml`: + +```toml +[serve.static] +plugins = ["bun-plugin-tailwind"] +``` + +Then, reference TailwindCSS in your HTML via `` tag, `@import` in CSS, or `import` in JavaScript. + +{% codetabs %} + +```html#index.html + + +``` + +```css#styles.css +/* Import TailwindCSS in your CSS */ +@import "tailwindcss"; +``` + +```ts#app.ts +/* Import TailwindCSS in your JavaScript */ +import "tailwindcss"; +``` + +{% /codetabs %} + +Only one of those are necessary, not all three. + +## Keyboard Shortcuts + +While the server is running: + +- `o + Enter` - Open in browser +- `c + Enter` - Clear console +- `q + Enter` (or Ctrl+C) - Quit server + +## Build for Production + +When you're ready to deploy, use `bun build` to create optimized production bundles: {% codetabs %} ```bash#CLI -$ bun build ./index.html --outdir=dist +$ bun build ./index.html --minify --outdir=dist ``` ```ts#API Bun.build({ entrypoints: ["./index.html"], outdir: "./dist", + minify: { + whitespace: true, + identifiers: true, + syntax: true, + } }); ``` {% /codetabs %} -Bun automatically: +Currently, plugins are only supported through `Bun.build`'s API or through `bunfig.toml` with the frontend dev server - not yet supported in `bun build`'s CLI. -- Bundles, tree-shakes, and optimizes your JavaScript, JSX and TypeScript -- Bundles and optimizes your CSS -- Copies & hashes images and other assets -- Updates all references to local files or packages in your HTML +### Watch Mode -## Zero Config, Maximum Performance - -The HTML bundler is enabled by default after Bun v1.2+. Drop in your existing HTML files and Bun will handle: - -- **TypeScript & JSX** - Write modern JavaScript for browsers without the setup -- **CSS** - Bundle CSS stylesheets directly from `` or `@import` -- **Images & Assets** - Automatic copying & hashing & rewriting of assets in JavaScript, CSS, and HTML - -## Watch mode - -You can run `bun build --watch` to watch for changes and rebuild automatically. +You can run `bun build --watch` to watch for changes and rebuild automatically. This works nicely for library development. You've never seen a watch mode this fast. -## Plugin API +### Plugin API Need more control? Configure the bundler through the JavaScript API and use Bun's builtin `HTMLRewriter` to preprocess HTML. @@ -102,3 +278,22 @@ Bun automatically handles all common web assets: - Any `` tag with an `href` attribute pointing to a local file is rewritten to the new path, and hashed All paths are resolved relative to your HTML file, making it easy to organize your project however you want. + +## This is a work in progress + +- No HMR support yet +- Need more plugins +- Need more configuration options for things like asset handling +- Need a way to configure CORS, headers, etc. + +If you want to submit a PR, most of the [code is here](https://github.com/oven-sh/bun/blob/main/src/js/internal/html.ts). You could even copy paste that file into your project and use it as a starting point. + +## How this works + +This is a small wrapper around Bun's support for HTML imports in JavaScript. + +### Adding a backend to your frontend + +To add a backend to your frontend, you can use the `"static"` option in `Bun.serve`. + +Learn more in [the full-stack docs](/docs/bundler/fullstack). diff --git a/docs/nav.ts b/docs/nav.ts index 615c1ce7df4af7..ff4f803e34e9f6 100644 --- a/docs/nav.ts +++ b/docs/nav.ts @@ -214,7 +214,7 @@ export default { page("bundler", "`Bun.build`", { description: "Bundle code for consumption in the browser with Bun's native bundler.", }), - page("bundler/html", "HTML", { + page("bundler/html", "Frontend & static sites", { description: `Bundle html files with Bun's native bundler.`, }), page("bundler/fullstack", "Fullstack Dev Server", { diff --git a/src/bun.js/api/server.zig b/src/bun.js/api/server.zig index 304f30fbece051..6b04ccd5ae3439 100644 --- a/src/bun.js/api/server.zig +++ b/src/bun.js/api/server.zig @@ -1110,7 +1110,7 @@ pub const ServerConfig = struct { var port = args.address.tcp.port; if (arguments.vm.transpiler.options.transform_options.origin) |origin| { - args.base_uri = origin; + args.base_uri = try bun.default_allocator.dupeZ(u8, origin); } defer { @@ -1150,16 +1150,15 @@ pub const ServerConfig = struct { while (try iter.next()) |key| { const path, const is_ascii = key.toOwnedSliceReturningAllASCII(bun.default_allocator) catch bun.outOfMemory(); + errdefer bun.default_allocator.free(path); const value = iter.value; - if (path.len == 0 or path[0] != '/') { - bun.default_allocator.free(path); + if (path.len == 0 or (path[0] != '/' and path[0] != '*')) { return global.throwInvalidArguments("Invalid static route \"{s}\". path must start with '/'", .{path}); } if (!is_ascii) { - bun.default_allocator.free(path); return global.throwInvalidArguments("Invalid static route \"{s}\". Please encode all non-ASCII characters in the path.", .{path}); } @@ -1219,6 +1218,9 @@ pub const ServerConfig = struct { if (sliced.len > 0) { defer sliced.deinit(); + if (args.base_uri.len > 0) { + bun.default_allocator.free(@constCast(args.base_uri)); + } args.base_uri = bun.default_allocator.dupe(u8, sliced.slice()) catch unreachable; } } @@ -1413,6 +1415,8 @@ pub const ServerConfig = struct { const protocol: string = if (args.ssl_config != null) "https" else "http"; const hostname = args.base_url.hostname; const needsBrackets: bool = strings.isIPV6Address(hostname) and hostname[0] != '['; + const original_base_uri = args.base_uri; + defer bun.default_allocator.free(@constCast(original_base_uri)); if (needsBrackets) { args.base_uri = (if ((port == 80 and args.ssl_config == null) or (port == 443 and args.ssl_config != null)) std.fmt.allocPrint(bun.default_allocator, "{s}://[{s}]/{s}", .{ @@ -7449,8 +7453,30 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp if (this.dev_server) |dev| { dev.attachRoutes(this) catch bun.outOfMemory(); } else { - bun.assert(this.config.onRequest != .zero); - app.any("/*", *ThisServer, this, onRequest); + const @"has /*" = brk: { + for (this.config.static_routes.items) |route| { + if (strings.eqlComptime(route.path, "/*")) { + break :brk true; + } + } + + break :brk false; + }; + + // "/*" routes are added backwards, so if they have a static route, it will never be matched + // so we need to check for that first + if (!@"has /*") { + bun.assert(this.config.onRequest != .zero); + app.any("/*", *ThisServer, this, onRequest); + } else if (this.config.onRequest != .zero) { + app.post("/*", *ThisServer, this, onRequest); + app.put("/*", *ThisServer, this, onRequest); + app.patch("/*", *ThisServer, this, onRequest); + app.delete("/*", *ThisServer, this, onRequest); + app.options("/*", *ThisServer, this, onRequest); + app.trace("/*", *ThisServer, this, onRequest); + app.connect("/*", *ThisServer, this, onRequest); + } } } diff --git a/src/bun.js/api/server/HTMLBundle.zig b/src/bun.js/api/server/HTMLBundle.zig index 39bc5ac5198442..a35407720698fa 100644 --- a/src/bun.js/api/server/HTMLBundle.zig +++ b/src/bun.js/api/server/HTMLBundle.zig @@ -68,6 +68,7 @@ pub const HTMLBundleRoute = struct { } pub fn init(html_bundle: *HTMLBundle) *HTMLBundleRoute { + html_bundle.ref(); return HTMLBundleRoute.new(.{ .html_bundle = html_bundle, .pending_responses = .{}, diff --git a/src/bun.js/bindings/HTMLEntryPoint.cpp b/src/bun.js/bindings/HTMLEntryPoint.cpp new file mode 100644 index 00000000000000..c805fea3f0373a --- /dev/null +++ b/src/bun.js/bindings/HTMLEntryPoint.cpp @@ -0,0 +1,41 @@ +#include "root.h" + +#include "JavaScriptCore/CallData.h" +#include +#include "InternalModuleRegistry.h" +#include "ModuleLoader.h" +#include "ZigGlobalObject.h" +#include + +namespace Bun { +using namespace JSC; +extern "C" JSInternalPromise* Bun__loadHTMLEntryPoint(Zig::GlobalObject* globalObject) +{ + auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + JSInternalPromise* promise = JSInternalPromise::create(vm, globalObject->internalPromiseStructure()); + + JSValue htmlModule = globalObject->internalModuleRegistry()->requireId(globalObject, vm, InternalModuleRegistry::InternalHtml); + if (UNLIKELY(scope.exception())) { + return promise->rejectWithCaughtException(globalObject, scope); + } + + JSObject* htmlModuleObject = htmlModule.getObject(); + if (UNLIKELY(!htmlModuleObject)) { + BUN_PANIC("Failed to load HTML entry point"); + } + + MarkedArgumentBuffer args; + JSValue result = JSC::call(globalObject, htmlModuleObject, args, "Failed to load HTML entry point"_s); + if (UNLIKELY(scope.exception())) { + return promise->rejectWithCaughtException(globalObject, scope); + } + + promise = jsDynamicCast(result); + if (UNLIKELY(!promise)) { + BUN_PANIC("Failed to load HTML entry point"); + } + return promise; +} + +} diff --git a/src/bun.js/javascript.zig b/src/bun.js/javascript.zig index d4667d97282669..a5c975c2e9f40d 100644 --- a/src/bun.js/javascript.zig +++ b/src/bun.js/javascript.zig @@ -766,6 +766,7 @@ pub const VirtualMachine = struct { console: *ConsoleObject, log: *logger.Log, main: string = "", + main_is_html_entrypoint: bool = false, main_resolved_path: bun.String = bun.String.empty, main_hash: u32 = 0, process: bun.JSC.C.JSObjectRef = null, @@ -3098,6 +3099,8 @@ pub const VirtualMachine = struct { } } + extern fn Bun__loadHTMLEntryPoint(global: *JSGlobalObject) *JSInternalPromise; + pub fn reloadEntryPoint(this: *VirtualMachine, entry_path: []const u8) !*JSInternalPromise { this.has_loaded = false; this.main = entry_path; @@ -3105,13 +3108,14 @@ pub const VirtualMachine = struct { try this.ensureDebugger(true); - try this.entry_point.generate( - this.allocator, - this.bun_watcher != .none, - entry_path, - main_file_name, - ); - this.eventLoop().ensureWaker(); + if (!this.main_is_html_entrypoint) { + try this.entry_point.generate( + this.allocator, + this.bun_watcher != .none, + entry_path, + main_file_name, + ); + } if (!this.transpiler.options.disable_transpilation) { if (try this.loadPreloads()) |promise| { @@ -3121,7 +3125,11 @@ pub const VirtualMachine = struct { return promise; } - const promise = JSModuleLoader.loadAndEvaluateModule(this.global, &String.init(main_file_name)) orelse return error.JSError; + const promise = if (!this.main_is_html_entrypoint) + JSModuleLoader.loadAndEvaluateModule(this.global, &String.init(main_file_name)) orelse return error.JSError + else + Bun__loadHTMLEntryPoint(this.global); + this.pending_internal_promise = promise; JSValue.fromCell(promise).ensureStillAlive(); return promise; diff --git a/src/bun.zig b/src/bun.zig index d1e54e51fff787..e6a9b97dde0f1e 100644 --- a/src/bun.zig +++ b/src/bun.zig @@ -3164,11 +3164,18 @@ pub fn NewRefCounted(comptime T: type, comptime deinit_fn: ?fn (self: *T) void, } pub fn deref(self: *T) void { - if (Environment.isDebug) log("0x{x} deref {d} - 1 = {d}", .{ @intFromPtr(self), self.ref_count, self.ref_count - 1 }); + const ref_count = self.ref_count; + if (Environment.isDebug) { + if (ref_count == 0 or ref_count == std.math.maxInt(@TypeOf(ref_count))) { + @panic("Use after-free detected on " ++ output_name); + } + } - self.ref_count -= 1; + if (Environment.isDebug) log("0x{x} deref {d} - 1 = {d}", .{ @intFromPtr(self), ref_count, ref_count - 1 }); - if (self.ref_count == 0) { + self.ref_count = ref_count - 1; + + if (ref_count == 1) { if (comptime deinit_fn) |deinit| { deinit(self); } else { diff --git a/src/bun_js.zig b/src/bun_js.zig index 904c16d0388a3e..0a96ee2a1b3bc2 100644 --- a/src/bun_js.zig +++ b/src/bun_js.zig @@ -42,6 +42,7 @@ pub const Run = struct { entry_path: string, arena: Arena, any_unhandled: bool = false, + is_html_entrypoint: bool = false, pub fn bootStandalone(ctx: Command.Context, entry_path: string, graph: bun.StandaloneModuleGraph) !void { JSC.markBinding(@src()); @@ -170,7 +171,7 @@ pub const Run = struct { return bun.shell.Interpreter.initAndRunFromFile(ctx, mini, entry_path); } - pub fn boot(ctx: Command.Context, entry_path: string) !void { + pub fn boot(ctx: Command.Context, entry_path: string, loader: ?bun.options.Loader) !void { JSC.markBinding(@src()); if (!ctx.debug.loaded_bunfig) { @@ -277,6 +278,8 @@ pub const Run = struct { doPreconnect(ctx.runtime_options.preconnect); + vm.main_is_html_entrypoint = (loader orelse vm.transpiler.options.loader(std.fs.path.extension(entry_path))) == .html; + const callback = OpaqueWrap(Run, Run.start); vm.global.vm().holdAPILock(&run, callback); } diff --git a/src/cli.zig b/src/cli.zig index 54a05dad0ced89..721ba19eb3705f 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -2196,7 +2196,7 @@ pub const Command = struct { var entry_point_buf: [bun.MAX_PATH_BYTES + trigger.len]u8 = undefined; const cwd = try std.posix.getcwd(&entry_point_buf); @memcpy(entry_point_buf[cwd.len..][0..trigger.len], trigger); - try BunJS.Run.boot(ctx, entry_point_buf[0 .. cwd.len + trigger.len]); + try BunJS.Run.boot(ctx, entry_point_buf[0 .. cwd.len + trigger.len], null); return; } diff --git a/src/cli/run_command.zig b/src/cli/run_command.zig index d7ae1c799c9ef4..ec7ba403c8a67d 100644 --- a/src/cli/run_command.zig +++ b/src/cli/run_command.zig @@ -1262,9 +1262,9 @@ pub const RunCommand = struct { Output.flush(); } - fn _bootAndHandleError(ctx: Command.Context, path: string) bool { + fn _bootAndHandleError(ctx: Command.Context, path: string, loader: ?bun.options.Loader) bool { Global.configureAllocator(.{ .long_running = true }); - Run.boot(ctx, ctx.allocator.dupe(u8, path) catch return false) catch |err| { + Run.boot(ctx, ctx.allocator.dupe(u8, path) catch return false, loader) catch |err| { ctx.log.print(Output.errorWriter()) catch {}; Output.prettyErrorln("error: Failed to run {s} due to error {s}", .{ @@ -1348,7 +1348,7 @@ pub const RunCommand = struct { bun.CLI.Arguments.loadConfigPath(ctx.allocator, true, "bunfig.toml", ctx, .RunCommand) catch {}; } - _ = _bootAndHandleError(ctx, absolute_script_path.?); + _ = _bootAndHandleError(ctx, absolute_script_path.?, null); return true; } pub fn exec( @@ -1441,7 +1441,7 @@ pub const RunCommand = struct { @memcpy(entry_point_buf[cwd.len..][0..trigger.len], trigger); const entry_path = entry_point_buf[0 .. cwd.len + trigger.len]; - Run.boot(ctx, ctx.allocator.dupe(u8, entry_path) catch return false) catch |err| { + Run.boot(ctx, ctx.allocator.dupe(u8, entry_path) catch return false, null) catch |err| { ctx.log.print(Output.errorWriter()) catch {}; Output.prettyErrorln("error: Failed to run {s} due to error {s}", .{ @@ -1528,13 +1528,20 @@ pub const RunCommand = struct { var resolved_mutable = resolved; const path = resolved_mutable.path().?; const loader: bun.options.Loader = this_transpiler.options.loaders.get(path.name.ext) orelse .tsx; - if (loader.canBeRunByBun()) { + if (loader.canBeRunByBun() or loader == .html) { log("Resolved to: `{s}`", .{path.text}); - return _bootAndHandleError(ctx, path.text); + return _bootAndHandleError(ctx, path.text, loader); } else { log("Resolved file `{s}` but ignoring because loader is {s}", .{ path.text, @tagName(loader) }); } - } else |_| {} + } else |_| { + // Support globs for HTML entry points. + if (strings.hasSuffixComptime(target_name, ".html")) { + if (strings.containsChar(target_name, '*')) { + return _bootAndHandleError(ctx, target_name, .html); + } + } + } // execute a node_modules/.bin/ command, or (run only) a system command like 'ls' @@ -1623,7 +1630,7 @@ pub const RunCommand = struct { var entry_point_buf: [bun.MAX_PATH_BYTES + trigger.len]u8 = undefined; const cwd = try std.posix.getcwd(&entry_point_buf); @memcpy(entry_point_buf[cwd.len..][0..trigger.len], trigger); - try Run.boot(ctx, entry_point_buf[0 .. cwd.len + trigger.len]); + try Run.boot(ctx, entry_point_buf[0 .. cwd.len + trigger.len], null); return; } @@ -1653,7 +1660,7 @@ pub const RunCommand = struct { ); }; - Run.boot(ctx, normalized_filename) catch |err| { + Run.boot(ctx, normalized_filename, null) catch |err| { ctx.log.print(Output.errorWriter()) catch {}; Output.err(err, "Failed to run script \"{s}\"", .{std.fs.path.basename(normalized_filename)}); @@ -1728,7 +1735,7 @@ pub const BunXFastPath = struct { bun.reinterpretSlice(u8, &direct_launch_buffer), wpath, ) catch return; - Run.boot(ctx, utf8) catch |err| { + Run.boot(ctx, utf8, null) catch |err| { ctx.log.print(Output.errorWriter()) catch {}; Output.err(err, "Failed to run bin \"{s}\"", .{std.fs.path.basename(utf8)}); Global.exit(1); diff --git a/src/js/internal/html.ts b/src/js/internal/html.ts new file mode 100644 index 00000000000000..d01fdf8d027d58 --- /dev/null +++ b/src/js/internal/html.ts @@ -0,0 +1,350 @@ +// This is the file that loads when you pass a, .html entry point to Bun. +import type { HTMLBundle, Server } from "bun"; +const initital = performance.now(); +const argv = process.argv; + +// `import` cannot be used in this file and only Bun builtin modules can be used. +const path = require("node:path"); + +const env = Bun.env; + +// This function is called at startup. +async function start() { + let args: string[] = []; + const cwd = process.cwd(); + let hostname = "localhost"; + let port: number | undefined = undefined; + + // Step 1. Resolve all HTML entry points + for (let i = 1, argvLength = argv.length; i < argvLength; i++) { + const arg = argv[i]; + + if (!arg.endsWith(".html")) { + if (arg.startsWith("--hostname=")) { + hostname = arg.slice("--hostname=".length); + if (hostname.includes(":")) { + const [host, portString] = hostname.split(":"); + hostname = host; + port = parseInt(portString, 10); + } + } else if (arg.startsWith("--port=")) { + port = parseInt(arg.slice("--port=".length), 10); + } else if (arg.startsWith("--host=")) { + hostname = arg.slice("--host=".length); + if (hostname.includes(":")) { + const [host, portString] = hostname.split(":"); + hostname = host; + port = parseInt(portString, 10); + } + } + + if (arg === "--help") { + console.log(` +Bun v${Bun.version} (html) + +Usage: + bun [...html-files] [options] + +Options: + + --port= + --host=, --hostname= + +Examples: + + bun index.html + bun ./index.html ./about.html --port=3000 + bun index.html --host=localhost:3000 + bun index.html --hostname=localhost:3000 + bun ./*.html + +This is a small wrapper around Bun.serve() that automatically serves the HTML files you pass in without +having to manually call Bun.serve() or write the boilerplate yourself. This runs Bun's bundler on +the HTML files, their JavaScript, and CSS, and serves them up. This doesn't do anything you can't do +yourself with Bun.serve(). +`); + process.exit(0); + } + + continue; + } + + if (arg.includes("*") || arg.includes("**") || arg.includes("{")) { + const glob = new Bun.Glob(arg); + + for (const file of glob.scanSync(cwd)) { + let resolved = path.resolve(cwd, file); + try { + resolved = Bun.resolveSync(resolved, cwd); + } catch { + resolved = Bun.resolveSync("./" + resolved, cwd); + } + + args.push(resolved); + } + } else { + let resolved = arg; + try { + resolved = Bun.resolveSync(arg, cwd); + } catch { + resolved = Bun.resolveSync("./" + arg, cwd); + } + + args.push(resolved); + } + + if (args.length > 1) { + args = [...new Set(args)]; + } + } + + if (args.length === 0) { + throw new Error("No HTML files found matching " + JSON.stringify(Bun.main)); + } + + // Add cwd to find longest common path + let needsPop = false; + if (args.length === 1) { + args.push(process.cwd()); + needsPop = true; + } + + // Find longest common path prefix to use as the base path when there are + // multiple entry points + let longestCommonPath = args.reduce((acc, curr) => { + if (!acc) return curr; + let i = 0; + while (i < acc.length && i < curr.length && acc[i] === curr[i]) i++; + return acc.slice(0, i); + }); + + if (path.platform === "win32") { + longestCommonPath = longestCommonPath.replaceAll("\\", "/"); + } + + if (needsPop) { + // Remove cwd from args + args.pop(); + } + + // Transform file paths into friendly URL paths + // - "index.html" -> "/" + // - "about/index.html" -> "/about" + // - "about/foo.html" -> "/about/foo" + // - "foo.html" -> "/foo" + const servePaths = args.map(arg => { + if (process.platform === "win32") { + arg = arg.replaceAll("\\", "/"); + } + const basename = path.basename(arg); + const isIndexHtml = basename === "index.html"; + + let servePath = arg; + if (servePath.startsWith(longestCommonPath)) { + servePath = servePath.slice(longestCommonPath.length); + } else { + const relative = path.relative(longestCommonPath, servePath); + if (!relative.startsWith("..")) { + servePath = relative; + } + } + + if (isIndexHtml && servePath.length === 0) { + servePath = "/"; + } else if (isIndexHtml) { + servePath = servePath.slice(0, -"index.html".length); + } + + if (servePath.endsWith(".html")) { + servePath = servePath.slice(0, -".html".length); + } + + if (servePath.endsWith("/")) { + servePath = servePath.slice(0, -1); + } + + if (servePath.startsWith("/")) { + servePath = servePath.slice(1); + } + + if (servePath === "/") servePath = ""; + + return servePath; + }); + + const htmlImports = await Promise.all( + args.map(arg => { + return import(arg).then(m => m.default); + }), + ); + + // If you're only providing one entry point, then match everything to it. + // (except for assets, which have higher precedence) + if (htmlImports.length === 1 && servePaths[0] === "") { + servePaths[0] = "*"; + } + + const staticRoutes = htmlImports.reduce( + (acc, htmlImport, index) => { + const html = htmlImport; + const servePath = servePaths[index]; + + acc["/" + servePath] = html; + return acc; + }, + {} as Record, + ); + var server: Server; + getServer: { + try { + server = Bun.serve({ + static: staticRoutes, + development: env.NODE_ENV !== "production", + + hostname, + port, + + // use the default port via existing port detection code. + // port: 3000, + + fetch(req: Request) { + return new Response("Not found", { status: 404 }); + }, + }); + break getServer; + } catch (error: any) { + if (error?.code === "EADDRINUSE") { + let defaultPort = port || parseInt(env.PORT || env.BUN_PORT || env.NODE_PORT || "3000", 10); + for (let remainingTries = 5; remainingTries > 0; remainingTries--) { + try { + server = Bun.serve({ + static: staticRoutes, + development: env.NODE_ENV !== "production", + + hostname, + + // Retry with a different port up to 4 times. + port: defaultPort++, + + fetch(req: Request) { + return new Response("Not found", { status: 404 }); + }, + }); + break getServer; + } catch (error: any) { + if (error?.code === "EADDRINUSE") { + continue; + } + throw error; + } + } + } + + throw error; + } + } + const elapsed = (performance.now() - initital).toFixed(2); + const enableANSIColors = Bun.enableANSIColors; + function printInitialMessage(isFirst: boolean) { + if (enableANSIColors) { + let topLine = `\n\x1b[1;34m\x1b[5mBun\x1b[0m \x1b[1;34mv${Bun.version}\x1b[0m`; + if (isFirst) { + topLine += ` \x1b[2mready in\x1b[0m \x1b[1m${elapsed}\x1b[0m ms`; + } + console.log(topLine + "\n"); + console.log(`\x1b[1;34m➜\x1b[0m \x1b[36m${server!.url.href}\x1b[0m`); + } else { + let topLine = `\n Bun v${Bun.version}`; + if (isFirst) { + topLine += ` ready in ${elapsed} ms`; + } + console.log(topLine + "\n"); + console.log(`url: ${server!.url.href}`); + } + if (htmlImports.length > 1 || (servePaths[0] !== "" && servePaths[0] !== "*")) { + console.log("\nRoutes:"); + + const pairs: { route: string; importPath: string }[] = []; + + for (let i = 0, length = servePaths.length; i < length; i++) { + const route = servePaths[i]; + const importPath = args[i]; + pairs.push({ route, importPath }); + } + pairs.sort((a, b) => { + if (b.route === "") return 1; + if (a.route === "") return -1; + return a.route.localeCompare(b.route); + }); + for (let i = 0, length = pairs.length; i < length; i++) { + const { route, importPath } = pairs[i]; + const isLast = i === length - 1; + const prefix = isLast ? " └── " : " ├── "; + if (enableANSIColors) { + console.log(`${prefix}\x1b[36m/${route}\x1b[0m \x1b[2m→ ${path.relative(process.cwd(), importPath)}\x1b[0m`); + } else { + console.log(`${prefix}/${route} → ${path.relative(process.cwd(), importPath)}`); + } + } + } + + if (isFirst && process.stdin.isTTY) { + if (enableANSIColors) { + console.log(); + console.log("\x1b[2mPress \x1b[2;36mh + Enter\x1b[39;2m to show shortcuts\x1b[0m"); + } else { + console.log(); + console.log("Press h + Enter to show shortcuts"); + } + } + } + + printInitialMessage(true); + + // Keyboard shortcuts + if (process.stdin.isTTY) { + // Handle Ctrl+C and other termination signals + process.on("SIGINT", () => process.exit()); + process.on("SIGHUP", () => process.exit()); + process.on("SIGTERM", () => process.exit()); + process.stdin.on("data", data => { + const key = data.toString().toLowerCase().replaceAll("\r\n", "\n"); + + switch (key) { + case "\x03": // Ctrl+C + case "q\n": + process.exit(); + break; + + case "c\n": + console.clear(); + printInitialMessage(false); + break; + + case "o\n": + const url = server.url.toString(); + + if (process.platform === "darwin") { + // TODO: copy the AppleScript from create-react-app or Vite. + Bun.spawn(["open", url]).exited.catch(() => {}); + } else if (process.platform === "win32") { + Bun.spawn(["start", url]).exited.catch(() => {}); + } else { + Bun.spawn(["xdg-open", url]).exited.catch(() => {}); + } + break; + + case "h\n": + console.clear(); + printInitialMessage(false); + console.log("\n Shortcuts\x1b[2m:\x1b[0m\n"); + console.log(" \x1b[2m→\x1b[0m \x1b[36mc + Enter\x1b[0m clear screen"); + console.log(" \x1b[2m→\x1b[0m \x1b[36mo + Enter\x1b[0m open in browser"); + console.log(" \x1b[2m→\x1b[0m \x1b[36mq + Enter\x1b[0m quit (or Ctrl+C)\n"); + break; + } + }); + } +} + +export default start; diff --git a/test/js/bun/http/bun-serve-html-entry.test.ts b/test/js/bun/http/bun-serve-html-entry.test.ts new file mode 100644 index 00000000000000..250629671db154 --- /dev/null +++ b/test/js/bun/http/bun-serve-html-entry.test.ts @@ -0,0 +1,477 @@ +import type { Subprocess, Server } from "bun"; +import { describe, test, expect } from "bun:test"; +import { bunEnv, bunExe, tempDirWithFiles } from "harness"; +import { join } from "path"; + +async function getServerUrl(process: Subprocess) { + // Read the port number from stdout + const decoder = new TextDecoder(); + let serverUrl = ""; + let text = ""; + for await (const chunk of process.stdout) { + const textChunk = decoder.decode(chunk, { stream: true }); + text += textChunk; + console.log(textChunk); + + if (text.includes("http://")) { + serverUrl = text.trim(); + serverUrl = serverUrl.slice(serverUrl.indexOf("http://")); + + serverUrl = serverUrl.slice(0, serverUrl.indexOf("\n")); + if (URL.canParse(serverUrl)) { + break; + } + + serverUrl = serverUrl.slice(0, serverUrl.indexOf("/n")); + serverUrl = serverUrl.slice(0, serverUrl.lastIndexOf("/")); + serverUrl = serverUrl.trim(); + + if (URL.canParse(serverUrl)) { + break; + } + } + } + + if (!serverUrl) { + throw new Error("Could not find server URL in stdout"); + } + + return serverUrl; +} + +test("bun ./index.html", async () => { + const dir = tempDirWithFiles("html-entry-test", { + "index.html": /*html*/ ` + + + + HTML Entry Test + + + + +
+

Hello from Bun!

+ +
+ + + `, + "styles.css": /*css*/ ` + .container { + max-width: 800px; + margin: 2rem auto; + text-align: center; + font-family: system-ui, sans-serif; + } + + button { + padding: 0.5rem 1rem; + font-size: 1.25rem; + border-radius: 0.25rem; + border: 2px solid #000; + background: #fff; + cursor: pointer; + transition: all 0.2s; + } + + button:hover { + background: #000; + color: #fff; + } + `, + "app.js": /*js*/ ` + const button = document.getElementById('counter'); + let count = 0; + button.addEventListener('click', () => { + count++; + button.textContent = \`Click me: \${count}\`; + }); + `, + }); + + // Start the server by running bun with the HTML file + await using process = Bun.spawn({ + cmd: [bunExe(), "index.html", "--port=0"], + env: { + ...bunEnv, + NODE_ENV: undefined, + }, + cwd: dir, + stdout: "pipe", + }); + + const serverUrl = await getServerUrl(process); + + try { + // Make a request to the server using the detected URL + const response = await fetch(serverUrl); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/html"); + + const html = await response.text(); + + // Verify the HTML content + expect(html).toContain("HTML Entry Test"); + expect(html).toContain('
'); + + // The bundler should have processed the CSS and JS files and injected them + expect(html).toMatch(//); + expect(html).toMatch(/ + + +
+

Welcome Home

+ About + +
+ + + `, + "about.html": /*html*/ ` + + + + About Page + + + + +
+

About Us

+ Home +

This is the about page

+
+ + + `, + "styles.css": /*css*/ ` + .container { + max-width: 800px; + margin: 2rem auto; + text-align: center; + font-family: system-ui, sans-serif; + } + a { + display: block; + margin: 1rem 0; + color: blue; + } + `, + "home.js": /*js*/ ` + const button = document.getElementById('counter'); + let count = 0; + button.addEventListener('click', () => { + count++; + button.textContent = \`Click me: \${count}\`; + }); + `, + "about.js": /*js*/ ` + const message = document.getElementById('message'); + message.textContent += " - Updated via JS"; + `, + }); + + // Start the server by running bun with multiple HTML files + await using process = Bun.spawn({ + cmd: [bunExe(), "index.html", "about.html", "--port=0"], + env: { + ...bunEnv, + NODE_ENV: undefined, + }, + cwd: dir, + stdout: "pipe", + }); + + const serverUrl = await getServerUrl(process); + + if (!serverUrl) { + throw new Error("Could not find server URL in stdout"); + } + + try { + // Test the home page + + const homeResponse = await fetch(serverUrl); + expect(homeResponse.status).toBe(200); + expect(homeResponse.headers.get("content-type")).toContain("text/html"); + + const homeHtml = await homeResponse.text(); + expect(homeHtml).toContain("Home Page"); + expect(homeHtml).toContain('About'); + expect(homeHtml).toMatch(/ + + + +
+

Welcome Home

+ +
+ + + `, + "about.html": /*html*/ ` + + + + About Page + + + + + +
+

About Us

+

This is the about page

+
+ + + `, + "contact.html": /*html*/ ` + + + + Contact Page + + + + + +
+

Contact Us

+
+ + +
+
+ + + `, + "shared.css": /*css*/ ` + nav { + padding: 1rem; + background: #f0f0f0; + text-align: center; + } + nav a { + margin: 0 1rem; + color: blue; + } + .container { + max-width: 800px; + margin: 2rem auto; + text-align: center; + font-family: system-ui, sans-serif; + } + form { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 300px; + margin: 0 auto; + } + input, button { + padding: 0.5rem; + font-size: 1rem; + } + `, + "home.js": /*js*/ ` + const button = document.getElementById('counter'); + let count = 0; + button.addEventListener('click', () => { + count++; + button.textContent = \`Click me: \${count}\`; + }); + `, + "about.js": /*js*/ ` + const message = document.getElementById('message'); + message.textContent += " - Updated via JS"; + `, + "contact.js": /*js*/ ` + const form = document.getElementById('contact-form'); + form.addEventListener('submit', (e) => { + e.preventDefault(); + const input = form.querySelector('input'); + alert(\`Thanks for your message, \${input.value}!\`); + input.value = ''; + }); + `, + // Add a non-HTML file to verify it's not picked up + "README.md": "# Test Project\nThis file should be ignored by the glob.", + }); + + // Start the server using glob pattern + await using process = Bun.spawn({ + cmd: [bunExe(), "*.html", "--port=0"], + env: { + ...bunEnv, + NODE_ENV: undefined, + }, + cwd: dir, + stdout: "pipe", + }); + console.log({ cwd: dir }); + const serverUrl = await getServerUrl(process); + + try { + // Test all three pages are served + const pages = ["", "about", "contact"]; + const titles = ["Home Page", "About Page", "Contact Page"]; + + for (const [i, route] of pages.entries()) { + const response = await fetch(new URL(route, serverUrl).href); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/html"); + + const html = await response.text(); + expect(html).toContain(`${titles[i]}`); + expect(html).toMatch(/ + + + `, + "error.js": /*js*/ ` + throw new Error("Error on purpose"); + `, + }); + async function getServers() { + const path = join(dir, "index.html"); + + const { default: html } = await import(path); + let servers: Server[] = []; + for (let i = 0; i < 10; i++) { + servers.push( + Bun.serve({ + port: 0, + static: { + "/": html, + }, + development: true, + fetch(req) { + return new Response("Not found", { status: 404 }); + }, + }), + ); + } + + delete require.cache[path]; + + return servers; + } + + { + let servers = await getServers(); + Bun.gc(); + await Bun.sleep(1); + for (const server of servers) { + await server.stop(true); + } + servers = []; + Bun.gc(); + } + + Bun.gc(true); +}); + +test("wildcard static routes", async () => { + const dir = tempDirWithFiles("bun-serve-html-error-handling", { + "index.html": /*html*/ ` + + + + + + Error Page +

Error Page

+ + + + `, + "error.js": /*js*/ ` + throw new Error("Error on purpose"); + `, + }); + const { default: html } = await import(join(dir, "index.html")); + for (let development of [true, false]) { + using server = Bun.serve({ + port: 0, + static: { + "/*": html, + }, + development, + fetch(req) { + return new Response("Not found", { status: 404 }); + }, + }); + + for (let url of [server.url, new URL("/potato", server.url)]) { + const response = await fetch(url); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/html"); + const text = await response.text(); + expect(text).toContain("Error Page"); + } + } +});