mirror of
https://github.com/oven-sh/bun
synced 2026-02-19 23:31:45 +00:00
Compare commits
9 Commits
claude/fix
...
claude/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e71b8cd33 | ||
|
|
9fbe6a5826 | ||
|
|
c0d97ebd88 | ||
|
|
0b580054a7 | ||
|
|
b817abe55e | ||
|
|
9256b3d777 | ||
|
|
6763fe5a8a | ||
|
|
7848648e09 | ||
|
|
379daff22d |
@@ -1184,7 +1184,8 @@ Currently, the `--compile` flag can only accept a single entrypoint at a time an
|
||||
|
||||
- `--outdir` — use `outfile` instead (except when using with `--splitting`).
|
||||
- `--public-path`
|
||||
- `--target=node` or `--target=browser`
|
||||
- `--target=node`
|
||||
- `--target=browser` (without HTML entrypoints — see [Standalone HTML](/bundler/standalone-html) for `--compile --target=browser` with `.html` files)
|
||||
- `--no-bundle` - we always bundle everything into the executable.
|
||||
|
||||
---
|
||||
|
||||
@@ -481,6 +481,16 @@ All paths are resolved relative to your HTML file, making it easy to organize yo
|
||||
|
||||
This is a small wrapper around Bun's support for HTML imports in JavaScript.
|
||||
|
||||
## Standalone HTML
|
||||
|
||||
You can bundle your entire frontend into a **single self-contained `.html` file** with no external dependencies using `--compile --target=browser`. All JavaScript, CSS, and images are inlined directly into the HTML.
|
||||
|
||||
```bash terminal icon="terminal"
|
||||
bun build --compile --target=browser ./index.html --outdir=dist
|
||||
```
|
||||
|
||||
Learn more in the [Standalone HTML docs](/bundler/standalone-html).
|
||||
|
||||
## Adding a backend to your frontend
|
||||
|
||||
To add a backend to your frontend, you can use the "routes" option in `Bun.serve`.
|
||||
|
||||
314
docs/bundler/standalone-html.mdx
Normal file
314
docs/bundler/standalone-html.mdx
Normal file
@@ -0,0 +1,314 @@
|
||||
---
|
||||
title: Standalone HTML
|
||||
description: Bundle a single-page app into a single self-contained .html file with no external dependencies
|
||||
---
|
||||
|
||||
Bun can bundle your entire frontend into a **single `.html` file** with zero external dependencies. JavaScript, TypeScript, JSX, CSS, images, fonts, videos, WASM — everything gets inlined into one file.
|
||||
|
||||
```bash terminal icon="terminal"
|
||||
bun build --compile --target=browser ./index.html --outdir=dist
|
||||
```
|
||||
|
||||
The output is a completely self-contained HTML document. No relative paths. No external files. No server required. Just one `.html` file that works anywhere a browser can open it.
|
||||
|
||||
## One file. Upload anywhere. It just works.
|
||||
|
||||
The output is a single `.html` file you can put anywhere:
|
||||
|
||||
- **Upload it to S3** or any static file host — no directory structure to maintain, just one file
|
||||
- **Double-click it from your desktop** — it opens in the browser and works offline, no localhost server needed
|
||||
- **Embed it in your webview** — No need to deal with relative files
|
||||
- **Insert it in an `<iframe>`** — embed interactive content in another page with a single file URL
|
||||
- **Serve it from anywhere** — any HTTP server, CDN, or file share. One file, zero configuration.
|
||||
|
||||
There's nothing to install, no `node_modules` to deploy, no build artifacts to coordinate, no relative paths to think about. The entire app — framework code, stylesheets, images, everything — lives in that one file.
|
||||
|
||||
## Truly one file
|
||||
|
||||
Normally, distributing a web page means managing a folder of assets — the HTML, the JavaScript bundles, the CSS files, the images. Move the HTML without the rest and everything breaks. Browsers have tried to solve this before: Safari's `.webarchive` and `.mhtml` are supposed to save a page as a single file, but in practice they unpack into a folder of loose files on your computer — defeating the purpose.
|
||||
|
||||
Standalone HTML is different. The output is a plain `.html` file. Not an archive. Not a folder. One file, with everything inside it. Every image, every font, every line of CSS and JavaScript is embedded directly in the HTML using standard `<style>` tags, `<script>` tags, and `data:` URIs. Any browser can open it. Any server can host it. Any file host can store it.
|
||||
|
||||
This makes it practical to distribute web pages the same way you'd distribute a PDF — as a single file you can move, copy, upload, or share without worrying about broken paths or missing assets.
|
||||
|
||||
## Quick start
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```html index.html icon="file-code"
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="./app.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
```tsx app.tsx icon="/icons/typescript.svg"
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
function App() {
|
||||
return <h1>Hello from a single HTML file!</h1>;
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
```
|
||||
|
||||
```css styles.css icon="file-code"
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
```bash terminal icon="terminal"
|
||||
bun build --compile --target=browser ./index.html --outdir=dist
|
||||
```
|
||||
|
||||
Open `dist/index.html` — the React app works with no server.
|
||||
|
||||
## Everything gets inlined
|
||||
|
||||
Bun inlines every local asset it finds in your HTML. If it has a relative path, it gets embedded into the output file. This isn't limited to images and stylesheets — it works with any file type.
|
||||
|
||||
### What gets inlined
|
||||
|
||||
| In your source | In the output |
|
||||
| ------------------------------------------------ | ------------------------------------------------------------------------ |
|
||||
| `<script src="./app.tsx">` | `<script type="module">...bundled code...</script>` |
|
||||
| `<link rel="stylesheet" href="./styles.css">` | `<style>...bundled CSS...</style>` |
|
||||
| `<img src="./logo.png">` | `<img src="data:image/png;base64,...">` |
|
||||
| `<img src="./icon.svg">` | `<img src="data:image/svg+xml;base64,...">` |
|
||||
| `<video src="./demo.mp4">` | `<video src="data:video/mp4;base64,...">` |
|
||||
| `<audio src="./click.wav">` | `<audio src="data:audio/wav;base64,...">` |
|
||||
| `<source src="./clip.webm">` | `<source src="data:video/webm;base64,...">` |
|
||||
| `<video poster="./thumb.jpg">` | `<video poster="data:image/jpeg;base64,...">` |
|
||||
| `<link rel="icon" href="./favicon.ico">` | `<link rel="icon" href="data:image/x-icon;base64,...">` |
|
||||
| `<link rel="manifest" href="./app.webmanifest">` | `<link rel="manifest" href="data:application/manifest+json;base64,...">` |
|
||||
| CSS `url("./bg.png")` | CSS `url(data:image/png;base64,...)` |
|
||||
| CSS `@import "./reset.css"` | Flattened into the `<style>` tag |
|
||||
| CSS `url("./font.woff2")` | CSS `url(data:font/woff2;base64,...)` |
|
||||
| JS `import "./styles.css"` | Merged into the `<style>` tag |
|
||||
|
||||
Images, fonts, WASM binaries, videos, audio files, SVGs — any file referenced by a relative path gets base64-encoded into a `data:` URI and embedded directly in the HTML. The MIME type is automatically detected from the file extension.
|
||||
|
||||
External URLs (like CDN links or absolute URLs) are left untouched.
|
||||
|
||||
## Using with React
|
||||
|
||||
React apps work out of the box. Bun handles JSX transpilation and npm package resolution automatically.
|
||||
|
||||
```bash terminal icon="terminal"
|
||||
bun install react react-dom
|
||||
```
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```html index.html icon="file-code"
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>My App</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="./app.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
```tsx app.tsx icon="/icons/typescript.svg"
|
||||
import React, { useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Counter } from "./components/Counter.tsx";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<main>
|
||||
<h1>Single-file React App</h1>
|
||||
<Counter />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
```
|
||||
|
||||
```tsx components/Counter.tsx icon="/icons/typescript.svg"
|
||||
import React, { useState } from "react";
|
||||
|
||||
export function Counter() {
|
||||
const [count, setCount] = useState(0);
|
||||
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
```bash terminal icon="terminal"
|
||||
bun build --compile --target=browser ./index.html --outdir=dist
|
||||
```
|
||||
|
||||
All of React, your components, and your CSS are bundled into `dist/index.html`. Upload that one file anywhere and it works.
|
||||
|
||||
## Using with Tailwind CSS
|
||||
|
||||
Install the plugin and reference Tailwind in your HTML or CSS:
|
||||
|
||||
```bash terminal icon="terminal"
|
||||
bun install --dev bun-plugin-tailwind
|
||||
```
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```html index.html icon="file-code"
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="tailwindcss" />
|
||||
</head>
|
||||
<body class="bg-gray-100 flex items-center justify-center min-h-screen">
|
||||
<div id="root"></div>
|
||||
<script src="./app.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
```tsx app.tsx icon="/icons/typescript.svg"
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Hello Tailwind</h1>
|
||||
<p className="text-gray-600 mt-2">This is a single HTML file.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Build with the plugin using the JavaScript API:
|
||||
|
||||
```ts build.ts icon="/icons/typescript.svg"
|
||||
await Bun.build({
|
||||
entrypoints: ["./index.html"],
|
||||
compile: true,
|
||||
target: "browser",
|
||||
outdir: "./dist",
|
||||
plugins: [require("bun-plugin-tailwind")],
|
||||
});
|
||||
```
|
||||
|
||||
```bash terminal icon="terminal"
|
||||
bun run build.ts
|
||||
```
|
||||
|
||||
The generated Tailwind CSS is inlined directly into the HTML file as a `<style>` tag.
|
||||
|
||||
## How it works
|
||||
|
||||
When you pass `--compile --target=browser` with an HTML entrypoint, Bun:
|
||||
|
||||
1. Parses the HTML and discovers all `<script>`, `<link>`, `<img>`, `<video>`, `<audio>`, `<source>`, and other asset references
|
||||
2. Bundles all JavaScript/TypeScript/JSX into a single module
|
||||
3. Bundles all CSS (including `@import` chains and CSS imported from JS) into a single stylesheet
|
||||
4. Converts every relative asset reference into a base64 `data:` URI
|
||||
5. Inlines the bundled JS as `<script type="module">` before `</body>`
|
||||
6. Inlines the bundled CSS as `<style>` in `<head>`
|
||||
7. Outputs a single `.html` file with no external dependencies
|
||||
|
||||
## Minification
|
||||
|
||||
Add `--minify` to minify the JavaScript and CSS:
|
||||
|
||||
```bash terminal icon="terminal"
|
||||
bun build --compile --target=browser --minify ./index.html --outdir=dist
|
||||
```
|
||||
|
||||
Or via the API:
|
||||
|
||||
```ts build.ts icon="/icons/typescript.svg"
|
||||
await Bun.build({
|
||||
entrypoints: ["./index.html"],
|
||||
compile: true,
|
||||
target: "browser",
|
||||
outdir: "./dist",
|
||||
minify: true,
|
||||
});
|
||||
```
|
||||
|
||||
## JavaScript API
|
||||
|
||||
You can use `Bun.build()` to produce standalone HTML programmatically:
|
||||
|
||||
```ts build.ts icon="/icons/typescript.svg"
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./index.html"],
|
||||
compile: true,
|
||||
target: "browser",
|
||||
outdir: "./dist", // optional — omit to get output as BuildArtifact
|
||||
minify: true,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error("Build failed:");
|
||||
for (const log of result.logs) {
|
||||
console.error(log);
|
||||
}
|
||||
} else {
|
||||
console.log("Built:", result.outputs[0].path);
|
||||
}
|
||||
```
|
||||
|
||||
When `outdir` is omitted, the output is available as a `BuildArtifact` in `result.outputs`:
|
||||
|
||||
```ts icon="/icons/typescript.svg"
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./index.html"],
|
||||
compile: true,
|
||||
target: "browser",
|
||||
});
|
||||
|
||||
const html = await result.outputs[0].text();
|
||||
await Bun.write("output.html", html);
|
||||
```
|
||||
|
||||
## Multiple HTML files
|
||||
|
||||
You can pass multiple HTML files as entrypoints. Each produces its own standalone HTML file:
|
||||
|
||||
```bash terminal icon="terminal"
|
||||
bun build --compile --target=browser ./index.html ./about.html --outdir=dist
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
Use `--env` to inline environment variables into the bundled JavaScript:
|
||||
|
||||
```bash terminal icon="terminal"
|
||||
API_URL=https://api.example.com bun build --compile --target=browser --env=inline ./index.html --outdir=dist
|
||||
```
|
||||
|
||||
References to `process.env.API_URL` in your JavaScript are replaced with the literal value at build time.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Code splitting** is not supported — `--splitting` cannot be used with `--compile --target=browser`
|
||||
- **Large assets** increase file size since they're base64-encoded (33% overhead vs the raw binary)
|
||||
- **External URLs** (CDN links, absolute URLs) are left as-is — only relative paths are inlined
|
||||
@@ -234,7 +234,7 @@
|
||||
{
|
||||
"group": "Asset Processing",
|
||||
"icon": "image",
|
||||
"pages": ["/bundler/html-static", "/bundler/css", "/bundler/loaders"]
|
||||
"pages": ["/bundler/html-static", "/bundler/standalone-html", "/bundler/css", "/bundler/loaders"]
|
||||
},
|
||||
{
|
||||
"group": "Single File Executable",
|
||||
|
||||
15
packages/bun-types/bun.d.ts
vendored
15
packages/bun-types/bun.d.ts
vendored
@@ -2781,11 +2781,17 @@ declare module "bun" {
|
||||
outdir?: string;
|
||||
|
||||
/**
|
||||
* Create a standalone executable
|
||||
* Create a standalone executable or self-contained HTML.
|
||||
*
|
||||
* When `true`, creates an executable for the current platform.
|
||||
* When a target string, creates an executable for that platform.
|
||||
*
|
||||
* When used with `target: "browser"`, produces self-contained HTML files
|
||||
* with all scripts, styles, and assets inlined. All `<script>` tags become
|
||||
* inline `<script>` with bundled code, all `<link rel="stylesheet">` tags
|
||||
* become inline `<style>` tags, and all asset references become `data:` URIs.
|
||||
* All entrypoints must be HTML files. Cannot be used with `splitting`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Create executable for current platform
|
||||
@@ -2803,6 +2809,13 @@ declare module "bun" {
|
||||
* compile: 'linux-x64',
|
||||
* outfile: './my-app'
|
||||
* });
|
||||
*
|
||||
* // Produce self-contained HTML
|
||||
* await Bun.build({
|
||||
* entrypoints: ['./index.html'],
|
||||
* target: 'browser',
|
||||
* compile: true,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
compile?: boolean | Bun.Build.CompileTarget | CompileBuildOptions;
|
||||
|
||||
@@ -183,13 +183,14 @@ pub fn addUrlForCss(
|
||||
source: *const logger.Source,
|
||||
mime_type_: ?[]const u8,
|
||||
unique_key: ?[]const u8,
|
||||
force_inline: bool,
|
||||
) void {
|
||||
{
|
||||
const mime_type = if (mime_type_) |m| m else MimeType.byExtension(bun.strings.trimLeadingChar(std.fs.path.extension(source.path.text), '.')).value;
|
||||
const contents = source.contents;
|
||||
// TODO: make this configurable
|
||||
const COPY_THRESHOLD = 128 * 1024; // 128kb
|
||||
const should_copy = contents.len >= COPY_THRESHOLD and unique_key != null;
|
||||
const should_copy = !force_inline and contents.len >= COPY_THRESHOLD and unique_key != null;
|
||||
if (should_copy) return;
|
||||
this.url_for_css = url_for_css: {
|
||||
|
||||
|
||||
@@ -977,45 +977,57 @@ pub const JSBundler = struct {
|
||||
}
|
||||
|
||||
if (this.compile) |*compile| {
|
||||
this.target = .bun;
|
||||
// When compile + target=browser + all HTML entrypoints, produce standalone HTML.
|
||||
// Otherwise, default to bun executable compile.
|
||||
const has_all_html_entrypoints = brk: {
|
||||
if (this.entry_points.count() == 0) break :brk false;
|
||||
for (this.entry_points.keys()) |ep| {
|
||||
if (!strings.hasSuffixComptime(ep, ".html")) break :brk false;
|
||||
}
|
||||
break :brk true;
|
||||
};
|
||||
const is_standalone_html = this.target == .browser and has_all_html_entrypoints;
|
||||
if (!is_standalone_html) {
|
||||
this.target = .bun;
|
||||
|
||||
const define_keys = compile.compile_target.defineKeys();
|
||||
const define_values = compile.compile_target.defineValues();
|
||||
for (define_keys, define_values) |key, value| {
|
||||
try this.define.insert(key, value);
|
||||
}
|
||||
|
||||
const base_public_path = bun.StandaloneModuleGraph.targetBasePublicPath(this.compile.?.compile_target.os, "root/");
|
||||
try this.public_path.append(base_public_path);
|
||||
|
||||
// When using --compile, only `external` sourcemaps work, as we do not
|
||||
// look at the source map comment. Override any other sourcemap type.
|
||||
if (this.source_map != .none) {
|
||||
this.source_map = .external;
|
||||
}
|
||||
|
||||
if (compile.outfile.isEmpty()) {
|
||||
const entry_point = this.entry_points.keys()[0];
|
||||
var outfile = std.fs.path.basename(entry_point);
|
||||
const ext = std.fs.path.extension(outfile);
|
||||
if (ext.len > 0) {
|
||||
outfile = outfile[0 .. outfile.len - ext.len];
|
||||
const define_keys = compile.compile_target.defineKeys();
|
||||
const define_values = compile.compile_target.defineValues();
|
||||
for (define_keys, define_values) |key, value| {
|
||||
try this.define.insert(key, value);
|
||||
}
|
||||
|
||||
if (strings.eqlComptime(outfile, "index")) {
|
||||
outfile = std.fs.path.basename(std.fs.path.dirname(entry_point) orelse "index");
|
||||
const base_public_path = bun.StandaloneModuleGraph.targetBasePublicPath(this.compile.?.compile_target.os, "root/");
|
||||
try this.public_path.append(base_public_path);
|
||||
|
||||
// When using --compile, only `external` sourcemaps work, as we do not
|
||||
// look at the source map comment. Override any other sourcemap type.
|
||||
if (this.source_map != .none) {
|
||||
this.source_map = .external;
|
||||
}
|
||||
|
||||
if (strings.eqlComptime(outfile, "bun")) {
|
||||
outfile = std.fs.path.basename(std.fs.path.dirname(entry_point) orelse "bun");
|
||||
}
|
||||
if (compile.outfile.isEmpty()) {
|
||||
const entry_point = this.entry_points.keys()[0];
|
||||
var outfile = std.fs.path.basename(entry_point);
|
||||
const ext = std.fs.path.extension(outfile);
|
||||
if (ext.len > 0) {
|
||||
outfile = outfile[0 .. outfile.len - ext.len];
|
||||
}
|
||||
|
||||
// If argv[0] is "bun" or "bunx", we don't check if the binary is standalone
|
||||
if (strings.eqlComptime(outfile, "bun") or strings.eqlComptime(outfile, "bunx")) {
|
||||
return globalThis.throwInvalidArguments("cannot use compile with an output file named 'bun' because bun won't realize it's a standalone executable. Please choose a different name for compile.outfile", .{});
|
||||
}
|
||||
if (strings.eqlComptime(outfile, "index")) {
|
||||
outfile = std.fs.path.basename(std.fs.path.dirname(entry_point) orelse "index");
|
||||
}
|
||||
|
||||
try compile.outfile.appendSliceExact(outfile);
|
||||
if (strings.eqlComptime(outfile, "bun")) {
|
||||
outfile = std.fs.path.basename(std.fs.path.dirname(entry_point) orelse "bun");
|
||||
}
|
||||
|
||||
// If argv[0] is "bun" or "bunx", we don't check if the binary is standalone
|
||||
if (strings.eqlComptime(outfile, "bun") or strings.eqlComptime(outfile, "bunx")) {
|
||||
return globalThis.throwInvalidArguments("cannot use compile with an output file named 'bun' because bun won't realize it's a standalone executable. Please choose a different name for compile.outfile", .{});
|
||||
}
|
||||
|
||||
try compile.outfile.appendSliceExact(outfile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1026,6 +1038,20 @@ pub const JSBundler = struct {
|
||||
return globalThis.throwInvalidArguments("ESM bytecode requires compile: true. Use format: 'cjs' for bytecode without compile.", .{});
|
||||
}
|
||||
|
||||
// Validate standalone HTML mode: compile + browser target + all HTML entrypoints
|
||||
if (this.compile != null and this.target == .browser) {
|
||||
const has_all_html = brk: {
|
||||
if (this.entry_points.count() == 0) break :brk false;
|
||||
for (this.entry_points.keys()) |ep| {
|
||||
if (!strings.hasSuffixComptime(ep, ".html")) break :brk false;
|
||||
}
|
||||
break :brk true;
|
||||
};
|
||||
if (has_all_html and this.code_splitting) {
|
||||
return globalThis.throwInvalidArguments("Cannot use compile with target 'browser' and splitting for standalone HTML", .{});
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -1707,6 +1707,15 @@ pub fn NewWrappedHandler(comptime tls: bool) type {
|
||||
|
||||
pub fn onClose(this: WrappedSocket, socket: Socket, err: c_int, data: ?*anyopaque) bun.JSError!void {
|
||||
if (comptime tls) {
|
||||
// Clean up the raw TCP socket from upgradeTLS() — its onClose
|
||||
// never fires because uws closes through the TLS context only.
|
||||
defer {
|
||||
if (!this.tcp.socket.isDetached()) {
|
||||
this.tcp.socket.detach();
|
||||
this.tcp.has_pending_activity.store(false, .release);
|
||||
this.tcp.deref();
|
||||
}
|
||||
}
|
||||
try TLSSocket.onClose(this.tls, socket, err, data);
|
||||
} else {
|
||||
try TLSSocket.onClose(this.tcp, socket, err, data);
|
||||
|
||||
@@ -24,6 +24,18 @@ static inline bool isEscapeCharacter(Char c)
|
||||
}
|
||||
}
|
||||
|
||||
// SIMD comparison against exact escape character values. Used to refine
|
||||
// the broad range match (0x10-0x1F / 0x90-0x9F) to only actual escape
|
||||
// introducers: 0x1B, 0x90, 0x98, 0x9B, 0x9D, 0x9E, 0x9F.
|
||||
template<typename SIMDType>
|
||||
static auto exactEscapeMatch(std::conditional_t<sizeof(SIMDType) == 1, simde_uint8x16_t, simde_uint16x8_t> chunk)
|
||||
{
|
||||
if constexpr (sizeof(SIMDType) == 1)
|
||||
return SIMD::equal<0x1b, 0x90, 0x98, 0x9b, 0x9d, 0x9e, 0x9f>(chunk);
|
||||
else
|
||||
return SIMD::equal<u'\x1b', u'\x90', u'\x98', u'\x9b', u'\x9d', u'\x9e', u'\x9f'>(chunk);
|
||||
}
|
||||
|
||||
// Find the first escape character in a string using SIMD
|
||||
template<typename Char>
|
||||
static const Char* findEscapeCharacter(const Char* start, const Char* end)
|
||||
@@ -43,8 +55,13 @@ static const Char* findEscapeCharacter(const Char* start, const Char* end)
|
||||
const auto chunk = SIMD::load(reinterpret_cast<const SIMDType*>(it));
|
||||
const auto chunkMasked = SIMD::bitAnd(chunk, escMask);
|
||||
const auto chunkIsEsc = SIMD::equal(chunkMasked, escVector);
|
||||
if (const auto index = SIMD::findFirstNonZeroIndex(chunkIsEsc))
|
||||
return it + *index;
|
||||
if (SIMD::findFirstNonZeroIndex(chunkIsEsc)) {
|
||||
// Broad mask matched 0x10-0x1F / 0x90-0x9F. Refine with exact
|
||||
// escape character comparison to filter out false positives.
|
||||
const auto exactMatch = exactEscapeMatch<SIMDType>(chunk);
|
||||
if (const auto exactIndex = SIMD::findFirstNonZeroIndex(exactMatch))
|
||||
return it + *exactIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// Check remaining characters
|
||||
|
||||
@@ -641,13 +641,16 @@ JSC_DEFINE_CUSTOM_GETTER(errorInstanceLazyStackCustomGetter, (JSGlobalObject * g
|
||||
OrdinalNumber column;
|
||||
String sourceURL;
|
||||
auto stackTrace = errorObject->stackTrace();
|
||||
if (stackTrace == nullptr) {
|
||||
return JSValue::encode(jsUndefined());
|
||||
}
|
||||
|
||||
JSValue result = computeErrorInfoToJSValue(vm, *stackTrace, line, column, sourceURL, errorObject, nullptr);
|
||||
stackTrace->clear();
|
||||
errorObject->setStackFrames(vm, {});
|
||||
JSValue result;
|
||||
if (stackTrace == nullptr) {
|
||||
WTF::Vector<JSC::StackFrame> emptyTrace;
|
||||
result = computeErrorInfoToJSValue(vm, emptyTrace, line, column, sourceURL, errorObject, nullptr);
|
||||
} else {
|
||||
result = computeErrorInfoToJSValue(vm, *stackTrace, line, column, sourceURL, errorObject, nullptr);
|
||||
stackTrace->clear();
|
||||
errorObject->setStackFrames(vm, {});
|
||||
}
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
errorObject->putDirect(vm, vm.propertyNames->stack, result, 0);
|
||||
return JSValue::encode(result);
|
||||
@@ -687,17 +690,27 @@ JSC_DEFINE_HOST_FUNCTION(errorConstructorFuncCaptureStackTrace, (JSC::JSGlobalOb
|
||||
JSCStackTrace::getFramesForCaller(vm, callFrame, errorObject, caller, stackTrace, stackTraceLimit);
|
||||
|
||||
if (auto* instance = jsDynamicCast<JSC::ErrorInstance*>(errorObject)) {
|
||||
// Force materialization before replacing the stack frames, so that JSC's
|
||||
// internal lazy error info mechanism doesn't later see the replaced (possibly empty)
|
||||
// stack trace and fail to create the stack property.
|
||||
if (!instance->hasMaterializedErrorInfo())
|
||||
instance->materializeErrorInfoIfNeeded(vm);
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
|
||||
instance->setStackFrames(vm, WTF::move(stackTrace));
|
||||
if (instance->hasMaterializedErrorInfo()) {
|
||||
|
||||
{
|
||||
const auto& propertyName = vm.propertyNames->stack;
|
||||
VM::DeletePropertyModeScope scope(vm, VM::DeletePropertyMode::IgnoreConfigurable);
|
||||
VM::DeletePropertyModeScope deleteScope(vm, VM::DeletePropertyMode::IgnoreConfigurable);
|
||||
DeletePropertySlot slot;
|
||||
JSObject::deleteProperty(instance, globalObject, propertyName, slot);
|
||||
if (auto* zigGlobalObject = jsDynamicCast<Zig::GlobalObject*>(globalObject)) {
|
||||
instance->putDirectCustomAccessor(vm, vm.propertyNames->stack, zigGlobalObject->m_lazyStackCustomGetterSetter.get(zigGlobalObject), JSC::PropertyAttribute::CustomAccessor | 0);
|
||||
} else {
|
||||
instance->putDirectCustomAccessor(vm, vm.propertyNames->stack, CustomGetterSetter::create(vm, errorInstanceLazyStackCustomGetter, errorInstanceLazyStackCustomSetter), JSC::PropertyAttribute::CustomAccessor | 0);
|
||||
}
|
||||
}
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
|
||||
if (auto* zigGlobalObject = jsDynamicCast<Zig::GlobalObject*>(globalObject)) {
|
||||
instance->putDirectCustomAccessor(vm, vm.propertyNames->stack, zigGlobalObject->m_lazyStackCustomGetterSetter.get(zigGlobalObject), JSC::PropertyAttribute::CustomAccessor | 0);
|
||||
} else {
|
||||
instance->putDirectCustomAccessor(vm, vm.propertyNames->stack, CustomGetterSetter::create(vm, errorInstanceLazyStackCustomGetter, errorInstanceLazyStackCustomSetter), JSC::PropertyAttribute::CustomAccessor | 0);
|
||||
}
|
||||
} else {
|
||||
OrdinalNumber line;
|
||||
|
||||
@@ -152,16 +152,18 @@ JSC::JSValue createNodeURLBinding(Zig::GlobalObject* globalObject)
|
||||
ASSERT(domainToAsciiFunction);
|
||||
auto domainToUnicodeFunction = JSC::JSFunction::create(vm, globalObject, 1, "domainToUnicode"_s, jsDomainToUnicode, ImplementationVisibility::Public);
|
||||
ASSERT(domainToUnicodeFunction);
|
||||
binding->putByIndexInline(
|
||||
binding->putDirectIndex(
|
||||
globalObject,
|
||||
(unsigned)0,
|
||||
domainToAsciiFunction,
|
||||
false);
|
||||
binding->putByIndexInline(
|
||||
0,
|
||||
JSC::PutDirectIndexMode::PutDirectIndexLikePutDirect);
|
||||
binding->putDirectIndex(
|
||||
globalObject,
|
||||
(unsigned)1,
|
||||
domainToUnicodeFunction,
|
||||
false);
|
||||
0,
|
||||
JSC::PutDirectIndexMode::PutDirectIndexLikePutDirect);
|
||||
return binding;
|
||||
}
|
||||
|
||||
|
||||
@@ -3026,22 +3026,20 @@ JSC::EncodedJSValue JSC__JSValue__fromEntries(JSC::JSGlobalObject* globalObject,
|
||||
return JSC::JSValue::encode(JSC::constructEmptyObject(globalObject));
|
||||
}
|
||||
|
||||
JSC::JSObject* object = nullptr;
|
||||
{
|
||||
JSC::ObjectInitializationScope initializationScope(vm);
|
||||
object = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), std::min(static_cast<unsigned int>(initialCapacity), JSFinalObject::maxInlineCapacity));
|
||||
JSC::JSObject* object = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), std::min(static_cast<unsigned int>(initialCapacity), JSFinalObject::maxInlineCapacity));
|
||||
|
||||
if (!clone) {
|
||||
for (size_t i = 0; i < initialCapacity; ++i) {
|
||||
object->putDirect(
|
||||
vm, JSC::PropertyName(JSC::Identifier::fromString(vm, Zig::toString(keys[i]))),
|
||||
Zig::toJSStringGC(values[i], globalObject), 0);
|
||||
}
|
||||
} else {
|
||||
for (size_t i = 0; i < initialCapacity; ++i) {
|
||||
object->putDirect(vm, JSC::PropertyName(Zig::toIdentifier(keys[i], globalObject)),
|
||||
Zig::toJSStringGC(values[i], globalObject), 0);
|
||||
}
|
||||
if (!clone) {
|
||||
for (size_t i = 0; i < initialCapacity; ++i) {
|
||||
object->putDirect(
|
||||
vm, JSC::PropertyName(JSC::Identifier::fromString(vm, Zig::toString(keys[i]))),
|
||||
Zig::toJSStringGC(values[i], globalObject), 0);
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
}
|
||||
} else {
|
||||
for (size_t i = 0; i < initialCapacity; ++i) {
|
||||
object->putDirect(vm, JSC::PropertyName(Zig::toIdentifier(keys[i], globalObject)),
|
||||
Zig::toJSStringGC(values[i], globalObject), 0);
|
||||
RETURN_IF_EXCEPTION(scope, {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,16 @@ pub const Chunk = struct {
|
||||
return this.entry_point.is_entry_point;
|
||||
}
|
||||
|
||||
/// Returns the HTML closing tag that must be escaped when this chunk's content
|
||||
/// is inlined into a standalone HTML file (e.g. "</script" for JS, "</style" for CSS).
|
||||
pub fn closingTagForContent(this: *const Chunk) []const u8 {
|
||||
return switch (this.content) {
|
||||
.javascript => "</script",
|
||||
.css => "</style",
|
||||
.html => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn getJSChunkForHTML(this: *const Chunk, chunks: []Chunk) ?*Chunk {
|
||||
const entry_point_id = this.entry_point.entry_point_id;
|
||||
for (chunks) |*other| {
|
||||
@@ -137,6 +147,54 @@ pub const Chunk = struct {
|
||||
return bun.default_allocator;
|
||||
}
|
||||
|
||||
/// Count occurrences of a closing HTML tag (e.g. `</script`, `</style`) in content.
|
||||
/// Used to calculate the extra bytes needed when escaping `</` → `<\/`.
|
||||
fn countClosingTags(content: []const u8, close_tag: []const u8) usize {
|
||||
const tag_suffix = close_tag[2..];
|
||||
var count: usize = 0;
|
||||
var remaining = content;
|
||||
while (strings.indexOf(remaining, "</")) |idx| {
|
||||
remaining = remaining[idx + 2 ..];
|
||||
if (remaining.len >= tag_suffix.len and
|
||||
strings.eqlCaseInsensitiveASCIIIgnoreLength(remaining[0..tag_suffix.len], tag_suffix))
|
||||
{
|
||||
count += 1;
|
||||
remaining = remaining[tag_suffix.len..];
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Copy `content` into `dest`, escaping occurrences of `close_tag` by
|
||||
/// replacing `</` with `<\/`. Returns the number of bytes written.
|
||||
/// Caller must ensure `dest` has room for `content.len + countClosingTags(...)` bytes.
|
||||
fn memcpyEscapingClosingTags(dest: []u8, content: []const u8, close_tag: []const u8) usize {
|
||||
const tag_suffix = close_tag[2..];
|
||||
var remaining = content;
|
||||
var dst: usize = 0;
|
||||
while (strings.indexOf(remaining, "</")) |idx| {
|
||||
@memcpy(dest[dst..][0..idx], remaining[0..idx]);
|
||||
dst += idx;
|
||||
remaining = remaining[idx + 2 ..];
|
||||
|
||||
if (remaining.len >= tag_suffix.len and
|
||||
strings.eqlCaseInsensitiveASCIIIgnoreLength(remaining[0..tag_suffix.len], tag_suffix))
|
||||
{
|
||||
dest[dst] = '<';
|
||||
dest[dst + 1] = '\\';
|
||||
dest[dst + 2] = '/';
|
||||
dst += 3;
|
||||
} else {
|
||||
dest[dst] = '<';
|
||||
dest[dst + 1] = '/';
|
||||
dst += 2;
|
||||
}
|
||||
}
|
||||
@memcpy(dest[dst..][0..remaining.len], remaining);
|
||||
dst += remaining.len;
|
||||
return dst;
|
||||
}
|
||||
|
||||
pub const CodeResult = struct {
|
||||
buffer: []u8,
|
||||
shifts: []SourceMap.SourceMapShifts,
|
||||
@@ -179,6 +237,40 @@ pub const Chunk = struct {
|
||||
display_size,
|
||||
force_absolute_path,
|
||||
source_map_shifts,
|
||||
null,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// Like `code()` but with standalone HTML support.
|
||||
/// When `standalone_chunk_contents` is provided, chunk piece references are
|
||||
/// resolved to inline code content instead of file paths. Asset references
|
||||
/// are resolved to data: URIs from url_for_css.
|
||||
pub fn codeStandalone(
|
||||
this: *IntermediateOutput,
|
||||
allocator_to_use: ?std.mem.Allocator,
|
||||
parse_graph: *const Graph,
|
||||
linker_graph: *const LinkerGraph,
|
||||
import_prefix: []const u8,
|
||||
chunk: *Chunk,
|
||||
chunks: []Chunk,
|
||||
display_size: ?*usize,
|
||||
force_absolute_path: bool,
|
||||
enable_source_map_shifts: bool,
|
||||
standalone_chunk_contents: []const ?[]const u8,
|
||||
) bun.OOM!CodeResult {
|
||||
return switch (enable_source_map_shifts) {
|
||||
inline else => |source_map_shifts| this.codeWithSourceMapShifts(
|
||||
allocator_to_use,
|
||||
parse_graph,
|
||||
linker_graph,
|
||||
import_prefix,
|
||||
chunk,
|
||||
chunks,
|
||||
display_size,
|
||||
force_absolute_path,
|
||||
source_map_shifts,
|
||||
standalone_chunk_contents,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -194,6 +286,7 @@ pub const Chunk = struct {
|
||||
display_size: ?*usize,
|
||||
force_absolute_path: bool,
|
||||
comptime enable_source_map_shifts: bool,
|
||||
standalone_chunk_contents: ?[]const ?[]const u8,
|
||||
) bun.OOM!CodeResult {
|
||||
const additional_files = graph.input_files.items(.additional_files);
|
||||
const unique_key_for_additional_files = graph.input_files.items(.unique_key_for_additional_file);
|
||||
@@ -219,12 +312,37 @@ pub const Chunk = struct {
|
||||
if (strings.eqlComptime(from_chunk_dir, "."))
|
||||
from_chunk_dir = "";
|
||||
|
||||
const urls_for_css = if (standalone_chunk_contents != null) graph.ast.items(.url_for_css) else &[_][]const u8{};
|
||||
|
||||
for (pieces.slice()) |piece| {
|
||||
count += piece.data_len;
|
||||
|
||||
switch (piece.query.kind) {
|
||||
.chunk, .asset, .scb, .html_import => {
|
||||
const index = piece.query.index;
|
||||
|
||||
// In standalone mode, inline chunk content and asset data URIs
|
||||
if (standalone_chunk_contents) |scc| {
|
||||
switch (piece.query.kind) {
|
||||
.chunk => {
|
||||
if (scc[index]) |content| {
|
||||
// Account for escaping </script or </style inside inline content.
|
||||
// Each occurrence of the closing tag adds 1 byte (`</` → `<\/`).
|
||||
count += content.len + countClosingTags(content, chunks[index].closingTagForContent());
|
||||
continue;
|
||||
}
|
||||
},
|
||||
.asset => {
|
||||
// Use data: URI from url_for_css if available
|
||||
if (index < urls_for_css.len and urls_for_css[index].len > 0) {
|
||||
count += urls_for_css[index].len;
|
||||
continue;
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
const file_path = switch (piece.query.kind) {
|
||||
.asset => brk: {
|
||||
const files = additional_files[index];
|
||||
@@ -293,6 +411,37 @@ pub const Chunk = struct {
|
||||
switch (piece.query.kind) {
|
||||
.asset, .chunk, .scb, .html_import => {
|
||||
const index = piece.query.index;
|
||||
|
||||
// In standalone mode, inline chunk content and asset data URIs
|
||||
if (standalone_chunk_contents) |scc| {
|
||||
const inline_content: ?[]const u8 = switch (piece.query.kind) {
|
||||
.chunk => scc[index],
|
||||
.asset => if (index < urls_for_css.len and urls_for_css[index].len > 0) urls_for_css[index] else null,
|
||||
else => null,
|
||||
};
|
||||
if (inline_content) |content| {
|
||||
if (enable_source_map_shifts) {
|
||||
switch (piece.query.kind) {
|
||||
.chunk => shift.before.advance(chunks[index].unique_key),
|
||||
.asset => shift.before.advance(unique_key_for_additional_files[index]),
|
||||
else => {},
|
||||
}
|
||||
shift.after.advance(content);
|
||||
shifts.appendAssumeCapacity(shift);
|
||||
}
|
||||
// For chunk content, escape closing tags (</script, </style)
|
||||
// that would prematurely terminate the inline tag.
|
||||
if (piece.query.kind == .chunk) {
|
||||
const written = memcpyEscapingClosingTags(remain, content, chunks[index].closingTagForContent());
|
||||
remain = remain[written..];
|
||||
} else {
|
||||
@memcpy(remain[0..content.len], content);
|
||||
remain = remain[content.len..];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const file_path = switch (piece.query.kind) {
|
||||
.asset => brk: {
|
||||
const files = additional_files[index];
|
||||
|
||||
@@ -68,6 +68,7 @@ pub const LinkerContext = struct {
|
||||
banner: []const u8 = "",
|
||||
footer: []const u8 = "",
|
||||
css_chunking: bool = false,
|
||||
compile_to_standalone_html: bool = false,
|
||||
source_maps: options.SourceMapOption = .none,
|
||||
target: options.Target = .browser,
|
||||
compile: bool = false,
|
||||
|
||||
@@ -378,7 +378,7 @@ fn getAST(
|
||||
.data = source.contents,
|
||||
}, Logger.Loc{ .start = 0 });
|
||||
var ast = JSAst.init((try js_parser.newLazyExportAST(allocator, transpiler.options.define, opts, log, root, source, "")).?);
|
||||
ast.addUrlForCss(allocator, source, "text/plain", null);
|
||||
ast.addUrlForCss(allocator, source, "text/plain", null, transpiler.options.compile_to_standalone_html);
|
||||
return ast;
|
||||
},
|
||||
.md => {
|
||||
@@ -394,7 +394,7 @@ fn getAST(
|
||||
.data = html,
|
||||
}, Logger.Loc{ .start = 0 });
|
||||
var ast = JSAst.init((try js_parser.newLazyExportAST(allocator, transpiler.options.define, opts, log, root, source, "")).?);
|
||||
ast.addUrlForCss(allocator, source, "text/html", null);
|
||||
ast.addUrlForCss(allocator, source, "text/html", null, transpiler.options.compile_to_standalone_html);
|
||||
return ast;
|
||||
},
|
||||
|
||||
@@ -645,7 +645,7 @@ fn getAST(
|
||||
.content_hash = content_hash,
|
||||
};
|
||||
var ast = JSAst.init((try js_parser.newLazyExportAST(allocator, transpiler.options.define, opts, log, root, source, "")).?);
|
||||
ast.addUrlForCss(allocator, source, null, unique_key);
|
||||
ast.addUrlForCss(allocator, source, null, unique_key, transpiler.options.compile_to_standalone_html);
|
||||
return ast;
|
||||
},
|
||||
}
|
||||
|
||||
@@ -965,6 +965,7 @@ pub const BundleV2 = struct {
|
||||
this.linker.options.banner = transpiler.options.banner;
|
||||
this.linker.options.footer = transpiler.options.footer;
|
||||
this.linker.options.css_chunking = transpiler.options.css_chunking;
|
||||
this.linker.options.compile_to_standalone_html = transpiler.options.compile_to_standalone_html;
|
||||
this.linker.options.source_maps = transpiler.options.source_map;
|
||||
this.linker.options.tree_shaking = transpiler.options.tree_shaking;
|
||||
this.linker.options.public_path = transpiler.options.public_path;
|
||||
@@ -1992,6 +1993,19 @@ pub const BundleV2 = struct {
|
||||
transpiler.options.emit_dce_annotations = config.emit_dce_annotations orelse !config.minify.whitespace;
|
||||
transpiler.options.ignore_dce_annotations = config.ignore_dce_annotations;
|
||||
transpiler.options.css_chunking = config.css_chunking;
|
||||
transpiler.options.compile_to_standalone_html = brk: {
|
||||
if (config.compile == null or config.target != .browser) break :brk false;
|
||||
// Only activate standalone HTML when all entrypoints are HTML files
|
||||
for (config.entry_points.keys()) |ep| {
|
||||
if (!bun.strings.hasSuffixComptime(ep, ".html")) break :brk false;
|
||||
}
|
||||
break :brk config.entry_points.count() > 0;
|
||||
};
|
||||
// When compiling to standalone HTML, don't use the bun executable compile path
|
||||
if (transpiler.options.compile_to_standalone_html) {
|
||||
transpiler.options.compile = false;
|
||||
config.compile = null;
|
||||
}
|
||||
transpiler.options.banner = config.banner.slice();
|
||||
transpiler.options.footer = config.footer.slice();
|
||||
transpiler.options.react_fast_refresh = config.react_fast_refresh;
|
||||
|
||||
@@ -97,7 +97,8 @@ pub fn calculateOutputFileListCapacity(c: *const bun.bundle_v2.LinkerContext, ch
|
||||
// module_info is generated for ESM bytecode in --compile builds
|
||||
const module_info_count = if (c.options.generate_bytecode_cache and c.options.output_format == .esm and c.options.compile) bytecode_count else 0;
|
||||
|
||||
return .{ @intCast(chunks.len + source_map_count + bytecode_count + module_info_count + c.parse_graph.additional_output_files.items.len), @intCast(source_map_count + bytecode_count + module_info_count) };
|
||||
const additional_output_files_count = if (c.options.compile_to_standalone_html) 0 else c.parse_graph.additional_output_files.items.len;
|
||||
return .{ @intCast(chunks.len + source_map_count + bytecode_count + module_info_count + additional_output_files_count), @intCast(source_map_count + bytecode_count + module_info_count) };
|
||||
}
|
||||
|
||||
pub fn insertForChunk(this: *OutputFileList, output_file: options.OutputFile) u32 {
|
||||
|
||||
@@ -382,7 +382,8 @@ pub fn generateChunksInParallel(
|
||||
var output_files = try OutputFileListBuilder.init(bun.default_allocator, c, chunks, c.parse_graph.additional_output_files.items.len);
|
||||
|
||||
const root_path = c.resolver.opts.output_dir;
|
||||
const more_than_one_output = c.parse_graph.additional_output_files.items.len > 0 or c.options.generate_bytecode_cache or (has_css_chunk and has_js_chunk) or (has_html_chunk and (has_js_chunk or has_css_chunk));
|
||||
const is_standalone = c.options.compile_to_standalone_html;
|
||||
const more_than_one_output = !is_standalone and (c.parse_graph.additional_output_files.items.len > 0 or c.options.generate_bytecode_cache or (has_css_chunk and has_js_chunk) or (has_html_chunk and (has_js_chunk or has_css_chunk)));
|
||||
|
||||
if (!c.resolver.opts.compile and more_than_one_output and !c.resolver.opts.supports_multiple_outputs) {
|
||||
try c.log.addError(null, Logger.Loc.Empty, "cannot write multiple output files without an output directory");
|
||||
@@ -393,13 +394,73 @@ pub fn generateChunksInParallel(
|
||||
var static_route_visitor = StaticRouteVisitor{ .c = c, .visited = bun.handleOom(bun.bit_set.AutoBitSet.initEmpty(bun.default_allocator, c.graph.files.len)) };
|
||||
defer static_route_visitor.deinit();
|
||||
|
||||
// For standalone mode, resolve JS/CSS chunks so we can inline their content into HTML.
|
||||
// Closing tag escaping (</script → <\\/script, </style → <\\/style) is handled during
|
||||
// the HTML assembly step in codeWithSourceMapShifts, not here.
|
||||
var standalone_chunk_contents: ?[]?[]const u8 = null;
|
||||
defer if (standalone_chunk_contents) |scc| {
|
||||
for (scc) |maybe_buf| {
|
||||
if (maybe_buf) |buf| {
|
||||
if (buf.len > 0)
|
||||
Chunk.IntermediateOutput.allocatorForSize(buf.len).free(@constCast(buf));
|
||||
}
|
||||
}
|
||||
bun.default_allocator.free(scc);
|
||||
};
|
||||
|
||||
if (is_standalone) {
|
||||
const scc = bun.handleOom(bun.default_allocator.alloc(?[]const u8, chunks.len));
|
||||
@memset(scc, null);
|
||||
standalone_chunk_contents = scc;
|
||||
|
||||
for (chunks, 0..) |*chunk_item, ci| {
|
||||
if (chunk_item.content == .html) continue;
|
||||
var ds: usize = 0;
|
||||
scc[ci] = (chunk_item.intermediate_output.code(
|
||||
null,
|
||||
c.parse_graph,
|
||||
&c.graph,
|
||||
c.options.public_path,
|
||||
chunk_item,
|
||||
chunks,
|
||||
&ds,
|
||||
false,
|
||||
false,
|
||||
) catch |err| bun.handleOom(err)).buffer;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't write to disk if compile mode is enabled - we need buffer values for compilation
|
||||
const is_compile = bundler.transpiler.options.compile;
|
||||
if (root_path.len > 0 and !is_compile) {
|
||||
try c.writeOutputFilesToDisk(root_path, chunks, &output_files);
|
||||
try c.writeOutputFilesToDisk(root_path, chunks, &output_files, standalone_chunk_contents);
|
||||
} else {
|
||||
// In-memory build
|
||||
// In-memory build (also used for standalone mode)
|
||||
for (chunks, 0..) |*chunk, chunk_index_in_chunks_list| {
|
||||
// In standalone mode, non-HTML chunks were already resolved in the first pass.
|
||||
// Insert a placeholder output file to keep chunk indices aligned.
|
||||
if (is_standalone and chunk.content != .html) {
|
||||
_ = output_files.insertForChunk(options.OutputFile.init(.{
|
||||
.data = .{ .buffer = .{ .data = &.{}, .allocator = bun.default_allocator } },
|
||||
.hash = null,
|
||||
.loader = chunk.content.loader(),
|
||||
.input_path = "",
|
||||
.display_size = 0,
|
||||
.output_kind = .chunk,
|
||||
.input_loader = .js,
|
||||
.output_path = "",
|
||||
.is_executable = false,
|
||||
.source_map_index = null,
|
||||
.bytecode_index = null,
|
||||
.module_info_index = null,
|
||||
.side = .client,
|
||||
.entry_point_index = null,
|
||||
.referenced_css_chunks = &.{},
|
||||
.bake_extra = .{},
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
var display_size: usize = 0;
|
||||
|
||||
const public_path = if (chunk.flags.is_browser_chunk_from_server_build)
|
||||
@@ -407,18 +468,32 @@ pub fn generateChunksInParallel(
|
||||
else
|
||||
c.options.public_path;
|
||||
|
||||
const _code_result = chunk.intermediate_output.code(
|
||||
null,
|
||||
c.parse_graph,
|
||||
&c.graph,
|
||||
public_path,
|
||||
chunk,
|
||||
chunks,
|
||||
&display_size,
|
||||
c.resolver.opts.compile and !chunk.flags.is_browser_chunk_from_server_build,
|
||||
chunk.content.sourcemap(c.options.source_maps) != .none,
|
||||
);
|
||||
var code_result = _code_result catch @panic("Failed to allocate memory for output file");
|
||||
const _code_result = if (is_standalone and chunk.content == .html)
|
||||
chunk.intermediate_output.codeStandalone(
|
||||
null,
|
||||
c.parse_graph,
|
||||
&c.graph,
|
||||
public_path,
|
||||
chunk,
|
||||
chunks,
|
||||
&display_size,
|
||||
false,
|
||||
false,
|
||||
standalone_chunk_contents.?,
|
||||
)
|
||||
else
|
||||
chunk.intermediate_output.code(
|
||||
null,
|
||||
c.parse_graph,
|
||||
&c.graph,
|
||||
public_path,
|
||||
chunk,
|
||||
chunks,
|
||||
&display_size,
|
||||
c.resolver.opts.compile and !chunk.flags.is_browser_chunk_from_server_build,
|
||||
chunk.content.sourcemap(c.options.source_maps) != .none,
|
||||
);
|
||||
var code_result = _code_result catch |err| bun.handleOom(err);
|
||||
|
||||
var sourcemap_output_file: ?options.OutputFile = null;
|
||||
const input_path = try bun.default_allocator.dupe(
|
||||
@@ -670,7 +745,26 @@ pub fn generateChunksInParallel(
|
||||
bun.assertf(chunk_index == chunk_index_in_chunks_list, "chunk_index ({d}) != chunk_index_in_chunks_list ({d})", .{ chunk_index, chunk_index_in_chunks_list });
|
||||
}
|
||||
|
||||
output_files.insertAdditionalOutputFiles(c.parse_graph.additional_output_files.items);
|
||||
if (!is_standalone) {
|
||||
output_files.insertAdditionalOutputFiles(c.parse_graph.additional_output_files.items);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_standalone) {
|
||||
// For standalone mode, filter to only HTML output files.
|
||||
// Deinit dropped items to free their heap allocations (paths, buffers).
|
||||
var result = output_files.take();
|
||||
var write_idx: usize = 0;
|
||||
for (result.items) |*item| {
|
||||
if (item.loader == .html) {
|
||||
result.items[write_idx] = item.*;
|
||||
write_idx += 1;
|
||||
} else {
|
||||
item.deinit();
|
||||
}
|
||||
}
|
||||
result.items.len = write_idx;
|
||||
return result;
|
||||
}
|
||||
|
||||
return output_files.take();
|
||||
|
||||
@@ -42,6 +42,7 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC
|
||||
chunk: *Chunk,
|
||||
chunks: []Chunk,
|
||||
minify_whitespace: bool,
|
||||
compile_to_standalone_html: bool,
|
||||
output: std.array_list.Managed(u8),
|
||||
end_tag_indices: struct {
|
||||
head: ?u32 = 0,
|
||||
@@ -49,6 +50,7 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC
|
||||
html: ?u32 = 0,
|
||||
},
|
||||
added_head_tags: bool,
|
||||
added_body_script: bool,
|
||||
|
||||
pub fn onWriteHTML(this: *@This(), bytes: []const u8) void {
|
||||
bun.handleOom(this.output.appendSlice(bytes));
|
||||
@@ -104,6 +106,18 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC
|
||||
element.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.compile_to_standalone_html and import_record.source_index.isValid()) {
|
||||
// In standalone HTML mode, inline assets as data: URIs
|
||||
const url_for_css = this.linker.parse_graph.ast.items(.url_for_css)[import_record.source_index.get()];
|
||||
if (url_for_css.len > 0) {
|
||||
element.setAttribute(url_attribute, url_for_css) catch {
|
||||
std.debug.panic("unexpected error from Element.setAttribute", .{});
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (unique_key_for_additional_files.len > 0) {
|
||||
// Replace the external href/src with the unique key so that we later will rewrite it to the final URL or pathname
|
||||
element.setAttribute(url_attribute, unique_key_for_additional_files) catch {
|
||||
@@ -142,17 +156,39 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC
|
||||
try endTag.before(slice, true);
|
||||
}
|
||||
|
||||
/// Insert inline script before </body> so DOM elements are available.
|
||||
fn addBodyTags(this: *@This(), endTag: *lol.EndTag) !void {
|
||||
if (this.added_body_script) return;
|
||||
this.added_body_script = true;
|
||||
|
||||
var html_appender = std.heap.stackFallback(256, bun.default_allocator);
|
||||
const allocator = html_appender.get();
|
||||
if (this.chunk.getJSChunkForHTML(this.chunks)) |js_chunk| {
|
||||
const script = bun.handleOom(std.fmt.allocPrintSentinel(allocator, "<script type=\"module\">{s}</script>", .{js_chunk.unique_key}, 0));
|
||||
defer allocator.free(script);
|
||||
try endTag.before(script, true);
|
||||
}
|
||||
}
|
||||
|
||||
fn getHeadTags(this: *@This(), allocator: std.mem.Allocator) bun.BoundedArray([]const u8, 2) {
|
||||
var array: bun.BoundedArray([]const u8, 2) = .{};
|
||||
// Put CSS before JS to reduce changes of flash of unstyled content
|
||||
if (this.chunk.getCSSChunkForHTML(this.chunks)) |css_chunk| {
|
||||
const link_tag = bun.handleOom(std.fmt.allocPrintSentinel(allocator, "<link rel=\"stylesheet\" crossorigin href=\"{s}\">", .{css_chunk.unique_key}, 0));
|
||||
array.appendAssumeCapacity(link_tag);
|
||||
}
|
||||
if (this.chunk.getJSChunkForHTML(this.chunks)) |js_chunk| {
|
||||
// type="module" scripts do not block rendering, so it is okay to put them in head
|
||||
const script = bun.handleOom(std.fmt.allocPrintSentinel(allocator, "<script type=\"module\" crossorigin src=\"{s}\"></script>", .{js_chunk.unique_key}, 0));
|
||||
array.appendAssumeCapacity(script);
|
||||
if (this.compile_to_standalone_html) {
|
||||
// In standalone HTML mode, only put CSS in <head>; JS goes before </body>
|
||||
if (this.chunk.getCSSChunkForHTML(this.chunks)) |css_chunk| {
|
||||
const style_tag = bun.handleOom(std.fmt.allocPrintSentinel(allocator, "<style>{s}</style>", .{css_chunk.unique_key}, 0));
|
||||
array.appendAssumeCapacity(style_tag);
|
||||
}
|
||||
} else {
|
||||
// Put CSS before JS to reduce chances of flash of unstyled content
|
||||
if (this.chunk.getCSSChunkForHTML(this.chunks)) |css_chunk| {
|
||||
const link_tag = bun.handleOom(std.fmt.allocPrintSentinel(allocator, "<link rel=\"stylesheet\" crossorigin href=\"{s}\">", .{css_chunk.unique_key}, 0));
|
||||
array.appendAssumeCapacity(link_tag);
|
||||
}
|
||||
if (this.chunk.getJSChunkForHTML(this.chunks)) |js_chunk| {
|
||||
// type="module" scripts do not block rendering, so it is okay to put them in head
|
||||
const script = bun.handleOom(std.fmt.allocPrintSentinel(allocator, "<script type=\"module\" crossorigin src=\"{s}\"></script>", .{js_chunk.unique_key}, 0));
|
||||
array.appendAssumeCapacity(script);
|
||||
}
|
||||
}
|
||||
return array;
|
||||
}
|
||||
@@ -170,7 +206,12 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC
|
||||
fn endBodyTagHandler(end: *lol.EndTag, opaque_this: ?*anyopaque) callconv(.c) lol.Directive {
|
||||
const this: *@This() = @ptrCast(@alignCast(opaque_this.?));
|
||||
if (this.linker.dev_server == null) {
|
||||
this.addHeadTags(end) catch return .stop;
|
||||
if (this.compile_to_standalone_html) {
|
||||
// In standalone mode, insert JS before </body> so DOM is available
|
||||
this.addBodyTags(end) catch return .stop;
|
||||
} else {
|
||||
this.addHeadTags(end) catch return .stop;
|
||||
}
|
||||
} else {
|
||||
this.end_tag_indices.body = @intCast(this.output.items.len);
|
||||
}
|
||||
@@ -180,7 +221,13 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC
|
||||
fn endHtmlTagHandler(end: *lol.EndTag, opaque_this: ?*anyopaque) callconv(.c) lol.Directive {
|
||||
const this: *@This() = @ptrCast(@alignCast(opaque_this.?));
|
||||
if (this.linker.dev_server == null) {
|
||||
this.addHeadTags(end) catch return .stop;
|
||||
if (this.compile_to_standalone_html) {
|
||||
// Fallback: if no </body> was found, insert both CSS and JS before </html>
|
||||
this.addHeadTags(end) catch return .stop;
|
||||
this.addBodyTags(end) catch return .stop;
|
||||
} else {
|
||||
this.addHeadTags(end) catch return .stop;
|
||||
}
|
||||
} else {
|
||||
this.end_tag_indices.html = @intCast(this.output.items.len);
|
||||
}
|
||||
@@ -199,6 +246,7 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC
|
||||
.log = c.log,
|
||||
.allocator = worker.allocator,
|
||||
.minify_whitespace = c.options.minify_whitespace,
|
||||
.compile_to_standalone_html = c.options.compile_to_standalone_html,
|
||||
.chunk = chunk,
|
||||
.chunks = chunks,
|
||||
.output = std.array_list.Managed(u8).init(output_allocator),
|
||||
@@ -209,6 +257,7 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC
|
||||
.head = null,
|
||||
},
|
||||
.added_head_tags = false,
|
||||
.added_body_script = false,
|
||||
};
|
||||
|
||||
HTMLScanner.HTMLProcessor(HTMLLoader, true).run(
|
||||
@@ -233,14 +282,27 @@ fn generateCompileResultForHTMLChunkImpl(worker: *ThreadPool.Worker, c: *LinkerC
|
||||
break :brk html;
|
||||
break :brk @intCast(html_loader.output.items.len); // inject at end of file.
|
||||
} else brk: {
|
||||
if (!html_loader.added_head_tags) {
|
||||
if (!html_loader.added_head_tags or !html_loader.added_body_script) {
|
||||
@branchHint(.cold); // this is if the document is missing all head, body, and html elements.
|
||||
var html_appender = std.heap.stackFallback(256, bun.default_allocator);
|
||||
const allocator = html_appender.get();
|
||||
const slices = html_loader.getHeadTags(allocator);
|
||||
for (slices.slice()) |slice| {
|
||||
bun.handleOom(html_loader.output.appendSlice(slice));
|
||||
allocator.free(slice);
|
||||
if (!html_loader.added_head_tags) {
|
||||
const slices = html_loader.getHeadTags(allocator);
|
||||
for (slices.slice()) |slice| {
|
||||
bun.handleOom(html_loader.output.appendSlice(slice));
|
||||
allocator.free(slice);
|
||||
}
|
||||
html_loader.added_head_tags = true;
|
||||
}
|
||||
if (!html_loader.added_body_script) {
|
||||
if (html_loader.compile_to_standalone_html) {
|
||||
if (html_loader.chunk.getJSChunkForHTML(html_loader.chunks)) |js_chunk| {
|
||||
const script = bun.handleOom(std.fmt.allocPrintSentinel(allocator, "<script type=\"module\">{s}</script>", .{js_chunk.unique_key}, 0));
|
||||
defer allocator.free(script);
|
||||
bun.handleOom(html_loader.output.appendSlice(script));
|
||||
}
|
||||
}
|
||||
html_loader.added_body_script = true;
|
||||
}
|
||||
}
|
||||
break :brk if (Environment.isDebug) undefined else 0; // value is ignored. fail loud if hit in debug
|
||||
|
||||
@@ -3,6 +3,7 @@ pub fn writeOutputFilesToDisk(
|
||||
root_path: string,
|
||||
chunks: []Chunk,
|
||||
output_files: *OutputFileListBuilder,
|
||||
standalone_chunk_contents: ?[]const ?[]const u8,
|
||||
) !void {
|
||||
const trace = bun.perf.trace("Bundler.writeOutputFilesToDisk");
|
||||
defer trace.end();
|
||||
@@ -42,6 +43,29 @@ pub fn writeOutputFilesToDisk(
|
||||
const bv2: *bundler.BundleV2 = @fieldParentPtr("linker", c);
|
||||
|
||||
for (chunks, 0..) |*chunk, chunk_index_in_chunks_list| {
|
||||
// In standalone mode, only write HTML chunks to disk.
|
||||
// Insert placeholder output files for non-HTML chunks to keep indices aligned.
|
||||
if (standalone_chunk_contents != null and chunk.content != .html) {
|
||||
_ = output_files.insertForChunk(options.OutputFile.init(.{
|
||||
.data = .{ .saved = 0 },
|
||||
.hash = null,
|
||||
.loader = chunk.content.loader(),
|
||||
.input_path = "",
|
||||
.display_size = 0,
|
||||
.output_kind = .chunk,
|
||||
.input_loader = .js,
|
||||
.output_path = "",
|
||||
.is_executable = false,
|
||||
.source_map_index = null,
|
||||
.bytecode_index = null,
|
||||
.module_info_index = null,
|
||||
.side = .client,
|
||||
.entry_point_index = null,
|
||||
.referenced_css_chunks = &.{},
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const trace2 = bun.perf.trace("Bundler.writeChunkToDisk");
|
||||
defer trace2.end();
|
||||
defer max_heap_allocator.reset();
|
||||
@@ -65,17 +89,31 @@ pub fn writeOutputFilesToDisk(
|
||||
else
|
||||
c.resolver.opts.public_path;
|
||||
|
||||
var code_result = chunk.intermediate_output.code(
|
||||
code_allocator,
|
||||
c.parse_graph,
|
||||
&c.graph,
|
||||
public_path,
|
||||
chunk,
|
||||
chunks,
|
||||
&display_size,
|
||||
c.resolver.opts.compile and !chunk.flags.is_browser_chunk_from_server_build,
|
||||
chunk.content.sourcemap(c.options.source_maps) != .none,
|
||||
) catch |err| bun.Output.panic("Failed to create output chunk: {s}", .{@errorName(err)});
|
||||
var code_result = if (standalone_chunk_contents) |scc|
|
||||
chunk.intermediate_output.codeStandalone(
|
||||
code_allocator,
|
||||
c.parse_graph,
|
||||
&c.graph,
|
||||
public_path,
|
||||
chunk,
|
||||
chunks,
|
||||
&display_size,
|
||||
false,
|
||||
false,
|
||||
scc,
|
||||
) catch |err| bun.Output.panic("Failed to create output chunk: {s}", .{@errorName(err)})
|
||||
else
|
||||
chunk.intermediate_output.code(
|
||||
code_allocator,
|
||||
c.parse_graph,
|
||||
&c.graph,
|
||||
public_path,
|
||||
chunk,
|
||||
chunks,
|
||||
&display_size,
|
||||
c.resolver.opts.compile and !chunk.flags.is_browser_chunk_from_server_build,
|
||||
chunk.content.sourcemap(c.options.source_maps) != .none,
|
||||
) catch |err| bun.Output.panic("Failed to create output chunk: {s}", .{@errorName(err)});
|
||||
|
||||
var source_map_output_file: ?options.OutputFile = null;
|
||||
|
||||
@@ -318,7 +356,7 @@ pub fn writeOutputFilesToDisk(
|
||||
.js,
|
||||
.hash = chunk.template.placeholder.hash,
|
||||
.output_kind = output_kind,
|
||||
.loader = .js,
|
||||
.loader = chunk.content.loader(),
|
||||
.source_map_index = source_map_index,
|
||||
.bytecode_index = bytecode_index,
|
||||
.size = @as(u32, @truncate(code_result.buffer.len)),
|
||||
@@ -344,10 +382,15 @@ pub fn writeOutputFilesToDisk(
|
||||
},
|
||||
}));
|
||||
|
||||
// We want the chunk index to remain the same in `output_files` so the indices in `OutputFile.referenced_css_chunks` work
|
||||
bun.assertf(chunk_index == chunk_index_in_chunks_list, "chunk_index ({d}) != chunk_index_in_chunks_list ({d})", .{ chunk_index, chunk_index_in_chunks_list });
|
||||
// We want the chunk index to remain the same in `output_files` so the indices in `OutputFile.referenced_css_chunks` work.
|
||||
// In standalone mode, non-HTML chunks are skipped so this invariant doesn't apply.
|
||||
if (standalone_chunk_contents == null)
|
||||
bun.assertf(chunk_index == chunk_index_in_chunks_list, "chunk_index ({d}) != chunk_index_in_chunks_list ({d})", .{ chunk_index, chunk_index_in_chunks_list });
|
||||
}
|
||||
|
||||
// In standalone mode, additional output files (assets) are inlined into the HTML.
|
||||
if (standalone_chunk_contents != null) return;
|
||||
|
||||
{
|
||||
const additional_output_files = output_files.getMutableAdditionalOutputFiles();
|
||||
output_files.total_insertions += @intCast(additional_output_files.len);
|
||||
|
||||
@@ -460,7 +460,6 @@ pub const Command = struct {
|
||||
banner: []const u8 = "",
|
||||
footer: []const u8 = "",
|
||||
css_chunking: bool = false,
|
||||
|
||||
bake: bool = false,
|
||||
bake_debug_dump_server: bool = false,
|
||||
bake_debug_disable_minify: bool = false,
|
||||
|
||||
@@ -3,6 +3,7 @@ pub const BuildCommand = struct {
|
||||
Global.configureAllocator(.{ .long_running = true });
|
||||
const allocator = ctx.allocator;
|
||||
var log = ctx.log;
|
||||
const user_requested_browser_target = ctx.args.target != null and ctx.args.target.? == .browser;
|
||||
if (ctx.bundler_options.compile or ctx.bundler_options.bytecode) {
|
||||
// set this early so that externals are set up correctly and define is right
|
||||
ctx.args.target = .bun;
|
||||
@@ -98,45 +99,76 @@ pub const BuildCommand = struct {
|
||||
var was_renamed_from_index = false;
|
||||
|
||||
if (ctx.bundler_options.compile) {
|
||||
if (ctx.bundler_options.outdir.len > 0) {
|
||||
Output.prettyErrorln("<r><red>error<r><d>:<r> cannot use --compile with --outdir", .{});
|
||||
Global.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const base_public_path = bun.StandaloneModuleGraph.targetBasePublicPath(compile_target.os, "root/");
|
||||
|
||||
this_transpiler.options.public_path = base_public_path;
|
||||
|
||||
if (outfile.len == 0) {
|
||||
outfile = std.fs.path.basename(this_transpiler.options.entry_points[0]);
|
||||
const ext = std.fs.path.extension(outfile);
|
||||
if (ext.len > 0) {
|
||||
outfile = outfile[0 .. outfile.len - ext.len];
|
||||
}
|
||||
|
||||
if (strings.eqlComptime(outfile, "index")) {
|
||||
outfile = std.fs.path.basename(std.fs.path.dirname(this_transpiler.options.entry_points[0]) orelse "index");
|
||||
was_renamed_from_index = !strings.eqlComptime(outfile, "index");
|
||||
}
|
||||
|
||||
if (strings.eqlComptime(outfile, "bun")) {
|
||||
outfile = std.fs.path.basename(std.fs.path.dirname(this_transpiler.options.entry_points[0]) orelse "bun");
|
||||
}
|
||||
}
|
||||
|
||||
// If argv[0] is "bun" or "bunx", we don't check if the binary is standalone
|
||||
if (strings.eqlComptime(outfile, "bun") or strings.eqlComptime(outfile, "bunx")) {
|
||||
Output.prettyErrorln("<r><red>error<r><d>:<r> cannot use --compile with an output file named 'bun' because bun won't realize it's a standalone executable. Please choose a different name for --outfile", .{});
|
||||
Global.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.bundler_options.transform_only) {
|
||||
Output.prettyErrorln("<r><red>error<r><d>:<r> --compile does not support --no-bundle", .{});
|
||||
Global.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if all entrypoints are HTML files for standalone HTML mode
|
||||
const has_all_html_entrypoints = brk: {
|
||||
if (this_transpiler.options.entry_points.len == 0) break :brk false;
|
||||
for (this_transpiler.options.entry_points) |entry_point| {
|
||||
if (!strings.hasSuffixComptime(entry_point, ".html")) break :brk false;
|
||||
}
|
||||
break :brk true;
|
||||
};
|
||||
|
||||
if (user_requested_browser_target and has_all_html_entrypoints) {
|
||||
// --compile --target=browser with all HTML entrypoints: produce self-contained HTML
|
||||
ctx.args.target = .browser;
|
||||
if (ctx.bundler_options.code_splitting) {
|
||||
Output.prettyErrorln("<r><red>error<r><d>:<r> cannot use --compile --target browser with --splitting", .{});
|
||||
Global.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
this_transpiler.options.compile_to_standalone_html = true;
|
||||
// This is not a bun executable compile - clear compile flags
|
||||
this_transpiler.options.compile = false;
|
||||
ctx.bundler_options.compile = false;
|
||||
|
||||
if (ctx.bundler_options.outdir.len == 0 and outfile.len == 0) {
|
||||
outfile = std.fs.path.basename(this_transpiler.options.entry_points[0]);
|
||||
}
|
||||
|
||||
this_transpiler.options.supports_multiple_outputs = ctx.bundler_options.outdir.len > 0;
|
||||
} else {
|
||||
// Standard --compile: produce standalone bun executable
|
||||
if (ctx.bundler_options.outdir.len > 0) {
|
||||
Output.prettyErrorln("<r><red>error<r><d>:<r> cannot use --compile with --outdir", .{});
|
||||
Global.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const base_public_path = bun.StandaloneModuleGraph.targetBasePublicPath(compile_target.os, "root/");
|
||||
|
||||
this_transpiler.options.public_path = base_public_path;
|
||||
|
||||
if (outfile.len == 0) {
|
||||
outfile = std.fs.path.basename(this_transpiler.options.entry_points[0]);
|
||||
const ext = std.fs.path.extension(outfile);
|
||||
if (ext.len > 0) {
|
||||
outfile = outfile[0 .. outfile.len - ext.len];
|
||||
}
|
||||
|
||||
if (strings.eqlComptime(outfile, "index")) {
|
||||
outfile = std.fs.path.basename(std.fs.path.dirname(this_transpiler.options.entry_points[0]) orelse "index");
|
||||
was_renamed_from_index = !strings.eqlComptime(outfile, "index");
|
||||
}
|
||||
|
||||
if (strings.eqlComptime(outfile, "bun")) {
|
||||
outfile = std.fs.path.basename(std.fs.path.dirname(this_transpiler.options.entry_points[0]) orelse "bun");
|
||||
}
|
||||
}
|
||||
|
||||
// If argv[0] is "bun" or "bunx", we don't check if the binary is standalone
|
||||
if (strings.eqlComptime(outfile, "bun") or strings.eqlComptime(outfile, "bunx")) {
|
||||
Output.prettyErrorln("<r><red>error<r><d>:<r> cannot use --compile with an output file named 'bun' because bun won't realize it's a standalone executable. Please choose a different name for --outfile", .{});
|
||||
Global.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.bundler_options.transform_only) {
|
||||
|
||||
@@ -378,10 +378,15 @@ class AssertionError extends Error {
|
||||
this.operator = operator;
|
||||
}
|
||||
ErrorCaptureStackTrace(this, stackStartFn || stackStartFunction);
|
||||
// JSC::Interpreter::getStackTrace() sometimes short-circuits without creating a .stack property.
|
||||
// e.g.: https://github.com/oven-sh/WebKit/blob/e32c6356625cfacebff0c61d182f759abf6f508a/Source/JavaScriptCore/interpreter/Interpreter.cpp#L501
|
||||
if ($isUndefinedOrNull(this.stack)) {
|
||||
ErrorCaptureStackTrace(this, AssertionError);
|
||||
// When all stack frames are above the stackStartFn (e.g. in async
|
||||
// contexts), captureStackTrace produces a stack with just the error
|
||||
// message and no frame lines. Retry with AssertionError as the filter
|
||||
// so we get at least the frames below the constructor.
|
||||
{
|
||||
const s = this.stack;
|
||||
if ($isUndefinedOrNull(s) || (typeof s === "string" && s.indexOf("\n at ") === -1)) {
|
||||
ErrorCaptureStackTrace(this, AssertionError);
|
||||
}
|
||||
}
|
||||
// Create error message including the error code in the name.
|
||||
this.stack; // eslint-disable-line no-unused-expressions
|
||||
|
||||
@@ -92,43 +92,55 @@ const kDeferredTimeouts = Symbol("deferredTimeouts");
|
||||
|
||||
const kEmptyObject = Object.freeze(Object.create(null));
|
||||
|
||||
export const enum ClientRequestEmitState {
|
||||
socket = 1,
|
||||
prefinish = 2,
|
||||
finish = 3,
|
||||
response = 4,
|
||||
}
|
||||
// These are declared as plain objects instead of `const enum` to prevent the
|
||||
// TypeScript enum reverse-mapping pattern (e.g. `Enum[Enum["x"] = 0] = "x"`)
|
||||
// from triggering setters on `Object.prototype` during module initialization.
|
||||
// See: https://github.com/oven-sh/bun/issues/24336
|
||||
export const ClientRequestEmitState = {
|
||||
socket: 1,
|
||||
prefinish: 2,
|
||||
finish: 3,
|
||||
response: 4,
|
||||
} as const;
|
||||
export type ClientRequestEmitState = (typeof ClientRequestEmitState)[keyof typeof ClientRequestEmitState];
|
||||
|
||||
export const enum NodeHTTPResponseAbortEvent {
|
||||
none = 0,
|
||||
abort = 1,
|
||||
timeout = 2,
|
||||
}
|
||||
export const enum NodeHTTPIncomingRequestType {
|
||||
FetchRequest,
|
||||
FetchResponse,
|
||||
NodeHTTPResponse,
|
||||
}
|
||||
export const enum NodeHTTPBodyReadState {
|
||||
none,
|
||||
pending = 1 << 1,
|
||||
done = 1 << 2,
|
||||
hasBufferedDataDuringPause = 1 << 3,
|
||||
}
|
||||
export const NodeHTTPResponseAbortEvent = {
|
||||
none: 0,
|
||||
abort: 1,
|
||||
timeout: 2,
|
||||
} as const;
|
||||
export type NodeHTTPResponseAbortEvent = (typeof NodeHTTPResponseAbortEvent)[keyof typeof NodeHTTPResponseAbortEvent];
|
||||
|
||||
export const NodeHTTPIncomingRequestType = {
|
||||
FetchRequest: 0,
|
||||
FetchResponse: 1,
|
||||
NodeHTTPResponse: 2,
|
||||
} as const;
|
||||
export type NodeHTTPIncomingRequestType =
|
||||
(typeof NodeHTTPIncomingRequestType)[keyof typeof NodeHTTPIncomingRequestType];
|
||||
|
||||
export const NodeHTTPBodyReadState = {
|
||||
none: 0,
|
||||
pending: 1 << 1,
|
||||
done: 1 << 2,
|
||||
hasBufferedDataDuringPause: 1 << 3,
|
||||
} as const;
|
||||
export type NodeHTTPBodyReadState = (typeof NodeHTTPBodyReadState)[keyof typeof NodeHTTPBodyReadState];
|
||||
|
||||
// Must be kept in sync with NodeHTTPResponse.Flags
|
||||
export const enum NodeHTTPResponseFlags {
|
||||
socket_closed = 1 << 0,
|
||||
request_has_completed = 1 << 1,
|
||||
export const NodeHTTPResponseFlags = {
|
||||
socket_closed: 1 << 0,
|
||||
request_has_completed: 1 << 1,
|
||||
closed_or_completed: (1 << 0) | (1 << 1),
|
||||
} as const;
|
||||
export type NodeHTTPResponseFlags = (typeof NodeHTTPResponseFlags)[keyof typeof NodeHTTPResponseFlags];
|
||||
|
||||
closed_or_completed = socket_closed | request_has_completed,
|
||||
}
|
||||
|
||||
export const enum NodeHTTPHeaderState {
|
||||
none,
|
||||
assigned,
|
||||
sent,
|
||||
}
|
||||
export const NodeHTTPHeaderState = {
|
||||
none: 0,
|
||||
assigned: 1,
|
||||
sent: 2,
|
||||
} as const;
|
||||
export type NodeHTTPHeaderState = (typeof NodeHTTPHeaderState)[keyof typeof NodeHTTPHeaderState];
|
||||
|
||||
function emitErrorNextTickIfErrorListenerNT(self, err, cb) {
|
||||
process.nextTick(emitErrorNextTickIfErrorListener, self, err, cb);
|
||||
|
||||
@@ -51,6 +51,15 @@ function onError(msg, err, callback) {
|
||||
process.nextTick(emitErrorNt, msg, err, callback);
|
||||
}
|
||||
|
||||
function isHTTPHeaderStateSentOrAssigned(state) {
|
||||
return state === NodeHTTPHeaderState.sent || state === NodeHTTPHeaderState.assigned;
|
||||
}
|
||||
function throwHeadersSentIfNecessary(self, action) {
|
||||
if (self._header != null || isHTTPHeaderStateSentOrAssigned(self[headerStateSymbol])) {
|
||||
throw $ERR_HTTP_HEADERS_SENT(action);
|
||||
}
|
||||
}
|
||||
|
||||
function write_(msg, chunk, encoding, callback, fromEnd) {
|
||||
if (typeof callback !== "function") callback = nop;
|
||||
|
||||
@@ -252,18 +261,14 @@ const OutgoingMessagePrototype = {
|
||||
|
||||
removeHeader(name) {
|
||||
validateString(name, "name");
|
||||
if ((this._header !== undefined && this._header !== null) || this[headerStateSymbol] === NodeHTTPHeaderState.sent) {
|
||||
throw $ERR_HTTP_HEADERS_SENT("remove");
|
||||
}
|
||||
throwHeadersSentIfNecessary(this, "remove");
|
||||
const headers = this[headersSymbol];
|
||||
if (!headers) return;
|
||||
headers.delete(name);
|
||||
},
|
||||
|
||||
setHeader(name, value) {
|
||||
if ((this._header !== undefined && this._header !== null) || this[headerStateSymbol] == NodeHTTPHeaderState.sent) {
|
||||
throw $ERR_HTTP_HEADERS_SENT("set");
|
||||
}
|
||||
throwHeadersSentIfNecessary(this, "set");
|
||||
validateHeaderName(name);
|
||||
validateHeaderValue(name, value);
|
||||
const headers = (this[headersSymbol] ??= new Headers());
|
||||
@@ -271,9 +276,7 @@ const OutgoingMessagePrototype = {
|
||||
return this;
|
||||
},
|
||||
setHeaders(headers) {
|
||||
if ((this._header != null) || this[headerStateSymbol] === NodeHTTPHeaderState.sent) {
|
||||
throw $ERR_HTTP_HEADERS_SENT("set");
|
||||
}
|
||||
throwHeadersSentIfNecessary(this, "set");
|
||||
|
||||
if (!headers || $isArray(headers) || typeof headers.keys !== "function" || typeof headers.get !== "function") {
|
||||
throw $ERR_INVALID_ARG_TYPE("headers", ["Headers", "Map"], headers);
|
||||
|
||||
@@ -1833,6 +1833,7 @@ pub const BundleOptions = struct {
|
||||
debugger: bool = false,
|
||||
|
||||
compile: bool = false,
|
||||
compile_to_standalone_html: bool = false,
|
||||
metafile: bool = false,
|
||||
/// Path to write JSON metafile (for Bun.build API)
|
||||
metafile_json_path: []const u8 = "",
|
||||
|
||||
519
test/bundler/standalone.test.ts
Normal file
519
test/bundler/standalone.test.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bunEnv, bunExe, tempDir } from "harness";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
describe("compile --target=browser", () => {
|
||||
test("inlines JS and CSS into HTML", async () => {
|
||||
using dir = tempDir("compile-browser-basic", {
|
||||
"index.html": `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><link rel="stylesheet" href="./style.css"></head>
|
||||
<body><script src="./app.js"></script></body>
|
||||
</html>`,
|
||||
"style.css": `body { color: red; }`,
|
||||
"app.js": `console.log("hello");`,
|
||||
});
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: [`${dir}/index.html`],
|
||||
compile: true,
|
||||
target: "browser",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.outputs.length).toBe(1);
|
||||
expect(result.outputs[0].loader).toBe("html");
|
||||
|
||||
const html = await result.outputs[0].text();
|
||||
expect(html).toContain("<style>");
|
||||
expect(html).toContain("color: red");
|
||||
expect(html).toContain("</style>");
|
||||
expect(html).toContain('<script type="module">');
|
||||
expect(html).toContain('console.log("hello")');
|
||||
expect(html).toContain("</script>");
|
||||
// Should NOT have external references
|
||||
expect(html).not.toContain('src="');
|
||||
expect(html).not.toContain('href="');
|
||||
});
|
||||
|
||||
test("uses type=module on inline scripts", async () => {
|
||||
using dir = tempDir("compile-browser-module", {
|
||||
"index.html": `<!DOCTYPE html><html><body><script src="./app.js"></script></body></html>`,
|
||||
"app.js": `console.log("module");`,
|
||||
});
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: [`${dir}/index.html`],
|
||||
compile: true,
|
||||
target: "browser",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const html = await result.outputs[0].text();
|
||||
expect(html).toContain('<script type="module">');
|
||||
expect(html).not.toMatch(/<script>(?!<)/);
|
||||
});
|
||||
|
||||
test("top-level await works with inline scripts", async () => {
|
||||
using dir = tempDir("compile-browser-tla", {
|
||||
"index.html": `<!DOCTYPE html><html><body><script src="./app.js"></script></body></html>`,
|
||||
"app.js": `const data = await Promise.resolve(42);
|
||||
console.log(data);`,
|
||||
});
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: [`${dir}/index.html`],
|
||||
compile: true,
|
||||
target: "browser",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const html = await result.outputs[0].text();
|
||||
expect(html).toContain('<script type="module">');
|
||||
expect(html).toContain("await");
|
||||
});
|
||||
|
||||
test("escapes </script> in inlined JS", async () => {
|
||||
using dir = tempDir("compile-browser-escape-script", {
|
||||
"index.html": `<!DOCTYPE html><html><body><script src="./app.js"></script></body></html>`,
|
||||
"app.js": `const x = "</script>";
|
||||
console.log(x);`,
|
||||
});
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: [`${dir}/index.html`],
|
||||
compile: true,
|
||||
target: "browser",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const html = await result.outputs[0].text();
|
||||
// The literal </script> inside JS must be escaped so it doesn't close the tag
|
||||
// Count actual </script> occurrences - should be exactly 1 (the closing tag)
|
||||
const scriptCloseCount = html.split("</script>").length - 1;
|
||||
expect(scriptCloseCount).toBe(1);
|
||||
// The escaped version should be present
|
||||
expect(html).toContain("<\\/script>");
|
||||
});
|
||||
|
||||
test("escapes </style> in inlined CSS", async () => {
|
||||
using dir = tempDir("compile-browser-escape-style", {
|
||||
"index.html": `<!DOCTYPE html>
|
||||
<html><head><link rel="stylesheet" href="./style.css"></head><body></body></html>`,
|
||||
"style.css": `body::after { content: "</style>"; }`,
|
||||
});
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: [`${dir}/index.html`],
|
||||
compile: true,
|
||||
target: "browser",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const html = await result.outputs[0].text();
|
||||
// The literal </style> inside CSS must be escaped
|
||||
const styleCloseCount = html.split("</style>").length - 1;
|
||||
expect(styleCloseCount).toBe(1);
|
||||
});
|
||||
|
||||
test("deep import chain with re-exports and multiple files", async () => {
|
||||
using dir = tempDir("compile-browser-deep-chain", {
|
||||
"index.html": `<!DOCTYPE html><html><body><script src="./app.js"></script></body></html>`,
|
||||
"app.js": `import { renderApp } from "./components/App.js";
|
||||
import { initRouter } from "./router/index.js";
|
||||
import { createStore } from "./store/index.js";
|
||||
|
||||
const store = createStore({ count: 0 });
|
||||
initRouter(store);
|
||||
renderApp(store);`,
|
||||
"components/App.js": `import { Header } from "./Header.js";
|
||||
import { Footer } from "./Footer.js";
|
||||
import { Counter } from "./Counter.js";
|
||||
|
||||
export function renderApp(store) {
|
||||
document.body.innerHTML = Header() + Counter(store) + Footer();
|
||||
}`,
|
||||
"components/Header.js": `import { APP_NAME } from "../config.js";
|
||||
export function Header() { return "<header>" + APP_NAME + "</header>"; }`,
|
||||
"components/Footer.js": `import { APP_VERSION } from "../config.js";
|
||||
export function Footer() { return "<footer>v" + APP_VERSION + "</footer>"; }`,
|
||||
"components/Counter.js": `import { formatNumber } from "../utils/format.js";
|
||||
export function Counter(store) {
|
||||
return "<div>Count: " + formatNumber(store.count) + "</div>";
|
||||
}`,
|
||||
"router/index.js": `import { parseRoute } from "./parser.js";
|
||||
import { matchRoute } from "./matcher.js";
|
||||
export function initRouter(store) {
|
||||
const route = parseRoute(window.location.pathname);
|
||||
matchRoute(route, store);
|
||||
}`,
|
||||
"router/parser.js": `export function parseRoute(path) {
|
||||
return path.split("/").filter(Boolean);
|
||||
}`,
|
||||
"router/matcher.js": `import { log } from "../utils/logger.js";
|
||||
export function matchRoute(route, store) {
|
||||
log("Matching route: " + route.join("/"));
|
||||
}`,
|
||||
"store/index.js": `import { log } from "../utils/logger.js";
|
||||
export function createStore(initial) {
|
||||
log("Store created");
|
||||
return { ...initial };
|
||||
}`,
|
||||
"utils/format.js": `export function formatNumber(n) { return n.toLocaleString(); }`,
|
||||
"utils/logger.js": `export function log(msg) { console.log("[LOG] " + msg); }`,
|
||||
"config.js": `export const APP_NAME = "MyApp";
|
||||
export const APP_VERSION = "1.0.0";`,
|
||||
});
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: [`${dir}/index.html`],
|
||||
compile: true,
|
||||
target: "browser",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.outputs.length).toBe(1);
|
||||
|
||||
const html = await result.outputs[0].text();
|
||||
// All modules from the deep chain should be bundled
|
||||
expect(html).toContain("MyApp");
|
||||
expect(html).toContain("1.0.0");
|
||||
expect(html).toContain("renderApp");
|
||||
expect(html).toContain("initRouter");
|
||||
expect(html).toContain("createStore");
|
||||
expect(html).toContain("formatNumber");
|
||||
expect(html).toContain("[LOG]");
|
||||
// Single output, no external refs
|
||||
expect(html).not.toContain('src="');
|
||||
expect(html).toContain('<script type="module">');
|
||||
});
|
||||
|
||||
test("CSS imported from JS and via link tag (deduplicated)", async () => {
|
||||
using dir = tempDir("compile-browser-css-dedup", {
|
||||
"index.html": `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><link rel="stylesheet" href="./shared.css"></head>
|
||||
<body><script src="./app.js"></script></body>
|
||||
</html>`,
|
||||
"app.js": `import "./shared.css";
|
||||
import "./components.css";
|
||||
console.log("app with css");`,
|
||||
"shared.css": `body { margin: 0; font-family: sans-serif; }`,
|
||||
"components.css": `@import "./buttons.css";
|
||||
.card { border: 1px solid #ccc; padding: 16px; }`,
|
||||
"buttons.css": `.btn { padding: 8px 16px; cursor: pointer; }
|
||||
.btn-primary { background: #007bff; color: white; }`,
|
||||
});
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: [`${dir}/index.html`],
|
||||
compile: true,
|
||||
target: "browser",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.outputs.length).toBe(1);
|
||||
|
||||
const html = await result.outputs[0].text();
|
||||
expect(html).toContain("<style>");
|
||||
expect(html).toContain("</style>");
|
||||
// shared.css content
|
||||
expect(html).toContain("font-family:");
|
||||
expect(html).toContain("sans-serif");
|
||||
// components.css content
|
||||
expect(html).toContain(".card");
|
||||
expect(html).toContain("padding:");
|
||||
// nested buttons.css content
|
||||
expect(html).toContain(".btn");
|
||||
expect(html).toContain(".btn-primary");
|
||||
expect(html).toContain("cursor: pointer");
|
||||
// JS should be inlined
|
||||
expect(html).toContain('console.log("app with css")');
|
||||
// No external refs
|
||||
expect(html).not.toContain('href="');
|
||||
expect(html).not.toContain("@import");
|
||||
});
|
||||
|
||||
test("nested CSS @import chain", async () => {
|
||||
using dir = tempDir("compile-browser-css-chain", {
|
||||
"index.html": `<!DOCTYPE html>
|
||||
<html><head><link rel="stylesheet" href="./main.css"></head><body></body></html>`,
|
||||
"main.css": `@import "./base.css";
|
||||
body { color: blue; }`,
|
||||
"base.css": `@import "./reset.css";
|
||||
* { box-sizing: border-box; }`,
|
||||
"reset.css": `html, body { margin: 0; padding: 0; }`,
|
||||
});
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: [`${dir}/index.html`],
|
||||
compile: true,
|
||||
target: "browser",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const html = await result.outputs[0].text();
|
||||
expect(html).toContain("<style>");
|
||||
// All three CSS files bundled together
|
||||
expect(html).toContain("margin: 0");
|
||||
expect(html).toContain("padding: 0");
|
||||
expect(html).toContain("box-sizing: border-box");
|
||||
expect(html).toMatch(/color:?\s*(blue|#00f)/);
|
||||
expect(html).not.toContain("@import");
|
||||
});
|
||||
|
||||
test("Bun.build() with outdir writes files to disk", async () => {
|
||||
using dir = tempDir("compile-browser-outdir", {
|
||||
"index.html": `<!DOCTYPE html>
|
||||
<html><head><link rel="stylesheet" href="./style.css"></head>
|
||||
<body><script src="./app.js"></script></body></html>`,
|
||||
"style.css": `h1 { font-weight: bold; }`,
|
||||
"app.js": `console.log("outdir test");`,
|
||||
});
|
||||
|
||||
const outdir = `${dir}/dist`;
|
||||
const result = await Bun.build({
|
||||
entrypoints: [`${dir}/index.html`],
|
||||
compile: true,
|
||||
target: "browser",
|
||||
outdir,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.outputs.length).toBe(1);
|
||||
|
||||
// Verify the file was actually written to disk
|
||||
expect(existsSync(`${outdir}/index.html`)).toBe(true);
|
||||
|
||||
const html = await Bun.file(`${outdir}/index.html`).text();
|
||||
expect(html).toContain("<style>");
|
||||
expect(html).toContain("font-weight: bold");
|
||||
expect(html).toContain('<script type="module">');
|
||||
expect(html).toContain('console.log("outdir test")');
|
||||
});
|
||||
|
||||
test("Bun.build() with outdir and image assets", async () => {
|
||||
const pixel = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4DwAAAQEABRjYTgAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
using dir = tempDir("compile-browser-outdir-assets", {
|
||||
"index.html": `<!DOCTYPE html>
|
||||
<html><body><img src="./logo.png"><script src="./app.js"></script></body></html>`,
|
||||
"logo.png": pixel,
|
||||
"app.js": `console.log("outdir with assets");`,
|
||||
});
|
||||
|
||||
const outdir = `${dir}/dist`;
|
||||
const result = await Bun.build({
|
||||
entrypoints: [`${dir}/index.html`],
|
||||
compile: true,
|
||||
target: "browser",
|
||||
outdir,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.outputs.length).toBe(1);
|
||||
|
||||
expect(existsSync(`${outdir}/index.html`)).toBe(true);
|
||||
|
||||
const html = await Bun.file(`${outdir}/index.html`).text();
|
||||
expect(html).toContain('src="data:image/png;base64,');
|
||||
expect(html).toContain('console.log("outdir with assets")');
|
||||
});
|
||||
|
||||
test("inlines images as data: URIs", async () => {
|
||||
// 1x1 red PNG
|
||||
const pixel = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4DwAAAQEABRjYTgAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
using dir = tempDir("compile-browser-image", {
|
||||
"index.html": `<!DOCTYPE html>
|
||||
<html><body><img src="./pixel.png"><script src="./app.js"></script></body></html>`,
|
||||
"pixel.png": pixel,
|
||||
"app.js": `console.log("with image");`,
|
||||
});
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: [`${dir}/index.html`],
|
||||
compile: true,
|
||||
target: "browser",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.outputs.length).toBe(1);
|
||||
|
||||
const html = await result.outputs[0].text();
|
||||
expect(html).toContain('src="data:image/png;base64,');
|
||||
expect(html).toContain('console.log("with image")');
|
||||
});
|
||||
|
||||
test("handles CSS url() references", async () => {
|
||||
const pixel = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4DwAAAQEABRjYTgAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
using dir = tempDir("compile-browser-css-url", {
|
||||
"index.html": `<!DOCTYPE html>
|
||||
<html><head><link rel="stylesheet" href="./style.css"></head><body></body></html>`,
|
||||
"style.css": `body { background: url("./bg.png") no-repeat; }`,
|
||||
"bg.png": pixel,
|
||||
});
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: [`${dir}/index.html`],
|
||||
compile: true,
|
||||
target: "browser",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const html = await result.outputs[0].text();
|
||||
expect(html).toContain("data:image/png;base64,");
|
||||
expect(html).toContain("<style>");
|
||||
});
|
||||
|
||||
test("non-HTML entrypoints with compile+browser falls back to normal compile", async () => {
|
||||
using dir = tempDir("compile-browser-no-html", {
|
||||
"app.js": `console.log("no html");`,
|
||||
});
|
||||
|
||||
// compile: true + target: "browser" with non-HTML entrypoints should
|
||||
// fall back to normal bun executable compile (not standalone HTML)
|
||||
const result = await Bun.build({
|
||||
entrypoints: [`${dir}/app.js`],
|
||||
compile: true,
|
||||
target: "browser",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("CLI --compile --target=browser with non-HTML falls back to normal compile", async () => {
|
||||
using dir = tempDir("compile-browser-cli-no-html", {
|
||||
"app.js": `console.log("test");`,
|
||||
});
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "build", "--compile", "--target=browser", `${dir}/app.js`],
|
||||
env: bunEnv,
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
});
|
||||
|
||||
const [_stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
// Non-HTML entrypoints with --compile --target=browser should fall back to normal bun compile
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("fails with splitting", async () => {
|
||||
using dir = tempDir("compile-browser-splitting", {
|
||||
"index.html": `<!DOCTYPE html><html><body><script src="./app.js"></script></body></html>`,
|
||||
"app.js": `console.log("test");`,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
Bun.build({
|
||||
entrypoints: [`${dir}/index.html`],
|
||||
compile: true,
|
||||
target: "browser",
|
||||
splitting: true,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("CLI --compile --target=browser produces single file", async () => {
|
||||
using dir = tempDir("compile-browser-cli", {
|
||||
"index.html": `<!DOCTYPE html>
|
||||
<html><head><link rel="stylesheet" href="./style.css"></head>
|
||||
<body><script src="./app.js"></script></body></html>`,
|
||||
"style.css": `h1 { font-weight: bold; }`,
|
||||
"app.js": `console.log("cli test");`,
|
||||
});
|
||||
|
||||
const outdir = `${dir}/out`;
|
||||
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "build", "--compile", "--target=browser", `${dir}/index.html`, "--outdir", outdir],
|
||||
env: bunEnv,
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
|
||||
// Check only HTML file exists in output
|
||||
const glob = new Bun.Glob("**/*");
|
||||
const files = Array.from(glob.scanSync({ cwd: outdir }));
|
||||
expect(files).toEqual(["index.html"]);
|
||||
|
||||
// Verify content
|
||||
const html = await Bun.file(`${outdir}/index.html`).text();
|
||||
expect(html).toContain("<style>");
|
||||
expect(html).toContain("font-weight: bold");
|
||||
expect(html).toContain('<script type="module">');
|
||||
expect(html).toContain('console.log("cli test")');
|
||||
|
||||
expect(stderr).toBe("");
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("malformed HTML without closing tags still inlines JS and CSS", async () => {
|
||||
// This tests the cold fallback path when no </head>, </body>, or </html> tags exist.
|
||||
// The document is just a fragment - the loader must still inject both CSS and JS.
|
||||
using dir = tempDir("compile-browser-malformed", {
|
||||
"index.html": `<div id="app"></div><link rel="stylesheet" href="./style.css"><script src="./app.js"></script>`,
|
||||
"style.css": `#app { color: green; }`,
|
||||
"app.js": `console.log("malformed html");`,
|
||||
});
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: [`${dir}/index.html`],
|
||||
compile: true,
|
||||
target: "browser",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.outputs.length).toBe(1);
|
||||
|
||||
const html = await result.outputs[0].text();
|
||||
// CSS should be inlined
|
||||
expect(html).toContain("<style>");
|
||||
expect(html).toContain("color: green");
|
||||
// JS should also be inlined (this was the bug - JS was dropped in fallback path)
|
||||
expect(html).toContain('<script type="module">');
|
||||
expect(html).toContain('console.log("malformed html")');
|
||||
});
|
||||
|
||||
test("minification works", async () => {
|
||||
using dir = tempDir("compile-browser-minify", {
|
||||
"index.html": `<!DOCTYPE html>
|
||||
<html><head><link rel="stylesheet" href="./style.css"></head>
|
||||
<body><script src="./app.js"></script></body></html>`,
|
||||
"style.css": `body {
|
||||
color: red;
|
||||
background: blue;
|
||||
}`,
|
||||
"app.js": `const message = "hello world";
|
||||
console.log(message);`,
|
||||
});
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: [`${dir}/index.html`],
|
||||
compile: true,
|
||||
target: "browser",
|
||||
minify: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const html = await result.outputs[0].text();
|
||||
expect(html).toContain("<style>");
|
||||
expect(html).toContain("</style>");
|
||||
expect(html).toContain('<script type="module">');
|
||||
expect(html).toContain("</script>");
|
||||
});
|
||||
});
|
||||
@@ -91,6 +91,30 @@ it("should find files", () => {
|
||||
expect(Object.values(routes).length).toBe(Object.values(fixture).length);
|
||||
});
|
||||
|
||||
it("should handle routes under GC pressure", () => {
|
||||
// Regression test for BUN-1K54: fromEntries used ObjectInitializationScope
|
||||
// with putDirect, which could crash when GC triggers during string allocation.
|
||||
const files = Array.from({ length: 128 }, (_, i) => `route${i}/index.tsx`);
|
||||
const { dir } = make(files);
|
||||
|
||||
const router = new FileSystemRouter({
|
||||
dir,
|
||||
fileExtensions: [".tsx"],
|
||||
style: "nextjs",
|
||||
});
|
||||
|
||||
// Access routes repeatedly with GC pressure to exercise the fromEntries path
|
||||
for (let i = 0; i < 10; i++) {
|
||||
Bun.gc(true);
|
||||
const routes = router.routes;
|
||||
const keys = Object.keys(routes);
|
||||
expect(keys.length).toBe(128);
|
||||
for (let j = 0; j < 128; j++) {
|
||||
expect(routes[`/route${j}`]).toBe(`${dir}/route${j}/index.tsx`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle empty dirs", () => {
|
||||
const { dir } = make([]);
|
||||
|
||||
|
||||
@@ -754,3 +754,39 @@ test("CallFrame.p.isAsync", async () => {
|
||||
|
||||
expect(prepare).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("captureStackTrace with constructor function not in stack returns error string", () => {
|
||||
// When the second argument to captureStackTrace is a function that isn't in
|
||||
// the call stack, all frames are filtered out and .stack should still return
|
||||
// the error name and message (matching Node.js behavior).
|
||||
function notInStack() {}
|
||||
|
||||
// Case 1: stack not accessed before captureStackTrace
|
||||
{
|
||||
const e = new Error("test");
|
||||
Error.captureStackTrace(e, notInStack);
|
||||
expect(e.stack).toBe("Error: test");
|
||||
}
|
||||
|
||||
// Case 2: stack accessed before captureStackTrace
|
||||
{
|
||||
const e = new Error("test");
|
||||
void e.stack;
|
||||
Error.captureStackTrace(e, notInStack);
|
||||
expect(e.stack).toBe("Error: test");
|
||||
}
|
||||
|
||||
// Case 3: empty message
|
||||
{
|
||||
const e = new Error();
|
||||
Error.captureStackTrace(e, notInStack);
|
||||
expect(e.stack).toBe("Error");
|
||||
}
|
||||
|
||||
// Case 4: custom error name
|
||||
{
|
||||
const e = new TypeError("bad type");
|
||||
Error.captureStackTrace(e, notInStack);
|
||||
expect(e.stack).toBe("TypeError: bad type");
|
||||
}
|
||||
});
|
||||
|
||||
63
test/regression/issue/12117.test.ts
Normal file
63
test/regression/issue/12117.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// Regression test for TLS upgrade raw socket leak (#12117, #24118, #25948)
|
||||
// When a TCP socket is upgraded to TLS via tls.connect({ socket }),
|
||||
// both a TLS wrapper and a raw TCP wrapper are created in Zig.
|
||||
// Previously, the raw socket's has_pending_activity was never set to
|
||||
// false on close, causing it (and all its retained objects) to leak.
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { tls as COMMON_CERT, expectMaxObjectTypeCount } from "harness";
|
||||
import { once } from "node:events";
|
||||
import net from "node:net";
|
||||
import tls from "node:tls";
|
||||
|
||||
describe("TLS upgrade", () => {
|
||||
it("should not leak TLSSocket objects after close", async () => {
|
||||
// Create a TLS server that echoes data and closes
|
||||
const server = tls.createServer(
|
||||
{
|
||||
key: COMMON_CERT.key,
|
||||
cert: COMMON_CERT.cert,
|
||||
},
|
||||
socket => {
|
||||
socket.end("hello");
|
||||
},
|
||||
);
|
||||
|
||||
await once(server.listen(0, "127.0.0.1"), "listening");
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
|
||||
// Simulate the MongoDB driver pattern: create a plain TCP socket,
|
||||
// then upgrade it to TLS via tls.connect({ socket }).
|
||||
// Do this multiple times to accumulate leaked objects.
|
||||
const iterations = 50;
|
||||
|
||||
try {
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const tcpSocket = net.createConnection({ host: "127.0.0.1", port });
|
||||
await once(tcpSocket, "connect");
|
||||
|
||||
const tlsSocket = tls.connect({
|
||||
socket: tcpSocket,
|
||||
ca: COMMON_CERT.cert,
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
await once(tlsSocket, "secureConnect");
|
||||
|
||||
// Read any data and destroy the TLS socket (simulates SDAM close)
|
||||
tlsSocket.on("data", () => {});
|
||||
tlsSocket.destroy();
|
||||
|
||||
await once(tlsSocket, "close");
|
||||
}
|
||||
} finally {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
}
|
||||
|
||||
// After all connections are closed and GC runs, the TLSSocket count
|
||||
// should be low. Before the fix, each iteration would leak 1 raw
|
||||
// TLSSocket (the TCP wrapper from upgradeTLS), accumulating over time.
|
||||
// Allow some slack for prototypes/structures (typically 2-3 baseline).
|
||||
await expectMaxObjectTypeCount(expect, "TLSSocket", 10, 1000);
|
||||
});
|
||||
});
|
||||
77
test/regression/issue/24336.test.ts
Normal file
77
test/regression/issue/24336.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { bunEnv, bunExe } from "harness";
|
||||
|
||||
// https://github.com/oven-sh/bun/issues/24336
|
||||
// require('http') should not trigger Object.prototype setters during module loading.
|
||||
// Node.js produces no output for both CJS and ESM, and Bun should match that behavior.
|
||||
test("require('http') does not trigger Object.prototype[0] setter", async () => {
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [
|
||||
bunExe(),
|
||||
"-e",
|
||||
`
|
||||
Object.defineProperty(Object.prototype, '0', {
|
||||
set() { console.log('SETTER_TRIGGERED'); }
|
||||
});
|
||||
require('http');
|
||||
`,
|
||||
],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
|
||||
expect(stdout).toBe("");
|
||||
expect(stderr).toBe("");
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("require('url') does not trigger Object.prototype[0] setter", async () => {
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [
|
||||
bunExe(),
|
||||
"-e",
|
||||
`
|
||||
Object.defineProperty(Object.prototype, '0', {
|
||||
set() { console.log('SETTER_TRIGGERED'); }
|
||||
});
|
||||
require('url');
|
||||
`,
|
||||
],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
|
||||
expect(stdout).toBe("");
|
||||
expect(stderr).toBe("");
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test("require('util') does not trigger Object.prototype[0] setter", async () => {
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [
|
||||
bunExe(),
|
||||
"-e",
|
||||
`
|
||||
Object.defineProperty(Object.prototype, '0', {
|
||||
set() { console.log('SETTER_TRIGGERED'); }
|
||||
});
|
||||
require('util');
|
||||
`,
|
||||
],
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
|
||||
expect(stdout).toBe("");
|
||||
expect(stderr).toBe("");
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
@@ -1,8 +1,44 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
// https://github.com/oven-sh/bun/issues/27014
|
||||
test("Bun.stripANSI does not hang on non-ANSI control characters", () => {
|
||||
// Bun.stripANSI() hangs on strings with control characters in 0x10-0x1F
|
||||
// that are not actual ANSI escape introducers (e.g. 0x16 SYN, 0x19 EM).
|
||||
test("stripANSI does not hang on non-escape control characters", () => {
|
||||
// This input contains 0x16, 0x19, 0x13, 0x14 which are in the 0x10-0x1F
|
||||
// range but are NOT ANSI escape introducers.
|
||||
const s = "\u0016zo\u00BAd\u0019\u00E8\u00E0\u0013?\u00C1+\u0014d\u00D3\u00E9";
|
||||
const result = Bun.stripANSI(s);
|
||||
expect(result).toBe(s);
|
||||
});
|
||||
|
||||
test("stripANSI still strips real ANSI escape sequences", () => {
|
||||
// ESC [ 31m = red color, ESC [ 0m = reset
|
||||
const input = "\x1b[31mhello\x1b[0m";
|
||||
expect(Bun.stripANSI(input)).toBe("hello");
|
||||
});
|
||||
|
||||
test("stripANSI handles mix of false-positive control chars and real escapes", () => {
|
||||
// 0x16 (SYN) should be preserved, but \x1b[31m should be stripped
|
||||
const input = "\x16before\x1b[31mcolored\x1b[0mafter\x19end";
|
||||
expect(Bun.stripANSI(input)).toBe("\x16beforecoloredafter\x19end");
|
||||
});
|
||||
|
||||
test("stripANSI handles string of only non-escape control characters", () => {
|
||||
const input = "\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1c\x1d\x1e\x1f";
|
||||
expect(Bun.stripANSI(input)).toBe(input);
|
||||
});
|
||||
|
||||
test("stripANSI finds real escape after false positives in same SIMD chunk", () => {
|
||||
// Place false-positive control chars followed by a real ESC within 16 bytes
|
||||
// so they land in the same SIMD chunk. The fix must scan past false positives
|
||||
// within a chunk to find the real escape character.
|
||||
const input = "\x10\x11\x12\x1b[31mred\x1b[0m";
|
||||
expect(Bun.stripANSI(input)).toBe("\x10\x11\x12red");
|
||||
});
|
||||
|
||||
test("stripANSI handles many false positives followed by real escape in same chunk", () => {
|
||||
// Fill most of a 16-byte SIMD chunk with false positives, then a real escape
|
||||
// at the end of the chunk. This tests that the entire chunk is scanned.
|
||||
const input = "\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1c\x1d\x1b[1m!\x1b[0m";
|
||||
expect(Bun.stripANSI(input)).toBe("\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1c\x1d!");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user